diff --git a/app/Console/Commands/AiTagArtworksCommand.php b/app/Console/Commands/AiTagArtworksCommand.php index a01138f6..adf928d6 100644 --- a/app/Console/Commands/AiTagArtworksCommand.php +++ b/app/Console/Commands/AiTagArtworksCommand.php @@ -47,21 +47,73 @@ final class AiTagArtworksCommand extends Command // Prompt // ------------------------------------------------------------------------- - private const SYSTEM_PROMPT = <<<'PROMPT' -You are an expert at analysing visual artwork and generating concise, descriptive tags. +private const SYSTEM_PROMPT = <<<'PROMPT' +You are a precise visual-art tagging engine for an artwork gallery. + +Your task is to analyse an artwork image and generate high-quality search tags that are useful for discovery, filtering, and categorisation. + +Prioritise tags that are: +- visually evident in the image +- concise and specific +- useful for gallery search + +Prefer concrete visual concepts over vague opinions. +Do not invent details that are not clearly visible. +Do not include artist names, brands, watermarks, or assumptions about intent unless directly visible. + +Return tags that describe: +- subject or scene +- art style or genre +- mood or atmosphere +- colour palette +- technique or medium if visually apparent +- composition or notable visual elements if relevant + +Avoid: +- generic filler tags like "beautiful", "nice", "art", "image" +- duplicate or near-duplicate tags +- full sentences +- overly broad tags when a more specific one is visible + +Output must be deterministic, compact, and consistent. PROMPT; - private const USER_PROMPT = <<<'PROMPT' -Analyse the artwork image and return a JSON array of relevant tags. -Cover: art style, subject/theme, dominant colours, mood, technique, and medium where visible. +private const USER_PROMPT = <<<'PROMPT' +Analyse this artwork image and return a JSON array of relevant tags. -Rules: -- Return ONLY a valid JSON array of lowercase strings — no markdown, no explanation. -- Each tag must be 1–4 words, no punctuation except hyphens. -- Between 6 and 12 tags total. +Requirements: +- Return ONLY a valid JSON array of lowercase strings. +- No markdown, no explanation, no extra text. +- Output between 8 and 14 tags. +- Each tag must be 1 to 3 words. +- Use only letters, numbers, spaces, and hyphens. +- Do not end tags with punctuation. +- Do not include duplicate or near-duplicate tags. +- Order tags from most important to least important. -Example output: -["digital painting","fantasy","portrait","dark tones","glowing eyes","detailed","dramatic lighting"] +Focus on tags from these groups when visible: +1. main subject or scene +2. style or genre +3. mood or atmosphere +4. dominant colours +5. medium or technique +6. notable visual elements or composition + +Tagging guidelines: +- Prefer specific tags over generic ones. +- Use searchable gallery-style tags. +- Include only what is clearly visible or strongly implied by the image. +- If the artwork is abstract, prioritise style, colour, mood, and composition. +- If the artwork is representational, prioritise subject, setting, style, and mood. +- If a detail is uncertain, leave it out. + +Good output example: +["fantasy portrait","digital painting","female warrior","blue tones","dramatic lighting","glowing eyes","cinematic mood","detailed armor"] + +Bad output example: +["art","beautiful image","very cool fantasy woman","amazing colors","masterpiece"] + +Now return only the JSON array. PROMPT; // ------------------------------------------------------------------------- diff --git a/app/Console/Commands/AvatarsMigrate.php b/app/Console/Commands/AvatarsMigrate.php index b844540c..e5714f02 100644 --- a/app/Console/Commands/AvatarsMigrate.php +++ b/app/Console/Commands/AvatarsMigrate.php @@ -49,7 +49,7 @@ class AvatarsMigrate extends Command * * @var int[] */ - protected $sizes = [32, 40, 64, 128, 256, 512]; + protected $sizes = [32, 40, 64, 80, 96, 128, 256, 512]; public function handle(): int { diff --git a/app/Console/Commands/RecalculateUserXpCommand.php b/app/Console/Commands/RecalculateUserXpCommand.php new file mode 100644 index 00000000..9cb2e142 --- /dev/null +++ b/app/Console/Commands/RecalculateUserXpCommand.php @@ -0,0 +1,163 @@ +argument('user_id'); + $all = (bool) $this->option('all'); + $dryRun = (bool) $this->option('dry-run'); + $syncAchievements = (bool) $this->option('sync-achievements'); + $chunk = max(1, (int) $this->option('chunk')); + + if ($userId !== null && $all) { + $this->error('Provide either a user_id or --all, not both.'); + + return self::FAILURE; + } + + if ($userId !== null) { + return $this->recalculateSingle((int) $userId, $xp, $achievements, $dryRun, $syncAchievements); + } + + if ($all) { + return $this->recalculateAll($xp, $achievements, $chunk, $dryRun, $syncAchievements); + } + + $this->error('Provide a user_id or use --all.'); + + return self::FAILURE; + } + + private function recalculateSingle( + int $userId, + XPService $xp, + AchievementService $achievements, + bool $dryRun, + bool $syncAchievements, + ): int { + $exists = DB::table('users')->where('id', $userId)->exists(); + if (! $exists) { + $this->error("User {$userId} not found."); + + return self::FAILURE; + } + + $label = $dryRun ? '[DRY-RUN]' : '[LIVE]'; + $this->line("{$label} Recomputing XP for user #{$userId}..."); + + $result = $xp->recalculateStoredProgress($userId, ! $dryRun); + $this->table( + ['Field', 'Stored', 'Computed'], + [ + ['xp', $result['previous']['xp'], $result['computed']['xp']], + ['level', $result['previous']['level'], $result['computed']['level']], + ['rank', $result['previous']['rank'], $result['computed']['rank']], + ] + ); + + if ($dryRun) { + if ($syncAchievements) { + $pending = $achievements->previewUnlocks($userId); + $this->line('Achievements preview: ' . (empty($pending) ? 'no pending unlocks' : implode(', ', $pending))); + } + + $this->warn('Dry-run: no changes written.'); + + return self::SUCCESS; + } + + if ($syncAchievements) { + $unlocked = $achievements->checkAchievements($userId); + $this->line('Achievements checked: ' . (empty($unlocked) ? 'no new unlocks' : implode(', ', $unlocked))); + } + + $this->info($result['changed'] ? "XP updated for user #{$userId}." : "User #{$userId} was already in sync."); + + return self::SUCCESS; + } + + private function recalculateAll( + XPService $xp, + AchievementService $achievements, + int $chunk, + bool $dryRun, + bool $syncAchievements, + ): int { + $total = DB::table('users')->whereNull('deleted_at')->count(); + $label = $dryRun ? '[DRY-RUN]' : '[LIVE]'; + + $this->info("{$label} Recomputing XP for {$total} users (chunk={$chunk})..."); + + $processed = 0; + $changed = 0; + $pendingAchievementUsers = 0; + $pendingAchievementUnlocks = 0; + $appliedAchievementUnlocks = 0; + $bar = $this->output->createProgressBar($total); + $bar->start(); + + DB::table('users') + ->whereNull('deleted_at') + ->orderBy('id') + ->chunkById($chunk, function ($users) use ($xp, $achievements, $dryRun, $syncAchievements, &$processed, &$changed, &$pendingAchievementUsers, &$pendingAchievementUnlocks, &$appliedAchievementUnlocks, $bar): void { + foreach ($users as $user) { + $result = $xp->recalculateStoredProgress((int) $user->id, ! $dryRun); + + if ($result['changed']) { + $changed++; + } + + if ($syncAchievements) { + if ($dryRun) { + $pending = $achievements->previewUnlocks((int) $user->id); + if (! empty($pending)) { + $pendingAchievementUsers++; + $pendingAchievementUnlocks += count($pending); + } + } else { + $unlocked = $achievements->checkAchievements((int) $user->id); + $appliedAchievementUnlocks += count($unlocked); + } + } + + $processed++; + $bar->advance(); + } + }); + + $bar->finish(); + $this->newLine(); + + $summary = "Done - {$processed} users processed, {$changed} " . ($dryRun ? 'would change.' : 'updated.'); + if ($syncAchievements) { + if ($dryRun) { + $summary .= " Achievement preview: {$pendingAchievementUnlocks} pending unlock(s) across {$pendingAchievementUsers} user(s)."; + } else { + $summary .= " Achievements re-checked: {$appliedAchievementUnlocks} unlock(s) applied."; + } + } + + $this->info($summary); + + return self::SUCCESS; + } +} diff --git a/app/Console/Commands/SyncCountriesCommand.php b/app/Console/Commands/SyncCountriesCommand.php new file mode 100644 index 00000000..1c1ad78a --- /dev/null +++ b/app/Console/Commands/SyncCountriesCommand.php @@ -0,0 +1,50 @@ +countrySyncService->sync( + allowFallback: ! (bool) $this->option('no-fallback'), + deactivateMissing: (bool) $this->option('deactivate-missing') ? true : null, + ); + } catch (Throwable $exception) { + $this->error($exception->getMessage()); + + return self::FAILURE; + } + + $this->info('Countries synchronized successfully.'); + $this->line('Source: '.($summary['source'] ?? 'unknown')); + $this->line('Fetched: '.(int) ($summary['total_fetched'] ?? 0)); + $this->line('Inserted: '.(int) ($summary['inserted'] ?? 0)); + $this->line('Updated: '.(int) ($summary['updated'] ?? 0)); + $this->line('Skipped: '.(int) ($summary['skipped'] ?? 0)); + $this->line('Invalid: '.(int) ($summary['invalid'] ?? 0)); + $this->line('Deactivated: '.(int) ($summary['deactivated'] ?? 0)); + $this->line('Backfilled users: '.(int) ($summary['backfilled_users'] ?? 0)); + + return self::SUCCESS; + } +} diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index 41ea6fc2..4bba1ae8 100644 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -13,11 +13,13 @@ use App\Console\Commands\AggregateTagInteractionAnalyticsCommand; use App\Console\Commands\SeedTagInteractionDemoCommand; use App\Console\Commands\EvaluateFeedWeightsCommand; use App\Console\Commands\AiTagArtworksCommand; +use App\Console\Commands\SyncCountriesCommand; use App\Console\Commands\CompareFeedAbCommand; use App\Console\Commands\RecalculateTrendingCommand; use App\Console\Commands\RecalculateRankingsCommand; use App\Console\Commands\MetricsSnapshotHourlyCommand; use App\Console\Commands\RecalculateHeatCommand; +use App\Jobs\UpdateLeaderboardsJob; use App\Jobs\RankComputeArtworkScoresJob; use App\Jobs\RankBuildListsJob; use App\Uploads\Commands\CleanupUploadsCommand; @@ -48,6 +50,7 @@ class Kernel extends ConsoleKernel EvaluateFeedWeightsCommand::class, CompareFeedAbCommand::class, AiTagArtworksCommand::class, + SyncCountriesCommand::class, \App\Console\Commands\MigrateFollows::class, RecalculateTrendingCommand::class, RecalculateRankingsCommand::class, @@ -88,6 +91,12 @@ class Kernel extends ConsoleKernel ->withoutOverlapping() ->runInBackground(); + $schedule->job(new UpdateLeaderboardsJob) + ->hourlyAt(20) + ->name('leaderboards-refresh') + ->withoutOverlapping() + ->runInBackground(); + // ── Rising Engine (Heat / Momentum) ───────────────────────────────── // Step 1: snapshot metric totals every hour at :00 $schedule->command('nova:metrics-snapshot-hourly') @@ -104,6 +113,12 @@ class Kernel extends ConsoleKernel // Step 3: prune old snapshots daily at 04:00 $schedule->command('nova:prune-metric-snapshots --keep-days=7') ->dailyAt('04:00'); + + $schedule->command('skinbase:sync-countries') + ->monthlyOn(1, '03:40') + ->name('sync-countries') + ->withoutOverlapping() + ->runInBackground(); } /** diff --git a/app/Events/Achievements/AchievementCheckRequested.php b/app/Events/Achievements/AchievementCheckRequested.php new file mode 100644 index 00000000..9f2ee1a4 --- /dev/null +++ b/app/Events/Achievements/AchievementCheckRequested.php @@ -0,0 +1,15 @@ +query('q', '')); + + $countries = Country::query() + ->when($search !== '', function ($query) use ($search): void { + $query->where(function ($countryQuery) use ($search): void { + $countryQuery + ->where('iso2', 'like', '%'.$search.'%') + ->orWhere('iso3', 'like', '%'.$search.'%') + ->orWhere('name_common', 'like', '%'.$search.'%') + ->orWhere('name_official', 'like', '%'.$search.'%'); + }); + }) + ->ordered() + ->paginate(50) + ->withQueryString(); + + return view('admin.countries.index', [ + 'countries' => $countries, + 'search' => $search, + ]); + } + + public function sync(Request $request, CountrySyncService $countrySyncService): RedirectResponse + { + try { + $summary = $countrySyncService->sync(); + } catch (Throwable $exception) { + return redirect() + ->route('admin.countries.index') + ->with('error', $exception->getMessage()); + } + + $message = sprintf( + 'Countries synced from %s. Inserted %d, updated %d, skipped %d, deactivated %d.', + (string) ($summary['source'] ?? 'unknown'), + (int) ($summary['inserted'] ?? 0), + (int) ($summary['updated'] ?? 0), + (int) ($summary['skipped'] ?? 0), + (int) ($summary['deactivated'] ?? 0), + ); + + return redirect() + ->route('admin.countries.index') + ->with('success', $message); + } + + public function cpMain(Request $request): View + { + $view = $this->index($request); + + return view('admin.countries.cpad', $view->getData()); + } + + public function cpSync(Request $request, CountrySyncService $countrySyncService): RedirectResponse + { + try { + $summary = $countrySyncService->sync(); + } catch (Throwable $exception) { + return redirect() + ->route('admin.cp.countries.main') + ->with('msg_error', $exception->getMessage()); + } + + $message = sprintf( + 'Countries synced from %s. Inserted %d, updated %d, skipped %d, deactivated %d.', + (string) ($summary['source'] ?? 'unknown'), + (int) ($summary['inserted'] ?? 0), + (int) ($summary['updated'] ?? 0), + (int) ($summary['skipped'] ?? 0), + (int) ($summary['deactivated'] ?? 0), + ); + + return redirect() + ->route('admin.cp.countries.main') + ->with('msg_success', $message); + } +} diff --git a/app/Http/Controllers/Admin/StoryAdminController.php b/app/Http/Controllers/Admin/StoryAdminController.php index 29a1812d..6bf00e97 100644 --- a/app/Http/Controllers/Admin/StoryAdminController.php +++ b/app/Http/Controllers/Admin/StoryAdminController.php @@ -9,6 +9,7 @@ use App\Models\Story; use App\Models\StoryTag; use App\Models\User; use App\Notifications\StoryStatusNotification; +use App\Services\StoryPublicationService; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Support\Str; @@ -78,6 +79,10 @@ class StoryAdminController extends Controller $story->tags()->sync($validated['tags']); } + if ($validated['status'] === 'published') { + app(StoryPublicationService::class)->afterPersistence($story, 'published', false); + } + return redirect()->route('admin.stories.edit', ['story' => $story->id]) ->with('status', 'Story created.'); } @@ -95,6 +100,8 @@ class StoryAdminController extends Controller public function update(Request $request, Story $story): RedirectResponse { + $wasPublished = $story->published_at !== null || $story->status === 'published'; + $validated = $request->validate([ 'creator_id' => ['required', 'integer', 'exists:users,id'], 'title' => ['required', 'string', 'max:255'], @@ -122,6 +129,10 @@ class StoryAdminController extends Controller $story->tags()->sync($validated['tags'] ?? []); + if (! $wasPublished && $validated['status'] === 'published') { + app(StoryPublicationService::class)->afterPersistence($story, 'published', false); + } + return back()->with('status', 'Story updated.'); } @@ -134,14 +145,11 @@ class StoryAdminController extends Controller public function publish(Story $story): RedirectResponse { - $story->update([ - 'status' => 'published', + app(StoryPublicationService::class)->publish($story, 'published', [ 'published_at' => $story->published_at ?? now(), 'reviewed_at' => now(), ]); - $story->creator?->notify(new StoryStatusNotification($story, 'published')); - return back()->with('status', 'Story published.'); } @@ -154,16 +162,13 @@ class StoryAdminController extends Controller public function approve(Request $request, Story $story): RedirectResponse { - $story->update([ - 'status' => 'published', + app(StoryPublicationService::class)->publish($story, 'approved', [ 'published_at' => $story->published_at ?? now(), 'reviewed_at' => now(), 'reviewed_by_id' => (int) $request->user()->id, 'rejected_reason' => null, ]); - $story->creator?->notify(new StoryStatusNotification($story, 'approved')); - return back()->with('status', 'Story approved and published.'); } diff --git a/app/Http/Controllers/Api/ArtworkCommentController.php b/app/Http/Controllers/Api/ArtworkCommentController.php index 09f237a6..db6a8161 100644 --- a/app/Http/Controllers/Api/ArtworkCommentController.php +++ b/app/Http/Controllers/Api/ArtworkCommentController.php @@ -5,8 +5,11 @@ namespace App\Http\Controllers\Api; use App\Http\Controllers\Controller; use App\Models\Artwork; use App\Models\ArtworkComment; +use App\Models\User; +use App\Models\UserMention; +use App\Notifications\ArtworkCommentedNotification; +use App\Notifications\ArtworkMentionedNotification; use App\Services\ContentSanitizer; -use App\Services\LegacySmileyMapper; use App\Support\AvatarUrl; use Carbon\Carbon; use Illuminate\Http\JsonResponse; @@ -113,6 +116,7 @@ class ArtworkCommentController extends Controller Cache::forget('comments.latest.all.page1'); $comment->load(['user', 'user.profile']); + $this->notifyRecipients($artwork, $comment, $request->user(), $parentId ? (int) $parentId : null); // Record activity event (fire-and-forget; never break the response) try { @@ -204,6 +208,8 @@ class ArtworkCommentController extends Controller 'display' => $user?->username ?? $user?->name ?? 'User', 'profile_url' => $user?->username ? '/@' . $user->username : '/profile/' . $userId, 'avatar_url' => AvatarUrl::forUser($userId, $avatarHash, 64), + 'level' => (int) ($user?->level ?? 1), + 'rank' => (string) ($user?->rank ?? 'Newbie'), ], ]; @@ -217,4 +223,48 @@ class ArtworkCommentController extends Controller return $data; } + + private function notifyRecipients(Artwork $artwork, ArtworkComment $comment, User $actor, ?int $parentId): void + { + $notifiedUserIds = []; + $creatorId = (int) ($artwork->user_id ?? 0); + + if ($creatorId > 0 && $creatorId !== (int) $actor->id) { + $creator = User::query()->find($creatorId); + if ($creator) { + $creator->notify(new ArtworkCommentedNotification($artwork, $comment, $actor)); + $notifiedUserIds[] = (int) $creator->id; + } + } + + if ($parentId) { + $parentUserId = (int) (ArtworkComment::query()->whereKey($parentId)->value('user_id') ?? 0); + if ($parentUserId > 0 && $parentUserId !== (int) $actor->id && ! in_array($parentUserId, $notifiedUserIds, true)) { + $parentUser = User::query()->find($parentUserId); + if ($parentUser) { + $parentUser->notify(new ArtworkCommentedNotification($artwork, $comment, $actor)); + $notifiedUserIds[] = (int) $parentUser->id; + } + } + } + + User::query() + ->whereIn( + 'id', + UserMention::query() + ->where('comment_id', (int) $comment->id) + ->pluck('mentioned_user_id') + ->map(fn ($id) => (int) $id) + ->unique() + ->all() + ) + ->get() + ->each(function (User $mentionedUser) use ($artwork, $comment, $actor): void { + if ((int) $mentionedUser->id === (int) $actor->id) { + return; + } + + $mentionedUser->notify(new ArtworkMentionedNotification($artwork, $comment, $actor)); + }); + } } diff --git a/app/Http/Controllers/Api/ArtworkInteractionController.php b/app/Http/Controllers/Api/ArtworkInteractionController.php index 1547bdf9..bd2fa37b 100644 --- a/app/Http/Controllers/Api/ArtworkInteractionController.php +++ b/app/Http/Controllers/Api/ArtworkInteractionController.php @@ -4,9 +4,13 @@ declare(strict_types=1); namespace App\Http\Controllers\Api; +use App\Events\Achievements\AchievementCheckRequested; use App\Http\Controllers\Controller; +use App\Models\Artwork; +use App\Notifications\ArtworkLikedNotification; use App\Services\FollowService; use App\Services\UserStatsService; +use App\Services\XPService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; @@ -14,11 +18,25 @@ use Illuminate\Support\Facades\Schema; final class ArtworkInteractionController extends Controller { + public function bookmark(Request $request, int $artworkId): JsonResponse + { + $this->toggleSimple( + request: $request, + table: 'artwork_bookmarks', + keyColumns: ['user_id', 'artwork_id'], + keyValues: ['user_id' => (int) $request->user()->id, 'artwork_id' => $artworkId], + insertPayload: ['created_at' => now(), 'updated_at' => now()], + requiredTable: 'artwork_bookmarks' + ); + + return response()->json($this->statusPayload((int) $request->user()->id, $artworkId)); + } + public function favorite(Request $request, int $artworkId): JsonResponse { $state = $request->boolean('state', true); - $this->toggleSimple( + $changed = $this->toggleSimple( request: $request, table: 'artwork_favourites', keyColumns: ['user_id', 'artwork_id'], @@ -33,7 +51,7 @@ final class ArtworkInteractionController extends Controller $creatorId = (int) DB::table('artworks')->where('id', $artworkId)->value('user_id'); if ($creatorId) { $svc = app(UserStatsService::class); - if ($state) { + if ($state && $changed) { $svc->incrementFavoritesReceived($creatorId); $svc->setLastActiveAt((int) $request->user()->id); @@ -46,7 +64,7 @@ final class ArtworkInteractionController extends Controller targetId: $artworkId, ); } catch (\Throwable) {} - } else { + } elseif (! $state && $changed) { $svc->decrementFavoritesReceived($creatorId); } } @@ -56,7 +74,7 @@ final class ArtworkInteractionController extends Controller public function like(Request $request, int $artworkId): JsonResponse { - $this->toggleSimple( + $changed = $this->toggleSimple( request: $request, table: 'artwork_likes', keyColumns: ['user_id', 'artwork_id'], @@ -67,6 +85,20 @@ final class ArtworkInteractionController extends Controller $this->syncArtworkStats($artworkId); + if ($request->boolean('state', true) && $changed) { + $creatorId = (int) DB::table('artworks')->where('id', $artworkId)->value('user_id'); + $actorId = (int) $request->user()->id; + if ($creatorId > 0 && $creatorId !== $actorId) { + app(XPService::class)->awardArtworkLikeReceived($creatorId, $artworkId, $actorId); + $creator = \App\Models\User::query()->find($creatorId); + $artwork = Artwork::query()->find($artworkId); + if ($creator && $artwork) { + $creator->notify(new ArtworkLikedNotification($artwork, $request->user())); + } + event(new AchievementCheckRequested($creatorId)); + } + } + return response()->json($this->statusPayload((int) $request->user()->id, $artworkId)); } @@ -104,8 +136,10 @@ final class ArtworkInteractionController extends Controller return response()->json(['message' => 'Cannot follow yourself'], 422); } - $svc = app(FollowService::class); - $state = $request->boolean('state', true); + $svc = app(FollowService::class); + $state = $request->has('state') + ? $request->boolean('state') + : ! $request->isMethod('delete'); if ($state) { $svc->follow($actorId, $userId); @@ -148,7 +182,7 @@ final class ArtworkInteractionController extends Controller array $keyValues, array $insertPayload, string $requiredTable - ): void { + ): bool { if (! Schema::hasTable($requiredTable)) { abort(422, 'Interaction unavailable'); } @@ -163,10 +197,13 @@ final class ArtworkInteractionController extends Controller if ($state) { if (! $query->exists()) { DB::table($table)->insert(array_merge($keyValues, $insertPayload)); + return true; } } else { - $query->delete(); + return $query->delete() > 0; } + + return false; } private function syncArtworkStats(int $artworkId): void @@ -194,6 +231,10 @@ final class ArtworkInteractionController extends Controller private function statusPayload(int $viewerId, int $artworkId): array { + $isBookmarked = Schema::hasTable('artwork_bookmarks') + ? DB::table('artwork_bookmarks')->where('user_id', $viewerId)->where('artwork_id', $artworkId)->exists() + : false; + $isFavorited = Schema::hasTable('artwork_favourites') ? DB::table('artwork_favourites')->where('user_id', $viewerId)->where('artwork_id', $artworkId)->exists() : false; @@ -206,15 +247,21 @@ final class ArtworkInteractionController extends Controller ? (int) DB::table('artwork_favourites')->where('artwork_id', $artworkId)->count() : 0; + $bookmarks = Schema::hasTable('artwork_bookmarks') + ? (int) DB::table('artwork_bookmarks')->where('artwork_id', $artworkId)->count() + : 0; + $likes = Schema::hasTable('artwork_likes') ? (int) DB::table('artwork_likes')->where('artwork_id', $artworkId)->count() : 0; return [ 'ok' => true, + 'is_bookmarked' => $isBookmarked, 'is_favorited' => $isFavorited, 'is_liked' => $isLiked, 'stats' => [ + 'bookmarks' => $bookmarks, 'favorites' => $favorites, 'likes' => $likes, ], diff --git a/app/Http/Controllers/Api/ArtworkViewController.php b/app/Http/Controllers/Api/ArtworkViewController.php index bb9e1fca..f065ffdc 100644 --- a/app/Http/Controllers/Api/ArtworkViewController.php +++ b/app/Http/Controllers/Api/ArtworkViewController.php @@ -7,6 +7,7 @@ namespace App\Http\Controllers\Api; use App\Http\Controllers\Controller; use App\Models\Artwork; use App\Services\ArtworkStatsService; +use App\Services\XPService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; @@ -26,7 +27,10 @@ use Illuminate\Http\Request; */ final class ArtworkViewController extends Controller { - public function __construct(private readonly ArtworkStatsService $stats) {} + public function __construct( + private readonly ArtworkStatsService $stats, + private readonly XPService $xp, + ) {} public function __invoke(Request $request, int $id): JsonResponse { @@ -52,6 +56,16 @@ final class ArtworkViewController extends Controller // Defer to Redis when available, fall back to direct DB increment. $this->stats->incrementViews((int) $artwork->id, 1, defer: true); + $viewerId = $request->user()?->id; + if ($artwork->user_id !== null && (int) $artwork->user_id !== (int) ($viewerId ?? 0)) { + $this->xp->awardArtworkViewReceived( + (int) $artwork->user_id, + (int) $artwork->id, + $viewerId, + (string) $request->ip(), + ); + } + // Mark this session so the artwork is not counted again. if ($request->hasSession()) { $request->session()->put($sessionKey, true); diff --git a/app/Http/Controllers/Api/CommunityActivityController.php b/app/Http/Controllers/Api/CommunityActivityController.php index 02c5d7d9..d6b2252e 100644 --- a/app/Http/Controllers/Api/CommunityActivityController.php +++ b/app/Http/Controllers/Api/CommunityActivityController.php @@ -36,6 +36,10 @@ final class CommunityActivityController extends Controller private function resolveFilter(Request $request): string { + if ($request->filled('type') && ! $request->filled('filter')) { + return (string) $request->query('type', 'all'); + } + if ($request->boolean('following') && ! $request->filled('filter')) { return 'following'; } diff --git a/app/Http/Controllers/Api/LeaderboardController.php b/app/Http/Controllers/Api/LeaderboardController.php new file mode 100644 index 00000000..31287e74 --- /dev/null +++ b/app/Http/Controllers/Api/LeaderboardController.php @@ -0,0 +1,35 @@ +json( + $leaderboards->getLeaderboard(Leaderboard::TYPE_CREATOR, (string) $request->query('period', 'weekly')) + ); + } + + public function artworks(Request $request, LeaderboardService $leaderboards): JsonResponse + { + return response()->json( + $leaderboards->getLeaderboard(Leaderboard::TYPE_ARTWORK, (string) $request->query('period', 'weekly')) + ); + } + + public function stories(Request $request, LeaderboardService $leaderboards): JsonResponse + { + return response()->json( + $leaderboards->getLeaderboard(Leaderboard::TYPE_STORY, (string) $request->query('period', 'weekly')) + ); + } +} diff --git a/app/Http/Controllers/Api/NotificationController.php b/app/Http/Controllers/Api/NotificationController.php index 07ab2091..102a6c17 100644 --- a/app/Http/Controllers/Api/NotificationController.php +++ b/app/Http/Controllers/Api/NotificationController.php @@ -2,7 +2,7 @@ namespace App\Http\Controllers\Api; -use App\Services\Posts\NotificationDigestService; +use App\Services\NotificationService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use App\Http\Controllers\Controller; @@ -14,48 +14,24 @@ use App\Http\Controllers\Controller; */ class NotificationController extends Controller { - public function __construct(private NotificationDigestService $digest) {} + public function __construct(private NotificationService $notifications) {} public function index(Request $request): JsonResponse { - $user = $request->user(); - $page = max(1, (int) $request->query('page', 1)); - - $notifications = $user->notifications() - ->latest() - ->limit(200) // aggregate from last 200 raw notifs - ->get(); - - $digested = $this->digest->aggregate($notifications); - - // Simple manual pagination on the digested array - $perPage = 20; - $total = count($digested); - $sliced = array_slice($digested, ($page - 1) * $perPage, $perPage); - $unread = $user->unreadNotifications()->count(); - - return response()->json([ - 'data' => array_values($sliced), - 'unread_count' => $unread, - 'meta' => [ - 'total' => $total, - 'current_page' => $page, - 'last_page' => (int) ceil($total / $perPage) ?: 1, - 'per_page' => $perPage, - ], - ]); + return response()->json( + $this->notifications->listForUser($request->user(), (int) $request->query('page', 1), 20) + ); } public function readAll(Request $request): JsonResponse { - $request->user()->unreadNotifications()->update(['read_at' => now()]); + $this->notifications->markAllRead($request->user()); return response()->json(['message' => 'All notifications marked as read.']); } public function markRead(Request $request, string $id): JsonResponse { - $notif = $request->user()->notifications()->findOrFail($id); - $notif->markAsRead(); + $this->notifications->markRead($request->user(), $id); return response()->json(['message' => 'Notification marked as read.']); } } diff --git a/app/Http/Controllers/Api/Posts/PostCommentController.php b/app/Http/Controllers/Api/Posts/PostCommentController.php index 8308377a..28a3c504 100644 --- a/app/Http/Controllers/Api/Posts/PostCommentController.php +++ b/app/Http/Controllers/Api/Posts/PostCommentController.php @@ -116,6 +116,8 @@ class PostCommentController extends Controller 'username' => $comment->user->username, 'name' => $comment->user->name, 'avatar' => $comment->user->profile?->avatar_url ?? null, + 'level' => (int) ($comment->user->level ?? 1), + 'rank' => (string) ($comment->user->rank ?? 'Newbie'), ], ]; } diff --git a/app/Http/Controllers/Api/ProfileApiController.php b/app/Http/Controllers/Api/ProfileApiController.php index f94a17c8..56676110 100644 --- a/app/Http/Controllers/Api/ProfileApiController.php +++ b/app/Http/Controllers/Api/ProfileApiController.php @@ -7,9 +7,8 @@ namespace App\Http\Controllers\Api; use App\Http\Controllers\Controller; use App\Models\Artwork; use App\Models\User; +use Carbon\CarbonInterface; use App\Services\ThumbnailPresenter; -use App\Services\ThumbnailService; -use App\Support\AvatarUrl; use App\Support\UsernamePolicy; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; @@ -57,20 +56,9 @@ final class ProfileApiController extends Controller $perPage = 24; $paginator = $query->cursorPaginate($perPage); - $data = collect($paginator->items())->map(function (Artwork $art) { - $present = ThumbnailPresenter::present($art, 'md'); - return [ - 'id' => $art->id, - 'name' => $art->title, - 'thumb' => $present['url'], - 'thumb_srcset' => $present['srcset'] ?? $present['url'], - 'width' => $art->width, - 'height' => $art->height, - 'username' => $art->user->username ?? null, - 'uname' => $art->user->username ?? $art->user->name ?? 'Skinbase', - 'published_at' => $art->published_at, - ]; - })->values(); + $data = collect($paginator->items()) + ->map(fn (Artwork $art) => $this->mapArtworkCardPayload($art)) + ->values(); return response()->json([ 'data' => $data, @@ -85,7 +73,8 @@ final class ProfileApiController extends Controller */ public function favourites(Request $request, string $username): JsonResponse { - if (! Schema::hasTable('user_favorites')) { + $favouriteTable = $this->resolveFavouriteTable(); + if ($favouriteTable === null) { return response()->json(['data' => [], 'next_cursor' => null, 'has_more' => false]); } @@ -95,16 +84,18 @@ final class ProfileApiController extends Controller } $perPage = 24; - $cursor = $request->input('cursor'); + $offset = max(0, (int) base64_decode((string) $request->input('cursor', ''), true)); - $favIds = DB::table('user_favorites as uf') - ->join('artworks as a', 'a.id', '=', 'uf.artwork_id') - ->where('uf.user_id', $user->id) + $favIds = DB::table($favouriteTable . ' as af') + ->join('artworks as a', 'a.id', '=', 'af.artwork_id') + ->where('af.user_id', $user->id) ->whereNull('a.deleted_at') ->where('a.is_public', true) ->where('a.is_approved', true) - ->orderByDesc('uf.created_at') - ->offset($cursor ? (int) base64_decode($cursor) : 0) + ->whereNotNull('a.published_at') + ->orderByDesc('af.created_at') + ->orderByDesc('af.artwork_id') + ->offset($offset) ->limit($perPage + 1) ->pluck('a.id'); @@ -120,24 +111,14 @@ final class ProfileApiController extends Controller ->get() ->keyBy('id'); - $data = $favIds->filter(fn ($id) => $indexed->has($id))->map(function ($id) use ($indexed) { - $art = $indexed[$id]; - $present = ThumbnailPresenter::present($art, 'md'); - return [ - 'id' => $art->id, - 'name' => $art->title, - 'thumb' => $present['url'], - 'thumb_srcset' => $present['srcset'] ?? $present['url'], - 'width' => $art->width, - 'height' => $art->height, - 'username' => $art->user->username ?? null, - 'uname' => $art->user->username ?? $art->user->name ?? 'Skinbase', - ]; - })->values(); + $data = $favIds + ->filter(fn ($id) => $indexed->has($id)) + ->map(fn ($id) => $this->mapArtworkCardPayload($indexed[$id])) + ->values(); return response()->json([ 'data' => $data, - 'next_cursor' => null, // Simple offset pagination for now + 'next_cursor' => $hasMore ? base64_encode((string) ($offset + $perPage)) : null, 'has_more' => $hasMore, ]); } @@ -174,4 +155,48 @@ final class ProfileApiController extends Controller $normalized = UsernamePolicy::normalize($username); return User::query()->whereRaw('LOWER(username) = ?', [$normalized])->first(); } + + private function resolveFavouriteTable(): ?string + { + foreach (['artwork_favourites', 'user_favorites', 'artworks_favourites', 'favourites'] as $table) { + if (Schema::hasTable($table)) { + return $table; + } + } + + return null; + } + + /** + * @return array + */ + private function mapArtworkCardPayload(Artwork $art): array + { + $present = ThumbnailPresenter::present($art, 'md'); + + return [ + 'id' => $art->id, + 'name' => $art->title, + 'thumb' => $present['url'], + 'thumb_srcset' => $present['srcset'] ?? $present['url'], + 'width' => $art->width, + 'height' => $art->height, + 'username' => $art->user->username ?? null, + 'uname' => $art->user->username ?? $art->user->name ?? 'Skinbase', + 'published_at' => $this->formatIsoDate($art->published_at), + ]; + } + + private function formatIsoDate(mixed $value): ?string + { + if ($value instanceof CarbonInterface) { + return $value->toISOString(); + } + + if ($value instanceof \DateTimeInterface) { + return $value->format(DATE_ATOM); + } + + return is_string($value) ? $value : null; + } } diff --git a/app/Http/Controllers/Api/SocialActivityController.php b/app/Http/Controllers/Api/SocialActivityController.php new file mode 100644 index 00000000..682a284b --- /dev/null +++ b/app/Http/Controllers/Api/SocialActivityController.php @@ -0,0 +1,34 @@ +query('filter', 'all'); + + if ($this->activity->requiresAuthentication($filter) && ! $request->user()) { + return response()->json(['error' => 'Unauthenticated'], 401); + } + + return response()->json( + $this->activity->communityFeed( + viewer: $request->user(), + filter: $filter, + page: (int) $request->query('page', 1), + perPage: (int) $request->query('per_page', 20), + actorUserId: $request->filled('user_id') ? (int) $request->query('user_id') : null, + ) + ); + } +} \ No newline at end of file diff --git a/app/Http/Controllers/Api/SocialCompatibilityController.php b/app/Http/Controllers/Api/SocialCompatibilityController.php new file mode 100644 index 00000000..1ea61233 --- /dev/null +++ b/app/Http/Controllers/Api/SocialCompatibilityController.php @@ -0,0 +1,212 @@ +validate([ + 'entity_type' => ['required', 'string', 'in:artwork,story'], + 'entity_id' => ['required', 'integer'], + 'state' => ['nullable', 'boolean'], + ]); + + $state = array_key_exists('state', $payload) + ? (bool) $payload['state'] + : ! $request->isMethod('delete'); + + if ($payload['entity_type'] === 'story') { + $story = Story::published()->findOrFail((int) $payload['entity_id']); + + $result = $this->social->toggleStoryLike($request->user(), $story, $state); + + return response()->json([ + 'ok' => (bool) ($result['ok'] ?? true), + 'liked' => (bool) ($result['liked'] ?? false), + 'likes_count' => (int) ($result['likes_count'] ?? 0), + 'is_liked' => (bool) ($result['liked'] ?? false), + 'stats' => [ + 'likes' => (int) ($result['likes_count'] ?? 0), + ], + ]); + } + + $artworkId = (int) $payload['entity_id']; + abort_unless(Artwork::public()->published()->whereKey($artworkId)->exists(), 404); + + return app(ArtworkInteractionController::class)->like( + $request->merge(['state' => $state]), + $artworkId, + ); + } + + public function comments(Request $request): JsonResponse + { + $payload = $request->validate([ + 'entity_type' => ['required', 'string', 'in:artwork,story'], + 'entity_id' => ['required', 'integer'], + 'content' => [$request->isMethod('get') ? 'nullable' : 'required', 'string', 'min:1', 'max:10000'], + 'parent_id' => ['nullable', 'integer'], + ]); + + if ($payload['entity_type'] === 'story') { + if ($request->isMethod('get')) { + $story = Story::published()->findOrFail((int) $payload['entity_id']); + + return response()->json( + $this->social->listStoryComments($story, $request->user()?->id, (int) $request->query('page', 1), 20) + ); + } + + $story = Story::published()->findOrFail((int) $payload['entity_id']); + $comment = $this->social->addStoryComment( + $request->user(), + $story, + (string) $payload['content'], + isset($payload['parent_id']) ? (int) $payload['parent_id'] : null, + ); + + return response()->json([ + 'data' => $this->social->formatComment($comment, (int) $request->user()->id, true), + ], 201); + } + + $artworkId = (int) $payload['entity_id']; + abort_unless(Artwork::public()->published()->whereKey($artworkId)->exists(), 404); + + if ($request->isMethod('get')) { + return app(ArtworkCommentController::class)->index($request, $artworkId); + } + + return app(ArtworkCommentController::class)->store( + $request->merge([ + 'content' => $payload['content'], + 'parent_id' => $payload['parent_id'] ?? null, + ]), + $artworkId, + ); + } + + public function bookmark(Request $request): JsonResponse + { + $payload = $request->validate([ + 'entity_type' => ['required', 'string', 'in:artwork,story'], + 'entity_id' => ['required', 'integer'], + 'state' => ['nullable', 'boolean'], + ]); + + $state = array_key_exists('state', $payload) + ? (bool) $payload['state'] + : ! $request->isMethod('delete'); + + if ($payload['entity_type'] === 'story') { + $story = Story::published()->findOrFail((int) $payload['entity_id']); + + $result = $this->social->toggleStoryBookmark($request->user(), $story, $state); + + return response()->json([ + 'ok' => (bool) ($result['ok'] ?? true), + 'bookmarked' => (bool) ($result['bookmarked'] ?? false), + 'bookmarks_count' => (int) ($result['bookmarks_count'] ?? 0), + 'is_bookmarked' => (bool) ($result['bookmarked'] ?? false), + 'stats' => [ + 'bookmarks' => (int) ($result['bookmarks_count'] ?? 0), + ], + ]); + } + + $artworkId = (int) $payload['entity_id']; + abort_unless(Artwork::public()->published()->whereKey($artworkId)->exists(), 404); + + return app(ArtworkInteractionController::class)->bookmark( + $request->merge(['state' => $state]), + $artworkId, + ); + } + + public function bookmarks(Request $request): JsonResponse + { + $payload = $request->validate([ + 'entity_type' => ['nullable', 'string', 'in:artwork,story'], + 'per_page' => ['nullable', 'integer', 'min:1', 'max:50'], + ]); + + $perPage = (int) ($payload['per_page'] ?? 20); + $userId = (int) $request->user()->id; + $type = $payload['entity_type'] ?? null; + + $items = collect(); + + if ($type === null || $type === 'artwork') { + $items = $items->concat( + Schema::hasTable('artwork_bookmarks') + ? DB::table('artwork_bookmarks') + ->join('artworks', 'artworks.id', '=', 'artwork_bookmarks.artwork_id') + ->where('artwork_bookmarks.user_id', $userId) + ->where('artworks.is_public', true) + ->where('artworks.is_approved', true) + ->select([ + 'artwork_bookmarks.created_at as saved_at', + 'artworks.id', + 'artworks.title', + 'artworks.slug', + ]) + ->latest('artwork_bookmarks.created_at') + ->limit($perPage) + ->get() + ->map(fn ($row) => [ + 'type' => 'artwork', + 'id' => (int) $row->id, + 'title' => (string) $row->title, + 'url' => route('art.show', ['id' => (int) $row->id, 'slug' => Str::slug((string) ($row->slug ?: $row->title)) ?: (string) $row->id]), + 'saved_at' => Carbon::parse($row->saved_at)->toIso8601String(), + ]) + : collect() + ); + } + + if ($type === null || $type === 'story') { + $items = $items->concat( + StoryBookmark::query() + ->with('story:id,slug,title') + ->where('user_id', $userId) + ->latest('created_at') + ->limit($perPage) + ->get() + ->filter(fn (StoryBookmark $bookmark) => $bookmark->story !== null) + ->map(fn (StoryBookmark $bookmark) => [ + 'type' => 'story', + 'id' => (int) $bookmark->story->id, + 'title' => (string) $bookmark->story->title, + 'url' => route('stories.show', ['slug' => $bookmark->story->slug]), + 'saved_at' => $bookmark->created_at?->toIso8601String(), + ]) + ); + } + + return response()->json([ + 'data' => $items + ->sortByDesc('saved_at') + ->take($perPage) + ->values() + ->all(), + ]); + } +} \ No newline at end of file diff --git a/app/Http/Controllers/Api/StoryCommentController.php b/app/Http/Controllers/Api/StoryCommentController.php new file mode 100644 index 00000000..8fe01e6b --- /dev/null +++ b/app/Http/Controllers/Api/StoryCommentController.php @@ -0,0 +1,58 @@ +findOrFail($storyId); + + return response()->json( + $this->social->listStoryComments($story, $request->user()?->id, (int) $request->query('page', 1), 20) + ); + } + + public function store(Request $request, int $storyId): JsonResponse + { + $story = Story::published()->findOrFail($storyId); + + $payload = $request->validate([ + 'content' => ['required', 'string', 'min:1', 'max:10000'], + 'parent_id' => ['nullable', 'integer'], + ]); + + $comment = $this->social->addStoryComment( + $request->user(), + $story, + (string) $payload['content'], + isset($payload['parent_id']) ? (int) $payload['parent_id'] : null, + ); + + return response()->json([ + 'data' => $this->social->formatComment($comment, $request->user()->id, true), + ], 201); + } + + public function destroy(Request $request, int $storyId, int $commentId): JsonResponse + { + $comment = StoryComment::query() + ->where('story_id', $storyId) + ->findOrFail($commentId); + + $this->social->deleteStoryComment($request->user(), $comment); + + return response()->json(['message' => 'Comment deleted.']); + } +} \ No newline at end of file diff --git a/app/Http/Controllers/Api/StoryInteractionController.php b/app/Http/Controllers/Api/StoryInteractionController.php new file mode 100644 index 00000000..3df78c36 --- /dev/null +++ b/app/Http/Controllers/Api/StoryInteractionController.php @@ -0,0 +1,34 @@ +findOrFail($storyId); + + return response()->json( + $this->social->toggleStoryLike($request->user(), $story, $request->boolean('state', true)) + ); + } + + public function bookmark(Request $request, int $storyId): JsonResponse + { + $story = Story::published()->findOrFail($storyId); + + return response()->json( + $this->social->toggleStoryBookmark($request->user(), $story, $request->boolean('state', true)) + ); + } +} \ No newline at end of file diff --git a/app/Http/Controllers/Api/UserAchievementsController.php b/app/Http/Controllers/Api/UserAchievementsController.php new file mode 100644 index 00000000..0e815abc --- /dev/null +++ b/app/Http/Controllers/Api/UserAchievementsController.php @@ -0,0 +1,18 @@ +json($achievements->summary($request->user()->id)); + } +} \ No newline at end of file diff --git a/app/Http/Controllers/Api/UserXpController.php b/app/Http/Controllers/Api/UserXpController.php new file mode 100644 index 00000000..5c6d3edc --- /dev/null +++ b/app/Http/Controllers/Api/UserXpController.php @@ -0,0 +1,18 @@ +json($xp->summary($request->user()->id)); + } +} diff --git a/app/Http/Controllers/ArtworkDownloadController.php b/app/Http/Controllers/ArtworkDownloadController.php index 4eb370d9..bc22b9e2 100644 --- a/app/Http/Controllers/ArtworkDownloadController.php +++ b/app/Http/Controllers/ArtworkDownloadController.php @@ -44,6 +44,10 @@ final class ArtworkDownloadController extends Controller } $filePath = $this->resolveOriginalPath($hash, $ext); + + $this->recordDownload($request, $artwork->id); + $this->incrementDownloadCountIfAvailable($artwork->id); + if (! File::isFile($filePath)) { Log::warning('Artwork original file missing for download.', [ 'artwork_id' => $artwork->id, @@ -55,8 +59,6 @@ final class ArtworkDownloadController extends Controller abort(404); } - $this->recordDownload($request, $artwork->id); - $this->incrementDownloadCountIfAvailable($artwork->id); $downloadName = $this->buildDownloadFilename((string) $artwork->file_name, $ext); diff --git a/app/Http/Controllers/Community/LatestController.php b/app/Http/Controllers/Community/LatestController.php index 52fb75d8..c99eebab 100644 --- a/app/Http/Controllers/Community/LatestController.php +++ b/app/Http/Controllers/Community/LatestController.php @@ -21,6 +21,10 @@ class LatestController extends Controller $perPage = 21; $artworks = $this->artworks->browsePublicArtworks($perPage); + $artworks->getCollection()->load([ + 'user:id,name,username', + 'user.profile:user_id,avatar_hash', + ]); $artworks->getCollection()->transform(function (Artwork $artwork) { $primaryCategory = $artwork->categories->sortBy('sort_order')->first(); @@ -34,10 +38,18 @@ class LatestController extends Controller 'content_type_name' => $primaryCategory?->contentType?->name ?? '', 'content_type_slug' => $primaryCategory?->contentType?->slug ?? '', 'category_name' => $categoryName, + 'category_slug' => $primaryCategory?->slug ?? '', 'gid_num' => $gid, + 'slug' => $artwork->slug, 'thumb_url' => $present['url'], 'thumb_srcset' => $present['srcset'] ?? $present['url'], 'uname' => $artwork->user->name ?? 'Skinbase', + 'username' => $artwork->user->username ?? '', + 'user_id' => $artwork->user->id, + 'avatar_hash' => $artwork->user->profile->avatar_hash ?? null, + 'avatar_url' => \App\Support\AvatarUrl::forUser((int) $artwork->user->id, $artwork->user->profile->avatar_hash ?? null, 64), + 'width' => $artwork->width, + 'height' => $artwork->height, 'published_at' => $artwork->published_at, // required by CursorPaginator ]; }); diff --git a/app/Http/Controllers/Dashboard/CommentController.php b/app/Http/Controllers/Dashboard/CommentController.php index c607ac6a..217e41f7 100644 --- a/app/Http/Controllers/Dashboard/CommentController.php +++ b/app/Http/Controllers/Dashboard/CommentController.php @@ -3,16 +3,67 @@ namespace App\Http\Controllers\Dashboard; use App\Http\Controllers\Controller; +use App\Services\ReceivedCommentsInboxService; +use Illuminate\Contracts\View\View; use Illuminate\Http\Request; class CommentController extends Controller { - public function index(Request $request) + public function __construct(private readonly ReceivedCommentsInboxService $inbox) {} + + public function received(Request $request): View { $user = $request->user(); - // Minimal placeholder: real implementation should query comments received or made - $comments = []; + $search = trim((string) $request->query('q', '')); + $sort = strtolower((string) $request->query('sort', 'newest')); - return view('dashboard.comments', ['comments' => $comments]); + if (! in_array($sort, ['newest', 'oldest'], true)) { + $sort = 'newest'; + } + + $baseQuery = $this->inbox->queryForUser($user) + ->with(['user.profile', 'artwork']); + + if ($search !== '') { + $baseQuery->where(function ($query) use ($search): void { + $query->where('content', 'like', '%' . $search . '%') + ->orWhere('raw_content', 'like', '%' . $search . '%') + ->orWhereHas('artwork', function ($artworkQuery) use ($search): void { + $artworkQuery->where('title', 'like', '%' . $search . '%') + ->orWhere('slug', 'like', '%' . $search . '%'); + }) + ->orWhereHas('user', function ($userQuery) use ($search): void { + $userQuery->where('username', 'like', '%' . $search . '%') + ->orWhere('name', 'like', '%' . $search . '%'); + }); + }); + } + + $orderedQuery = (clone $baseQuery) + ->orderBy('created_at', $sort === 'oldest' ? 'asc' : 'desc'); + + $comments = $orderedQuery->paginate(12)->withQueryString(); + + $statsBaseQuery = clone $baseQuery; + $freshlyClearedCount = $this->inbox->unreadCountForUser($user); + $totalComments = (clone $statsBaseQuery)->count(); + $recentComments = (clone $statsBaseQuery)->where('created_at', '>=', now()->subDays(7))->count(); + $uniqueCommenters = (clone $statsBaseQuery)->distinct('user_id')->count('user_id'); + $activeArtworks = (clone $statsBaseQuery)->distinct('artwork_id')->count('artwork_id'); + + $this->inbox->markInboxRead($user); + + return view('dashboard.comments', [ + 'comments' => $comments, + 'search' => $search, + 'sort' => $sort, + 'freshlyClearedCount' => $freshlyClearedCount, + 'stats' => [ + 'total' => $totalComments, + 'recent' => $recentComments, + 'commenters' => $uniqueCommenters, + 'artworks' => $activeArtworks, + ], + ]); } } diff --git a/app/Http/Controllers/Dashboard/DashboardPreferenceController.php b/app/Http/Controllers/Dashboard/DashboardPreferenceController.php new file mode 100644 index 00000000..fec5525c --- /dev/null +++ b/app/Http/Controllers/Dashboard/DashboardPreferenceController.php @@ -0,0 +1,34 @@ +validate([ + 'pinned_spaces' => ['present', 'array', 'max:' . DashboardPreference::MAX_PINNED_SPACES], + 'pinned_spaces.*' => ['string'], + ]); + + $pinnedSpaces = DashboardPreference::sanitizePinnedSpaces($validated['pinned_spaces'] ?? []); + + DashboardPreference::query()->updateOrCreate( + ['user_id' => $request->user()->id], + ['pinned_spaces' => $pinnedSpaces] + ); + + return response()->json([ + 'data' => [ + 'pinned_spaces' => $pinnedSpaces, + ], + ]); + } +} \ No newline at end of file diff --git a/app/Http/Controllers/Dashboard/NotificationController.php b/app/Http/Controllers/Dashboard/NotificationController.php new file mode 100644 index 00000000..97c737bc --- /dev/null +++ b/app/Http/Controllers/Dashboard/NotificationController.php @@ -0,0 +1,25 @@ +query('page', 1)); + $payload = $this->notifications->listForUser($request->user(), $page, 15); + + return view('dashboard.notifications', [ + 'notifications' => collect($payload['data'] ?? []), + 'notificationsMeta' => $payload['meta'] ?? [], + 'unreadCount' => (int) ($payload['unread_count'] ?? 0), + ]); + } +} \ No newline at end of file diff --git a/app/Http/Controllers/DashboardController.php b/app/Http/Controllers/DashboardController.php index 9e284877..174818a5 100644 --- a/app/Http/Controllers/DashboardController.php +++ b/app/Http/Controllers/DashboardController.php @@ -5,25 +5,60 @@ declare(strict_types=1); namespace App\Http\Controllers; use App\Models\Artwork; +use App\Models\DashboardPreference; use App\Models\Story; use App\Models\User; +use App\Services\ReceivedCommentsInboxService; +use App\Services\XPService; use App\Support\AvatarUrl; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Carbon; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\DB; - final class DashboardController extends Controller { + public function __construct( + private readonly XPService $xp, + private readonly ReceivedCommentsInboxService $receivedCommentsInbox, + ) {} + public function index(Request $request) { $user = $request->user(); + $xpSummary = $this->xp->summary((int) $user->id); + $artworksCount = Artwork::query() + ->where('user_id', $user->id) + ->whereNull('deleted_at') + ->count(); + $storiesCount = Story::query()->where('creator_id', $user->id)->count(); + $followersCount = (int) DB::table('user_followers')->where('user_id', $user->id)->count(); + $followingCount = (int) DB::table('user_followers')->where('follower_id', $user->id)->count(); + $favoritesCount = (int) DB::table('artwork_favourites')->where('user_id', $user->id)->count(); + $unreadNotificationsCount = $user->unreadNotifications()->count(); + $receivedCommentsCount = $this->receivedCommentsInbox->unreadCountForUser($user); + $isCreator = $artworksCount > 0; + $pinnedSpaces = DashboardPreference::pinnedSpacesForUser($user); return view('dashboard', [ 'page_title' => 'Dashboard', 'dashboard_user_name' => $user?->username ?: $user?->name ?: 'Creator', - 'dashboard_is_creator' => Artwork::query()->where('user_id', $user->id)->exists(), + 'dashboard_is_creator' => $isCreator, + 'dashboard_level' => $xpSummary['level'], + 'dashboard_rank' => $xpSummary['rank'], + 'dashboard_received_comments_count' => $receivedCommentsCount, + 'dashboard_overview' => [ + 'artworks' => $artworksCount, + 'stories' => $storiesCount, + 'followers' => $followersCount, + 'following' => $followingCount, + 'favorites' => $favoritesCount, + 'notifications' => $unreadNotificationsCount, + 'received_comments' => $receivedCommentsCount, + ], + 'dashboard_preferences' => [ + 'pinned_spaces' => $pinnedSpaces, + ], ]); } @@ -195,6 +230,8 @@ final class DashboardController extends Controller 'username' => $artwork->user?->username, 'name' => $artwork->user?->name, 'url' => $artwork->user?->username ? '/@' . $artwork->user->username : null, + 'level' => (int) ($artwork->user?->level ?? 1), + 'rank' => (string) ($artwork->user?->rank ?? 'Newbie'), ], ]; }) @@ -238,6 +275,8 @@ final class DashboardController extends Controller 'users.id', 'users.username', 'users.name', + 'users.level', + 'users.rank', 'up.avatar_hash', DB::raw('COALESCE(us.followers_count, 0) as followers_count'), DB::raw('COALESCE(us.uploads_count, 0) as uploads_count'), @@ -255,6 +294,8 @@ final class DashboardController extends Controller 'name' => $row->name, 'url' => $username !== '' ? '/@' . $username : null, 'avatar' => AvatarUrl::forUser((int) $row->id, $row->avatar_hash, 64), + 'level' => (int) ($row->level ?? 1), + 'rank' => (string) ($row->rank ?? 'Newbie'), 'followers_count' => (int) $row->followers_count, 'uploads_count' => (int) $row->uploads_count, ]; diff --git a/app/Http/Controllers/GalleryController.php b/app/Http/Controllers/GalleryController.php deleted file mode 100644 index 1df7a51a..00000000 --- a/app/Http/Controllers/GalleryController.php +++ /dev/null @@ -1,41 +0,0 @@ -query('page', 1)); - $hits = 20; - - $query = Artwork::where('user_id', $user->id) - ->approved() - ->published() - ->public() - ->orderByDesc('published_at'); - - $total = (int) $query->count(); - - $artworks = $query->skip(($page - 1) * $hits)->take($hits)->get(); - - return view('legacy::gallery', [ - 'user' => $user, - 'artworks' => $artworks, - 'page' => $page, - 'hits' => $hits, - 'total' => $total, - ]); - } -} diff --git a/app/Http/Controllers/Legacy/BuddiesController.php b/app/Http/Controllers/Legacy/BuddiesController.php deleted file mode 100644 index 504cdc52..00000000 --- a/app/Http/Controllers/Legacy/BuddiesController.php +++ /dev/null @@ -1,37 +0,0 @@ -user(); - if (! $user) { - return redirect()->route('login'); - } - - $perPage = 50; - - try { - $query = DB::table('friends_list as t1') - ->leftJoin('users as t2', 't1.user_id', '=', 't2.id') - ->leftJoin('user_profiles as p', 'p.user_id', '=', 't2.id') - ->where('t1.friend_id', $user->id) - ->select('t1.id', 't1.user_id', 't1.friend_id', 't2.name as uname', 'p.avatar_hash as icon', 't1.date_added') - ->orderByDesc('t1.date_added'); - - $followers = $query->paginate($perPage)->withQueryString(); - } catch (\Throwable $e) { - $followers = collect(); - } - - $page_title = ($user->name ?? $user->username ?? 'User') . ': Followers'; - - return view('legacy::buddies', compact('followers', 'page_title')); - } -} diff --git a/app/Http/Controllers/Legacy/CategoryRedirectController.php b/app/Http/Controllers/Legacy/CategoryRedirectController.php new file mode 100644 index 00000000..869f4843 --- /dev/null +++ b/app/Http/Controllers/Legacy/CategoryRedirectController.php @@ -0,0 +1,62 @@ +resolveCategory($groupSlug, $slugPart, $id); + + if ($category && $category->contentType) { + $target = $category->url; + + if ($request->getQueryString()) { + $target .= '?' . $request->getQueryString(); + } + + return redirect()->to($target, 301); + } + + return redirect()->route('categories.index', $request->query(), 301); + } + + private function resolveCategory(string $groupSlug, string $slugPart, ?string $id): ?Category + { + if ($id !== null && ctype_digit((string) $id)) { + $category = Category::query() + ->with('contentType') + ->find((int) $id); + + if ($category) { + return $category; + } + } + + if ($slugPart !== '') { + $category = Category::query() + ->with('contentType') + ->where('slug', $slugPart) + ->whereHas('parent', fn ($query) => $query->where('slug', $groupSlug)) + ->first(); + + if ($category) { + return $category; + } + } + + return Category::query() + ->with('contentType') + ->where('slug', $groupSlug) + ->whereNull('parent_id') + ->first(); + } +} diff --git a/app/Http/Controllers/Legacy/MembersController.php b/app/Http/Controllers/Legacy/MembersController.php index ad26827b..21c4bf7b 100644 --- a/app/Http/Controllers/Legacy/MembersController.php +++ b/app/Http/Controllers/Legacy/MembersController.php @@ -3,48 +3,54 @@ namespace App\Http\Controllers\Legacy; use App\Http\Controllers\Controller; +use App\Models\Artwork; +use App\Services\ArtworkService; use Illuminate\Http\Request; -use App\Services\LegacyService; -use Illuminate\Support\Str; class MembersController extends Controller { - protected LegacyService $legacy; + protected ArtworkService $artworks; - public function __construct(LegacyService $legacy) + public function __construct(ArtworkService $artworks) { - $this->legacy = $legacy; + $this->artworks = $artworks; } public function photos(Request $request, $id = null) { - $id = (int) ($id ?: 545); + $artworks = $this->artworks->getArtworksByContentType('photography', 40); - $result = $this->legacy->categoryPage('', null, $id); - if (! $result) { - return redirect('/'); - } + $artworks->getCollection()->load([ + 'user:id,name,username', + 'user.profile:user_id,avatar_hash', + ]); - // categoryPage returns an array with keys used by legacy.browse - $page_title = $result['page_title'] ?? ($result['category']->category_name ?? 'Members Photos'); - $artworks = $result['artworks'] ?? collect(); + $artworks->getCollection()->transform(function (Artwork $artwork) { + $primaryCategory = $artwork->categories->sortBy('sort_order')->first(); + $present = \App\Services\ThumbnailPresenter::present($artwork, 'md'); - // Ensure artworks include `slug`, `thumb`, and `thumb_srcset` properties expected by the legacy view - if ($artworks && method_exists($artworks, 'getCollection')) { - $artworks->getCollection()->transform(function ($row) { - $row->slug = $row->slug ?? Str::slug($row->name ?? ''); - $row->thumb = $row->thumb ?? ($row->thumb_url ?? null); - $row->thumb_srcset = $row->thumb_srcset ?? ($row->thumb_srcset ?? null); - return $row; - }); - } elseif (is_iterable($artworks)) { - $artworks = collect($artworks)->map(function ($row) { - $row->slug = $row->slug ?? Str::slug($row->name ?? ''); - $row->thumb = $row->thumb ?? ($row->thumb_url ?? null); - $row->thumb_srcset = $row->thumb_srcset ?? ($row->thumb_srcset ?? null); - return $row; - }); - } + return (object) [ + 'id' => $artwork->id, + 'name' => $artwork->title, + 'slug' => $artwork->slug, + 'url' => '/art/' . $artwork->id . '/' . $artwork->slug, + 'thumb' => $present['url'], + 'thumb_url' => $present['url'], + 'thumb_srcset' => $present['srcset'] ?? $present['url'], + 'uname' => $artwork->user->name ?? 'Skinbase', + 'username' => $artwork->user->username ?? '', + 'avatar_url' => \App\Support\AvatarUrl::forUser((int) ($artwork->user->id ?? 0), $artwork->user->profile->avatar_hash ?? null, 64), + 'content_type_name' => $primaryCategory?->contentType?->name ?? 'Photography', + 'content_type_slug' => $primaryCategory?->contentType?->slug ?? 'photography', + 'category_name' => $primaryCategory?->name ?? '', + 'category_slug' => $primaryCategory?->slug ?? '', + 'width' => $artwork->width, + 'height' => $artwork->height, + 'published_at' => $artwork->published_at, + ]; + }); + + $page_title = 'Member Photos'; return view('legacy::browse', compact('page_title', 'artworks')); } diff --git a/app/Http/Controllers/Legacy/MyBuddiesController.php b/app/Http/Controllers/Legacy/MyBuddiesController.php deleted file mode 100644 index 3296eb8f..00000000 --- a/app/Http/Controllers/Legacy/MyBuddiesController.php +++ /dev/null @@ -1,57 +0,0 @@ -user(); - if (! $user) { - return redirect()->route('login'); - } - - $perPage = 50; - - try { - $query = DB::table('friends_list as t1') - ->leftJoin('users as t2', 't1.friend_id', '=', 't2.id') - ->leftJoin('user_profiles as p', 'p.user_id', '=', 't2.id') - ->where('t1.user_id', $user->id) - ->select('t1.id', 't1.friend_id', 't1.user_id', 't2.name as uname', 'p.avatar_hash as icon', 't1.date_added') - ->orderByDesc('t1.date_added'); - - $buddies = $query->paginate($perPage)->withQueryString(); - } catch (\Throwable $e) { - $buddies = collect(); - } - - $page_title = ($user->name ?? $user->username ?? 'User') . ': Following List'; - - return view('legacy::mybuddies', compact('buddies', 'page_title')); - } - - public function destroy(Request $request, $id) - { - $user = $request->user(); - if (! $user) { - abort(403); - } - - try { - $deleted = DB::table('friends_list')->where('id', $id)->where('user_id', $user->id)->delete(); - if ($deleted) { - $request->session()->flash('status', 'Removed from following list.'); - } - } catch (\Throwable $e) { - $request->session()->flash('error', 'Could not remove buddy.'); - } - - return redirect()->route('legacy.mybuddies'); - } -} diff --git a/app/Http/Controllers/Legacy/ReceivedCommentsController.php b/app/Http/Controllers/Legacy/ReceivedCommentsController.php index 2c670aba..7c510acb 100644 --- a/app/Http/Controllers/Legacy/ReceivedCommentsController.php +++ b/app/Http/Controllers/Legacy/ReceivedCommentsController.php @@ -18,7 +18,7 @@ class ReceivedCommentsController extends Controller } $hits = 33; - $page = max(1, (int) $request->query('page', 1)); + $currentPage = max(1, (int) $request->query('page', 1)); $base = ArtworkComment::with(['user', 'artwork']) ->whereHas('artwork', function ($q) use ($user) { @@ -30,7 +30,7 @@ class ReceivedCommentsController extends Controller return view('legacy::received-comments', [ 'comments' => $comments, - 'page' => $page, + 'currentPage' => $currentPage, 'hits' => $hits, 'total' => $comments->total(), ]); diff --git a/app/Http/Controllers/StoryController.php b/app/Http/Controllers/StoryController.php index db1494e1..6bba419d 100644 --- a/app/Http/Controllers/StoryController.php +++ b/app/Http/Controllers/StoryController.php @@ -8,18 +8,20 @@ use DOMDocument; use DOMElement; use DOMNode; use App\Models\Artwork; +use App\Models\StoryComment; use App\Models\Story; use App\Models\StoryTag; use App\Models\StoryView; use App\Models\User; -use App\Notifications\StoryStatusNotification; +use App\Services\SocialService; +use App\Services\StoryPublicationService; +use App\Support\AvatarUrl; use Illuminate\Http\JsonResponse; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Http\UploadedFile; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Cache; -use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Schema; use Illuminate\Support\Facades\Storage; use Illuminate\Support\Str; @@ -88,7 +90,7 @@ class StoryController extends Controller public function show(Request $request, string $slug): View { $story = Story::published() - ->with(['creator.profile', 'tags']) + ->with(['creator.profile', 'creator.statistics', 'tags']) ->where('slug', $slug) ->firstOrFail(); @@ -127,28 +129,49 @@ class StoryController extends Controller ->get(['id', 'title', 'slug']); } - $discussionComments = collect(); - if ($story->creator_id !== null && Schema::hasTable('profile_comments')) { - $discussionComments = DB::table('profile_comments as pc') - ->join('users as u', 'u.id', '=', 'pc.author_user_id') - ->where('pc.profile_user_id', $story->creator_id) - ->where('pc.is_active', true) - ->orderByDesc('pc.created_at') - ->limit(8) - ->get([ - 'pc.id', - 'pc.body', - 'pc.created_at', - 'u.username as author_username', - ]); - } + $social = app(SocialService::class); + $initialComments = Schema::hasTable('story_comments') + ? StoryComment::query() + ->with(['user.profile', 'approvedReplies']) + ->where('story_id', $story->id) + ->where('is_approved', true) + ->whereNull('parent_id') + ->whereNull('deleted_at') + ->latest('created_at') + ->limit(10) + ->get() + ->map(fn (StoryComment $comment) => $social->formatComment($comment, $request->user()?->id, true)) + ->values() + ->all() + : []; + + $storyState = $social->storyStateFor($request->user(), $story); + + $storySocialProps = [ + 'story' => [ + 'id' => (int) $story->id, + 'slug' => (string) $story->slug, + 'title' => (string) $story->title, + ], + 'creator' => $story->creator ? [ + 'id' => (int) $story->creator->id, + 'username' => (string) ($story->creator->username ?? ''), + 'display_name' => (string) ($story->creator->name ?: $story->creator->username ?: 'Creator'), + 'avatar_url' => AvatarUrl::forUser((int) $story->creator->id, $story->creator->profile?->avatar_hash, 128), + 'followers_count' => (int) ($story->creator->statistics?->followers_count ?? 0), + 'profile_url' => $story->creator->username ? '/@' . $story->creator->username : null, + ] : null, + 'state' => $storyState, + 'comments' => $initialComments, + 'is_authenticated' => $request->user() !== null, + ]; return view('web.stories.show', [ 'story' => $story, 'safeContent' => $storyContentHtml, 'relatedStories' => $relatedStories, 'relatedArtworks' => $relatedArtworks, - 'comments' => $discussionComments, + 'storySocialProps' => $storySocialProps, 'page_title' => $story->title . ' - Skinbase Stories', 'page_meta_description' => $story->excerpt ?: Str::limit(strip_tags((string) $story->content), 160), 'page_canonical' => route('stories.show', $story->slug), @@ -212,6 +235,10 @@ class StoryController extends Controller $story->tags()->sync($this->resolveTagIds($validated)); + if ($resolved['status'] === 'published') { + app(StoryPublicationService::class)->afterPersistence($story, 'published', false); + } + if ($resolved['status'] === 'published') { return redirect()->route('stories.show', ['slug' => $story->slug]) ->with('status', 'Story published.'); @@ -275,6 +302,8 @@ class StoryController extends Controller { abort_unless($this->canManageStory($request, $story), 403); + $wasPublished = $story->published_at !== null || $story->status === 'published'; + $validated = $this->validateStoryPayload($request); $resolved = $this->resolveWorkflowState($request, $validated, false); $serializedContent = $this->normalizeStoryContent($validated['content'] ?? []); @@ -302,6 +331,10 @@ class StoryController extends Controller $story->tags()->sync($this->resolveTagIds($validated)); + if (! $wasPublished && $resolved['status'] === 'published') { + app(StoryPublicationService::class)->afterPersistence($story, 'published', false); + } + return back()->with('status', 'Story updated.'); } @@ -370,14 +403,10 @@ class StoryController extends Controller { abort_unless($this->canManageStory($request, $story), 403); - $story->update([ - 'status' => 'published', + app(StoryPublicationService::class)->publish($story, 'published', [ 'published_at' => now(), - 'scheduled_for' => null, ]); - $story->creator?->notify(new StoryStatusNotification($story, 'published')); - return redirect()->route('stories.show', ['slug' => $story->slug])->with('status', 'Story published.'); } @@ -512,11 +541,19 @@ class StoryController extends Controller $story->tags()->sync($this->resolveTagIds(['tags_csv' => $validated['tags_csv']])); } + if ($workflow['status'] === 'published') { + app(StoryPublicationService::class)->afterPersistence($story, 'published', false); + } + return response()->json([ 'ok' => true, 'story_id' => (int) $story->id, 'status' => $story->status, 'message' => 'Story created.', + 'edit_url' => route('creator.stories.edit', ['story' => $story->id]), + 'preview_url' => route('creator.stories.preview', ['story' => $story->id]), + 'analytics_url' => route('creator.stories.analytics', ['story' => $story->id]), + 'public_url' => route('stories.show', ['slug' => $story->slug]), ]); } @@ -540,6 +577,7 @@ class StoryController extends Controller $story = Story::query()->findOrFail((int) $validated['story_id']); abort_unless($this->canManageStory($request, $story), 403); + $wasPublished = $story->published_at !== null || $story->status === 'published'; $workflow = $this->resolveWorkflowState($request, array_merge([ 'status' => $story->status, @@ -576,11 +614,19 @@ class StoryController extends Controller $story->tags()->sync($this->resolveTagIds(['tags_csv' => $validated['tags_csv']])); } + if (! $wasPublished && $workflow['status'] === 'published') { + app(StoryPublicationService::class)->afterPersistence($story, 'published', false); + } + return response()->json([ 'ok' => true, 'story_id' => (int) $story->id, 'status' => $story->status, 'message' => 'Story updated.', + 'edit_url' => route('creator.stories.edit', ['story' => $story->id]), + 'preview_url' => route('creator.stories.preview', ['story' => $story->id]), + 'analytics_url' => route('creator.stories.analytics', ['story' => $story->id]), + 'public_url' => route('stories.show', ['slug' => $story->slug]), ]); } @@ -631,6 +677,7 @@ class StoryController extends Controller 'og_image' => $validated['og_image'] ?? ($validated['cover_image'] ?? null), ]); } else { + $wasPublished = $story->published_at !== null || $story->status === 'published'; $nextContent = array_key_exists('content', $validated) ? $this->normalizeStoryContent($validated['content']) : (string) $story->content; @@ -655,6 +702,14 @@ class StoryController extends Controller 'scheduled_for' => ! empty($validated['scheduled_for']) ? now()->parse((string) $validated['scheduled_for']) : $story->scheduled_for, ]); $story->save(); + + if (! $wasPublished && $story->status === 'published') { + if ($story->published_at === null) { + $story->forceFill(['published_at' => now()])->save(); + } + + app(StoryPublicationService::class)->afterPersistence($story, 'published', false); + } } if (! empty($validated['tags_csv'])) { @@ -666,6 +721,10 @@ class StoryController extends Controller 'story_id' => (int) $story->id, 'saved_at' => now()->toIso8601String(), 'message' => 'Saved just now', + 'edit_url' => route('creator.stories.edit', ['story' => $story->id]), + 'preview_url' => route('creator.stories.preview', ['story' => $story->id]), + 'analytics_url' => route('creator.stories.analytics', ['story' => $story->id]), + 'public_url' => route('stories.show', ['slug' => $story->slug]), ]); } @@ -1047,7 +1106,7 @@ class StoryController extends Controller 'orderedList' => '
    ' . $inner . '
', 'listItem' => '
  • ' . $inner . '
  • ', 'horizontalRule' => '
    ', - 'codeBlock' => '
    ' . e($this->extractTipTapText($node)) . '
    ', + 'codeBlock' => $this->renderCodeBlockNode($attrs, $node), 'image' => $this->renderImageNode($attrs), 'artworkEmbed' => $this->renderArtworkEmbedNode($attrs), 'galleryBlock' => $this->renderGalleryBlockNode($attrs), @@ -1057,6 +1116,23 @@ class StoryController extends Controller }; } + private function renderCodeBlockNode(array $attrs, array $node): string + { + $language = strtolower(trim((string) ($attrs['language'] ?? ''))); + $language = preg_match('/^[a-z0-9_+-]+$/', $language) === 1 ? $language : ''; + $escapedCode = e($this->extractTipTapText($node)); + + $preAttributes = $language !== '' + ? ' data-language="' . e($language) . '"' + : ''; + + $codeAttributes = $language !== '' + ? ' class="language-' . e($language) . '" data-language="' . e($language) . '"' + : ''; + + return '' . $escapedCode . ''; + } + private function renderImageNode(array $attrs): string { $src = (string) ($attrs['src'] ?? ''); diff --git a/app/Http/Controllers/User/BuddiesController.php b/app/Http/Controllers/User/BuddiesController.php deleted file mode 100644 index 379d0e21..00000000 --- a/app/Http/Controllers/User/BuddiesController.php +++ /dev/null @@ -1,37 +0,0 @@ -user(); - if (! $user) { - return redirect()->route('login'); - } - - $perPage = 50; - - try { - $query = DB::table('friends_list as t1') - ->leftJoin('users as t2', 't1.user_id', '=', 't2.id') - ->leftJoin('user_profiles as p', 'p.user_id', '=', 't2.id') - ->where('t1.friend_id', $user->id) - ->select('t1.id', 't1.user_id', 't1.friend_id', 't2.name as uname', 'p.avatar_hash as icon', 't1.date_added') - ->orderByDesc('t1.date_added'); - - $followers = $query->paginate($perPage)->withQueryString(); - } catch (\Throwable $e) { - $followers = collect(); - } - - $page_title = ($user->name ?? $user->username ?? 'User') . ': Followers'; - - return view('user.buddies', compact('followers', 'page_title')); - } -} diff --git a/app/Http/Controllers/User/FavouritesController.php b/app/Http/Controllers/User/FavouritesController.php index d81db1dd..79e0b839 100644 --- a/app/Http/Controllers/User/FavouritesController.php +++ b/app/Http/Controllers/User/FavouritesController.php @@ -3,68 +3,25 @@ namespace App\Http\Controllers\User; use App\Http\Controllers\Controller; +use App\Models\User; use App\Services\UserStatsService; +use App\Support\UsernamePolicy; use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; -use Illuminate\Support\Str; -use App\Models\ArtworkFavourite; class FavouritesController extends Controller { public function index(Request $request, $userId = null, $username = null) { - $userId = $userId ? (int) $userId : ($request->user()->id ?? null); - - $page = max(1, (int) $request->query('page', 1)); - $hits = 20; - $start = ($page - 1) * $hits; - - $total = 0; - $results = collect(); - - try { - $query = ArtworkFavourite::with(['artwork.user']) - ->where('user_id', $userId) - ->orderByDesc('created_at') - ->orderByDesc('artwork_id'); - - $total = (int) $query->count(); - - $favorites = $query->skip($start)->take($hits)->get(); - - $results = $favorites->map(function ($fav) { - $art = $fav->artwork; - if (! $art) { - return null; - } - $item = (object) $art->toArray(); - $item->uname = $art->user?->username ?? $art->user?->name ?? null; - $item->datum = $fav->created_at; - return $item; - })->filter(); - } catch (\Throwable $e) { - $total = 0; - $results = collect(); + $user = $this->resolveLegacyFavouritesUser($request, $userId, $username); + if (! $user) { + abort(404); } - $results = collect($results)->filter()->values()->transform(function ($row) { - $row->name = $row->name ?? $row->title ?? ''; - $row->slug = $row->slug ?? Str::slug($row->name); - $row->encoded = isset($row->id) ? app(\App\Helpers\Thumb::class)::encodeId((int) $row->id) : null; - return $row; - }); - - $displayName = $username ?: (DB::table('users')->where('id', $userId)->value('username') ?? ''); - $page_title = $displayName . ' Favourites'; - - return view('user.favourites', [ - 'results' => $results, - 'page_title' => $page_title, - 'user_id' => $userId, - 'page' => $page, - 'hits' => $hits, - 'total' => $total, - ]); + return redirect()->route('profile.show', [ + 'username' => strtolower((string) $user->username), + 'tab' => 'favourites', + ], 301); } public function destroy(Request $request, $userId, $artworkId) @@ -82,6 +39,31 @@ class FavouritesController extends Controller app(UserStatsService::class)->decrementFavoritesReceived($creatorId); } - return redirect()->route('legacy.favourites', ['id' => $userId])->with('status', 'Removed from favourites'); + $username = strtolower((string) ($auth->username ?? DB::table('users')->where('id', (int) $userId)->value('username') ?? '')); + + return redirect()->route('profile.show', [ + 'username' => $username, + 'tab' => 'favourites', + ])->with('status', 'Removed from favourites'); + } + + private function resolveLegacyFavouritesUser(Request $request, mixed $userId, mixed $username): ?User + { + if (is_string($userId) && ! is_numeric($userId) && $username === null) { + $username = $userId; + $userId = null; + } + + if (is_numeric($userId)) { + return User::query()->find((int) $userId); + } + + if (is_string($username) && $username !== '') { + $normalized = UsernamePolicy::normalize($username); + + return User::query()->whereRaw('LOWER(username) = ?', [$normalized])->first(); + } + + return $request->user(); } } diff --git a/app/Http/Controllers/User/MembersController.php b/app/Http/Controllers/User/MembersController.php index 374a8692..9a1cd021 100644 --- a/app/Http/Controllers/User/MembersController.php +++ b/app/Http/Controllers/User/MembersController.php @@ -3,46 +3,54 @@ namespace App\Http\Controllers\User; use App\Http\Controllers\Controller; +use App\Models\Artwork; +use App\Services\ArtworkService; use Illuminate\Http\Request; -use App\Services\LegacyService; -use Illuminate\Support\Str; class MembersController extends Controller { - protected LegacyService $legacy; + protected ArtworkService $artworks; - public function __construct(LegacyService $legacy) + public function __construct(ArtworkService $artworks) { - $this->legacy = $legacy; + $this->artworks = $artworks; } public function photos(Request $request, $id = null) { - $id = (int) ($id ?: 545); + $artworks = $this->artworks->getArtworksByContentType('photography', 40); - $result = $this->legacy->categoryPage('', null, $id); - if (! $result) { - return redirect('/'); - } + $artworks->getCollection()->load([ + 'user:id,name,username', + 'user.profile:user_id,avatar_hash', + ]); - $page_title = $result['page_title'] ?? ($result['category']->category_name ?? 'Members Photos'); - $artworks = $result['artworks'] ?? collect(); + $artworks->getCollection()->transform(function (Artwork $artwork) { + $primaryCategory = $artwork->categories->sortBy('sort_order')->first(); + $present = \App\Services\ThumbnailPresenter::present($artwork, 'md'); - if ($artworks && method_exists($artworks, 'getCollection')) { - $artworks->getCollection()->transform(function ($row) { - $row->slug = $row->slug ?? Str::slug($row->name ?? ''); - $row->thumb = $row->thumb ?? ($row->thumb_url ?? null); - $row->thumb_srcset = $row->thumb_srcset ?? ($row->thumb_srcset ?? null); - return $row; - }); - } elseif (is_iterable($artworks)) { - $artworks = collect($artworks)->map(function ($row) { - $row->slug = $row->slug ?? Str::slug($row->name ?? ''); - $row->thumb = $row->thumb ?? ($row->thumb_url ?? null); - $row->thumb_srcset = $row->thumb_srcset ?? ($row->thumb_srcset ?? null); - return $row; - }); - } + return (object) [ + 'id' => $artwork->id, + 'name' => $artwork->title, + 'slug' => $artwork->slug, + 'url' => '/art/' . $artwork->id . '/' . $artwork->slug, + 'thumb' => $present['url'], + 'thumb_url' => $present['url'], + 'thumb_srcset' => $present['srcset'] ?? $present['url'], + 'uname' => $artwork->user->name ?? 'Skinbase', + 'username' => $artwork->user->username ?? '', + 'avatar_url' => \App\Support\AvatarUrl::forUser((int) ($artwork->user->id ?? 0), $artwork->user->profile->avatar_hash ?? null, 64), + 'content_type_name' => $primaryCategory?->contentType?->name ?? 'Photography', + 'content_type_slug' => $primaryCategory?->contentType?->slug ?? 'photography', + 'category_name' => $primaryCategory?->name ?? '', + 'category_slug' => $primaryCategory?->slug ?? '', + 'width' => $artwork->width, + 'height' => $artwork->height, + 'published_at' => $artwork->published_at, + ]; + }); + + $page_title = 'Member Photos'; return view('web.members.photos', compact('page_title', 'artworks')); } diff --git a/app/Http/Controllers/User/MyBuddiesController.php b/app/Http/Controllers/User/MyBuddiesController.php deleted file mode 100644 index 2f9970d1..00000000 --- a/app/Http/Controllers/User/MyBuddiesController.php +++ /dev/null @@ -1,56 +0,0 @@ -user(); - if (! $user) { - return redirect()->route('login'); - } - - $perPage = 50; - - try { - $query = DB::table('friends_list as t1') - ->leftJoin('users as t2', 't1.friend_id', '=', 't2.id') - ->leftJoin('user_profiles as p', 'p.user_id', '=', 't2.id') - ->where('t1.user_id', $user->id) - ->select('t1.id', 't1.friend_id', 't1.user_id', 't2.name as uname', 't2.username as user_username', 'p.avatar_hash as icon', 't1.date_added') - ->orderByDesc('t1.date_added'); - - $buddies = $query->paginate($perPage)->withQueryString(); - } catch (\Throwable $e) { - $buddies = collect(); - } - - $page_title = ($user->name ?? $user->username ?? 'User') . ': Following List'; - - return view('user.mybuddies', compact('buddies', 'page_title')); - } - - public function destroy(Request $request, $id) - { - $user = $request->user(); - if (! $user) { - abort(403); - } - - try { - $deleted = DB::table('friends_list')->where('id', $id)->where('user_id', $user->id)->delete(); - if ($deleted) { - $request->session()->flash('status', 'Removed from following list.'); - } - } catch (\Throwable $e) { - $request->session()->flash('error', 'Could not remove buddy.'); - } - - return redirect()->route('legacy.mybuddies'); - } -} diff --git a/app/Http/Controllers/User/ProfileController.php b/app/Http/Controllers/User/ProfileController.php index ba555aa8..241395de 100644 --- a/app/Http/Controllers/User/ProfileController.php +++ b/app/Http/Controllers/User/ProfileController.php @@ -14,15 +14,21 @@ use App\Http\Requests\Settings\VerifyEmailChangeRequest; use App\Mail\EmailChangedSecurityAlertMail; use App\Mail\EmailChangeVerificationCodeMail; use App\Models\Artwork; +use App\Models\Country; use App\Models\ProfileComment; use App\Models\Story; use App\Models\User; +use Carbon\CarbonInterface; use App\Services\Security\CaptchaVerifier; use App\Services\AvatarService; use App\Services\ArtworkService; use App\Services\FollowService; +use App\Services\AchievementService; +use App\Services\LeaderboardService; +use App\Services\Countries\CountryCatalogService; use App\Services\ThumbnailPresenter; use App\Services\ThumbnailService; +use App\Services\XPService; use App\Services\UsernameApprovalService; use App\Services\UserStatsService; use App\Support\AvatarUrl; @@ -49,6 +55,10 @@ class ProfileController extends Controller private readonly FollowService $followService, private readonly UserStatsService $userStats, private readonly CaptchaVerifier $captchaVerifier, + private readonly XPService $xp, + private readonly AchievementService $achievements, + private readonly LeaderboardService $leaderboards, + private readonly CountryCatalogService $countryCatalog, ) { } @@ -74,7 +84,31 @@ class ProfileController extends Controller return redirect()->route('profile.show', ['username' => strtolower((string) $user->username)], 301); } - return $this->renderUserProfile($request, $user); + return $this->renderProfilePage($request, $user); + } + + public function showGalleryByUsername(Request $request, string $username) + { + $normalized = UsernamePolicy::normalize($username); + $user = User::query()->whereRaw('LOWER(username) = ?', [$normalized])->first(); + + if (! $user) { + $redirect = DB::table('username_redirects') + ->whereRaw('LOWER(old_username) = ?', [$normalized]) + ->value('new_username'); + + if ($redirect) { + return redirect()->route('profile.gallery', ['username' => strtolower((string) $redirect)], 301); + } + + abort(404); + } + + if ($username !== strtolower((string) $user->username)) { + return redirect()->route('profile.gallery', ['username' => strtolower((string) $user->username)], 301); + } + + return $this->renderProfilePage($request, $user, 'Profile/ProfileGallery', true); } public function legacyById(Request $request, int $id, ?string $username = null) @@ -119,20 +153,27 @@ class ProfileController extends Controller 'body' => ['required', 'string', 'min:2', 'max:2000'], ]); - ProfileComment::create([ + $comment = ProfileComment::create([ 'profile_user_id' => $target->id, 'author_user_id' => Auth::id(), 'body' => $request->input('body'), ]); + app(XPService::class)->awardCommentCreated((int) Auth::id(), (int) $comment->id, 'profile'); + return Redirect::route('profile.show', ['username' => strtolower((string) $target->username)]) ->with('status', 'Comment posted!'); } public function edit(Request $request): View { + $user = $request->user()->loadMissing(['profile', 'country']); + $selectedCountry = $this->countryCatalog->resolveUserCountry($user); + return view('profile.edit', [ - 'user' => $request->user(), + 'user' => $user, + 'countries' => $this->countryCatalog->profileSelectOptions(), + 'selectedCountryId' => $selectedCountry?->id, ]); } @@ -141,7 +182,7 @@ class ProfileController extends Controller */ public function editSettings(Request $request) { - $user = $request->user(); + $user = $request->user()->loadMissing(['profile', 'country']); $cooldownDays = $this->usernameCooldownDays(); $lastUsernameChangeAt = $this->lastUsernameChangeAt($user); $usernameCooldownRemainingDays = 0; @@ -188,15 +229,8 @@ class ProfileController extends Controller } catch (\Throwable $e) {} } - // Country list - $countries = collect(); - try { - if (Schema::hasTable('country_list')) { - $countries = DB::table('country_list')->orderBy('country_name')->get(); - } elseif (Schema::hasTable('countries')) { - $countries = DB::table('countries')->orderBy('name')->get(); - } - } catch (\Throwable $e) {} + $selectedCountry = $this->countryCatalog->resolveUserCountry($user); + $countries = $this->countryCatalog->profileSelectOptions(); // Avatar URL $avatarHash = $profileData['avatar_hash'] ?? $user->icon ?? null; @@ -222,7 +256,8 @@ class ProfileController extends Controller 'description' => $user->description ?? null, 'gender' => $user->gender ?? null, 'birthday' => $user->birth ?? null, - 'country_code' => $user->country_code ?? null, + 'country_id' => $selectedCountry?->id ?? $user->country_id ?? null, + 'country_code' => $selectedCountry?->iso2 ?? $user->country_code ?? null, 'email_notifications' => $emailNotifications, 'upload_notifications' => $uploadNotifications, 'follower_notifications' => $followerNotifications, @@ -238,7 +273,7 @@ class ProfileController extends Controller 'usernameCooldownDays' => $cooldownDays, 'usernameCooldownRemainingDays' => $usernameCooldownRemainingDays, 'usernameCooldownActive' => $usernameCooldownRemainingDays > 0, - 'countries' => $countries->values(), + 'countries' => $countries, 'flash' => [ 'status' => session('status'), 'error' => session('error'), @@ -434,10 +469,12 @@ class ProfileController extends Controller public function updatePersonalSection(UpdatePersonalSectionRequest $request): RedirectResponse|JsonResponse { $validated = $request->validated(); + $selectedCountry = $this->resolveCountrySelection($validated['country_id'] ?? null); + $this->persistUserCountrySelection($request->user(), $selectedCountry); $profileUpdates = [ 'birthdate' => $validated['birthday'] ?? null, - 'country_code' => $validated['country'] ?? null, + 'country_code' => $selectedCountry?->iso2, ]; if (!empty($validated['gender'])) { @@ -513,6 +550,29 @@ class ProfileController extends Controller DB::table('user_profiles')->updateOrInsert(['user_id' => $userId], $filtered); } + private function resolveCountrySelection(int|string|null $countryId = null, ?string $countryCode = null): ?Country + { + if (is_numeric($countryId) && (int) $countryId > 0) { + return $this->countryCatalog->findById((int) $countryId); + } + + if ($countryCode !== null && trim($countryCode) !== '') { + return $this->countryCatalog->findByIso2($countryCode); + } + + return null; + } + + private function persistUserCountrySelection(User $user, ?Country $country): void + { + if (! Schema::hasColumn('users', 'country_id')) { + return; + } + + $user->country_id = $country?->id; + $user->save(); + } + private function usernameCooldownDays(): int { return max(1, (int) config('usernames.rename_cooldown_days', 30)); @@ -655,7 +715,15 @@ class ProfileController extends Controller $profileUpdates['gender'] = $map[$g] ?? strtoupper($validated['gender']); } - if (!empty($validated['country'])) $profileUpdates['country_code'] = $validated['country']; + if (array_key_exists('country_id', $validated) || array_key_exists('country', $validated)) { + $selectedCountry = $this->resolveCountrySelection( + $validated['country_id'] ?? null, + $validated['country'] ?? null, + ); + + $this->persistUserCountrySelection($user, $selectedCountry); + $profileUpdates['country_code'] = $selectedCountry?->iso2; + } if (array_key_exists('mailing', $validated)) { $profileUpdates['mlist'] = filter_var($validated['mailing'], FILTER_VALIDATE_BOOLEAN) ? 1 : 0; @@ -768,7 +836,7 @@ class ProfileController extends Controller return Redirect::route('dashboard.profile')->with('status', 'password-updated'); } - private function renderUserProfile(Request $request, User $user) + private function renderProfilePage(Request $request, User $user, string $component = 'Profile/ProfileShow', bool $galleryOnly = false) { $isOwner = Auth::check() && Auth::id() === $user->id; $viewer = Auth::user(); @@ -777,21 +845,7 @@ class ProfileController extends Controller // ── Artworks (cursor-paginated) ────────────────────────────────────── $artworks = $this->artworkService->getArtworksByUser($user->id, $isOwner, $perPage) ->through(function (Artwork $art) { - $present = ThumbnailPresenter::present($art, 'md'); - return (object) [ - 'id' => $art->id, - 'name' => $art->title, - 'picture' => $art->file_name, - 'datum' => $art->published_at, - 'published_at' => $art->published_at, // required by cursor paginator (orders by this column) - 'thumb' => $present['url'], - 'thumb_srcset' => $present['srcset'] ?? $present['url'], - 'uname' => $art->user->name ?? 'Skinbase', - 'username' => $art->user->username ?? null, - 'user_id' => $art->user_id, - 'width' => $art->width, - 'height' => $art->height, - ]; + return (object) $this->mapArtworkCardPayload($art); }); // ── Featured artworks for this user ───────────────────────────────── @@ -829,27 +883,42 @@ class ProfileController extends Controller } // ── Favourites ─────────────────────────────────────────────────────── - $favourites = collect(); - if (Schema::hasTable('user_favorites')) { - $favIds = DB::table('user_favorites as uf') - ->join('artworks as a', 'a.id', '=', 'uf.artwork_id') - ->where('uf.user_id', $user->id) + $favouriteLimit = 12; + $favouriteTable = $this->resolveFavouriteTable(); + $favourites = [ + 'data' => [], + 'next_cursor' => null, + ]; + if ($favouriteTable !== null) { + $favIds = DB::table($favouriteTable . ' as af') + ->join('artworks as a', 'a.id', '=', 'af.artwork_id') + ->where('af.user_id', $user->id) ->whereNull('a.deleted_at') ->where('a.is_public', true) ->where('a.is_approved', true) - ->orderByDesc('uf.created_at') - ->limit(12) + ->whereNotNull('a.published_at') + ->orderByDesc('af.created_at') + ->orderByDesc('af.artwork_id') + ->limit($favouriteLimit + 1) ->pluck('a.id'); if ($favIds->isNotEmpty()) { + $hasMore = $favIds->count() > $favouriteLimit; + $favIds = $favIds->take($favouriteLimit); + $indexed = Artwork::with('user:id,name,username') ->whereIn('id', $favIds) ->get() ->keyBy('id'); - // Preserve the ordering from the favourites table - $favourites = $favIds + + $favourites = [ + 'data' => $favIds ->filter(fn ($id) => $indexed->has($id)) - ->map(fn ($id) => $indexed[$id]); + ->map(fn ($id) => $this->mapArtworkCardPayload($indexed[$id])) + ->values() + ->all(), + 'next_cursor' => $hasMore ? base64_encode((string) $favouriteLimit) : null, + ]; } } @@ -916,6 +985,7 @@ class ProfileController extends Controller ->select([ 'pc.id', 'pc.body', 'pc.created_at', 'u.id as author_id', 'u.username as author_username', 'u.name as author_name', + 'u.level as author_level', 'u.rank as author_rank', 'up.avatar_hash as author_avatar_hash', 'up.signature as author_signature', ]) ->get() @@ -925,12 +995,16 @@ class ProfileController extends Controller 'created_at' => $row->created_at, 'author_id' => $row->author_id, 'author_name' => $row->author_username ?? $row->author_name ?? 'Unknown', + 'author_level' => (int) ($row->author_level ?? 1), + 'author_rank' => (string) ($row->author_rank ?? 'Newbie'), 'author_profile_url' => '/@' . strtolower((string) ($row->author_username ?? $row->author_id)), 'author_avatar' => AvatarUrl::forUser((int) $row->author_id, $row->author_avatar_hash, 50), 'author_signature' => $row->author_signature, ]); } + $xpSummary = $this->xp->summary((int) $user->id); + $creatorStories = Story::query() ->published() ->with(['tags']) @@ -959,21 +1033,19 @@ class ProfileController extends Controller 'views' => (int) $story->views, 'likes_count' => (int) $story->likes_count, 'comments_count' => (int) $story->comments_count, + 'creator_level' => $xpSummary['level'], + 'creator_rank' => $xpSummary['rank'], 'published_at' => $story->published_at?->toISOString(), ]); // ── Profile data ───────────────────────────────────────────────────── $profile = $user->profile; + $country = $this->countryCatalog->resolveUserCountry($user); + $countryCode = $country?->iso2 ?? $profile?->country_code; + $countryName = $country?->name_common; - // ── Country name (from old country_list table if available) ────────── - $countryName = null; - if ($profile?->country_code) { - if (Schema::hasTable('country_list')) { - $countryName = DB::table('country_list') - ->where('country_code', $profile->country_code) - ->value('country_name'); - } - $countryName = $countryName ?? strtoupper((string) $profile->country_code); + if ($countryName === null && $profile?->country_code) { + $countryName = strtoupper((string) $profile->country_code); } // ── Cover image hero (preferred) ──────────────────────────────────── @@ -1013,9 +1085,13 @@ class ProfileController extends Controller ]; } - $canonical = url('/@' . strtolower((string) ($user->username ?? ''))); + $usernameSlug = strtolower((string) ($user->username ?? '')); + $canonical = url('/@' . $usernameSlug); + $galleryUrl = url('/@' . $usernameSlug . '/gallery'); + $achievementSummary = $this->achievements->summary((int) $user->id); + $leaderboardRank = $this->leaderboards->creatorRankSummary((int) $user->id); - return Inertia::render('Profile/ProfileShow', [ + return Inertia::render($component, [ 'user' => [ 'id' => $user->id, 'username' => $user->username, @@ -1025,18 +1101,25 @@ class ProfileController extends Controller 'cover_position'=> (int) ($user->cover_position ?? 50), 'created_at' => $user->created_at?->toISOString(), 'last_visit_at' => $user->last_visit_at ? (string) $user->last_visit_at : null, + 'xp' => $xpSummary['xp'], + 'level' => $xpSummary['level'], + 'rank' => $xpSummary['rank'], + 'next_level_xp' => $xpSummary['next_level_xp'], + 'current_level_xp' => $xpSummary['current_level_xp'], + 'progress_percent' => $xpSummary['progress_percent'], + 'max_level' => $xpSummary['max_level'], ], 'profile' => $profile ? [ 'about' => $profile->about ?? null, 'website' => $profile->website ?? null, - 'country_code' => $profile->country_code ?? null, + 'country_code' => $countryCode, 'gender' => $profile->gender ?? null, 'birthdate' => $profile->birthdate ?? null, 'cover_image' => $profile->cover_image ?? null, ] : null, 'artworks' => $artworkPayload, 'featuredArtworks' => $featuredArtworks->values(), - 'favourites' => $favourites->values(), + 'favourites' => $favourites, 'stats' => $stats, 'socialLinks' => $socialLinks, 'followerCount' => $followerCount, @@ -1045,14 +1128,71 @@ class ProfileController extends Controller 'heroBgUrl' => $heroBgUrl, 'profileComments' => $profileComments->values(), 'creatorStories' => $creatorStories->values(), + 'achievements' => $achievementSummary, + 'leaderboardRank' => $leaderboardRank, 'countryName' => $countryName, 'isOwner' => $isOwner, 'auth' => $authData, + 'profileUrl' => $canonical, + 'galleryUrl' => $galleryUrl, ])->withViewData([ - 'page_title' => ($user->username ?? $user->name ?? 'User') . ' on Skinbase', - 'page_canonical' => $canonical, - 'page_meta_description' => 'View the profile of ' . ($user->username ?? $user->name) . ' on Skinbase.org — artworks, favourites and more.', + 'page_title' => $galleryOnly + ? (($user->username ?? $user->name ?? 'User') . ' Gallery on Skinbase') + : (($user->username ?? $user->name ?? 'User') . ' on Skinbase'), + 'page_canonical' => $galleryOnly ? $galleryUrl : $canonical, + 'page_meta_description' => $galleryOnly + ? ('Browse the public gallery of ' . ($user->username ?? $user->name) . ' on Skinbase.') + : ('View the profile of ' . ($user->username ?? $user->name) . ' on Skinbase.org — artworks, favourites and more.'), 'og_image' => $avatarUrl, ]); } + + private function resolveFavouriteTable(): ?string + { + foreach (['artwork_favourites', 'user_favorites', 'artworks_favourites', 'favourites'] as $table) { + if (Schema::hasTable($table)) { + return $table; + } + } + + return null; + } + + /** + * @return array + */ + private function mapArtworkCardPayload(Artwork $art): array + { + $present = ThumbnailPresenter::present($art, 'md'); + + return [ + 'id' => $art->id, + 'name' => $art->title, + 'picture' => $art->file_name, + 'datum' => $this->formatIsoDate($art->published_at), + 'published_at' => $this->formatIsoDate($art->published_at), + 'thumb' => $present['url'], + 'thumb_srcset' => $present['srcset'] ?? $present['url'], + 'uname' => $art->user->name ?? 'Skinbase', + 'username' => $art->user->username ?? null, + 'user_id' => $art->user_id, + 'author_level' => (int) ($art->user?->level ?? 1), + 'author_rank' => (string) ($art->user?->rank ?? 'Newbie'), + 'width' => $art->width, + 'height' => $art->height, + ]; + } + + private function formatIsoDate(mixed $value): ?string + { + if ($value instanceof CarbonInterface) { + return $value->toISOString(); + } + + if ($value instanceof \DateTimeInterface) { + return $value->format(DATE_ATOM); + } + + return is_string($value) ? $value : null; + } } diff --git a/app/Http/Controllers/User/TodayDownloadsController.php b/app/Http/Controllers/User/TodayDownloadsController.php index 25f34f17..df71be0f 100644 --- a/app/Http/Controllers/User/TodayDownloadsController.php +++ b/app/Http/Controllers/User/TodayDownloadsController.php @@ -5,7 +5,6 @@ namespace App\Http\Controllers\User; use App\Http\Controllers\Controller; use Illuminate\Http\Request; use App\Models\ArtworkDownload; -use Illuminate\Support\Facades\Storage; use Illuminate\Support\Str; use Carbon\Carbon; @@ -17,7 +16,11 @@ class TodayDownloadsController extends Controller $today = Carbon::now()->toDateString(); - $query = ArtworkDownload::with(['artwork']) + $query = ArtworkDownload::with([ + 'artwork.user:id,name,username', + 'artwork.user.profile:user_id,avatar_hash', + 'artwork.categories:id,name,slug', + ]) ->whereDate('created_at', $today) ->whereHas('artwork', function ($q) { $q->public()->published()->whereNull('deleted_at'); @@ -34,13 +37,31 @@ class TodayDownloadsController extends Controller $art = \App\Models\Artwork::find($row->artwork_id); } + if (! $art) { + return (object) [ + 'id' => null, + 'name' => 'Artwork', + 'slug' => 'artwork', + 'thumb' => 'https://files.skinbase.org/default/missing_md.webp', + 'thumb_url' => 'https://files.skinbase.org/default/missing_md.webp', + 'thumb_srcset' => 'https://files.skinbase.org/default/missing_md.webp', + 'category_name' => '', + 'category_slug' => '', + 'num_downloads' => $row->num_downloads ?? 0, + ]; + } + $name = $art->title ?? null; $picture = $art->file_name ?? null; $ext = pathinfo($picture ?? '', PATHINFO_EXTENSION) ?: 'jpg'; $encoded = null; $present = $art ? \App\Services\ThumbnailPresenter::present($art, 'md') : null; $thumb = $present ? $present['url'] : 'https://files.skinbase.org/default/missing_md.webp'; - $categoryId = $art->categories->first()->id ?? null; + $primaryCategory = $art->categories->first(); + $categoryId = $primaryCategory->id ?? null; + $categoryName = $primaryCategory->name ?? ''; + $categorySlug = $primaryCategory->slug ?? ''; + $avatarHash = $art->user->profile->avatar_hash ?? null; return (object) [ 'id' => $art->id ?? null, @@ -50,8 +71,17 @@ class TodayDownloadsController extends Controller 'ext' => $ext, 'encoded' => $encoded, 'thumb' => $thumb, + 'thumb_url' => $thumb, 'thumb_srcset' => $thumb, 'category' => $categoryId, + 'category_name' => $categoryName, + 'category_slug' => $categorySlug, + 'uname' => $art->user->name ?? 'Skinbase', + 'username' => $art->user->username ?? '', + 'avatar_url' => \App\Support\AvatarUrl::forUser((int) ($art->user->id ?? 0), $avatarHash, 64), + 'width' => $art->width, + 'height' => $art->height, + 'published_at' => $art->published_at, 'num_downloads' => $row->num_downloads ?? 0, 'gid_num' => $categoryId ? ((int) $categoryId % 5) * 5 : 0, ]; diff --git a/app/Http/Controllers/Web/BrowseGalleryController.php b/app/Http/Controllers/Web/BrowseGalleryController.php index 9f826e57..88b1f9f4 100644 --- a/app/Http/Controllers/Web/BrowseGalleryController.php +++ b/app/Http/Controllers/Web/BrowseGalleryController.php @@ -264,35 +264,6 @@ class BrowseGalleryController extends \App\Http\Controllers\Controller ); } - public function legacyCategory(Request $request, ?string $group = null, ?string $slug = null, ?string $id = null) - { - if ($id !== null && ctype_digit((string) $id)) { - $category = Category::with('contentType')->find((int) $id); - if (! $category || ! $category->contentType) { - abort(404); - } - - return redirect($category->url, 301); - } - - $contentSlug = strtolower((string) $group); - if (! in_array($contentSlug, self::CONTENT_TYPE_SLUGS, true)) { - abort(404); - } - - $target = '/' . $contentSlug; - $normalizedSlug = trim((string) $slug, '/'); - if ($normalizedSlug !== '') { - $target .= '/' . strtolower($normalizedSlug); - } - - if ($request->query()) { - $target .= '?' . http_build_query($request->query()); - } - - return redirect($target, 301); - } - private function presentArtwork(Artwork $artwork): object { $primaryCategory = $artwork->categories->sortBy('sort_order')->first(); diff --git a/app/Http/Controllers/Web/CommunityActivityController.php b/app/Http/Controllers/Web/CommunityActivityController.php index 40b7d383..1b2c81f8 100644 --- a/app/Http/Controllers/Web/CommunityActivityController.php +++ b/app/Http/Controllers/Web/CommunityActivityController.php @@ -45,6 +45,10 @@ final class CommunityActivityController extends Controller private function resolveFilter(Request $request): string { + if ($request->filled('type') && ! $request->filled('filter')) { + return (string) $request->query('type', 'all'); + } + if ($request->boolean('following') && ! $request->filled('filter')) { return 'following'; } diff --git a/app/Http/Controllers/Web/GalleryController.php b/app/Http/Controllers/Web/GalleryController.php index 16cd160d..5192eaf6 100644 --- a/app/Http/Controllers/Web/GalleryController.php +++ b/app/Http/Controllers/Web/GalleryController.php @@ -4,51 +4,23 @@ namespace App\Http\Controllers\Web; use App\Http\Controllers\Controller; use Illuminate\Http\Request; -use App\Models\Artwork; use App\Models\User; -use Illuminate\Support\Facades\Schema; +use App\Support\UsernamePolicy; class GalleryController extends Controller { public function show(Request $request, $userId, $username = null) { - $user = User::find((int)$userId); + $user = User::find((int) $userId); if (! $user) { abort(404); } - // canonicalize username in URL when possible - try { - $correctName = $user->name ?? $user->uname ?? null; - if ($username && $correctName && $username !== $correctName) { - $qs = $request->getQueryString(); - $url = route('legacy.gallery', ['id' => $user->id, 'username' => $correctName]); - if ($qs) $url .= '?' . $qs; - return redirect($url, 301); - } - } catch (\Throwable $e) { - // ignore + $usernameSlug = UsernamePolicy::normalize((string) ($user->username ?? $user->name ?? '')); + if ($usernameSlug === '') { + abort(404); } - $page = max(1, (int) $request->query('page', 1)); - $hits = 20; - - $query = Artwork::where('user_id', $user->id) - ->approved() - ->published() - ->public() - ->orderByDesc('published_at'); - - $total = (int) $query->count(); - - $artworks = $query->skip(($page - 1) * $hits)->take($hits)->get(); - - return view('web.gallery', [ - 'user' => $user, - 'artworks' => $artworks, - 'page' => $page, - 'hits' => $hits, - 'total' => $total, - ]); + return redirect()->route('profile.gallery', ['username' => $usernameSlug], 301); } } diff --git a/app/Http/Controllers/Web/LeaderboardPageController.php b/app/Http/Controllers/Web/LeaderboardPageController.php new file mode 100644 index 00000000..5a5e51e7 --- /dev/null +++ b/app/Http/Controllers/Web/LeaderboardPageController.php @@ -0,0 +1,35 @@ +normalizePeriod((string) $request->query('period', 'weekly')); + $type = match ((string) $request->query('type', 'creators')) { + 'artworks', Leaderboard::TYPE_ARTWORK => Leaderboard::TYPE_ARTWORK, + 'stories', Leaderboard::TYPE_STORY => Leaderboard::TYPE_STORY, + default => Leaderboard::TYPE_CREATOR, + }; + + return Inertia::render('Leaderboard/LeaderboardPage', [ + 'initialType' => $type, + 'initialPeriod' => $period, + 'initialData' => $leaderboards->getLeaderboard($type, $period), + 'meta' => [ + 'title' => 'Top Creators & Artworks Leaderboard | Skinbase', + 'description' => 'Track the leading creators, artworks, and stories across Skinbase by daily, weekly, monthly, and all-time performance.', + ], + ]); + } +} diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php index b01d22a3..88f623d8 100644 --- a/app/Http/Middleware/HandleInertiaRequests.php +++ b/app/Http/Middleware/HandleInertiaRequests.php @@ -16,6 +16,10 @@ final class HandleInertiaRequests extends Middleware */ public function rootView(Request $request): string { + if ($request->path() === 'leaderboard') { + return 'leaderboard'; + } + if (str_starts_with($request->path(), 'studio')) { return 'studio'; } diff --git a/app/Http/Middleware/RedirectLegacyProfileSubdomain.php b/app/Http/Middleware/RedirectLegacyProfileSubdomain.php new file mode 100644 index 00000000..5f85aa4d --- /dev/null +++ b/app/Http/Middleware/RedirectLegacyProfileSubdomain.php @@ -0,0 +1,106 @@ +resolveCanonicalUsername($request); + + if ($canonicalUsername !== null) { + return redirect()->to($this->targetUrl($request, $canonicalUsername), 301); + } + + return $next($request); + } + + private function resolveCanonicalUsername(Request $request): ?string + { + $configuredHost = parse_url((string) config('app.url'), PHP_URL_HOST); + + if (! is_string($configuredHost) || $configuredHost === '') { + return null; + } + + $requestHost = strtolower($request->getHost()); + $configuredHost = strtolower($configuredHost); + + if ($requestHost === $configuredHost || ! str_ends_with($requestHost, '.' . $configuredHost)) { + return null; + } + + $subdomain = substr($requestHost, 0, -strlen('.' . $configuredHost)); + + if ($subdomain === '' || str_contains($subdomain, '.')) { + return null; + } + + $candidate = UsernamePolicy::normalize($subdomain); + + if ($candidate === '' || $this->isReservedSubdomain($candidate)) { + return null; + } + + $username = User::query() + ->whereRaw('LOWER(username) = ?', [$candidate]) + ->value('username'); + + if (is_string($username) && $username !== '') { + return UsernamePolicy::normalize($username); + } + + if (! Schema::hasTable('username_redirects')) { + return null; + } + + $redirect = DB::table('username_redirects') + ->whereRaw('LOWER(old_username) = ?', [$candidate]) + ->value('new_username'); + + return is_string($redirect) && $redirect !== '' + ? UsernamePolicy::normalize($redirect) + : null; + } + + private function isReservedSubdomain(string $candidate): bool + { + $reserved = UsernamePolicy::reserved(); + + foreach ([config('cp.webroot'), config('cpad.webroot')] as $prefix) { + $value = strtolower(trim((string) $prefix, '/')); + if ($value !== '') { + $reserved[] = $value; + } + } + + return in_array($candidate, array_values(array_unique($reserved)), true); + } + + private function targetUrl(Request $request, string $username): string + { + $canonicalPath = match ($request->getPathInfo()) { + '/gallery', '/gallery/' => '/@' . $username . '/gallery', + default => '/@' . $username, + }; + + $target = rtrim((string) config('app.url'), '/') . $canonicalPath; + $query = $request->getQueryString(); + + if (is_string($query) && $query !== '') { + $target .= '?' . $query; + } + + return $target; + } +} diff --git a/app/Http/Requests/ProfileUpdateRequest.php b/app/Http/Requests/ProfileUpdateRequest.php index 9ba6b166..05874042 100644 --- a/app/Http/Requests/ProfileUpdateRequest.php +++ b/app/Http/Requests/ProfileUpdateRequest.php @@ -32,7 +32,8 @@ class ProfileUpdateRequest extends FormRequest 'month' => ['nullable', 'numeric', 'between:1,12'], 'year' => ['nullable', 'numeric', 'digits:4'], 'gender' => ['nullable', 'in:m,f,n,M,F,N,X,x'], - 'country' => ['nullable', 'string', 'max:10'], + 'country' => ['nullable', 'string', 'size:2'], + 'country_id' => ['nullable', 'integer', Rule::exists('countries', 'id')], 'mailing' => ['nullable', 'boolean'], 'notify' => ['nullable', 'boolean'], 'auto_post_upload' => ['nullable', 'boolean'], diff --git a/app/Http/Requests/Settings/UpdatePersonalSectionRequest.php b/app/Http/Requests/Settings/UpdatePersonalSectionRequest.php index 0929421e..84c61e22 100644 --- a/app/Http/Requests/Settings/UpdatePersonalSectionRequest.php +++ b/app/Http/Requests/Settings/UpdatePersonalSectionRequest.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace App\Http\Requests\Settings; use Illuminate\Foundation\Http\FormRequest; +use Illuminate\Validation\Rule; class UpdatePersonalSectionRequest extends FormRequest { @@ -18,7 +19,7 @@ class UpdatePersonalSectionRequest extends FormRequest return [ 'birthday' => ['nullable', 'date', 'before:today'], 'gender' => ['nullable', 'in:m,f,x,M,F,X'], - 'country' => ['nullable', 'string', 'max:10'], + 'country_id' => ['nullable', 'integer', Rule::exists('countries', 'id')], ]; } } diff --git a/app/Http/Resources/ArtworkResource.php b/app/Http/Resources/ArtworkResource.php index 8100fe74..371b05cd 100644 --- a/app/Http/Resources/ArtworkResource.php +++ b/app/Http/Resources/ArtworkResource.php @@ -42,9 +42,14 @@ class ArtworkResource extends JsonResource $viewerId = (int) optional($request->user())->id; $isLiked = false; $isFavorited = false; + $isBookmarked = false; $isFollowing = false; $viewerAward = null; + $bookmarksCount = Schema::hasTable('artwork_bookmarks') + ? (int) DB::table('artwork_bookmarks')->where('artwork_id', (int) $this->id)->count() + : 0; + if ($viewerId > 0) { if (Schema::hasTable('artwork_likes')) { $isLiked = DB::table('artwork_likes') @@ -53,6 +58,13 @@ class ArtworkResource extends JsonResource ->exists(); } + if (Schema::hasTable('artwork_bookmarks')) { + $isBookmarked = DB::table('artwork_bookmarks') + ->where('user_id', $viewerId) + ->where('artwork_id', (int) $this->id) + ->exists(); + } + $isFavorited = DB::table('artwork_favourites') ->where('user_id', $viewerId) ->where('artwork_id', (int) $this->id) @@ -114,6 +126,7 @@ class ArtworkResource extends JsonResource 'followers_count' => $followerCount, ], 'viewer' => [ + 'is_bookmarked' => $isBookmarked, 'is_liked' => $isLiked, 'is_favorited' => $isFavorited, 'is_following_author' => $isFollowing, @@ -121,6 +134,7 @@ class ArtworkResource extends JsonResource 'id' => $viewerId > 0 ? $viewerId : null, ], 'stats' => [ + 'bookmarks' => $bookmarksCount, 'views' => (int) ($this->stats?->views ?? 0), 'downloads' => (int) ($this->stats?->downloads ?? 0), 'favorites' => (int) ($this->stats?->favorites ?? 0), diff --git a/app/Jobs/UpdateLeaderboardsJob.php b/app/Jobs/UpdateLeaderboardsJob.php new file mode 100644 index 00000000..0b963822 --- /dev/null +++ b/app/Jobs/UpdateLeaderboardsJob.php @@ -0,0 +1,24 @@ +refreshAll(); + } +} diff --git a/app/Listeners/Achievements/CheckUserAchievements.php b/app/Listeners/Achievements/CheckUserAchievements.php new file mode 100644 index 00000000..97ea1851 --- /dev/null +++ b/app/Listeners/Achievements/CheckUserAchievements.php @@ -0,0 +1,19 @@ +achievements->checkAchievements($event->userId); + } +} \ No newline at end of file diff --git a/app/Listeners/Posts/AwardXpForPostCommented.php b/app/Listeners/Posts/AwardXpForPostCommented.php new file mode 100644 index 00000000..c743e55f --- /dev/null +++ b/app/Listeners/Posts/AwardXpForPostCommented.php @@ -0,0 +1,18 @@ +xp->awardCommentCreated((int) $event->commenter->id, (int) $event->comment->id, 'post'); + } +} diff --git a/app/Models/Achievement.php b/app/Models/Achievement.php new file mode 100644 index 00000000..0b6c70a5 --- /dev/null +++ b/app/Models/Achievement.php @@ -0,0 +1,46 @@ + 'integer', + 'condition_value' => 'integer', + ]; + } + + public function userAchievements(): HasMany + { + return $this->hasMany(UserAchievement::class, 'achievement_id'); + } + + public function users(): BelongsToMany + { + return $this->belongsToMany(User::class, 'user_achievements', 'achievement_id', 'user_id') + ->withPivot('unlocked_at'); + } +} diff --git a/app/Models/ActivityEvent.php b/app/Models/ActivityEvent.php index 4540f966..9fbb33b5 100644 --- a/app/Models/ActivityEvent.php +++ b/app/Models/ActivityEvent.php @@ -11,7 +11,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; * Unified activity feed event. * * Types: upload | comment | favorite | award | follow - * target_type: artwork | user + * target_type: artwork | story | user * * @property int $id * @property int $actor_id @@ -54,6 +54,7 @@ class ActivityEvent extends Model const TYPE_FOLLOW = 'follow'; const TARGET_ARTWORK = 'artwork'; + const TARGET_STORY = 'story'; const TARGET_USER = 'user'; // ── Relations ───────────────────────────────────────────────────────────── diff --git a/app/Models/Country.php b/app/Models/Country.php new file mode 100644 index 00000000..0e8bbf30 --- /dev/null +++ b/app/Models/Country.php @@ -0,0 +1,88 @@ + 'boolean', + 'is_featured' => 'boolean', + 'sort_order' => 'integer', + ]; + } + + public function users(): HasMany + { + return $this->hasMany(User::class); + } + + public function scopeActive(Builder $query): Builder + { + return $query->where('active', true); + } + + public function scopeOrdered(Builder $query): Builder + { + return $query + ->orderByDesc('is_featured') + ->orderBy('sort_order') + ->orderBy('name_common'); + } + + public function getFlagCssClassAttribute(): ?string + { + $iso2 = strtoupper((string) $this->iso2); + + if (! preg_match('/^[A-Z]{2}$/', $iso2)) { + return null; + } + + return 'fi fi-'.strtolower($iso2); + } + + public function getLocalFlagPathAttribute(): ?string + { + $iso2 = strtoupper((string) $this->iso2); + + if (! preg_match('/^[A-Z]{2}$/', $iso2)) { + return null; + } + + return '/gfx/flags/shiny/24/'.rawurlencode($iso2).'.png'; + } +} diff --git a/app/Models/DashboardPreference.php b/app/Models/DashboardPreference.php new file mode 100644 index 00000000..da1aef8a --- /dev/null +++ b/app/Models/DashboardPreference.php @@ -0,0 +1,91 @@ + + */ + private const ALLOWED_PINNED_SPACES = [ + '/dashboard/profile', + '/dashboard/notifications', + '/dashboard/comments/received', + '/dashboard/followers', + '/dashboard/following', + '/dashboard/favorites', + '/dashboard/artworks', + '/dashboard/gallery', + '/dashboard/awards', + '/creator/stories', + '/studio', + ]; + + protected $table = 'dashboard_preferences'; + protected $primaryKey = 'user_id'; + public $incrementing = false; + protected $keyType = 'int'; + + protected $fillable = [ + 'user_id', + 'pinned_spaces', + ]; + + protected function casts(): array + { + return [ + 'pinned_spaces' => 'array', + ]; + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class, 'user_id'); + } + + /** + * @param array $hrefs + * @return list + */ + public static function sanitizePinnedSpaces(array $hrefs): array + { + $allowed = array_fill_keys(self::ALLOWED_PINNED_SPACES, true); + $sanitized = []; + + foreach ($hrefs as $href) { + if (! is_string($href) || ! isset($allowed[$href])) { + continue; + } + + if (in_array($href, $sanitized, true)) { + continue; + } + + $sanitized[] = $href; + + if (count($sanitized) >= self::MAX_PINNED_SPACES) { + break; + } + } + + return $sanitized; + } + + /** + * @return list + */ + public static function pinnedSpacesForUser(User $user): array + { + $preference = static::query()->find($user->id); + $spaces = $preference?->pinned_spaces; + + return is_array($spaces) ? static::sanitizePinnedSpaces($spaces) : []; + } +} diff --git a/app/Models/Leaderboard.php b/app/Models/Leaderboard.php new file mode 100644 index 00000000..bc4e098c --- /dev/null +++ b/app/Models/Leaderboard.php @@ -0,0 +1,37 @@ + 'integer', + 'score' => 'float', + ]; + } +} diff --git a/app/Models/Story.php b/app/Models/Story.php index be94d674..18f6138a 100644 --- a/app/Models/Story.php +++ b/app/Models/Story.php @@ -5,6 +5,8 @@ declare(strict_types=1); namespace App\Models; use App\Models\StoryLike; +use App\Models\StoryBookmark; +use App\Models\StoryComment; use App\Models\StoryView; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\BelongsTo; @@ -101,6 +103,16 @@ class Story extends Model return $this->hasMany(StoryLike::class, 'story_id'); } + public function comments(): HasMany + { + return $this->hasMany(StoryComment::class, 'story_id'); + } + + public function bookmarks(): HasMany + { + return $this->hasMany(StoryBookmark::class, 'story_id'); + } + // ── Scopes ─────────────────────────────────────────────────────────── public function scopePublished($query) diff --git a/app/Models/StoryBookmark.php b/app/Models/StoryBookmark.php new file mode 100644 index 00000000..42226d7d --- /dev/null +++ b/app/Models/StoryBookmark.php @@ -0,0 +1,36 @@ + 'integer', + 'user_id' => 'integer', + 'created_at' => 'datetime', + 'updated_at' => 'datetime', + ]; + + public function story(): BelongsTo + { + return $this->belongsTo(Story::class, 'story_id'); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class, 'user_id'); + } +} \ No newline at end of file diff --git a/app/Models/StoryComment.php b/app/Models/StoryComment.php new file mode 100644 index 00000000..86c171c7 --- /dev/null +++ b/app/Models/StoryComment.php @@ -0,0 +1,62 @@ + 'integer', + 'user_id' => 'integer', + 'parent_id' => 'integer', + 'is_approved' => 'boolean', + 'created_at' => 'datetime', + 'updated_at' => 'datetime', + 'deleted_at' => 'datetime', + ]; + + public function story(): BelongsTo + { + return $this->belongsTo(Story::class, 'story_id'); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class, 'user_id'); + } + + public function parent(): BelongsTo + { + return $this->belongsTo(self::class, 'parent_id'); + } + + public function replies(): HasMany + { + return $this->hasMany(self::class, 'parent_id')->orderBy('created_at'); + } + + public function approvedReplies(): HasMany + { + return $this->replies()->where('is_approved', true)->whereNull('deleted_at')->with(['user.profile', 'approvedReplies']); + } +} \ No newline at end of file diff --git a/app/Models/User.php b/app/Models/User.php index 205e7dc5..169bc5ca 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -5,6 +5,7 @@ namespace App\Models; // use Illuminate\Contracts\Auth\MustVerifyEmail; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\BelongsToMany; +use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasOne; use Illuminate\Database\Eloquent\Relations\HasMany; use App\Models\SocialAccount; @@ -12,6 +13,9 @@ use App\Models\Conversation; use App\Models\ConversationParticipant; use App\Models\Message; use App\Models\Notification; +use App\Models\Achievement; +use App\Models\UserAchievement; +use App\Models\UserXpLog; use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; @@ -50,6 +54,9 @@ class User extends Authenticatable 'spam_reports', 'approved_posts', 'flagged_posts', + 'xp', + 'level', + 'rank', 'password', 'role', 'allow_messages_from', @@ -88,6 +95,9 @@ class User extends Authenticatable 'spam_reports' => 'integer', 'approved_posts' => 'integer', 'flagged_posts' => 'integer', + 'xp' => 'integer', + 'level' => 'integer', + 'rank' => 'string', 'password' => 'hashed', 'allow_messages_from' => 'string', ]; @@ -108,6 +118,16 @@ class User extends Authenticatable return $this->hasOne(UserProfile::class, 'user_id'); } + public function dashboardPreference(): HasOne + { + return $this->hasOne(DashboardPreference::class, 'user_id'); + } + + public function country(): BelongsTo + { + return $this->belongsTo(Country::class); + } + public function statistics(): HasOne { return $this->hasOne(UserStatistic::class, 'user_id'); @@ -140,6 +160,22 @@ class User extends Authenticatable return $this->hasMany(ProfileComment::class, 'profile_user_id'); } + public function xpLogs(): HasMany + { + return $this->hasMany(UserXpLog::class, 'user_id'); + } + + public function userAchievements(): HasMany + { + return $this->hasMany(UserAchievement::class, 'user_id'); + } + + public function achievements(): BelongsToMany + { + return $this->belongsToMany(Achievement::class, 'user_achievements', 'user_id', 'achievement_id') + ->withPivot('unlocked_at'); + } + // ── Messaging ──────────────────────────────────────────────────────────── public function conversations(): BelongsToMany diff --git a/app/Models/UserAchievement.php b/app/Models/UserAchievement.php new file mode 100644 index 00000000..600532be --- /dev/null +++ b/app/Models/UserAchievement.php @@ -0,0 +1,42 @@ + 'integer', + 'achievement_id' => 'integer', + 'unlocked_at' => 'datetime', + ]; + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function achievement(): BelongsTo + { + return $this->belongsTo(Achievement::class, 'achievement_id'); + } +} diff --git a/app/Models/UserXpLog.php b/app/Models/UserXpLog.php new file mode 100644 index 00000000..3581b1cd --- /dev/null +++ b/app/Models/UserXpLog.php @@ -0,0 +1,39 @@ + 'integer', + 'xp' => 'integer', + 'reference_id' => 'integer', + 'created_at' => 'datetime', + ]; + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/app/Notifications/AchievementUnlockedNotification.php b/app/Notifications/AchievementUnlockedNotification.php new file mode 100644 index 00000000..9347b71c --- /dev/null +++ b/app/Notifications/AchievementUnlockedNotification.php @@ -0,0 +1,40 @@ + 'achievement_unlocked', + 'achievement_id' => $this->achievement->id, + 'achievement_slug' => $this->achievement->slug, + 'title' => $this->achievement->name, + 'icon' => $this->achievement->icon, + 'message' => '🎉 You unlocked: ' . $this->achievement->name, + 'xp_reward' => (int) $this->achievement->xp_reward, + 'url' => '/dashboard?panel=achievements', + ]; + } +} diff --git a/app/Notifications/ArtworkCommentedNotification.php b/app/Notifications/ArtworkCommentedNotification.php new file mode 100644 index 00000000..d6bb772b --- /dev/null +++ b/app/Notifications/ArtworkCommentedNotification.php @@ -0,0 +1,51 @@ +actor->name ?: $this->actor->username ?: 'Someone'; + $slug = Str::slug((string) ($this->artwork->slug ?: $this->artwork->title)) ?: (string) $this->artwork->id; + + return [ + 'type' => 'artwork_commented', + 'artwork_id' => (int) $this->artwork->id, + 'artwork_title' => $this->artwork->title, + 'comment_id' => (int) $this->comment->id, + 'actor_id' => (int) $this->actor->id, + 'actor_name' => $this->actor->name, + 'actor_username' => $this->actor->username, + 'message' => $label . ' commented on your artwork', + 'url' => route('art.show', ['id' => $this->artwork->id, 'slug' => $slug]) . '#comment-' . $this->comment->id, + ]; + } +} \ No newline at end of file diff --git a/app/Notifications/ArtworkLikedNotification.php b/app/Notifications/ArtworkLikedNotification.php new file mode 100644 index 00000000..adc91112 --- /dev/null +++ b/app/Notifications/ArtworkLikedNotification.php @@ -0,0 +1,48 @@ +actor->name ?: $this->actor->username ?: 'Someone'; + $slug = Str::slug((string) ($this->artwork->slug ?: $this->artwork->title)) ?: (string) $this->artwork->id; + + return [ + 'type' => 'artwork_liked', + 'artwork_id' => (int) $this->artwork->id, + 'artwork_title' => $this->artwork->title, + 'actor_id' => (int) $this->actor->id, + 'actor_name' => $this->actor->name, + 'actor_username' => $this->actor->username, + 'message' => $label . ' liked your artwork', + 'url' => route('art.show', ['id' => $this->artwork->id, 'slug' => $slug]), + ]; + } +} \ No newline at end of file diff --git a/app/Notifications/ArtworkMentionedNotification.php b/app/Notifications/ArtworkMentionedNotification.php new file mode 100644 index 00000000..42b4d1d7 --- /dev/null +++ b/app/Notifications/ArtworkMentionedNotification.php @@ -0,0 +1,51 @@ +actor->name ?: $this->actor->username ?: 'Someone'; + $slug = Str::slug((string) ($this->artwork->slug ?: $this->artwork->title)) ?: (string) $this->artwork->id; + + return [ + 'type' => 'artwork_mentioned', + 'artwork_id' => (int) $this->artwork->id, + 'artwork_title' => $this->artwork->title, + 'comment_id' => (int) $this->comment->id, + 'actor_id' => (int) $this->actor->id, + 'actor_name' => $this->actor->name, + 'actor_username' => $this->actor->username, + 'message' => $label . ' mentioned you in an artwork comment', + 'url' => route('art.show', ['id' => $this->artwork->id, 'slug' => $slug]) . '#comment-' . $this->comment->id, + ]; + } +} \ No newline at end of file diff --git a/app/Notifications/ArtworkSharedNotification.php b/app/Notifications/ArtworkSharedNotification.php index d969f749..9d813ce9 100644 --- a/app/Notifications/ArtworkSharedNotification.php +++ b/app/Notifications/ArtworkSharedNotification.php @@ -24,6 +24,11 @@ class ArtworkSharedNotification extends Notification implements ShouldQueue return ['database']; } + public function databaseType(object $notifiable): string + { + return 'artwork_shared'; + } + public function toDatabase(object $notifiable): array { return [ @@ -34,7 +39,7 @@ class ArtworkSharedNotification extends Notification implements ShouldQueue 'sharer_id' => $this->sharer->id, 'sharer_name' => $this->sharer->name, 'sharer_username' => $this->sharer->username, - 'message' => "{$this->sharer->name} shared your artwork "{$this->artwork->title}"", + 'message' => $this->sharer->name . ' shared your artwork "' . $this->artwork->title . '"', 'url' => "/@{$this->sharer->username}?tab=posts", ]; } diff --git a/app/Notifications/PostCommentedNotification.php b/app/Notifications/PostCommentedNotification.php index e32930ea..a6bf29f2 100644 --- a/app/Notifications/PostCommentedNotification.php +++ b/app/Notifications/PostCommentedNotification.php @@ -24,6 +24,11 @@ class PostCommentedNotification extends Notification implements ShouldQueue return ['database']; } + public function databaseType(object $notifiable): string + { + return 'post_commented'; + } + public function toDatabase(object $notifiable): array { return [ diff --git a/app/Notifications/StoryCommentedNotification.php b/app/Notifications/StoryCommentedNotification.php new file mode 100644 index 00000000..d27291c9 --- /dev/null +++ b/app/Notifications/StoryCommentedNotification.php @@ -0,0 +1,49 @@ +actor->name ?: $this->actor->username ?: 'Someone'; + + return [ + 'type' => 'story_commented', + 'story_id' => (int) $this->story->id, + 'story_title' => $this->story->title, + 'comment_id' => (int) $this->comment->id, + 'actor_id' => (int) $this->actor->id, + 'actor_name' => $this->actor->name, + 'actor_username' => $this->actor->username, + 'message' => $label . ' commented on your story', + 'url' => route('stories.show', ['slug' => $this->story->slug]) . '#story-comment-' . $this->comment->id, + ]; + } +} \ No newline at end of file diff --git a/app/Notifications/StoryLikedNotification.php b/app/Notifications/StoryLikedNotification.php new file mode 100644 index 00000000..773e4e5a --- /dev/null +++ b/app/Notifications/StoryLikedNotification.php @@ -0,0 +1,46 @@ +actor->name ?: $this->actor->username ?: 'Someone'; + + return [ + 'type' => 'story_liked', + 'story_id' => (int) $this->story->id, + 'story_title' => $this->story->title, + 'actor_id' => (int) $this->actor->id, + 'actor_name' => $this->actor->name, + 'actor_username' => $this->actor->username, + 'message' => $label . ' liked your story', + 'url' => route('stories.show', ['slug' => $this->story->slug]), + ]; + } +} \ No newline at end of file diff --git a/app/Notifications/StoryMentionedNotification.php b/app/Notifications/StoryMentionedNotification.php new file mode 100644 index 00000000..3c068b23 --- /dev/null +++ b/app/Notifications/StoryMentionedNotification.php @@ -0,0 +1,49 @@ +actor->name ?: $this->actor->username ?: 'Someone'; + + return [ + 'type' => 'story_mentioned', + 'story_id' => (int) $this->story->id, + 'story_title' => $this->story->title, + 'comment_id' => (int) $this->comment->id, + 'actor_id' => (int) $this->actor->id, + 'actor_name' => $this->actor->name, + 'actor_username' => $this->actor->username, + 'message' => $label . ' mentioned you in a story comment', + 'url' => route('stories.show', ['slug' => $this->story->slug]) . '#story-comment-' . $this->comment->id, + ]; + } +} \ No newline at end of file diff --git a/app/Notifications/StoryStatusNotification.php b/app/Notifications/StoryStatusNotification.php index 30b08965..fd75485a 100644 --- a/app/Notifications/StoryStatusNotification.php +++ b/app/Notifications/StoryStatusNotification.php @@ -24,6 +24,13 @@ class StoryStatusNotification extends Notification return ['database']; } + public function databaseType(object $notifiable): string + { + return in_array($this->event, ['approved', 'published'], true) + ? 'story_published' + : 'story_status'; + } + public function toDatabase(object $notifiable): array { $message = match ($this->event) { @@ -34,7 +41,9 @@ class StoryStatusNotification extends Notification }; return [ - 'type' => 'story.' . $this->event, + 'type' => in_array($this->event, ['approved', 'published'], true) + ? 'story_published' + : 'story.' . $this->event, 'story_id' => $this->story->id, 'title' => $this->story->title, 'slug' => $this->story->slug, diff --git a/app/Notifications/UserFollowedNotification.php b/app/Notifications/UserFollowedNotification.php new file mode 100644 index 00000000..b66d02fc --- /dev/null +++ b/app/Notifications/UserFollowedNotification.php @@ -0,0 +1,40 @@ +actor->name ?: $this->actor->username ?: 'Someone'; + + return [ + 'type' => 'user_followed', + 'actor_id' => (int) $this->actor->id, + 'actor_name' => $this->actor->name, + 'actor_username' => $this->actor->username, + 'message' => $label . ' started following you', + 'url' => $this->actor->username ? '/@' . $this->actor->username : null, + ]; + } +} \ No newline at end of file diff --git a/app/Observers/ArtworkCommentObserver.php b/app/Observers/ArtworkCommentObserver.php index 793a6e66..31ed96f4 100644 --- a/app/Observers/ArtworkCommentObserver.php +++ b/app/Observers/ArtworkCommentObserver.php @@ -7,6 +7,7 @@ namespace App\Observers; use App\Models\ArtworkComment; use App\Services\UserStatsService; use App\Services\UserMentionSyncService; +use App\Services\XPService; use Illuminate\Support\Facades\DB; /** @@ -18,6 +19,7 @@ class ArtworkCommentObserver public function __construct( private readonly UserStatsService $userStats, private readonly UserMentionSyncService $mentionSync, + private readonly XPService $xp, ) {} public function created(ArtworkComment $comment): void @@ -30,6 +32,7 @@ class ArtworkCommentObserver // The commenter is "active" $this->userStats->ensureRow($comment->user_id); $this->userStats->setLastActiveAt($comment->user_id); + $this->xp->awardCommentCreated((int) $comment->user_id, (int) $comment->id, 'artwork'); $this->mentionSync->syncForComment($comment); } diff --git a/app/Observers/ArtworkObserver.php b/app/Observers/ArtworkObserver.php index d6569bc1..a3911bc8 100644 --- a/app/Observers/ArtworkObserver.php +++ b/app/Observers/ArtworkObserver.php @@ -4,12 +4,14 @@ declare(strict_types=1); namespace App\Observers; +use App\Events\Achievements\AchievementCheckRequested; use App\Models\Artwork; use App\Jobs\RecComputeSimilarByTagsJob; use App\Jobs\RecComputeSimilarHybridJob; use App\Jobs\Posts\AutoUploadPostJob; use App\Services\ArtworkSearchIndexer; use App\Services\UserStatsService; +use App\Services\XPService; /** * Syncs artwork documents to Meilisearch on every relevant model event. @@ -22,6 +24,7 @@ class ArtworkObserver public function __construct( private readonly ArtworkSearchIndexer $indexer, private readonly UserStatsService $userStats, + private readonly XPService $xp, ) {} /** New artwork created — index; bump uploadscount + last_upload_at. */ @@ -30,6 +33,11 @@ class ArtworkObserver $this->indexer->index($artwork); $this->userStats->incrementUploads($artwork->user_id); $this->userStats->setLastUploadAt($artwork->user_id, $artwork->created_at); + + if ($artwork->published_at !== null) { + $this->xp->awardArtworkPublished((int) $artwork->user_id, (int) $artwork->id); + event(new AchievementCheckRequested((int) $artwork->user_id)); + } } /** Artwork updated — covers publish, approval, metadata changes. */ @@ -52,6 +60,9 @@ class ArtworkObserver // Auto-upload post: fire only when artwork transitions to published for the first time if ($artwork->wasChanged('published_at') && $artwork->published_at !== null) { + $this->xp->awardArtworkPublished((int) $artwork->user_id, (int) $artwork->id); + event(new AchievementCheckRequested((int) $artwork->user_id)); + $user = $artwork->user; $autoPost = $user?->profile?->auto_post_upload ?? true; if ($autoPost) { diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 3569f649..8d1c17b3 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -24,6 +24,8 @@ use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Log; use Illuminate\Queue\Events\JobFailed; +use App\Services\ReceivedCommentsInboxService; +use Klevze\ControlPanel\Framework\Core\Menu; class AppServiceProvider extends ServiceProvider { @@ -32,6 +34,11 @@ class AppServiceProvider extends ServiceProvider */ public function register(): void { + $this->app->singleton( + \App\Services\Countries\CountryRemoteProviderInterface::class, + \App\Services\Countries\CountryRemoteProvider::class, + ); + // Bind UploadDraftService interface to implementation $this->app->singleton(UploadDraftServiceInterface::class, function ($app) { return new UploadDraftService($app->make('filesystem')); @@ -55,6 +62,8 @@ class AppServiceProvider extends ServiceProvider */ public function boot(): void { + $this->registerCpadMenuItems(); + // Map the 'legacy' view namespace to resources/views/_legacy so all // view('legacy::foo') and @include('legacy::foo') calls resolve correctly // after the folder was renamed from legacy/ to _legacy/. @@ -66,6 +75,7 @@ class AppServiceProvider extends ServiceProvider $this->configureDownloadRateLimiter(); $this->configureArtworkRateLimiters(); $this->configureReactionRateLimiters(); + $this->configureSocialRateLimiters(); $this->configureSettingsRateLimiters(); $this->configureMailFailureLogging(); @@ -91,10 +101,22 @@ class AppServiceProvider extends ServiceProvider \App\Events\Posts\PostCommented::class, \App\Listeners\Posts\SendPostCommentedNotification::class, ); + Event::listen( + \App\Events\Posts\PostCommented::class, + \App\Listeners\Posts\AwardXpForPostCommented::class, + ); + Event::listen( + \App\Events\Achievements\AchievementCheckRequested::class, + \App\Listeners\Achievements\CheckUserAchievements::class, + ); + Event::listen( + \App\Events\Achievements\UserXpUpdated::class, + \App\Listeners\Achievements\CheckUserAchievements::class, + ); // Provide toolbar counts and user info to layout views (port of legacy toolbar logic) View::composer(['layouts.nova', 'layouts.nova.*'], function ($view) { - $uploadCount = $favCount = $msgCount = $noticeCount = 0; + $uploadCount = $favCount = $msgCount = $noticeCount = $receivedCommentsCount = 0; $avatarHash = null; $displayName = null; $userId = null; @@ -130,11 +152,18 @@ class AppServiceProvider extends ServiceProvider } try { - $noticeCount = DB::table('notification')->where('user_id', $userId)->where('new', 1)->count(); + $noticeCount = DB::table('notifications')->where('user_id', $userId)->whereNull('read_at')->count(); } catch (\Throwable $e) { $noticeCount = 0; } + try { + $receivedCommentsCount = $this->app->make(ReceivedCommentsInboxService::class) + ->unreadCountForUser(Auth::user()); + } catch (\Throwable $e) { + $receivedCommentsCount = 0; + } + try { $profile = DB::table('user_profiles')->where('user_id', $userId)->first(); $avatarHash = $profile->avatar_hash ?? null; @@ -145,7 +174,7 @@ class AppServiceProvider extends ServiceProvider $displayName = Auth::user()->name ?: (Auth::user()->username ?? ''); } - $view->with(compact('userId','uploadCount', 'favCount', 'msgCount', 'noticeCount', 'avatarHash', 'displayName')); + $view->with(compact('userId','uploadCount', 'favCount', 'msgCount', 'noticeCount', 'receivedCommentsCount', 'avatarHash', 'displayName')); }); // Replace the framework HandleCors with our ConditionalCors so the @@ -315,6 +344,27 @@ class AppServiceProvider extends ServiceProvider }); } + private function configureSocialRateLimiters(): void + { + RateLimiter::for('social-write', function (Request $request): array { + $userId = $request->user()?->id ?? 'guest'; + + return [ + Limit::perMinute(60)->by('social-write:user:' . $userId), + Limit::perMinute(120)->by('social-write:ip:' . $request->ip()), + ]; + }); + + RateLimiter::for('social-read', function (Request $request): array { + $userId = $request->user()?->id ?? 'guest'; + + return [ + Limit::perMinute(240)->by('social-read:user:' . $userId), + Limit::perMinute(480)->by('social-read:ip:' . $request->ip()), + ]; + }); + } + private function configureSettingsRateLimiters(): void { RateLimiter::for('username-check', function (Request $request): Limit { @@ -336,4 +386,20 @@ class AppServiceProvider extends ServiceProvider return Limit::perHour(1)->by($key); }); } + + private function registerCpadMenuItems(): void + { + if (! class_exists(Menu::class)) { + return; + } + + try { + /** @var Menu $menu */ + $menu = $this->app->make(Menu::class); + $menu->addHeaderItem('Countries', 'fa-solid fa-flag', 'admin.cp.countries.main'); + $menu->addItem('Users', 'Countries', 'fa-solid fa-flag', 'admin.cp.countries.main'); + } catch (\Throwable) { + // Control panel menu registration should never block the app boot. + } + } } diff --git a/app/Services/AchievementService.php b/app/Services/AchievementService.php new file mode 100644 index 00000000..2dcedb18 --- /dev/null +++ b/app/Services/AchievementService.php @@ -0,0 +1,228 @@ +resolveUser($user); + $unlocked = []; + + foreach ($this->unlockableDefinitions($currentUser) as $achievement) { + if ($this->unlockAchievement($currentUser, $achievement)) { + $unlocked[] = $achievement->slug; + } + } + + $this->forgetSummaryCache((int) $currentUser->id); + + return $unlocked; + } + + public function previewUnlocks(User|int $user): array + { + $currentUser = $this->resolveUser($user); + + return $this->unlockableDefinitions($currentUser) + ->pluck('slug') + ->values() + ->all(); + } + + public function unlockAchievement(User|int $user, Achievement|int $achievement): bool + { + $currentUser = $user instanceof User ? $user : User::query()->findOrFail($user); + $currentAchievement = $achievement instanceof Achievement + ? $achievement + : Achievement::query()->findOrFail($achievement); + + $inserted = false; + + DB::transaction(function () use ($currentUser, $currentAchievement, &$inserted): void { + $result = UserAchievement::query()->insertOrIgnore([ + 'user_id' => (int) $currentUser->id, + 'achievement_id' => (int) $currentAchievement->id, + 'unlocked_at' => now(), + ]); + + if ($result === 0) { + return; + } + + $inserted = true; + }); + + if (! $inserted) { + return false; + } + + if ((int) $currentAchievement->xp_reward > 0) { + $this->xp->addXP( + (int) $currentUser->id, + (int) $currentAchievement->xp_reward, + 'achievement_unlocked:' . $currentAchievement->slug, + (int) $currentAchievement->id, + false, + ); + } + + $currentUser->notify(new AchievementUnlockedNotification($currentAchievement)); + $this->achievementPosts->achievementUnlocked($currentUser, $currentAchievement); + $this->forgetSummaryCache((int) $currentUser->id); + + return true; + } + + public function hasAchievement(User|int $user, string $achievementSlug): bool + { + $userId = $user instanceof User ? (int) $user->id : $user; + + return UserAchievement::query() + ->where('user_id', $userId) + ->whereHas('achievement', fn ($query) => $query->where('slug', $achievementSlug)) + ->exists(); + } + + public function summary(User|int $user): array + { + $userId = $user instanceof User ? (int) $user->id : $user; + + return Cache::remember($this->summaryCacheKey($userId), now()->addMinutes(10), function () use ($userId): array { + $currentUser = User::query()->with('statistics')->findOrFail($userId); + $progress = $this->progressSnapshot($currentUser); + $unlockedMap = UserAchievement::query() + ->where('user_id', $userId) + ->get() + ->keyBy('achievement_id'); + + $items = $this->definitions()->map(function (Achievement $achievement) use ($progress, $unlockedMap): array { + $progressValue = $this->progressValue($progress, $achievement); + /** @var UserAchievement|null $unlocked */ + $unlocked = $unlockedMap->get($achievement->id); + + return [ + 'id' => (int) $achievement->id, + 'name' => $achievement->name, + 'slug' => $achievement->slug, + 'description' => $achievement->description, + 'icon' => $achievement->icon, + 'xp_reward' => (int) $achievement->xp_reward, + 'type' => $achievement->type, + 'condition_type' => $achievement->condition_type, + 'condition_value' => (int) $achievement->condition_value, + 'progress' => min((int) $achievement->condition_value, $progressValue), + 'progress_percent' => $achievement->condition_value > 0 + ? (int) round((min((int) $achievement->condition_value, $progressValue) / (int) $achievement->condition_value) * 100) + : 100, + 'unlocked' => $unlocked !== null, + 'unlocked_at' => $unlocked?->unlocked_at?->toIso8601String(), + ]; + }); + + return [ + 'unlocked' => $items->where('unlocked', true)->sortByDesc('unlocked_at')->values()->all(), + 'locked' => $items->where('unlocked', false)->values()->all(), + 'recent' => $items->where('unlocked', true)->sortByDesc('unlocked_at')->take(4)->values()->all(), + 'counts' => [ + 'total' => $items->count(), + 'unlocked' => $items->where('unlocked', true)->count(), + 'locked' => $items->where('unlocked', false)->count(), + ], + ]; + }); + } + + public function definitions() + { + return Cache::remember('achievements:definitions', now()->addHour(), function () { + return Achievement::query()->orderBy('type')->orderBy('condition_value')->get(); + }); + } + + public function forgetDefinitionsCache(): void + { + Cache::forget('achievements:definitions'); + } + + private function progressValue(array $progress, Achievement $achievement): int + { + return (int) ($progress[$achievement->condition_type] ?? 0); + } + + private function resolveUser(User|int $user): User + { + return $user instanceof User + ? $user->loadMissing('statistics') + : User::query()->with('statistics')->findOrFail($user); + } + + private function unlockableDefinitions(User $user): Collection + { + $progress = $this->progressSnapshot($user); + $unlockedSlugs = $this->unlockedSlugs((int) $user->id); + + return $this->definitions()->filter(function (Achievement $achievement) use ($progress, $unlockedSlugs): bool { + if ($this->progressValue($progress, $achievement) < (int) $achievement->condition_value) { + return false; + } + + return ! isset($unlockedSlugs[$achievement->slug]); + })->values(); + } + + private function progressSnapshot(User $user): array + { + return [ + 'upload_count' => Artwork::query() + ->published() + ->where('user_id', $user->id) + ->count(), + 'likes_received' => (int) DB::table('artwork_likes as likes') + ->join('artworks as artworks', 'artworks.id', '=', 'likes.artwork_id') + ->where('artworks.user_id', $user->id) + ->count(), + 'followers_count' => (int) ($user->statistics?->followers_count ?? $user->followers()->count()), + 'stories_published' => Story::query()->published()->where('creator_id', $user->id)->count(), + 'level_reached' => (int) ($user->level ?? 1), + ]; + } + + private function unlockedSlugs(int $userId): array + { + return UserAchievement::query() + ->where('user_id', $userId) + ->join('achievements', 'achievements.id', '=', 'user_achievements.achievement_id') + ->pluck('achievements.slug') + ->flip() + ->all(); + } + + private function forgetSummaryCache(int $userId): void + { + Cache::forget($this->summaryCacheKey($userId)); + } + + private function summaryCacheKey(int $userId): string + { + return 'achievements:summary:' . $userId; + } +} diff --git a/app/Services/ActivityService.php b/app/Services/ActivityService.php new file mode 100644 index 00000000..33a7341c --- /dev/null +++ b/app/Services/ActivityService.php @@ -0,0 +1,34 @@ +communityActivity->getFeed($viewer, $filter, $page, $perPage, $actorUserId); + } + + public function requiresAuthentication(string $filter): bool + { + return $this->communityActivity->requiresAuthentication($filter); + } +} \ No newline at end of file diff --git a/app/Services/CommunityActivityService.php b/app/Services/CommunityActivityService.php index e07ff3e2..b6864214 100644 --- a/app/Services/CommunityActivityService.php +++ b/app/Services/CommunityActivityService.php @@ -5,9 +5,11 @@ declare(strict_types=1); namespace App\Services; use App\Enums\ReactionType; +use App\Models\ActivityEvent; use App\Models\Artwork; use App\Models\ArtworkComment; use App\Models\CommentReaction; +use App\Models\Story; use App\Models\User; use App\Models\UserMention; use App\Services\ThumbnailPresenter; @@ -74,13 +76,15 @@ final class CommunityActivityService $commentModels = $this->fetchCommentModels($sourceLimit, repliesOnly: false); $replyModels = $this->fetchCommentModels($sourceLimit, repliesOnly: true); $reactionModels = $this->fetchReactionModels($sourceLimit); + $recordedActivities = $this->fetchRecordedActivities($sourceLimit); $commentActivities = $commentModels->map(fn (ArtworkComment $comment) => $this->mapCommentActivity($comment, 'comment')); $replyActivities = $replyModels->map(fn (ArtworkComment $comment) => $this->mapCommentActivity($comment, 'reply')); $reactionActivities = $reactionModels->map(fn (CommentReaction $reaction) => $this->mapReactionActivity($reaction)); $mentionActivities = $this->fetchMentionActivities($sourceLimit); - $merged = $commentActivities + $merged = $recordedActivities + ->concat($commentActivities) ->concat($replyActivities) ->concat($reactionActivities) ->concat($mentionActivities) @@ -136,6 +140,89 @@ final class CommunityActivityService ]; } + private function fetchRecordedActivities(int $limit): Collection + { + $events = ActivityEvent::query() + ->select(['id', 'actor_id', 'type', 'target_type', 'target_id', 'meta', 'created_at']) + ->with([ + 'actor' => function ($query) { + $query + ->select('id', 'name', 'username', 'role', 'is_active', 'created_at') + ->with('profile:user_id,avatar_hash') + ->withCount('artworks'); + }, + ]) + ->whereHas('actor', fn ($query) => $query->where('is_active', true)->whereNull('deleted_at')) + ->latest('created_at') + ->limit($limit) + ->get(); + + if ($events->isEmpty()) { + return collect(); + } + + $artworkIds = $events + ->where('target_type', ActivityEvent::TARGET_ARTWORK) + ->pluck('target_id') + ->map(fn ($id) => (int) $id) + ->unique() + ->values() + ->all(); + + $storyIds = $events + ->where('target_type', ActivityEvent::TARGET_STORY) + ->pluck('target_id') + ->map(fn ($id) => (int) $id) + ->unique() + ->values() + ->all(); + + $targetUserIds = $events + ->where('target_type', ActivityEvent::TARGET_USER) + ->pluck('target_id') + ->map(fn ($id) => (int) $id) + ->unique() + ->values() + ->all(); + + $artworks = empty($artworkIds) + ? collect() + : Artwork::query() + ->select('id', 'user_id', 'title', 'slug', 'hash', 'thumb_ext', 'published_at', 'deleted_at', 'is_public', 'is_approved') + ->whereIn('id', $artworkIds) + ->public() + ->published() + ->whereNull('deleted_at') + ->get() + ->keyBy('id'); + + $stories = empty($storyIds) + ? collect() + : Story::query() + ->select('id', 'creator_id', 'title', 'slug', 'cover_image', 'published_at', 'status') + ->whereIn('id', $storyIds) + ->published() + ->get() + ->keyBy('id'); + + $targetUsers = empty($targetUserIds) + ? collect() + : User::query() + ->select('id', 'name', 'username', 'role', 'is_active', 'created_at') + ->with('profile:user_id,avatar_hash') + ->withCount('artworks') + ->whereIn('id', $targetUserIds) + ->where('is_active', true) + ->whereNull('deleted_at') + ->get() + ->keyBy('id'); + + return $events + ->map(fn (ActivityEvent $event) => $this->mapRecordedActivity($event, $artworks, $stories, $targetUsers)) + ->filter() + ->values(); + } + private function fetchCommentModels(int $limit, bool $repliesOnly): Collection { return ArtworkComment::query() @@ -262,6 +349,52 @@ final class CommunityActivityService ]; } + private function mapRecordedActivity(ActivityEvent $event, Collection $artworks, Collection $stories, Collection $targetUsers): ?array + { + if ($event->type === ActivityEvent::TYPE_COMMENT && $event->target_type === ActivityEvent::TARGET_ARTWORK) { + return null; + } + + $artwork = $event->target_type === ActivityEvent::TARGET_ARTWORK + ? $artworks->get((int) $event->target_id) + : null; + + $story = $event->target_type === ActivityEvent::TARGET_STORY + ? $stories->get((int) $event->target_id) + : null; + + $targetUser = $event->target_type === ActivityEvent::TARGET_USER + ? $targetUsers->get((int) $event->target_id) + : null; + + if ($event->target_type === ActivityEvent::TARGET_ARTWORK && ! $artwork) { + return null; + } + + if ($event->target_type === ActivityEvent::TARGET_STORY && ! $story) { + return null; + } + + if ($event->target_type === ActivityEvent::TARGET_USER && ! $targetUser) { + return null; + } + + $iso = $event->created_at?->toIso8601String(); + + return [ + 'id' => 'event:' . $event->id, + 'type' => (string) $event->type, + 'user' => $this->buildUserPayload($event->actor), + 'artwork' => $this->buildArtworkPayload($artwork), + 'story' => $this->buildStoryPayload($story), + 'target_user' => $this->buildUserPayload($targetUser), + 'meta' => is_array($event->meta) ? $event->meta : [], + 'created_at' => $iso, + 'time_ago' => $event->created_at?->diffForHumans(), + 'sort_timestamp' => $iso, + ]; + } + private function fetchMentionActivities(int $limit): Collection { if (! Schema::hasTable('user_mentions')) { @@ -384,6 +517,20 @@ final class CommunityActivityService ]; } + private function buildStoryPayload(?Story $story): ?array + { + if (! $story) { + return null; + } + + return [ + 'id' => (int) $story->id, + 'title' => html_entity_decode((string) ($story->title ?? 'Story'), ENT_QUOTES | ENT_HTML5, 'UTF-8'), + 'url' => route('stories.show', ['slug' => $story->slug]), + 'cover_url' => $story->cover_url, + ]; + } + private function buildCommentPayload(ArtworkComment $comment): array { $artwork = $this->buildArtworkPayload($comment->artwork); diff --git a/app/Services/Countries/CountryCatalogService.php b/app/Services/Countries/CountryCatalogService.php new file mode 100644 index 00000000..60648a00 --- /dev/null +++ b/app/Services/Countries/CountryCatalogService.php @@ -0,0 +1,100 @@ + + */ + public function activeCountries(): Collection + { + if (! Schema::hasTable('countries')) { + return collect(); + } + + /** @var Collection $countries */ + $countries = Cache::remember( + self::ACTIVE_ALL_CACHE_KEY, + max(60, (int) config('skinbase-countries.cache_ttl', 86400)), + fn (): Collection => Country::query()->active()->ordered()->get(), + ); + + return $countries; + } + + /** + * @return array> + */ + public function profileSelectOptions(): array + { + return Cache::remember( + self::PROFILE_SELECT_CACHE_KEY, + max(60, (int) config('skinbase-countries.cache_ttl', 86400)), + fn (): array => $this->activeCountries() + ->map(fn (Country $country): array => [ + 'id' => $country->id, + 'iso2' => $country->iso2, + 'name' => $country->name_common, + 'flag_emoji' => $country->flag_emoji, + 'flag_css_class' => $country->flag_css_class, + 'is_featured' => $country->is_featured, + 'flag_path' => $country->local_flag_path, + ]) + ->values() + ->all(), + ); + } + + public function findById(?int $countryId): ?Country + { + if ($countryId === null || $countryId <= 0 || ! Schema::hasTable('countries')) { + return null; + } + + return Country::query()->find($countryId); + } + + public function findByIso2(?string $iso2): ?Country + { + $normalized = strtoupper(trim((string) $iso2)); + + if ($normalized === '' || ! preg_match('/^[A-Z]{2}$/', $normalized) || ! Schema::hasTable('countries')) { + return null; + } + + return Country::query()->where('iso2', $normalized)->first(); + } + + public function resolveUserCountry(User $user): ?Country + { + if ($user->relationLoaded('country') && $user->country instanceof Country) { + return $user->country; + } + + if (! empty($user->country_id)) { + return $this->findById((int) $user->country_id); + } + + $countryCode = strtoupper((string) ($user->profile?->country_code ?? '')); + + return $countryCode !== '' ? $this->findByIso2($countryCode) : null; + } + + public function flushCache(): void + { + Cache::forget(self::ACTIVE_ALL_CACHE_KEY); + Cache::forget(self::PROFILE_SELECT_CACHE_KEY); + } +} diff --git a/app/Services/Countries/CountryRemoteProvider.php b/app/Services/Countries/CountryRemoteProvider.php new file mode 100644 index 00000000..89c412c6 --- /dev/null +++ b/app/Services/Countries/CountryRemoteProvider.php @@ -0,0 +1,115 @@ +http->acceptJson() + ->connectTimeout(max(1, (int) config('skinbase-countries.connect_timeout', 5))) + ->timeout(max(1, (int) config('skinbase-countries.timeout', 10))) + ->retry( + max(0, (int) config('skinbase-countries.retry_times', 2)), + max(0, (int) config('skinbase-countries.retry_sleep_ms', 250)), + throw: false, + ) + ->get($endpoint); + + if (! $response->successful()) { + throw new RuntimeException(sprintf('Country sync request failed with status %d.', $response->status())); + } + + $payload = $response->json(); + + if (! is_array($payload)) { + throw new RuntimeException('Country sync response was not a JSON array.'); + } + + return $this->normalizePayload($payload); + } + + public function normalizePayload(array $payload): array + { + $normalized = []; + + foreach ($payload as $record) { + if (! is_array($record)) { + continue; + } + + $country = $this->normalizeRecord($record); + + if ($country !== null) { + $normalized[] = $country; + } + } + + return $normalized; + } + + /** + * @param array $record + * @return array|null + */ + private function normalizeRecord(array $record): ?array + { + $iso2 = strtoupper(trim((string) ($record['cca2'] ?? $record['iso2'] ?? ''))); + + if (! preg_match('/^[A-Z]{2}$/', $iso2)) { + return null; + } + + $iso3 = strtoupper(trim((string) ($record['cca3'] ?? $record['iso3'] ?? ''))); + $iso3 = preg_match('/^[A-Z]{3}$/', $iso3) ? $iso3 : null; + + $numericCode = trim((string) ($record['ccn3'] ?? $record['numeric_code'] ?? '')); + $numericCode = preg_match('/^\d{1,3}$/', $numericCode) + ? str_pad($numericCode, 3, '0', STR_PAD_LEFT) + : null; + + $name = $record['name'] ?? []; + $nameCommon = trim((string) ($name['common'] ?? $record['name_common'] ?? '')); + + if ($nameCommon === '') { + return null; + } + + $nameOfficial = trim((string) ($name['official'] ?? $record['name_official'] ?? '')); + $flags = $record['flags'] ?? []; + $flagSvgUrl = trim((string) ($flags['svg'] ?? $record['flag_svg_url'] ?? '')); + $flagPngUrl = trim((string) ($flags['png'] ?? $record['flag_png_url'] ?? '')); + $flagEmoji = trim((string) ($record['flag'] ?? $record['flag_emoji'] ?? '')); + $region = trim((string) ($record['region'] ?? '')); + $subregion = trim((string) ($record['subregion'] ?? '')); + + return [ + 'iso2' => $iso2, + 'iso3' => $iso3, + 'numeric_code' => $numericCode, + 'name_common' => $nameCommon, + 'name_official' => $nameOfficial !== '' ? $nameOfficial : null, + 'region' => $region !== '' ? $region : null, + 'subregion' => $subregion !== '' ? $subregion : null, + 'flag_svg_url' => $flagSvgUrl !== '' ? $flagSvgUrl : null, + 'flag_png_url' => $flagPngUrl !== '' ? $flagPngUrl : null, + 'flag_emoji' => $flagEmoji !== '' ? $flagEmoji : null, + ]; + } +} diff --git a/app/Services/Countries/CountryRemoteProviderInterface.php b/app/Services/Countries/CountryRemoteProviderInterface.php new file mode 100644 index 00000000..f265c642 --- /dev/null +++ b/app/Services/Countries/CountryRemoteProviderInterface.php @@ -0,0 +1,23 @@ +> + */ + public function fetchAll(): array; + + /** + * Normalize a raw payload into syncable country records. + * + * @param array $payload + * @return array> + */ + public function normalizePayload(array $payload): array; +} diff --git a/app/Services/Countries/CountrySyncService.php b/app/Services/Countries/CountrySyncService.php new file mode 100644 index 00000000..4db65f17 --- /dev/null +++ b/app/Services/Countries/CountrySyncService.php @@ -0,0 +1,193 @@ + + */ + public function sync(bool $allowFallback = true, ?bool $deactivateMissing = null): array + { + if (! (bool) config('skinbase-countries.enabled', true)) { + throw new RuntimeException('Countries sync is disabled by configuration.'); + } + + $summary = [ + 'source' => null, + 'total_fetched' => 0, + 'inserted' => 0, + 'updated' => 0, + 'skipped' => 0, + 'invalid' => 0, + 'deactivated' => 0, + 'backfilled_users' => 0, + ]; + + try { + $records = $this->remoteProvider->fetchAll(); + $summary['source'] = (string) config('skinbase-countries.remote_source', 'remote'); + } catch (Throwable $exception) { + if (! $allowFallback || ! (bool) config('skinbase-countries.fallback_seed_enabled', true)) { + throw new RuntimeException('Country sync failed: '.$exception->getMessage(), previous: $exception); + } + + $records = $this->loadFallbackRecords(); + $summary['source'] = 'fallback'; + } + + if ($records === []) { + throw new RuntimeException('Country sync did not yield any valid country records.'); + } + + $summary['total_fetched'] = count($records); + $seenIso2 = []; + $featured = array_values(array_filter(array_map( + static fn (mixed $iso2): string => strtoupper(trim((string) $iso2)), + (array) config('skinbase-countries.featured_countries', []), + ))); + $featuredOrder = array_flip($featured); + + DB::transaction(function () use (&$summary, $records, &$seenIso2, $featuredOrder, $deactivateMissing): void { + foreach ($records as $record) { + $iso2 = strtoupper((string) ($record['iso2'] ?? '')); + + if (! preg_match('/^[A-Z]{2}$/', $iso2)) { + $summary['invalid']++; + continue; + } + + if (isset($seenIso2[$iso2])) { + $summary['skipped']++; + continue; + } + + $seenIso2[$iso2] = true; + + $country = Country::query()->firstOrNew(['iso2' => $iso2]); + $exists = $country->exists; + $featuredIndex = $featuredOrder[$iso2] ?? null; + + $country->fill([ + 'iso' => $iso2, + 'iso3' => $record['iso3'] ?? null, + 'numeric_code' => $record['numeric_code'] ?? null, + 'name' => $record['name_common'], + 'native' => $record['name_official'] ?? null, + 'continent' => $this->continentCode($record['region'] ?? null), + 'name_common' => $record['name_common'], + 'name_official' => $record['name_official'] ?? null, + 'region' => $record['region'] ?? null, + 'subregion' => $record['subregion'] ?? null, + 'flag_svg_url' => $record['flag_svg_url'] ?? null, + 'flag_png_url' => $record['flag_png_url'] ?? null, + 'flag_emoji' => $record['flag_emoji'] ?? null, + 'active' => true, + 'is_featured' => $featuredIndex !== null, + 'sort_order' => $featuredIndex !== null ? $featuredIndex + 1 : 1000, + ]); + + if (! $exists) { + $country->save(); + $summary['inserted']++; + continue; + } + + if ($country->isDirty()) { + $country->save(); + $summary['updated']++; + continue; + } + + $summary['skipped']++; + } + + if ($deactivateMissing ?? (bool) config('skinbase-countries.deactivate_missing', false)) { + $summary['deactivated'] = Country::query() + ->where('active', true) + ->whereNotIn('iso2', array_keys($seenIso2)) + ->update(['active' => false]); + } + }); + + $summary['backfilled_users'] = $this->backfillUsersFromLegacyProfileCodes(); + $this->catalog->flushCache(); + + return $summary; + } + + /** + * @return array> + */ + private function loadFallbackRecords(): array + { + $path = (string) config('skinbase-countries.fallback_seed_path', database_path('data/countries-fallback.json')); + + if (! is_file($path)) { + throw new RuntimeException('Country fallback dataset is missing.'); + } + + try { + $decoded = json_decode((string) file_get_contents($path), true, 512, JSON_THROW_ON_ERROR); + } catch (JsonException $exception) { + throw new RuntimeException('Country fallback dataset is invalid JSON.', previous: $exception); + } + + if (! is_array($decoded)) { + throw new RuntimeException('Country fallback dataset is not a JSON array.'); + } + + return $this->remoteProvider->normalizePayload($decoded); + } + + private function backfillUsersFromLegacyProfileCodes(): int + { + if (! Schema::hasTable('user_profiles') || ! Schema::hasTable('users') || ! Schema::hasColumn('users', 'country_id')) { + return 0; + } + + $rows = DB::table('users as users') + ->join('user_profiles as profiles', 'profiles.user_id', '=', 'users.id') + ->join('countries as countries', 'countries.iso2', '=', 'profiles.country_code') + ->whereNull('users.country_id') + ->whereNotNull('profiles.country_code') + ->select(['users.id as user_id', 'countries.id as country_id']) + ->get(); + + foreach ($rows as $row) { + DB::table('users') + ->where('id', (int) $row->user_id) + ->update(['country_id' => (int) $row->country_id]); + } + + return $rows->count(); + } + + private function continentCode(?string $region): ?string + { + return Arr::get([ + 'Africa' => 'AF', + 'Americas' => 'AM', + 'Asia' => 'AS', + 'Europe' => 'EU', + 'Oceania' => 'OC', + 'Antarctic' => 'AN', + ], trim((string) $region)); + } +} diff --git a/app/Services/FollowService.php b/app/Services/FollowService.php index cd29cdbf..ff4ce9f0 100644 --- a/app/Services/FollowService.php +++ b/app/Services/FollowService.php @@ -2,6 +2,9 @@ namespace App\Services; +use App\Models\User; +use App\Notifications\UserFollowedNotification; +use App\Events\Achievements\AchievementCheckRequested; use Illuminate\Support\Facades\DB; /** @@ -16,6 +19,8 @@ use Illuminate\Support\Facades\DB; */ final class FollowService { + public function __construct(private readonly XPService $xp) {} + /** * Follow $targetId on behalf of $actorId. * @@ -60,6 +65,15 @@ final class FollowService targetId: $targetId, ); } catch (\Throwable) {} + + $targetUser = User::query()->find($targetId); + $actorUser = User::query()->find($actorId); + if ($targetUser && $actorUser) { + $targetUser->notify(new UserFollowedNotification($actorUser)); + } + + $this->xp->awardFollowerReceived($targetId, $actorId); + event(new AchievementCheckRequested($targetId)); } return $inserted; diff --git a/app/Services/LeaderboardService.php b/app/Services/LeaderboardService.php new file mode 100644 index 00000000..e9de320e --- /dev/null +++ b/app/Services/LeaderboardService.php @@ -0,0 +1,553 @@ +normalizePeriod($period); + $rows = $normalizedPeriod === Leaderboard::PERIOD_ALL_TIME + ? $this->allTimeCreatorRows() + : $this->windowedCreatorRows($this->periodStart($normalizedPeriod)); + + return $this->persistRows(Leaderboard::TYPE_CREATOR, $normalizedPeriod, $rows, self::CREATOR_STORE_LIMIT); + } + + public function calculateArtworkLeaderboard(string $period): int + { + $normalizedPeriod = $this->normalizePeriod($period); + $rows = $normalizedPeriod === Leaderboard::PERIOD_ALL_TIME + ? $this->allTimeArtworkRows() + : $this->windowedArtworkRows($this->periodStart($normalizedPeriod)); + + return $this->persistRows(Leaderboard::TYPE_ARTWORK, $normalizedPeriod, $rows, self::ENTITY_STORE_LIMIT); + } + + public function calculateStoryLeaderboard(string $period): int + { + $normalizedPeriod = $this->normalizePeriod($period); + $rows = $normalizedPeriod === Leaderboard::PERIOD_ALL_TIME + ? $this->allTimeStoryRows() + : $this->windowedStoryRows($this->periodStart($normalizedPeriod)); + + return $this->persistRows(Leaderboard::TYPE_STORY, $normalizedPeriod, $rows, self::ENTITY_STORE_LIMIT); + } + + public function refreshAll(): array + { + $results = []; + + foreach ([ + Leaderboard::TYPE_CREATOR, + Leaderboard::TYPE_ARTWORK, + Leaderboard::TYPE_STORY, + ] as $type) { + foreach ($this->periods() as $period) { + $results[$type][$period] = match ($type) { + Leaderboard::TYPE_CREATOR => $this->calculateCreatorLeaderboard($period), + Leaderboard::TYPE_ARTWORK => $this->calculateArtworkLeaderboard($period), + Leaderboard::TYPE_STORY => $this->calculateStoryLeaderboard($period), + }; + } + } + + return $results; + } + + public function getLeaderboard(string $type, string $period, int $limit = 50): array + { + $normalizedType = $this->normalizeType($type); + $normalizedPeriod = $this->normalizePeriod($period); + $limit = max(1, min($limit, 100)); + + return Cache::remember( + $this->cacheKey($normalizedType, $normalizedPeriod, $limit), + self::CACHE_TTL_SECONDS, + function () use ($normalizedType, $normalizedPeriod, $limit): array { + $items = Leaderboard::query() + ->where('type', $normalizedType) + ->where('period', $normalizedPeriod) + ->orderByDesc('score') + ->orderBy('entity_id') + ->limit($limit) + ->get(['entity_id', 'score']) + ->values(); + + if ($items->isEmpty()) { + return [ + 'type' => $normalizedType, + 'period' => $normalizedPeriod, + 'items' => [], + ]; + } + + $entities = match ($normalizedType) { + Leaderboard::TYPE_CREATOR => $this->creatorEntities($items->pluck('entity_id')->all()), + Leaderboard::TYPE_ARTWORK => $this->artworkEntities($items->pluck('entity_id')->all()), + Leaderboard::TYPE_STORY => $this->storyEntities($items->pluck('entity_id')->all()), + }; + + return [ + 'type' => $normalizedType, + 'period' => $normalizedPeriod, + 'items' => $items->values()->map(function (Leaderboard $row, int $index) use ($entities): array { + return [ + 'rank' => $index + 1, + 'score' => round((float) $row->score, 1), + 'entity' => $entities[(int) $row->entity_id] ?? null, + ]; + })->filter(fn (array $item): bool => $item['entity'] !== null)->values()->all(), + ]; + } + ); + } + + public function creatorRankSummary(int $userId, string $period = Leaderboard::PERIOD_WEEKLY): ?array + { + $normalizedPeriod = $this->normalizePeriod($period); + + return Cache::remember( + sprintf('leaderboard:creator-rank:%d:%s', $userId, $normalizedPeriod), + self::CACHE_TTL_SECONDS, + function () use ($userId, $normalizedPeriod): ?array { + $row = Leaderboard::query() + ->where('type', Leaderboard::TYPE_CREATOR) + ->where('period', $normalizedPeriod) + ->where('entity_id', $userId) + ->first(['entity_id', 'score']); + + if (! $row) { + return null; + } + + $higherScores = Leaderboard::query() + ->where('type', Leaderboard::TYPE_CREATOR) + ->where('period', $normalizedPeriod) + ->where(function ($query) use ($row): void { + $query->where('score', '>', $row->score) + ->orWhere(function ($ties) use ($row): void { + $ties->where('score', '=', $row->score) + ->where('entity_id', '<', $row->entity_id); + }); + }) + ->count(); + + return [ + 'period' => $normalizedPeriod, + 'rank' => $higherScores + 1, + 'score' => round((float) $row->score, 1), + ]; + } + ); + } + + public function periods(): array + { + return [ + Leaderboard::PERIOD_DAILY, + Leaderboard::PERIOD_WEEKLY, + Leaderboard::PERIOD_MONTHLY, + Leaderboard::PERIOD_ALL_TIME, + ]; + } + + public function normalizePeriod(string $period): string + { + return match (strtolower(trim($period))) { + 'daily' => Leaderboard::PERIOD_DAILY, + 'weekly' => Leaderboard::PERIOD_WEEKLY, + 'monthly' => Leaderboard::PERIOD_MONTHLY, + 'all', 'all_time', 'all-time' => Leaderboard::PERIOD_ALL_TIME, + default => Leaderboard::PERIOD_WEEKLY, + }; + } + + private function normalizeType(string $type): string + { + return match (strtolower(trim($type))) { + 'creator', 'creators' => Leaderboard::TYPE_CREATOR, + 'artwork', 'artworks' => Leaderboard::TYPE_ARTWORK, + 'story', 'stories' => Leaderboard::TYPE_STORY, + default => Leaderboard::TYPE_CREATOR, + }; + } + + private function periodStart(string $period): CarbonImmutable + { + $now = CarbonImmutable::now(); + + return match ($period) { + Leaderboard::PERIOD_DAILY => $now->subDay(), + Leaderboard::PERIOD_WEEKLY => $now->subWeek(), + Leaderboard::PERIOD_MONTHLY => $now->subMonth(), + default => $now->subWeek(), + }; + } + + private function persistRows(string $type, string $period, Collection $rows, int $limit): int + { + $trimmed = $rows + ->sortByDesc('score') + ->take($limit) + ->values(); + + DB::transaction(function () use ($type, $period, $trimmed): void { + Leaderboard::query() + ->where('type', $type) + ->where('period', $period) + ->delete(); + + if ($trimmed->isNotEmpty()) { + $timestamp = now(); + Leaderboard::query()->insert( + $trimmed->map(fn (array $row): array => [ + 'type' => $type, + 'period' => $period, + 'entity_id' => (int) $row['entity_id'], + 'score' => round((float) $row['score'], 2), + 'created_at' => $timestamp, + 'updated_at' => $timestamp, + ])->all() + ); + } + }); + + $this->flushCache($type, $period); + + return $trimmed->count(); + } + + private function flushCache(string $type, string $period): void + { + foreach ([10, 25, 50, 100] as $limit) { + Cache::forget($this->cacheKey($type, $period, $limit)); + } + + if ($type === Leaderboard::TYPE_CREATOR) { + Cache::forget('leaderboard:top-creators-widget:' . $period); + } + } + + private function cacheKey(string $type, string $period, int $limit): string + { + return sprintf('leaderboard:%s:%s:%d', $type, $period, $limit); + } + + private function allTimeCreatorRows(): Collection + { + return User::query() + ->from('users') + ->leftJoin('user_statistics as us', 'us.user_id', '=', 'users.id') + ->whereNull('users.deleted_at') + ->where('users.is_active', true) + ->select([ + 'users.id', + DB::raw('COALESCE(users.xp, 0) as xp'), + DB::raw('COALESCE(us.followers_count, 0) as followers_count'), + DB::raw('COALESCE(us.favorites_received_count, 0) as likes_received'), + DB::raw('COALESCE(us.artwork_views_received_count, 0) as artwork_views'), + ]) + ->get() + ->map(function ($row): array { + $score = ((int) $row->xp * 1) + + ((int) $row->followers_count * 10) + + ((int) $row->likes_received * 2) + + ((int) $row->artwork_views * 0.1); + + return [ + 'entity_id' => (int) $row->id, + 'score' => $score, + ]; + }) + ->filter(fn (array $row): bool => $row['score'] > 0) + ->values(); + } + + private function windowedCreatorRows(CarbonImmutable $start): Collection + { + $xp = DB::table('user_xp_logs') + ->select('user_id', DB::raw('SUM(xp) as xp')) + ->where('created_at', '>=', $start) + ->groupBy('user_id'); + + $followers = DB::table('user_followers') + ->select('user_id', DB::raw('COUNT(*) as followers_count')) + ->where('created_at', '>=', $start) + ->groupBy('user_id'); + + $likes = DB::table('artwork_likes as likes') + ->join('artworks as artworks', 'artworks.id', '=', 'likes.artwork_id') + ->select('artworks.user_id', DB::raw('COUNT(*) as likes_received')) + ->where('likes.created_at', '>=', $start) + ->groupBy('artworks.user_id'); + + $views = DB::query() + ->fromSub($this->artworkSnapshotDeltas($start), 'deltas') + ->join('artworks as artworks', 'artworks.id', '=', 'deltas.artwork_id') + ->select('artworks.user_id', DB::raw('SUM(deltas.views_delta) as artwork_views')) + ->groupBy('artworks.user_id'); + + return User::query() + ->from('users') + ->leftJoinSub($xp, 'xp', 'xp.user_id', '=', 'users.id') + ->leftJoinSub($followers, 'followers', 'followers.user_id', '=', 'users.id') + ->leftJoinSub($likes, 'likes', 'likes.user_id', '=', 'users.id') + ->leftJoinSub($views, 'views', 'views.user_id', '=', 'users.id') + ->whereNull('users.deleted_at') + ->where('users.is_active', true) + ->select([ + 'users.id', + DB::raw('COALESCE(xp.xp, 0) as xp'), + DB::raw('COALESCE(followers.followers_count, 0) as followers_count'), + DB::raw('COALESCE(likes.likes_received, 0) as likes_received'), + DB::raw('COALESCE(views.artwork_views, 0) as artwork_views'), + ]) + ->get() + ->map(function ($row): array { + $score = ((int) $row->xp * 1) + + ((int) $row->followers_count * 10) + + ((int) $row->likes_received * 2) + + ((float) $row->artwork_views * 0.1); + + return [ + 'entity_id' => (int) $row->id, + 'score' => $score, + ]; + }) + ->filter(fn (array $row): bool => $row['score'] > 0) + ->values(); + } + + private function allTimeArtworkRows(): Collection + { + return Artwork::query() + ->from('artworks') + ->join('artwork_stats as stats', 'stats.artwork_id', '=', 'artworks.id') + ->public() + ->select([ + 'artworks.id', + DB::raw('COALESCE(stats.favorites, 0) as likes_count'), + DB::raw('COALESCE(stats.views, 0) as views_count'), + DB::raw('COALESCE(stats.downloads, 0) as downloads_count'), + DB::raw('COALESCE(stats.comments_count, 0) as comments_count'), + ]) + ->get() + ->map(fn ($row): array => [ + 'entity_id' => (int) $row->id, + 'score' => ((int) $row->likes_count * 3) + + ((int) $row->views_count * 1) + + ((int) $row->downloads_count * 5) + + ((int) $row->comments_count * 4), + ]) + ->filter(fn (array $row): bool => $row['score'] > 0) + ->values(); + } + + private function windowedArtworkRows(CarbonImmutable $start): Collection + { + $views = $this->artworkSnapshotDeltas($start); + + $likes = DB::table('artwork_likes') + ->select('artwork_id', DB::raw('COUNT(*) as favourites_delta')) + ->where('created_at', '>=', $start) + ->groupBy('artwork_id'); + + $downloads = DB::table('artwork_downloads') + ->select('artwork_id', DB::raw('COUNT(*) as downloads_delta')) + ->where('created_at', '>=', $start) + ->groupBy('artwork_id'); + + $comments = DB::table('artwork_comments') + ->select('artwork_id', DB::raw('COUNT(*) as comments_delta')) + ->where('created_at', '>=', $start) + ->where('is_approved', true) + ->whereNull('deleted_at') + ->groupBy('artwork_id'); + + return Artwork::query() + ->from('artworks') + ->leftJoinSub($views, 'views', 'views.artwork_id', '=', 'artworks.id') + ->leftJoinSub($likes, 'likes', 'likes.artwork_id', '=', 'artworks.id') + ->leftJoinSub($downloads, 'downloads', 'downloads.artwork_id', '=', 'artworks.id') + ->leftJoinSub($comments, 'comments', 'comments.artwork_id', '=', 'artworks.id') + ->where('artworks.is_public', true) + ->where('artworks.is_approved', true) + ->whereNull('artworks.deleted_at') + ->whereNotNull('artworks.published_at') + ->select([ + 'artworks.id', + DB::raw('COALESCE(likes.favourites_delta, 0) as favourites_delta'), + DB::raw('COALESCE(views.views_delta, 0) as views_delta'), + DB::raw('COALESCE(downloads.downloads_delta, 0) as downloads_delta'), + DB::raw('COALESCE(comments.comments_delta, 0) as comments_delta'), + ]) + ->get() + ->map(fn ($row): array => [ + 'entity_id' => (int) $row->id, + 'score' => ((int) $row->favourites_delta * 3) + + ((int) $row->views_delta * 1) + + ((int) $row->downloads_delta * 5) + + ((int) $row->comments_delta * 4), + ]) + ->filter(fn (array $row): bool => $row['score'] > 0) + ->values(); + } + + private function allTimeStoryRows(): Collection + { + return Story::query() + ->published() + ->select(['id', 'views', 'likes_count', 'comments_count', 'reading_time']) + ->get() + ->map(fn (Story $story): array => [ + 'entity_id' => (int) $story->id, + 'score' => ((int) $story->views * 1) + + ((int) $story->likes_count * 3) + + ((int) $story->comments_count * 4) + + ((int) $story->reading_time * 0.5), + ]) + ->filter(fn (array $row): bool => $row['score'] > 0) + ->values(); + } + + private function windowedStoryRows(CarbonImmutable $start): Collection + { + $views = StoryView::query() + ->select('story_id', DB::raw('COUNT(*) as views_count')) + ->where('created_at', '>=', $start) + ->groupBy('story_id'); + + $likes = StoryLike::query() + ->select('story_id', DB::raw('COUNT(*) as likes_count')) + ->where('created_at', '>=', $start) + ->groupBy('story_id'); + + return Story::query() + ->from('stories') + ->leftJoinSub($views, 'views', 'views.story_id', '=', 'stories.id') + ->leftJoinSub($likes, 'likes', 'likes.story_id', '=', 'stories.id') + ->published() + ->select([ + 'stories.id', + 'stories.comments_count', + 'stories.reading_time', + DB::raw('COALESCE(views.views_count, 0) as views_count'), + DB::raw('COALESCE(likes.likes_count, 0) as likes_count'), + ]) + ->get() + ->map(fn ($row): array => [ + 'entity_id' => (int) $row->id, + 'score' => ((int) $row->views_count * 1) + + ((int) $row->likes_count * 3) + + ((int) $row->comments_count * 4) + + ((int) $row->reading_time * 0.5), + ]) + ->filter(fn (array $row): bool => $row['score'] > 0) + ->values(); + } + + private function artworkSnapshotDeltas(CarbonImmutable $start): \Illuminate\Database\Query\Builder + { + return ArtworkMetricSnapshotHourly::query() + ->from('artwork_metric_snapshots_hourly as snapshots') + ->where('snapshots.bucket_hour', '>=', $start) + ->select([ + 'snapshots.artwork_id', + DB::raw('GREATEST(MAX(snapshots.views_count) - MIN(snapshots.views_count), 0) as views_delta'), + DB::raw('GREATEST(MAX(snapshots.downloads_count) - MIN(snapshots.downloads_count), 0) as downloads_delta'), + DB::raw('GREATEST(MAX(snapshots.favourites_count) - MIN(snapshots.favourites_count), 0) as favourites_delta'), + DB::raw('GREATEST(MAX(snapshots.comments_count) - MIN(snapshots.comments_count), 0) as comments_delta'), + ]) + ->groupBy('snapshots.artwork_id') + ->toBase(); + } + + private function creatorEntities(array $ids): array + { + return User::query() + ->from('users') + ->leftJoin('user_profiles as profiles', 'profiles.user_id', '=', 'users.id') + ->whereIn('users.id', $ids) + ->select([ + 'users.id', + 'users.username', + 'users.name', + 'users.level', + 'users.rank', + 'profiles.avatar_hash', + ]) + ->get() + ->mapWithKeys(fn ($row): array => [ + (int) $row->id => [ + 'id' => (int) $row->id, + 'type' => Leaderboard::TYPE_CREATOR, + 'name' => (string) ($row->username ?: $row->name ?: 'Creator'), + 'username' => $row->username, + 'url' => $row->username ? '/@' . $row->username : null, + 'avatar' => \App\Support\AvatarUrl::forUser((int) $row->id, $row->avatar_hash, 128), + 'level' => (int) ($row->level ?? 1), + 'rank' => (string) ($row->rank ?? 'Newbie'), + ], + ]) + ->all(); + } + + private function artworkEntities(array $ids): array + { + return Artwork::query() + ->with(['user.profile']) + ->whereIn('id', $ids) + ->get() + ->mapWithKeys(fn (Artwork $artwork): array => [ + (int) $artwork->id => [ + 'id' => (int) $artwork->id, + 'type' => Leaderboard::TYPE_ARTWORK, + 'name' => $artwork->title, + 'url' => '/art/' . $artwork->id . '/' . $artwork->slug, + 'image' => $artwork->thumbUrl('md') ?? $artwork->thumbnail_url, + 'creator_name' => (string) ($artwork->user?->username ?: $artwork->user?->name ?: 'Creator'), + 'creator_url' => $artwork->user?->username ? '/@' . $artwork->user->username : null, + ], + ]) + ->all(); + } + + private function storyEntities(array $ids): array + { + return Story::query() + ->with('creator.profile') + ->whereIn('id', $ids) + ->get() + ->mapWithKeys(fn (Story $story): array => [ + (int) $story->id => [ + 'id' => (int) $story->id, + 'type' => Leaderboard::TYPE_STORY, + 'name' => $story->title, + 'url' => '/stories/' . $story->slug, + 'image' => $story->cover_url, + 'creator_name' => (string) ($story->creator?->username ?: $story->creator?->name ?: 'Creator'), + 'creator_url' => $story->creator?->username ? '/@' . $story->creator->username : null, + ], + ]) + ->all(); + } +} diff --git a/app/Services/NotificationService.php b/app/Services/NotificationService.php new file mode 100644 index 00000000..4d076447 --- /dev/null +++ b/app/Services/NotificationService.php @@ -0,0 +1,90 @@ +notifications() + ->latest() + ->paginate($resolvedPerPage, ['*'], 'page', $resolvedPage); + + $actorIds = collect($notifications->items()) + ->map(function (Notification $notification): ?int { + $data = is_array($notification->data) ? $notification->data : []; + + return isset($data['actor_id']) ? (int) $data['actor_id'] : null; + }) + ->filter() + ->unique() + ->values(); + + $actors = $actorIds->isEmpty() + ? collect() + : User::query() + ->with('profile:user_id,avatar_hash') + ->whereIn('id', $actorIds->all()) + ->get() + ->keyBy('id'); + + return [ + 'data' => collect($notifications->items()) + ->map(fn (Notification $notification) => $this->mapNotification($notification, $actors)) + ->values() + ->all(), + 'unread_count' => $user->unreadNotifications()->count(), + 'meta' => [ + 'total' => $notifications->total(), + 'current_page' => $notifications->currentPage(), + 'last_page' => $notifications->lastPage(), + 'per_page' => $notifications->perPage(), + ], + ]; + } + + public function markAllRead(User $user): void + { + $user->unreadNotifications()->update(['read_at' => now()]); + } + + public function markRead(User $user, string $id): void + { + $notification = $user->notifications()->findOrFail($id); + $notification->markAsRead(); + } + + private function mapNotification(Notification $notification, Collection $actors): array + { + $data = is_array($notification->data) ? $notification->data : []; + $actorId = isset($data['actor_id']) ? (int) $data['actor_id'] : null; + $actor = $actorId ? $actors->get($actorId) : null; + + return [ + 'id' => (string) $notification->id, + 'type' => (string) ($data['type'] ?? $notification->type ?? 'notification'), + 'message' => (string) ($data['message'] ?? 'New activity'), + 'url' => $data['url'] ?? null, + 'created_at' => $notification->created_at?->toIso8601String(), + 'time_ago' => $notification->created_at?->diffForHumans(), + 'read' => $notification->read_at !== null, + 'actor' => $actor ? [ + 'id' => (int) $actor->id, + 'name' => $actor->name, + 'username' => $actor->username, + 'avatar_url' => AvatarUrl::forUser((int) $actor->id, $actor->profile?->avatar_hash, 64), + 'profile_url' => $actor->username ? '/@' . $actor->username : null, + ] : null, + ]; + } +} \ No newline at end of file diff --git a/app/Services/Posts/PostAchievementService.php b/app/Services/Posts/PostAchievementService.php index eb0ff9e1..b93878bf 100644 --- a/app/Services/Posts/PostAchievementService.php +++ b/app/Services/Posts/PostAchievementService.php @@ -2,6 +2,7 @@ namespace App\Services\Posts; +use App\Models\Achievement; use App\Models\Post; use App\Models\PostTarget; use App\Models\User; @@ -67,6 +68,16 @@ class PostAchievementService ], $artworkId); } + public function achievementUnlocked(User $user, Achievement $achievement): void + { + $this->createAchievementPost($user, 'unlock_' . $achievement->slug, [ + 'achievement_id' => $achievement->id, + 'achievement_name' => $achievement->name, + 'message' => '🎉 Unlocked achievement: ' . $achievement->name, + 'xp_reward' => (int) $achievement->xp_reward, + ]); + } + // ───────────────────────────────────────────────────────────────────────── private function createAchievementPost( diff --git a/app/Services/ReceivedCommentsInboxService.php b/app/Services/ReceivedCommentsInboxService.php new file mode 100644 index 00000000..af64f321 --- /dev/null +++ b/app/Services/ReceivedCommentsInboxService.php @@ -0,0 +1,67 @@ +whereHas('artwork', function ($query) use ($user): void { + $query->where('user_id', $user->id) + ->where('is_approved', true) + ->whereNull('deleted_at'); + }) + ->where('user_id', '!=', $user->id) + ->where('is_approved', true) + ->whereNull('deleted_at'); + } + + public function unreadCountForUser(User $user): int + { + return (int) $this->unreadQueryForUser($user)->count(); + } + + public function markInboxRead(User $user): void + { + $readAt = Carbon::now(); + + $this->unreadQueryForUser($user) + ->select('artwork_comments.id') + ->orderBy('artwork_comments.id') + ->chunkById(200, function ($comments) use ($user, $readAt): void { + $rows = collect($comments)->map(function ($comment) use ($user, $readAt): array { + return [ + 'user_id' => $user->id, + 'artwork_comment_id' => (int) $comment->id, + 'read_at' => $readAt, + 'created_at' => $readAt, + 'updated_at' => $readAt, + ]; + })->all(); + + if ($rows !== []) { + DB::table('user_received_comment_reads')->insertOrIgnore($rows); + } + }, 'artwork_comments.id', 'id'); + } + + private function unreadQueryForUser(User $user): Builder + { + return $this->queryForUser($user) + ->whereNotExists(function ($query) use ($user): void { + $query->selectRaw('1') + ->from('user_received_comment_reads as ucr') + ->whereColumn('ucr.artwork_comment_id', 'artwork_comments.id') + ->where('ucr.user_id', $user->id); + }); + } +} diff --git a/app/Services/SocialService.php b/app/Services/SocialService.php new file mode 100644 index 00000000..e60a1ea3 --- /dev/null +++ b/app/Services/SocialService.php @@ -0,0 +1,300 @@ +followService->follow($actorId, $targetId); + } else { + $this->followService->unfollow($actorId, $targetId); + } + + return [ + 'following' => $state, + 'followers_count' => $this->followService->followersCount($targetId), + ]; + } + + public function toggleStoryLike(User $actor, Story $story, bool $state): array + { + $changed = false; + + if ($state) { + $like = StoryLike::query()->firstOrCreate([ + 'story_id' => (int) $story->id, + 'user_id' => (int) $actor->id, + ]); + $changed = $like->wasRecentlyCreated; + } else { + $changed = StoryLike::query() + ->where('story_id', $story->id) + ->where('user_id', $actor->id) + ->delete() > 0; + } + + $likesCount = StoryLike::query()->where('story_id', $story->id)->count(); + $story->forceFill(['likes_count' => $likesCount])->save(); + + if ($state && $changed) { + $this->activity->record((int) $actor->id, 'story_like', 'story', (int) $story->id); + + if ((int) $story->creator_id > 0 && (int) $story->creator_id !== (int) $actor->id) { + $creator = User::query()->find($story->creator_id); + if ($creator) { + $creator->notify(new StoryLikedNotification($story, $actor)); + event(new AchievementCheckRequested((int) $creator->id)); + } + } + } + + return [ + 'ok' => true, + 'liked' => StoryLike::query()->where('story_id', $story->id)->where('user_id', $actor->id)->exists(), + 'likes_count' => $likesCount, + ]; + } + + public function toggleStoryBookmark(User $actor, Story $story, bool $state): array + { + if ($state) { + StoryBookmark::query()->firstOrCreate([ + 'story_id' => (int) $story->id, + 'user_id' => (int) $actor->id, + ]); + } else { + StoryBookmark::query() + ->where('story_id', $story->id) + ->where('user_id', $actor->id) + ->delete(); + } + + return [ + 'ok' => true, + 'bookmarked' => StoryBookmark::query()->where('story_id', $story->id)->where('user_id', $actor->id)->exists(), + 'bookmarks_count' => StoryBookmark::query()->where('story_id', $story->id)->count(), + ]; + } + + public function listStoryComments(Story $story, ?int $viewerId, int $page = 1, int $perPage = 20): array + { + $comments = StoryComment::query() + ->with(['user.profile', 'approvedReplies']) + ->where('story_id', $story->id) + ->where('is_approved', true) + ->whereNull('parent_id') + ->whereNull('deleted_at') + ->latest('created_at') + ->paginate($perPage, ['*'], 'page', max(1, $page)); + + return [ + 'data' => $comments->getCollection()->map(fn (StoryComment $comment) => $this->formatComment($comment, $viewerId, true))->values()->all(), + 'meta' => [ + 'current_page' => $comments->currentPage(), + 'last_page' => $comments->lastPage(), + 'total' => $comments->total(), + 'per_page' => $comments->perPage(), + ], + ]; + } + + public function addStoryComment(User $actor, Story $story, string $raw, ?int $parentId = null): StoryComment + { + $trimmed = trim($raw); + if ($trimmed === '' || mb_strlen($trimmed) > self::COMMENT_MAX_LENGTH) { + abort(422, 'Invalid comment content.'); + } + + $errors = ContentSanitizer::validate($trimmed); + if ($errors) { + abort(422, implode(' ', $errors)); + } + + $parent = null; + if ($parentId !== null) { + $parent = StoryComment::query() + ->where('story_id', $story->id) + ->where('id', $parentId) + ->where('is_approved', true) + ->whereNull('deleted_at') + ->first(); + + if (! $parent) { + abort(422, 'The comment you are replying to is no longer available.'); + } + } + + $comment = DB::transaction(function () use ($actor, $story, $trimmed, $parent): StoryComment { + $comment = StoryComment::query()->create([ + 'story_id' => (int) $story->id, + 'user_id' => (int) $actor->id, + 'parent_id' => $parent?->id, + 'content' => $trimmed, + 'raw_content' => $trimmed, + 'rendered_content' => ContentSanitizer::render($trimmed), + 'is_approved' => true, + ]); + + $commentsCount = StoryComment::query() + ->where('story_id', $story->id) + ->whereNull('deleted_at') + ->count(); + + $story->forceFill(['comments_count' => $commentsCount])->save(); + + return $comment; + }); + + $comment->load(['user.profile', 'approvedReplies']); + + $this->activity->record((int) $actor->id, 'story_comment', 'story', (int) $story->id, ['comment_id' => (int) $comment->id]); + $this->xp->awardCommentCreated((int) $actor->id, (int) $comment->id, 'story'); + + $this->notifyStoryCommentRecipients($story, $comment, $actor, $parent); + + return $comment; + } + + public function deleteStoryComment(User $actor, StoryComment $comment): void + { + $story = $comment->story; + $canDelete = (int) $comment->user_id === (int) $actor->id + || (int) ($story?->creator_id ?? 0) === (int) $actor->id + || $actor->hasRole('admin') + || $actor->hasRole('moderator'); + + abort_unless($canDelete, 403); + + $comment->delete(); + + if ($story) { + $commentsCount = StoryComment::query() + ->where('story_id', $story->id) + ->whereNull('deleted_at') + ->count(); + + $story->forceFill(['comments_count' => $commentsCount])->save(); + } + } + + public function storyStateFor(?User $viewer, Story $story): array + { + if (! $viewer) { + return [ + 'liked' => false, + 'bookmarked' => false, + 'is_following_creator' => false, + 'likes_count' => (int) $story->likes_count, + 'comments_count' => (int) $story->comments_count, + 'bookmarks_count' => StoryBookmark::query()->where('story_id', $story->id)->count(), + ]; + } + + return [ + 'liked' => StoryLike::query()->where('story_id', $story->id)->where('user_id', $viewer->id)->exists(), + 'bookmarked' => StoryBookmark::query()->where('story_id', $story->id)->where('user_id', $viewer->id)->exists(), + 'is_following_creator' => $story->creator_id ? $this->followService->isFollowing((int) $viewer->id, (int) $story->creator_id) : false, + 'likes_count' => StoryLike::query()->where('story_id', $story->id)->count(), + 'comments_count' => StoryComment::query()->where('story_id', $story->id)->whereNull('deleted_at')->count(), + 'bookmarks_count' => StoryBookmark::query()->where('story_id', $story->id)->count(), + ]; + } + + public function formatComment(StoryComment $comment, ?int $viewerId, bool $includeReplies = false): array + { + $user = $comment->user; + $avatarHash = $user?->profile?->avatar_hash; + + return [ + 'id' => (int) $comment->id, + 'parent_id' => $comment->parent_id, + 'raw_content' => $comment->raw_content ?? $comment->content, + 'rendered_content' => $comment->rendered_content, + 'created_at' => $comment->created_at?->toIso8601String(), + 'time_ago' => $comment->created_at ? Carbon::parse($comment->created_at)->diffForHumans() : null, + 'can_delete' => $viewerId !== null && ((int) $comment->user_id === $viewerId || (int) ($comment->story?->creator_id ?? 0) === $viewerId), + 'user' => [ + 'id' => (int) ($user?->id ?? 0), + 'username' => $user?->username, + 'display' => $user?->username ?? $user?->name ?? 'User', + 'profile_url' => $user?->username ? '/@' . $user->username : null, + 'avatar_url' => AvatarUrl::forUser((int) ($user?->id ?? 0), $avatarHash, 64), + 'level' => (int) ($user?->level ?? 1), + 'rank' => (string) ($user?->rank ?? 'Newbie'), + ], + 'replies' => $includeReplies && $comment->relationLoaded('approvedReplies') + ? $comment->approvedReplies->map(fn (StoryComment $reply) => $this->formatComment($reply, $viewerId, true))->values()->all() + : [], + ]; + } + + private function notifyStoryCommentRecipients(Story $story, StoryComment $comment, User $actor, ?StoryComment $parent): void + { + $notifiedUserIds = []; + + if ((int) ($story->creator_id ?? 0) > 0 && (int) $story->creator_id !== (int) $actor->id) { + $creator = User::query()->find($story->creator_id); + if ($creator) { + $creator->notify(new StoryCommentedNotification($story, $comment, $actor)); + $notifiedUserIds[] = (int) $creator->id; + } + } + + if ($parent && (int) $parent->user_id !== (int) $actor->id && ! in_array((int) $parent->user_id, $notifiedUserIds, true)) { + $parentUser = User::query()->find($parent->user_id); + if ($parentUser) { + $parentUser->notify(new StoryCommentedNotification($story, $comment, $actor)); + $notifiedUserIds[] = (int) $parentUser->id; + } + } + + $mentionedUsers = User::query() + ->whereIn(DB::raw('LOWER(username)'), $this->extractMentions((string) ($comment->raw_content ?? ''))) + ->get(); + + foreach ($mentionedUsers as $mentionedUser) { + if ((int) $mentionedUser->id === (int) $actor->id || in_array((int) $mentionedUser->id, $notifiedUserIds, true)) { + continue; + } + + $mentionedUser->notify(new StoryMentionedNotification($story, $comment, $actor)); + } + } + + private function extractMentions(string $content): array + { + preg_match_all('/(^|[^A-Za-z0-9_])@([A-Za-z0-9_-]{3,20})/', $content, $matches); + + return collect($matches[2] ?? []) + ->map(fn ($username) => strtolower((string) $username)) + ->unique() + ->values() + ->all(); + } +} \ No newline at end of file diff --git a/app/Services/StoryPublicationService.php b/app/Services/StoryPublicationService.php new file mode 100644 index 00000000..2b1f7691 --- /dev/null +++ b/app/Services/StoryPublicationService.php @@ -0,0 +1,72 @@ +isPublished($story); + + $story->fill(array_merge([ + 'status' => 'published', + 'published_at' => $story->published_at ?? now(), + 'scheduled_for' => null, + ], $attributes)); + + if ($story->isDirty()) { + $story->save(); + } + + $this->afterPersistence($story, $notificationEvent, $wasPublished); + + return $story; + } + + public function afterPersistence(Story $story, string $notificationEvent = 'published', bool $wasPublished = false): void + { + if (! $this->isPublished($story)) { + return; + } + + if (! $wasPublished && $story->creator_id !== null) { + $this->xp->awardStoryPublished((int) $story->creator_id, (int) $story->id); + event(new AchievementCheckRequested((int) $story->creator_id)); + + try { + $this->activity->record( + actorId: (int) $story->creator_id, + type: ActivityEvent::TYPE_UPLOAD, + targetType: ActivityEvent::TARGET_STORY, + targetId: (int) $story->id, + meta: [ + 'story_slug' => (string) $story->slug, + 'story_title' => (string) $story->title, + ], + ); + } catch (\Throwable) { + // Activity logging should not block publication. + } + } + + $story->creator?->notify(new StoryStatusNotification($story, $notificationEvent)); + } + + private function isPublished(Story $story): bool + { + return $story->published_at !== null || $story->status === 'published'; + } +} \ No newline at end of file diff --git a/app/Services/UserStatsService.php b/app/Services/UserStatsService.php index 042f4717..600705e8 100644 --- a/app/Services/UserStatsService.php +++ b/app/Services/UserStatsService.php @@ -7,6 +7,7 @@ namespace App\Services; use App\Jobs\IndexUserJob; use Illuminate\Support\Carbon; use Illuminate\Support\Facades\DB; +use InvalidArgumentException; /** * UserStatsService – single source of truth for user_statistics counters. @@ -253,7 +254,7 @@ final class UserStatsService DB::table('user_statistics') ->where('user_id', $userId) ->update([ - $column => DB::raw("GREATEST(0, COALESCE({$column}, 0) + {$by})"), + $column => $this->nonNegativeCounterExpression($column, $by), 'updated_at' => now(), ]); } @@ -264,7 +265,7 @@ final class UserStatsService ->where('user_id', $userId) ->where($column, '>', 0) ->update([ - $column => DB::raw("GREATEST(0, COALESCE({$column}, 0) - {$by})"), + $column => $this->nonNegativeCounterExpression($column, -$by), 'updated_at' => now(), ]); } @@ -279,6 +280,22 @@ final class UserStatsService ]); } + private function nonNegativeCounterExpression(string $column, int $delta) + { + if (! preg_match('/^[A-Za-z_][A-Za-z0-9_]*$/', $column)) { + throw new InvalidArgumentException('Invalid statistics column name.'); + } + + $driver = DB::connection()->getDriverName(); + $deltaSql = $delta >= 0 ? "+ {$delta}" : "- ".abs($delta); + + if ($driver === 'sqlite') { + return DB::raw("max(0, COALESCE({$column}, 0) {$deltaSql})"); + } + + return DB::raw("GREATEST(0, COALESCE({$column}, 0) {$deltaSql})"); + } + /** * Queue a Meilisearch reindex for the user. * Uses IndexUserJob to avoid blocking the request. diff --git a/app/Services/XPService.php b/app/Services/XPService.php new file mode 100644 index 00000000..4d2d579a --- /dev/null +++ b/app/Services/XPService.php @@ -0,0 +1,292 @@ + 0, + 2 => 100, + 3 => 300, + 4 => 800, + 5 => 2000, + 6 => 5000, + 7 => 12000, + ]; + + private const RANKS = [ + 1 => 'Newbie', + 2 => 'Explorer', + 3 => 'Contributor', + 4 => 'Creator', + 5 => 'Pro Creator', + 6 => 'Elite', + 7 => 'Legend', + ]; + + private const DAILY_CAPS = [ + 'artwork_view_received' => 200, + 'comment_created' => 100, + 'story_published' => 200, + 'artwork_published' => 250, + 'follower_received' => 400, + 'artwork_like_received' => 500, + ]; + + public function addXP( + User|int $user, + int $amount, + string $action, + ?int $referenceId = null, + bool $dispatchEvent = true, + ): bool + { + if ($amount <= 0) { + return false; + } + + $userId = $user instanceof User ? (int) $user->id : $user; + if ($userId <= 0) { + return false; + } + + $baseAction = $this->baseAction($action); + $awardAmount = $this->applyDailyCap($userId, $amount, $baseAction); + + if ($awardAmount <= 0) { + return false; + } + + DB::transaction(function () use ($userId, $awardAmount, $action, $referenceId): void { + /** @var User $lockedUser */ + $lockedUser = User::query()->lockForUpdate()->findOrFail($userId); + $nextXp = max(0, (int) $lockedUser->xp + $awardAmount); + $level = $this->calculateLevel($nextXp); + $rank = $this->getRank($level); + + $lockedUser->forceFill([ + 'xp' => $nextXp, + 'level' => $level, + 'rank' => $rank, + ])->save(); + + UserXpLog::query()->create([ + 'user_id' => $userId, + 'action' => $action, + 'xp' => $awardAmount, + 'reference_id' => $referenceId, + 'created_at' => now(), + ]); + }); + + $this->forgetSummaryCache($userId); + + if ($dispatchEvent) { + event(new UserXpUpdated($userId)); + } + + return true; + } + + public function awardArtworkPublished(int $userId, int $artworkId): bool + { + return $this->awardUnique($userId, 50, 'artwork_published', $artworkId); + } + + public function awardArtworkLikeReceived(int $userId, int $artworkId, int $actorId): bool + { + return $this->awardUnique($userId, 5, 'artwork_like_received', $artworkId, $actorId); + } + + public function awardFollowerReceived(int $userId, int $followerId): bool + { + return $this->awardUnique($userId, 20, 'follower_received', $followerId, $followerId); + } + + public function awardStoryPublished(int $userId, int $storyId): bool + { + return $this->awardUnique($userId, 40, 'story_published', $storyId); + } + + public function awardCommentCreated(int $userId, int $referenceId, string $scope = 'generic'): bool + { + return $this->awardUnique($userId, 5, 'comment_created:' . $scope, $referenceId); + } + + public function awardArtworkViewReceived(int $userId, int $artworkId, ?int $viewerId = null, ?string $ipAddress = null): bool + { + $viewerKey = $viewerId !== null && $viewerId > 0 + ? 'user:' . $viewerId + : 'guest:' . sha1((string) ($ipAddress ?: 'guest')); + + $expiresAt = now()->endOfDay(); + $qualifierKey = sprintf('xp:view:qualifier:%d:%d:%s:%s', $userId, $artworkId, $viewerKey, now()->format('Ymd')); + if (! Cache::add($qualifierKey, true, $expiresAt)) { + return false; + } + + $bucketKey = sprintf('xp:view:bucket:%d:%s', $userId, now()->format('Ymd')); + Cache::add($bucketKey, 0, $expiresAt); + $bucketCount = Cache::increment($bucketKey); + + if ($bucketCount % 10 !== 0) { + return false; + } + + return $this->addXP($userId, 1, 'artwork_view_received', $artworkId); + } + + public function calculateLevel(int $xp): int + { + $resolvedLevel = 1; + + foreach (self::LEVEL_THRESHOLDS as $level => $threshold) { + if ($xp >= $threshold) { + $resolvedLevel = $level; + } + } + + return $resolvedLevel; + } + + public function getRank(int $level): string + { + return self::RANKS[$level] ?? Arr::last(self::RANKS); + } + + public function summary(User|int $user): array + { + $userId = $user instanceof User ? (int) $user->id : $user; + + return Cache::remember( + $this->summaryCacheKey($userId), + now()->addMinutes(10), + function () use ($userId): array { + $currentUser = User::query()->findOrFail($userId, ['id', 'xp', 'level', 'rank']); + $currentLevel = max(1, (int) $currentUser->level); + $currentXp = max(0, (int) $currentUser->xp); + $currentThreshold = self::LEVEL_THRESHOLDS[$currentLevel] ?? 0; + $nextLevel = min($currentLevel + 1, array_key_last(self::LEVEL_THRESHOLDS)); + $nextLevelXp = self::LEVEL_THRESHOLDS[$nextLevel] ?? $currentXp; + $range = max(1, $nextLevelXp - $currentThreshold); + $progressWithinLevel = min($range, max(0, $currentXp - $currentThreshold)); + $progressPercent = $currentLevel >= array_key_last(self::LEVEL_THRESHOLDS) + ? 100 + : (int) round(($progressWithinLevel / $range) * 100); + + return [ + 'xp' => $currentXp, + 'level' => $currentLevel, + 'rank' => (string) ($currentUser->rank ?: $this->getRank($currentLevel)), + 'current_level_xp' => $currentThreshold, + 'next_level_xp' => $nextLevelXp, + 'progress_xp' => $progressWithinLevel, + 'progress_percent' => $progressPercent, + 'max_level' => $currentLevel >= array_key_last(self::LEVEL_THRESHOLDS), + ]; + } + ); + } + + public function recalculateStoredProgress(User|int $user, bool $write = true): array + { + $userId = $user instanceof User ? (int) $user->id : $user; + + /** @var User $currentUser */ + $currentUser = User::query()->findOrFail($userId, ['id', 'xp', 'level', 'rank']); + + $computedXp = (int) UserXpLog::query() + ->where('user_id', $userId) + ->sum('xp'); + + $computedLevel = $this->calculateLevel($computedXp); + $computedRank = $this->getRank($computedLevel); + $changed = (int) $currentUser->xp !== $computedXp + || (int) $currentUser->level !== $computedLevel + || (string) $currentUser->rank !== $computedRank; + + if ($write && $changed) { + $currentUser->forceFill([ + 'xp' => $computedXp, + 'level' => $computedLevel, + 'rank' => $computedRank, + ])->save(); + + $this->forgetSummaryCache($userId); + } + + return [ + 'user_id' => $userId, + 'changed' => $changed, + 'previous' => [ + 'xp' => (int) $currentUser->xp, + 'level' => (int) $currentUser->level, + 'rank' => (string) $currentUser->rank, + ], + 'computed' => [ + 'xp' => $computedXp, + 'level' => $computedLevel, + 'rank' => $computedRank, + ], + ]; + } + + private function awardUnique(int $userId, int $amount, string $action, int $referenceId, ?int $actorId = null): bool + { + $actionKey = $actorId !== null ? $action . ':' . $actorId : $action; + + $alreadyAwarded = UserXpLog::query() + ->where('user_id', $userId) + ->where('action', $actionKey) + ->where('reference_id', $referenceId) + ->exists(); + + if ($alreadyAwarded) { + return false; + } + + return $this->addXP($userId, $amount, $actionKey, $referenceId); + } + + private function applyDailyCap(int $userId, int $amount, string $baseAction): int + { + $cap = self::DAILY_CAPS[$baseAction] ?? null; + if ($cap === null) { + return $amount; + } + + $dayStart = Carbon::now()->startOfDay(); + $awardedToday = (int) UserXpLog::query() + ->where('user_id', $userId) + ->where('action', 'like', $baseAction . '%') + ->where('created_at', '>=', $dayStart) + ->sum('xp'); + + return max(0, min($amount, $cap - $awardedToday)); + } + + private function baseAction(string $action): string + { + return explode(':', $action, 2)[0]; + } + + private function forgetSummaryCache(int $userId): void + { + Cache::forget($this->summaryCacheKey($userId)); + } + + private function summaryCacheKey(int $userId): string + { + return 'xp:summary:' . $userId; + } +} diff --git a/app/Support/AvatarUrl.php b/app/Support/AvatarUrl.php index 6b1415ae..c36042a0 100644 --- a/app/Support/AvatarUrl.php +++ b/app/Support/AvatarUrl.php @@ -3,7 +3,6 @@ namespace App\Support; use Illuminate\Support\Facades\DB; -use Illuminate\Support\Facades\Storage; class AvatarUrl { @@ -22,16 +21,14 @@ class AvatarUrl } $base = rtrim((string) config('cdn.avatar_url', 'https://files.skinbase.org'), '/'); + $resolvedSize = self::resolveSize($size); // Use hash-based path: avatars/ab/cd/{hash}/{size}.webp?v={hash} $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); - return sprintf('%s/avatars/%s/%s/%s/%d.webp', $base, $p1, $p2, $avatarHash, $size); + return sprintf('%s/avatars/%s/%s/%s/%d.webp', $base, $p1, $p2, $avatarHash, $resolvedSize); } public static function default(): string @@ -59,4 +56,26 @@ class AvatarUrl return self::$hashCache[$userId]; } + + private static function resolveSize(int $requestedSize): int + { + $sizes = array_values(array_filter( + (array) config('avatars.sizes', [32, 64, 128, 256, 512]), + static fn ($size): bool => (int) $size > 0 + )); + + if ($sizes === []) { + return max(1, $requestedSize); + } + + sort($sizes); + + foreach ($sizes as $size) { + if ($requestedSize <= (int) $size) { + return (int) $size; + } + } + + return (int) end($sizes); + } } diff --git a/bootstrap/app.php b/bootstrap/app.php index 33988007..bbf5c6c6 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -18,6 +18,7 @@ return Application::configure(basePath: dirname(__DIR__)) ]); $middleware->web(append: [ + \App\Http\Middleware\RedirectLegacyProfileSubdomain::class, \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). diff --git a/bootstrap/providers.php b/bootstrap/providers.php index e114dc75..f6d9de70 100644 --- a/bootstrap/providers.php +++ b/bootstrap/providers.php @@ -7,4 +7,5 @@ return [ cPad\Plugins\Artworks\ServiceProvider::class, cPad\Plugins\News\ServiceProvider::class, cPad\Plugins\Forum\ServiceProvider::class, + cPad\Plugins\Site\ServiceProvider::class, ]; diff --git a/config/skinbase-countries.php b/config/skinbase-countries.php new file mode 100644 index 00000000..6d920294 --- /dev/null +++ b/config/skinbase-countries.php @@ -0,0 +1,23 @@ + (bool) env('SKINBASE_COUNTRIES_ENABLED', true), + 'remote_source' => env('SKINBASE_COUNTRIES_REMOTE_SOURCE', 'restcountries'), + 'endpoint' => env( + 'SKINBASE_COUNTRIES_ENDPOINT', + 'https://restcountries.com/v3.1/all?fields=cca2,cca3,ccn3,name,region,subregion,flags,flag' + ), + 'connect_timeout' => (int) env('SKINBASE_COUNTRIES_CONNECT_TIMEOUT', 5), + 'timeout' => (int) env('SKINBASE_COUNTRIES_TIMEOUT', 10), + 'retry_times' => (int) env('SKINBASE_COUNTRIES_RETRY_TIMES', 2), + 'retry_sleep_ms' => (int) env('SKINBASE_COUNTRIES_RETRY_SLEEP_MS', 250), + 'deactivate_missing' => (bool) env('SKINBASE_COUNTRIES_DEACTIVATE_MISSING', false), + 'cache_ttl' => (int) env('SKINBASE_COUNTRIES_CACHE_TTL', 86400), + 'featured_countries' => array_values(array_filter(array_map( + static fn (string $iso2): string => strtoupper(trim($iso2)), + explode(',', (string) env('SKINBASE_COUNTRIES_FEATURED', 'SI,HR,AT,DE,IT,US')), + ))), + 'use_local_flags' => (bool) env('SKINBASE_COUNTRIES_USE_LOCAL_FLAGS', true), + 'fallback_seed_enabled' => (bool) env('SKINBASE_COUNTRIES_FALLBACK_ENABLED', true), + 'fallback_seed_path' => database_path('data/countries-fallback.json'), +]; diff --git a/config/vision.php b/config/vision.php index c8452296..4d5dafaf 100644 --- a/config/vision.php +++ b/config/vision.php @@ -50,7 +50,7 @@ return [ |-------------------------------------------------------------------------- */ 'lm_studio' => [ - 'base_url' => env('LM_STUDIO_URL', 'http://172.28.16.1:8200'), + 'base_url' => env('LM_STUDIO_URL', 'http://192.168.0.100:8200'), 'model' => env('LM_STUDIO_MODEL', 'google/gemma-3-4b'), 'timeout' => (int) env('LM_STUDIO_TIMEOUT', 60), 'connect_timeout' => (int) env('LM_STUDIO_CONNECT_TIMEOUT', 5), diff --git a/database/data/countries-fallback.json b/database/data/countries-fallback.json new file mode 100644 index 00000000..f68d1d9f --- /dev/null +++ b/database/data/countries-fallback.json @@ -0,0 +1 @@ +[{"flags":{"png":"https://flagcdn.com/w320/zw.png","svg":"https://flagcdn.com/zw.svg","alt":"The flag of Zimbabwe is composed of seven equal horizontal bands of green, yellow, red, black, red, yellow and green, with a white isosceles triangle superimposed on the hoist side of the field. This triangle is edged in black, spans about one-fourth the width of the field and has its base on the hoist end. A yellow Zimbabwe Bird superimposed on a five-pointed red star is centered in the triangle."},"flag":"🇿🇼","name":{"common":"Zimbabwe","official":"Republic of Zimbabwe","nativeName":{"bwg":{"official":"Republic of Zimbabwe","common":"Zimbabwe"},"eng":{"official":"Republic of Zimbabwe","common":"Zimbabwe"},"kck":{"official":"Republic of Zimbabwe","common":"Zimbabwe"},"khi":{"official":"Republic of Zimbabwe","common":"Zimbabwe"},"ndc":{"official":"Republic of Zimbabwe","common":"Zimbabwe"},"nde":{"official":"Republic of Zimbabwe","common":"Zimbabwe"},"nya":{"official":"Republic of Zimbabwe","common":"Zimbabwe"},"sna":{"official":"Republic of Zimbabwe","common":"Zimbabwe"},"sot":{"official":"Republic of Zimbabwe","common":"Zimbabwe"},"toi":{"official":"Republic of Zimbabwe","common":"Zimbabwe"},"tsn":{"official":"Republic of Zimbabwe","common":"Zimbabwe"},"tso":{"official":"Republic of Zimbabwe","common":"Zimbabwe"},"ven":{"official":"Republic of Zimbabwe","common":"Zimbabwe"},"xho":{"official":"Republic of Zimbabwe","common":"Zimbabwe"},"zib":{"official":"Republic of Zimbabwe","common":"Zimbabwe"}}},"cca2":"ZW","ccn3":"716","cca3":"ZWE","region":"Africa","subregion":"Southern Africa"},{"flags":{"png":"https://flagcdn.com/w320/ki.png","svg":"https://flagcdn.com/ki.svg","alt":"The flag of Kiribati is divided into two halves. While the upper half has a red field, at the center of which is a yellow frigate bird flying over the top half of a rising yellow sun with seventeen visible rays, the lower half is composed of six horizontal wavy bands of white alternating with blue to depict the ocean."},"flag":"🇰🇮","name":{"common":"Kiribati","official":"Independent and Sovereign Republic of Kiribati","nativeName":{"eng":{"official":"Independent and Sovereign Republic of Kiribati","common":"Kiribati"},"gil":{"official":"Ribaberiki Kiribati","common":"Kiribati"}}},"cca2":"KI","ccn3":"296","cca3":"KIR","region":"Oceania","subregion":"Micronesia"},{"flags":{"png":"https://flagcdn.com/w320/gh.png","svg":"https://flagcdn.com/gh.svg","alt":"The flag of Ghana is composed of three equal horizontal bands of red, gold and green, with a five-pointed black star centered in the gold band."},"flag":"🇬🇭","name":{"common":"Ghana","official":"Republic of Ghana","nativeName":{"eng":{"official":"Republic of Ghana","common":"Ghana"}}},"cca2":"GH","ccn3":"288","cca3":"GHA","region":"Africa","subregion":"Western Africa"},{"flags":{"png":"https://flagcdn.com/w320/kp.png","svg":"https://flagcdn.com/kp.svg","alt":"The flag of North Korea is composed of three horizontal bands — a large central white-edged red band, and a blue band above and beneath the red band. On the hoist side of the red band is a red five-pointed star within a white circle."},"flag":"🇰🇵","name":{"common":"North Korea","official":"Democratic People's Republic of Korea","nativeName":{"kor":{"official":"조선민주주의인민공화국","common":"조선"}}},"cca2":"KP","ccn3":"408","cca3":"PRK","region":"Asia","subregion":"Eastern Asia"},{"flags":{"png":"https://flagcdn.com/w320/es.png","svg":"https://flagcdn.com/es.svg","alt":"The flag of Spain is composed of three horizontal bands of red, yellow and red, with the yellow band twice the height of the red bands. In the yellow band is the national coat of arms offset slightly towards the hoist side of center."},"flag":"🇪🇸","name":{"common":"Spain","official":"Kingdom of Spain","nativeName":{"spa":{"official":"Reino de España","common":"España"}}},"cca2":"ES","ccn3":"724","cca3":"ESP","region":"Europe","subregion":"Southern Europe"},{"flags":{"png":"https://flagcdn.com/w320/jo.png","svg":"https://flagcdn.com/jo.svg","alt":"The flag of Jordan is composed of three equal horizontal bands of black, white and green, with a red isosceles triangle superimposed on the hoist side of the field. This triangle has its base on the hoist end, spans about half the width of the field and bears a small seven-pointed white star at its center."},"flag":"🇯🇴","name":{"common":"Jordan","official":"Hashemite Kingdom of Jordan","nativeName":{"ara":{"official":"المملكة الأردنية الهاشمية","common":"الأردن"}}},"cca2":"JO","ccn3":"400","cca3":"JOR","region":"Asia","subregion":"Western Asia"},{"flags":{"png":"https://flagcdn.com/w320/sm.png","svg":"https://flagcdn.com/sm.svg","alt":"The flag of San Marino is composed of two equal horizontal bands of white and light blue, with the national coat of arms superimposed in the center."},"flag":"🇸🇲","name":{"common":"San Marino","official":"Republic of San Marino","nativeName":{"ita":{"official":"Repubblica di San Marino","common":"San Marino"}}},"cca2":"SM","ccn3":"674","cca3":"SMR","region":"Europe","subregion":"Southern Europe"},{"flags":{"png":"https://flagcdn.com/w320/ci.png","svg":"https://flagcdn.com/ci.svg","alt":"The flag of Ivory Coast is composed of three equal vertical bands of orange, white and green."},"flag":"🇨🇮","name":{"common":"Ivory Coast","official":"Republic of Côte d'Ivoire","nativeName":{"fra":{"official":"République de Côte d'Ivoire","common":"Côte d'Ivoire"}}},"cca2":"CI","ccn3":"384","cca3":"CIV","region":"Africa","subregion":"Western Africa"},{"flags":{"png":"https://flagcdn.com/w320/br.png","svg":"https://flagcdn.com/br.svg","alt":"The flag of Brazil has a green field with a large yellow rhombus in the center. Within the rhombus is a dark blue globe with twenty-seven small five-pointed white stars depicting a starry sky and a thin white convex horizontal band inscribed with the national motto 'Ordem e Progresso' across its center."},"flag":"🇧🇷","name":{"common":"Brazil","official":"Federative Republic of Brazil","nativeName":{"por":{"official":"República Federativa do Brasil","common":"Brasil"}}},"cca2":"BR","ccn3":"076","cca3":"BRA","region":"Americas","subregion":"South America"},{"flags":{"png":"https://flagcdn.com/w320/co.png","svg":"https://flagcdn.com/co.svg","alt":"The flag of Colombia is composed of three horizontal bands of yellow, blue and red, with the yellow band twice the height of the other two bands."},"flag":"🇨🇴","name":{"common":"Colombia","official":"Republic of Colombia","nativeName":{"spa":{"official":"República de Colombia","common":"Colombia"}}},"cca2":"CO","ccn3":"170","cca3":"COL","region":"Americas","subregion":"South America"},{"flags":{"png":"https://flagcdn.com/w320/hm.png","svg":"https://flagcdn.com/hm.svg","alt":"The flag of Heard Island and McDonald Islands has a dark blue field. It features the flag of the United Kingdom — the Union Jack — in the canton, beneath which is a large white seven-pointed star. A representation of the Southern Cross constellation, made up of one small five-pointed and four larger seven-pointed white stars, is situated on the fly side of the field."},"flag":"🇭🇲","name":{"common":"Heard Island and McDonald Islands","official":"Heard Island and McDonald Islands","nativeName":{"eng":{"official":"Heard Island and McDonald Islands","common":"Heard Island and McDonald Islands"}}},"cca2":"HM","ccn3":"334","cca3":"HMD","region":"Antarctic","subregion":""},{"flags":{"png":"https://flagcdn.com/w320/dm.png","svg":"https://flagcdn.com/dm.svg","alt":"The flag of Dominica has a green field with a large centered tricolor cross. The vertical and horizontal parts of the cross each comprise three bands of yellow, black and white. A red circle, bearing a hoist-side facing purple Sisserou parrot standing on a twig and encircled by ten five-pointed yellow-edged green stars, is superimposed at the center of the cross."},"flag":"🇩🇲","name":{"common":"Dominica","official":"Commonwealth of Dominica","nativeName":{"eng":{"official":"Commonwealth of Dominica","common":"Dominica"}}},"cca2":"DM","ccn3":"212","cca3":"DMA","region":"Americas","subregion":"Caribbean"},{"flags":{"png":"https://flagcdn.com/w320/dj.png","svg":"https://flagcdn.com/dj.svg","alt":"The flag of Djibouti is composed of two equal horizontal bands of light blue and light green, with a white isosceles triangle superimposed on the hoist side of the field. The triangle has its base on the hoist end, spans about two-fifth the width of the field and bears a red five-pointed star at its center."},"flag":"🇩🇯","name":{"common":"Djibouti","official":"Republic of Djibouti","nativeName":{"ara":{"official":"جمهورية جيبوتي","common":"جيبوتي‎"},"fra":{"official":"République de Djibouti","common":"Djibouti"}}},"cca2":"DJ","ccn3":"262","cca3":"DJI","region":"Africa","subregion":"Eastern Africa"},{"flags":{"png":"https://flagcdn.com/w320/si.png","svg":"https://flagcdn.com/si.svg","alt":"The flag of Slovenia is composed of three equal horizontal bands of white, blue and red. The national coat of arms is situated in the upper hoist side of the field centered on the boundary between the white and blue bands."},"flag":"🇸🇮","name":{"common":"Slovenia","official":"Republic of Slovenia","nativeName":{"slv":{"official":"Republika Slovenija","common":"Slovenija"}}},"cca2":"SI","ccn3":"705","cca3":"SVN","region":"Europe","subregion":"Central Europe"},{"flags":{"png":"https://flagcdn.com/w320/ai.png","svg":"https://flagcdn.com/ai.svg","alt":"The flag of Anguilla is blue, with the flag of the UK in the canton and the national coat of arms centered in the fly half. The coat of arms depicts three orange dolphins in an interlocking circular design on a white background with a turquoise-blue field below."},"flag":"🇦🇮","name":{"common":"Anguilla","official":"Anguilla","nativeName":{"eng":{"official":"Anguilla","common":"Anguilla"}}},"cca2":"AI","ccn3":"660","cca3":"AIA","region":"Americas","subregion":"Caribbean"},{"flags":{"png":"https://flagcdn.com/w320/mk.png","svg":"https://flagcdn.com/mk.svg","alt":"The flag of North Macedonia has a red field, at the center of which is a golden-yellow sun with eight broadening rays that extend to the edges of the field."},"flag":"🇲🇰","name":{"common":"North Macedonia","official":"Republic of North Macedonia","nativeName":{"mkd":{"official":"Република Северна Македонија","common":"Македонија"}}},"cca2":"MK","ccn3":"807","cca3":"MKD","region":"Europe","subregion":"Southeast Europe"},{"flags":{"png":"https://flagcdn.com/w320/sb.png","svg":"https://flagcdn.com/sb.svg","alt":"The flag of Solomon Islands features a thin yellow diagonal band that extends from the lower hoist-side corner to the upper fly-side corner of the field. Above and beneath this band are a blue and green triangle respectively. Five white five-pointed stars arranged in an X shape are situated on the hoist side of the upper blue triangle."},"flag":"🇸🇧","name":{"common":"Solomon Islands","official":"Solomon Islands","nativeName":{"eng":{"official":"Solomon Islands","common":"Solomon Islands"}}},"cca2":"SB","ccn3":"090","cca3":"SLB","region":"Oceania","subregion":"Melanesia"},{"flags":{"png":"https://flagcdn.com/w320/td.png","svg":"https://flagcdn.com/td.svg","alt":"The flag of Chad is composed of three equal vertical bands of blue, gold and red."},"flag":"🇹🇩","name":{"common":"Chad","official":"Republic of Chad","nativeName":{"ara":{"official":"جمهورية تشاد","common":"تشاد‎"},"fra":{"official":"République du Tchad","common":"Tchad"}}},"cca2":"TD","ccn3":"148","cca3":"TCD","region":"Africa","subregion":"Middle Africa"},{"flags":{"png":"https://flagcdn.com/w320/bi.png","svg":"https://flagcdn.com/bi.svg","alt":"The flag of Burundi is divided by a white diagonal cross into four alternating triangular areas of red at the top and bottom, and green on the hoist and fly sides. A white circle, with three green-edged red six-pointed stars arranged to form a triangle, is superimposed at the center of the cross."},"flag":"🇧🇮","name":{"common":"Burundi","official":"Republic of Burundi","nativeName":{"fra":{"official":"République du Burundi","common":"Burundi"},"run":{"official":"Republika y'Uburundi ","common":"Uburundi"}}},"cca2":"BI","ccn3":"108","cca3":"BDI","region":"Africa","subregion":"Eastern Africa"},{"flags":{"png":"https://flagcdn.com/w320/it.png","svg":"https://flagcdn.com/it.svg","alt":"The flag of Italy is composed of three equal vertical bands of green, white and red."},"flag":"🇮🇹","name":{"common":"Italy","official":"Italian Republic","nativeName":{"ita":{"official":"Repubblica italiana","common":"Italia"}}},"cca2":"IT","ccn3":"380","cca3":"ITA","region":"Europe","subregion":"Southern Europe"},{"flags":{"png":"https://flagcdn.com/w320/et.png","svg":"https://flagcdn.com/et.svg","alt":"The flag of Ethiopia is composed of three equal horizontal bands of green, yellow and red, with the national emblem superimposed at the center of the field. The national emblem comprises a light blue circle bearing a golden-yellow pentagram with single yellow rays emanating from the angles between the points of the pentagram."},"flag":"🇪🇹","name":{"common":"Ethiopia","official":"Federal Democratic Republic of Ethiopia","nativeName":{"amh":{"official":"የኢትዮጵያ ፌዴራላዊ ዲሞክራሲያዊ ሪፐብሊክ","common":"ኢትዮጵያ"}}},"cca2":"ET","ccn3":"231","cca3":"ETH","region":"Africa","subregion":"Eastern Africa"},{"flags":{"png":"https://flagcdn.com/w320/vn.png","svg":"https://flagcdn.com/vn.svg","alt":"The flag of Vietnam features a large five-pointed yellow star on a red field."},"flag":"🇻🇳","name":{"common":"Vietnam","official":"Socialist Republic of Vietnam","nativeName":{"vie":{"official":"Cộng hòa xã hội chủ nghĩa Việt Nam","common":"Việt Nam"}}},"cca2":"VN","ccn3":"704","cca3":"VNM","region":"Asia","subregion":"South-Eastern Asia"},{"flags":{"png":"https://flagcdn.com/w320/tg.png","svg":"https://flagcdn.com/tg.svg","alt":"The flag of Togo is composed of five equal horizontal bands of green alternating with yellow. A red square bearing a five-pointed white star is superimposed in the canton."},"flag":"🇹🇬","name":{"common":"Togo","official":"Togolese Republic","nativeName":{"fra":{"official":"République togolaise","common":"Togo"}}},"cca2":"TG","ccn3":"768","cca3":"TGO","region":"Africa","subregion":"Western Africa"},{"flags":{"png":"https://flagcdn.com/w320/mq.png","svg":"https://flagcdn.com/mq.svg","alt":"The flag of Martinique consists of a red triangle at the hoist, with two horizontal bands, the upper green and the lower black."},"flag":"🇲🇶","name":{"common":"Martinique","official":"Martinique","nativeName":{"fra":{"official":"Martinique","common":"Martinique"}}},"cca2":"MQ","ccn3":"474","cca3":"MTQ","region":"Americas","subregion":"Caribbean"},{"flags":{"png":"https://flagcdn.com/w320/pl.png","svg":"https://flagcdn.com/pl.svg","alt":"The flag of Poland is composed of two equal horizontal bands of white and red."},"flag":"🇵🇱","name":{"common":"Poland","official":"Republic of Poland","nativeName":{"pol":{"official":"Rzeczpospolita Polska","common":"Polska"}}},"cca2":"PL","ccn3":"616","cca3":"POL","region":"Europe","subregion":"Central Europe"},{"flags":{"png":"https://flagcdn.com/w320/pt.png","svg":"https://flagcdn.com/pt.svg","alt":"The flag of Portugal is composed of two vertical bands of green and red in the ratio of 2:3, with the coat of arms of Portugal centered over the two-color boundary."},"flag":"🇵🇹","name":{"common":"Portugal","official":"Portuguese Republic","nativeName":{"por":{"official":"República Portuguesa","common":"Portugal"}}},"cca2":"PT","ccn3":"620","cca3":"PRT","region":"Europe","subregion":"Southern Europe"},{"flags":{"png":"https://flagcdn.com/w320/cx.png","svg":"https://flagcdn.com/cx.svg","alt":"The flag of Christmas Island is divided diagonally from upper hoist side to lower fly side. The upper triangle is green with a yellow image of a Golden Bosun Bird. The lower triangle is blue with the Southern Cross constellation. A centered yellow disk displays a green map of the country."},"flag":"🇨🇽","name":{"common":"Christmas Island","official":"Territory of Christmas Island","nativeName":{"eng":{"official":"Territory of Christmas Island","common":"Christmas Island"}}},"cca2":"CX","ccn3":"162","cca3":"CXR","region":"Oceania","subregion":"Australia and New Zealand"},{"flags":{"png":"https://flagcdn.com/w320/il.png","svg":"https://flagcdn.com/il.svg","alt":"The flag of Israel has a white field with a blue hexagram — the Magen David — centered between two equal horizontal blue bands situated near the top and bottom edges of the field."},"flag":"🇮🇱","name":{"common":"Israel","official":"State of Israel","nativeName":{"ara":{"official":"دولة إسرائيل","common":"إسرائيل"},"heb":{"official":"מדינת ישראל","common":"ישראל"}}},"cca2":"IL","ccn3":"376","cca3":"ISR","region":"Asia","subregion":"Western Asia"},{"flags":{"png":"https://flagcdn.com/w320/ar.png","svg":"https://flagcdn.com/ar.svg","alt":"The flag of Argentina features three equal horizontal bands of light blue, white and light blue. A brown-edged golden sun is centered in the white band."},"flag":"🇦🇷","name":{"common":"Argentina","official":"Argentine Republic","nativeName":{"grn":{"official":"Argentine Republic","common":"Argentina"},"spa":{"official":"República Argentina","common":"Argentina"}}},"cca2":"AR","ccn3":"032","cca3":"ARG","region":"Americas","subregion":"South America"},{"flags":{"png":"https://upload.wikimedia.org/wikipedia/commons/thumb/5/5c/Flag_of_the_Taliban.svg/320px-Flag_of_the_Taliban.svg.png","svg":"https://upload.wikimedia.org/wikipedia/commons/5/5c/Flag_of_the_Taliban.svg","alt":"The flag of the Islamic Emirate of Afghanistan has a white field with Arabic inscriptions — the Shahada — in black across its center."},"flag":"🇦🇫","name":{"common":"Afghanistan","official":"Islamic Republic of Afghanistan","nativeName":{"prs":{"official":"جمهوری اسلامی افغانستان","common":"افغانستان"},"pus":{"official":"د افغانستان اسلامي جمهوریت","common":"افغانستان"},"tuk":{"official":"Owganystan Yslam Respublikasy","common":"Owganystan"}}},"cca2":"AF","ccn3":"004","cca3":"AFG","region":"Asia","subregion":"Southern Asia"},{"flags":{"png":"https://flagcdn.com/w320/pg.png","svg":"https://flagcdn.com/pg.svg","alt":"The flag of Papua New Guinea is divided diagonally, from the upper hoist-side corner to the lower fly-side corner, into a lower black and an upper red triangle. On the hoist side of the lower black triangle is a representation of the Southern Cross constellation made up of one small and four larger five-pointed white stars. A golden Raggiana bird-of-paradise is situated on the fly side of the upper red triangle."},"flag":"🇵🇬","name":{"common":"Papua New Guinea","official":"Independent State of Papua New Guinea","nativeName":{"eng":{"official":"Independent State of Papua New Guinea","common":"Papua New Guinea"},"hmo":{"official":"Independen Stet bilong Papua Niugini","common":"Papua Niu Gini"},"tpi":{"official":"Independen Stet bilong Papua Niugini","common":"Papua Niugini"}}},"cca2":"PG","ccn3":"598","cca3":"PNG","region":"Oceania","subregion":"Melanesia"},{"flags":{"png":"https://flagcdn.com/w320/nz.png","svg":"https://flagcdn.com/nz.svg","alt":"The flag of New Zealand has a dark blue field with the flag of the United Kingdom — the Union Jack — in the canton and a representation of the Southern Cross constellation, made up of four five-pointed white-edged red stars, on the fly side of the field."},"flag":"🇳🇿","name":{"common":"New Zealand","official":"New Zealand","nativeName":{"eng":{"official":"New Zealand","common":"New Zealand"},"mri":{"official":"Aotearoa","common":"Aotearoa"},"nzs":{"official":"New Zealand","common":"New Zealand"}}},"cca2":"NZ","ccn3":"554","cca3":"NZL","region":"Oceania","subregion":"Australia and New Zealand"},{"flags":{"png":"https://flagcdn.com/w320/sv.png","svg":"https://flagcdn.com/sv.svg","alt":"The flag of El Salvador is composed of three equal horizontal bands of cobalt blue, white and cobalt blue, with the national coat of arms centered in the white band."},"flag":"🇸🇻","name":{"common":"El Salvador","official":"Republic of El Salvador","nativeName":{"spa":{"official":"República de El Salvador","common":"El Salvador"}}},"cca2":"SV","ccn3":"222","cca3":"SLV","region":"Americas","subregion":"Central America"},{"flags":{"png":"https://flagcdn.com/w320/cz.png","svg":"https://flagcdn.com/cz.svg","alt":"The flag of Czechia is composed of two equal horizontal bands of white and red, with a blue isosceles triangle superimposed on the hoist side of the field. The triangle has its base on the hoist end and spans about two-fifth the width of the field."},"flag":"🇨🇿","name":{"common":"Czechia","official":"Czech Republic","nativeName":{"ces":{"official":"Česká republika","common":"Česko"},"slk":{"official":"Česká republika","common":"Česko"}}},"cca2":"CZ","ccn3":"203","cca3":"CZE","region":"Europe","subregion":"Central Europe"},{"flags":{"png":"https://flagcdn.com/w320/cm.png","svg":"https://flagcdn.com/cm.svg","alt":"The flag of Cameroon is composed of three equal vertical bands of green, red and yellow, with a yellow five-pointed star in the center."},"flag":"🇨🇲","name":{"common":"Cameroon","official":"Republic of Cameroon","nativeName":{"eng":{"official":"Republic of Cameroon","common":"Cameroon"},"fra":{"official":"République du Cameroun","common":"Cameroun"}}},"cca2":"CM","ccn3":"120","cca3":"CMR","region":"Africa","subregion":"Middle Africa"},{"flags":{"png":"https://flagcdn.com/w320/ye.png","svg":"https://flagcdn.com/ye.svg","alt":"The flag of Yemen is composed of three equal horizontal bands of red, white and black."},"flag":"🇾🇪","name":{"common":"Yemen","official":"Republic of Yemen","nativeName":{"ara":{"official":"الجمهورية اليمنية","common":"اليَمَن"}}},"cca2":"YE","ccn3":"887","cca3":"YEM","region":"Asia","subregion":"Western Asia"},{"flags":{"png":"https://flagcdn.com/w320/pa.png","svg":"https://flagcdn.com/pa.svg","alt":"The flag of Panama is composed of four equal rectangular areas — a white rectangular area with a blue five-pointed star at its center, a red rectangular area, a white rectangular area with a red five-pointed star at its center, and a blue rectangular area — in the upper hoist side, upper fly side, lower fly side and lower hoist side respectively."},"flag":"🇵🇦","name":{"common":"Panama","official":"Republic of Panama","nativeName":{"spa":{"official":"República de Panamá","common":"Panamá"}}},"cca2":"PA","ccn3":"591","cca3":"PAN","region":"Americas","subregion":"Central America"},{"flags":{"png":"https://flagcdn.com/w320/tl.png","svg":"https://flagcdn.com/tl.svg","alt":"The flag of Timor-Leste has a red field with two isosceles triangles which share a common base on the hoist end. The smaller black triangle, which bears a five-pointed white star at its center and spans one-third the width of the field, is superimposed on the larger yellow triangle that extends to the center of the field."},"flag":"🇹🇱","name":{"common":"Timor-Leste","official":"Democratic Republic of Timor-Leste","nativeName":{"por":{"official":"República Democrática de Timor-Leste","common":"Timor-Leste"},"tet":{"official":"Repúblika Demokrátika Timór-Leste","common":"Timór-Leste"}}},"cca2":"TL","ccn3":"626","cca3":"TLS","region":"Asia","subregion":"South-Eastern Asia"},{"flags":{"png":"https://flagcdn.com/w320/cu.png","svg":"https://flagcdn.com/cu.svg","alt":"The flag of Cuba is composed of five equal horizontal bands of blue alternating with white and a red equilateral triangle superimposed on the hoist side of the field. The triangle has its base on the hoist end, spans about two-fifth the width of the field and bears a white five-pointed star at its center."},"flag":"🇨🇺","name":{"common":"Cuba","official":"Republic of Cuba","nativeName":{"spa":{"official":"República de Cuba","common":"Cuba"}}},"cca2":"CU","ccn3":"192","cca3":"CUB","region":"Americas","subregion":"Caribbean"},{"flags":{"png":"https://flagcdn.com/w320/ru.png","svg":"https://flagcdn.com/ru.svg","alt":"The flag of Russia is composed of three equal horizontal bands of white, blue and red."},"flag":"🇷🇺","name":{"common":"Russia","official":"Russian Federation","nativeName":{"rus":{"official":"Российская Федерация","common":"Россия"}}},"cca2":"RU","ccn3":"643","cca3":"RUS","region":"Europe","subregion":"Eastern Europe"},{"flags":{"png":"https://flagcdn.com/w320/ag.png","svg":"https://flagcdn.com/ag.svg","alt":"The flag of Antigua and Barbuda has a red field with an inverted isosceles triangle based on the top edge and spanning the height of the field. This triangle has three horizontal bands of black, light blue and white, with the light blue band half the height of the two other bands. The top half of a golden-yellow sun is situated in the lower two-third of the black band to depict a rising sun."},"flag":"🇦🇬","name":{"common":"Antigua and Barbuda","official":"Antigua and Barbuda","nativeName":{"eng":{"official":"Antigua and Barbuda","common":"Antigua and Barbuda"}}},"cca2":"AG","ccn3":"028","cca3":"ATG","region":"Americas","subregion":"Caribbean"},{"flags":{"png":"https://flagcdn.com/w320/bl.png","svg":"https://flagcdn.com/bl.svg","alt":"The flag of Saint Barthélemy features the national coat of arms centered on a white field. The coat of arms is a shield divided into three horizontal stripes: three gold fleurs-de-lis on blue, above a white Maltese cross on red, over three gold crowns on blue. Below the shield is a banner with \"OUANALAO,\" which is what the indigenous people called the island. On top of the shield is a mural crown."},"flag":"🇧🇱","name":{"common":"Saint Barthélemy","official":"Collectivity of Saint Barthélemy","nativeName":{"fra":{"official":"Collectivité de Saint-Barthélemy","common":"Saint-Barthélemy"}}},"cca2":"BL","ccn3":"652","cca3":"BLM","region":"Americas","subregion":"Caribbean"},{"flags":{"png":"https://flagcdn.com/w320/bo.png","svg":"https://flagcdn.com/bo.svg","alt":"The flag of Bolivia is composed of three equal horizontal bands of red, yellow and green, with the national coat of arms centered in the yellow band."},"flag":"🇧🇴","name":{"common":"Bolivia","official":"Plurinational State of Bolivia","nativeName":{"aym":{"official":"Wuliwya Suyu","common":"Wuliwya"},"grn":{"official":"Tetã Volívia","common":"Volívia"},"que":{"official":"Buliwya Mamallaqta","common":"Buliwya"},"spa":{"official":"Estado Plurinacional de Bolivia","common":"Bolivia"}}},"cca2":"BO","ccn3":"068","cca3":"BOL","region":"Americas","subregion":"South America"},{"flags":{"png":"https://flagcdn.com/w320/au.png","svg":"https://flagcdn.com/au.svg","alt":"The flag of Australia has a dark blue field. It features the flag of the United Kingdom — the Union Jack — in the canton, beneath which is a large white seven-pointed star. A representation of the Southern Cross constellation, made up of one small five-pointed and four larger seven-pointed white stars, is situated on the fly side of the field."},"flag":"🇦🇺","name":{"common":"Australia","official":"Commonwealth of Australia","nativeName":{"eng":{"official":"Commonwealth of Australia","common":"Australia"}}},"cca2":"AU","ccn3":"036","cca3":"AUS","region":"Oceania","subregion":"Australia and New Zealand"},{"flags":{"png":"https://flagcdn.com/w320/um.png","svg":"https://flagcdn.com/um.svg","alt":"The flag of the United States Minor Outlying Islands is composed of thirteen equal horizontal bands of red alternating with white. A blue rectangle, bearing fifty small five-pointed white stars arranged in nine rows where rows of six stars alternate with rows of five stars, is superimposed in the canton."},"flag":"🇺🇲","name":{"common":"United States Minor Outlying Islands","official":"United States Minor Outlying Islands","nativeName":{"eng":{"official":"United States Minor Outlying Islands","common":"United States Minor Outlying Islands"}}},"cca2":"UM","ccn3":"581","cca3":"UMI","region":"Americas","subregion":"North America"},{"flags":{"png":"https://flagcdn.com/w320/fj.png","svg":"https://flagcdn.com/fj.svg","alt":"The flag of Fiji has a light blue field. It features the flag of the United Kingdom — the Union Jack — in the canton and the shield of the national coat of arms centered in the fly half."},"flag":"🇫🇯","name":{"common":"Fiji","official":"Republic of Fiji","nativeName":{"eng":{"official":"Republic of Fiji","common":"Fiji"},"fij":{"official":"Matanitu Tugalala o Viti","common":"Viti"},"hif":{"official":"रिपब्लिक ऑफ फीजी","common":"फिजी"}}},"cca2":"FJ","ccn3":"242","cca3":"FJI","region":"Oceania","subregion":"Melanesia"},{"flags":{"png":"https://flagcdn.com/w320/mm.png","svg":"https://flagcdn.com/mm.svg","alt":"The flag of Myanmar is composed of three equal horizontal bands of yellow, green and red, with a large five-pointed white star superimposed at the center of the field."},"flag":"🇲🇲","name":{"common":"Myanmar","official":"Republic of the Union of Myanmar","nativeName":{"mya":{"official":"ပြည်ထောင်စု သမ္မတ မြန်မာနိုင်ငံတော်","common":"မြန်မာ"}}},"cca2":"MM","ccn3":"104","cca3":"MMR","region":"Asia","subregion":"South-Eastern Asia"},{"flags":{"png":"https://flagcdn.com/w320/st.png","svg":"https://flagcdn.com/st.svg","alt":"The flag of São Tomé and Príncipe has three horizontal bands of green, yellow (double-width), and green, with two black five-pointed stars in the center of the yellow band and a red isosceles triangle based on the hoist side."},"flag":"🇸🇹","name":{"common":"São Tomé and Príncipe","official":"Democratic Republic of São Tomé and Príncipe","nativeName":{"por":{"official":"República Democrática do São Tomé e Príncipe","common":"São Tomé e Príncipe"}}},"cca2":"ST","ccn3":"678","cca3":"STP","region":"Africa","subregion":"Middle Africa"},{"flags":{"png":"https://flagcdn.com/w320/mg.png","svg":"https://flagcdn.com/mg.svg","alt":"The flag of Madagascar features a white vertical band on the hoist side that takes up about one-third the width of the field, and two equal horizontal bands of red and green adjoining the vertical band."},"flag":"🇲🇬","name":{"common":"Madagascar","official":"Republic of Madagascar","nativeName":{"fra":{"official":"République de Madagascar","common":"Madagascar"},"mlg":{"official":"Repoblikan'i Madagasikara","common":"Madagasikara"}}},"cca2":"MG","ccn3":"450","cca3":"MDG","region":"Africa","subregion":"Eastern Africa"},{"flags":{"png":"https://flagcdn.com/w320/tv.png","svg":"https://flagcdn.com/tv.svg","alt":"The flag of Tuvalu has a light blue field with the flag of the United Kingdom — the Union Jack — in the canton. A representation of the country's nine Islands using nine five-pointed yellow stars is situated in the fly half of the field."},"flag":"🇹🇻","name":{"common":"Tuvalu","official":"Tuvalu","nativeName":{"eng":{"official":"Tuvalu","common":"Tuvalu"},"tvl":{"official":"Tuvalu","common":"Tuvalu"}}},"cca2":"TV","ccn3":"798","cca3":"TUV","region":"Oceania","subregion":"Polynesia"},{"flags":{"png":"https://flagcdn.com/w320/gg.png","svg":"https://flagcdn.com/gg.svg","alt":"The flag of Guernsey is white with a red cross extending to the edges. Superimposed on it is a gold equal-armed cross capped with serifs."},"flag":"🇬🇬","name":{"common":"Guernsey","official":"Bailiwick of Guernsey","nativeName":{"eng":{"official":"Bailiwick of Guernsey","common":"Guernsey"},"fra":{"official":"Bailliage de Guernesey","common":"Guernesey"},"nfr":{"official":"Dgèrnésiais","common":"Dgèrnésiais"}}},"cca2":"GG","ccn3":"831","cca3":"GGY","region":"Europe","subregion":"Northern Europe"},{"flags":{"png":"https://flagcdn.com/w320/cc.png","svg":"https://flagcdn.com/cc.svg","alt":"The flag of the Cocos (Keeling) Islands consists of a green field with a palm tree on a gold disc in the canton, a gold crescent moon in the center of the flag, and a gold southern cross in the fly side."},"flag":"🇨🇨","name":{"common":"Cocos (Keeling) Islands","official":"Territory of the Cocos (Keeling) Islands","nativeName":{"eng":{"official":"Territory of the Cocos (Keeling) Islands","common":"Cocos (Keeling) Islands"}}},"cca2":"CC","ccn3":"166","cca3":"CCK","region":"Oceania","subregion":"Australia and New Zealand"},{"flags":{"png":"https://flagcdn.com/w320/hu.png","svg":"https://flagcdn.com/hu.svg","alt":"The flag of Hungary is composed of three equal horizontal bands of red, white and green."},"flag":"🇭🇺","name":{"common":"Hungary","official":"Hungary","nativeName":{"hun":{"official":"Magyarország","common":"Magyarország"}}},"cca2":"HU","ccn3":"348","cca3":"HUN","region":"Europe","subregion":"Central Europe"},{"flags":{"png":"https://flagcdn.com/w320/eg.png","svg":"https://flagcdn.com/eg.svg","alt":"The flag of Egypt is composed of three equal horizontal bands of red, white and black, with Egypt's national emblem — a hoist-side facing gold eagle of Saladin — centered in the white band."},"flag":"🇪🇬","name":{"common":"Egypt","official":"Arab Republic of Egypt","nativeName":{"ara":{"official":"جمهورية مصر العربية","common":"مصر"}}},"cca2":"EG","ccn3":"818","cca3":"EGY","region":"Africa","subregion":"Northern Africa"},{"flags":{"png":"https://flagcdn.com/w320/kg.png","svg":"https://flagcdn.com/kg.svg","alt":"The flag of Kyrgyzstan features a yellow sun with forty rays at the center of a red field. At the center of the sun is a stylized depiction of a tunduk."},"flag":"🇰🇬","name":{"common":"Kyrgyzstan","official":"Kyrgyz Republic","nativeName":{"kir":{"official":"Кыргыз Республикасы","common":"Кыргызстан"},"rus":{"official":"Кыргызская Республика","common":"Киргизия"}}},"cca2":"KG","ccn3":"417","cca3":"KGZ","region":"Asia","subregion":"Central Asia"},{"flags":{"png":"https://flagcdn.com/w320/id.png","svg":"https://flagcdn.com/id.svg","alt":"The flag of Indonesia is composed of two equal horizontal bands of red and white."},"flag":"🇮🇩","name":{"common":"Indonesia","official":"Republic of Indonesia","nativeName":{"ind":{"official":"Republik Indonesia","common":"Indonesia"}}},"cca2":"ID","ccn3":"360","cca3":"IDN","region":"Asia","subregion":"South-Eastern Asia"},{"flags":{"png":"https://flagcdn.com/w320/tn.png","svg":"https://flagcdn.com/tn.svg","alt":"The flag of Tunisia has a red field. A white circle bearing a five-pointed red star within a fly-side facing red crescent is situated at the center of the field."},"flag":"🇹🇳","name":{"common":"Tunisia","official":"Tunisian Republic","nativeName":{"ara":{"official":"الجمهورية التونسية","common":"تونس"}}},"cca2":"TN","ccn3":"788","cca3":"TUN","region":"Africa","subregion":"Northern Africa"},{"flags":{"png":"https://flagcdn.com/w320/as.png","svg":"https://flagcdn.com/as.svg","alt":"The flag of American Samoa features a large white triangle edged in red that is based on the fly side and extends to the hoist side and is charged with an eagle, all on a blue field."},"flag":"🇦🇸","name":{"common":"American Samoa","official":"American Samoa","nativeName":{"eng":{"official":"American Samoa","common":"American Samoa"},"smo":{"official":"Sāmoa Amelika","common":"Sāmoa Amelika"}}},"cca2":"AS","ccn3":"016","cca3":"ASM","region":"Oceania","subregion":"Polynesia"},{"flags":{"png":"https://flagcdn.com/w320/om.png","svg":"https://flagcdn.com/om.svg","alt":"The flag of Oman features a red vertical band on the hoist side that takes up about one-fourth the width of the field, and three equal horizontal bands of white, red and green adjoining the vertical band. At the top of the vertical band is the white emblem of Oman."},"flag":"🇴🇲","name":{"common":"Oman","official":"Sultanate of Oman","nativeName":{"ara":{"official":"سلطنة عمان","common":"عمان"}}},"cca2":"OM","ccn3":"512","cca3":"OMN","region":"Asia","subregion":"Western Asia"},{"flags":{"png":"https://flagcdn.com/w320/pf.png","svg":"https://flagcdn.com/pf.svg","alt":"The flag of French Polynesia has two red horizontal bands flanking a wide white band in a 1:2:1 ratio. Centered on the white band is a disk with a blue-and-white wave pattern depicting the sea on the lower half and a gold-and-white ray pattern depicting the sun on the upper half. A canoe on the disk has a crew of five."},"flag":"🇵🇫","name":{"common":"French Polynesia","official":"French Polynesia","nativeName":{"fra":{"official":"Polynésie française","common":"Polynésie française"}}},"cca2":"PF","ccn3":"258","cca3":"PYF","region":"Oceania","subregion":"Polynesia"},{"flags":{"png":"https://flagcdn.com/w320/us.png","svg":"https://flagcdn.com/us.svg","alt":"The flag of the United States of America is composed of thirteen equal horizontal bands of red alternating with white. A blue rectangle, bearing fifty small five-pointed white stars arranged in nine rows where rows of six stars alternate with rows of five stars, is superimposed in the canton."},"flag":"🇺🇸","name":{"common":"United States","official":"United States of America","nativeName":{"eng":{"official":"United States of America","common":"United States"}}},"cca2":"US","ccn3":"840","cca3":"USA","region":"Americas","subregion":"North America"},{"flags":{"png":"https://flagcdn.com/w320/fk.png","svg":"https://flagcdn.com/fk.svg","alt":"The flag of the Falkland Islands is blue with the UK flag in the canton and the national coat of arms centered in the fly half. The coat of arms has a white ram above a ship, with a scroll at the bottom bearing the motto \"DESIRE THE RIGHT.\""},"flag":"🇫🇰","name":{"common":"Falkland Islands","official":"Falkland Islands","nativeName":{"eng":{"official":"Falkland Islands","common":"Falkland Islands"}}},"cca2":"FK","ccn3":"238","cca3":"FLK","region":"Americas","subregion":"South America"},{"flags":{"png":"https://flagcdn.com/w320/mf.png","svg":"https://flagcdn.com/mf.svg","alt":"The flag of Saint Martin is composed of three equal vertical bands of blue, white and red."},"flag":"🇲🇫","name":{"common":"Saint Martin","official":"Saint Martin","nativeName":{"fra":{"official":"Saint-Martin","common":"Saint-Martin"}}},"cca2":"MF","ccn3":"663","cca3":"MAF","region":"Americas","subregion":"Caribbean"},{"flags":{"png":"https://flagcdn.com/w320/sr.png","svg":"https://flagcdn.com/sr.svg","alt":"The flag of Suriname is composed of five horizontal bands of green, white, red, white and green in the ratio of 2:1:4:1:2. A large five-pointed yellow star is centered in the red band."},"flag":"🇸🇷","name":{"common":"Suriname","official":"Republic of Suriname","nativeName":{"nld":{"official":"Republiek Suriname","common":"Suriname"}}},"cca2":"SR","ccn3":"740","cca3":"SUR","region":"Americas","subregion":"South America"},{"flags":{"png":"https://flagcdn.com/w320/az.png","svg":"https://flagcdn.com/az.svg","alt":"The flag of Azerbaijan features three equal horizontal bands of blue, red and green, with a white fly-side facing crescent and eight-pointed star centered in the red band."},"flag":"🇦🇿","name":{"common":"Azerbaijan","official":"Republic of Azerbaijan","nativeName":{"aze":{"official":"Azərbaycan Respublikası","common":"Azərbaycan"}}},"cca2":"AZ","ccn3":"031","cca3":"AZE","region":"Asia","subregion":"Western Asia"},{"flags":{"png":"https://flagcdn.com/w320/nl.png","svg":"https://flagcdn.com/nl.svg","alt":"The flag of the Netherlands is composed of three equal horizontal bands of red, white and blue."},"flag":"🇳🇱","name":{"common":"Netherlands","official":"Kingdom of the Netherlands","nativeName":{"nld":{"official":"Koninkrijk der Nederlanden","common":"Nederland"}}},"cca2":"NL","ccn3":"528","cca3":"NLD","region":"Europe","subregion":"Western Europe"},{"flags":{"png":"https://flagcdn.com/w320/ps.png","svg":"https://flagcdn.com/ps.svg","alt":"The flag of Palestine has three equal horizontal stripes of black, white, and green overlaid by a red triangle issuing from the hoist."},"flag":"🇵🇸","name":{"common":"Palestine","official":"State of Palestine","nativeName":{"ara":{"official":"دولة فلسطين","common":"فلسطين"}}},"cca2":"PS","ccn3":"275","cca3":"PSE","region":"Asia","subregion":"Western Asia"},{"flags":{"png":"https://flagcdn.com/w320/sk.png","svg":"https://flagcdn.com/sk.svg","alt":"The flag of Slovakia is composed of three equal horizontal bands of white, blue and red. The coat of arms of Slovakia is superimposed at the center of the field slightly towards the hoist side."},"flag":"🇸🇰","name":{"common":"Slovakia","official":"Slovak Republic","nativeName":{"slk":{"official":"Slovenská republika","common":"Slovensko"}}},"cca2":"SK","ccn3":"703","cca3":"SVK","region":"Europe","subregion":"Central Europe"},{"flags":{"png":"https://flagcdn.com/w320/ug.png","svg":"https://flagcdn.com/ug.svg","alt":"The flag of Uganda is composed of six equal horizontal bands of black, yellow, red, black, yellow and red. A white circle bearing a hoist-side facing grey red-crested crane is superimposed in the center of the field."},"flag":"🇺🇬","name":{"common":"Uganda","official":"Republic of Uganda","nativeName":{"eng":{"official":"Republic of Uganda","common":"Uganda"},"swa":{"official":"Republic of Uganda","common":"Uganda"}}},"cca2":"UG","ccn3":"800","cca3":"UGA","region":"Africa","subregion":"Eastern Africa"},{"flags":{"png":"https://flagcdn.com/w320/ky.png","svg":"https://flagcdn.com/ky.svg","alt":"The flag of the Cayman Islands is composed of a blue field with the UK flag in the canton and the national coat of arms centered on the fly half. The coat of arms includes a crest with a pineapple and a turtle above a shield bearing a golden lion. Below are three green stars over white and blue wavy lines. A scroll below the shield bears the motto \"HE HATH FOUNDED IT UPON THE SEAS.\""},"flag":"🇰🇾","name":{"common":"Cayman Islands","official":"Cayman Islands","nativeName":{"eng":{"official":"Cayman Islands","common":"Cayman Islands"}}},"cca2":"KY","ccn3":"136","cca3":"CYM","region":"Americas","subregion":"Caribbean"},{"flags":{"png":"https://flagcdn.com/w320/lc.png","svg":"https://flagcdn.com/lc.svg","alt":"The flag of Saint Lucia has a light blue field, at the center of which are two triangles which share a common base — a small golden-yellow isosceles triangle superimposed on a large white-edged black isosceles triangle."},"flag":"🇱🇨","name":{"common":"Saint Lucia","official":"Saint Lucia","nativeName":{"eng":{"official":"Saint Lucia","common":"Saint Lucia"}}},"cca2":"LC","ccn3":"662","cca3":"LCA","region":"Americas","subregion":"Caribbean"},{"flags":{"png":"https://flagcdn.com/w320/ly.png","svg":"https://flagcdn.com/ly.svg","alt":"The flag of Libya is composed of three horizontal bands of red, black and green, with the black band twice the height of the other two bands. At the center of the black band is a fly-side facing white crescent and a five-pointed white star placed just outside the crescent opening."},"flag":"🇱🇾","name":{"common":"Libya","official":"State of Libya","nativeName":{"ara":{"official":"الدولة ليبيا","common":"‏ليبيا"}}},"cca2":"LY","ccn3":"434","cca3":"LBY","region":"Africa","subregion":"Northern Africa"},{"flags":{"png":"https://flagcdn.com/w320/mp.png","svg":"https://flagcdn.com/mp.svg","alt":"The flag of the Northern Mariana Islands is blue with a five-pointed white star on a gray latte stone (a traditional foundation stone) in the center, surrounded by a head lei (wreath)."},"flag":"🇲🇵","name":{"common":"Northern Mariana Islands","official":"Commonwealth of the Northern Mariana Islands","nativeName":{"cal":{"official":"Commonwealth of the Northern Mariana Islands","common":"Northern Mariana Islands"},"cha":{"official":"Sankattan Siha Na Islas Mariånas","common":"Na Islas Mariånas"},"eng":{"official":"Commonwealth of the Northern Mariana Islands","common":"Northern Mariana Islands"}}},"cca2":"MP","ccn3":"580","cca3":"MNP","region":"Oceania","subregion":"Micronesia"},{"flags":{"png":"https://flagcdn.com/w320/am.png","svg":"https://flagcdn.com/am.svg","alt":"The flag of Armenia is composed of three equal horizontal bands of red, blue and orange."},"flag":"🇦🇲","name":{"common":"Armenia","official":"Republic of Armenia","nativeName":{"hye":{"official":"Հայաստանի Հանրապետություն","common":"Հայաստան"}}},"cca2":"AM","ccn3":"051","cca3":"ARM","region":"Asia","subregion":"Western Asia"},{"flags":{"png":"https://flagcdn.com/w320/km.png","svg":"https://flagcdn.com/km.svg","alt":"The flag of Comoros is composed of four equal horizontal bands of yellow, white, red and blue, with a green isosceles triangle superimposed on the hoist side of the field. This triangle has its base on the hoist end, spans about two-fifth the width of the field and bears a fly-side facing white crescent and four five-pointed white stars arranged in a vertical line along the opening of the crescent."},"flag":"🇰🇲","name":{"common":"Comoros","official":"Union of the Comoros","nativeName":{"ara":{"official":"الاتحاد القمري","common":"القمر‎"},"fra":{"official":"Union des Comores","common":"Comores"},"zdj":{"official":"Udzima wa Komori","common":"Komori"}}},"cca2":"KM","ccn3":"174","cca3":"COM","region":"Africa","subregion":"Eastern Africa"},{"flags":{"png":"https://flagcdn.com/w320/gy.png","svg":"https://flagcdn.com/gy.svg","alt":"The flag of Guyana has a green field with two isosceles triangles which share a common base on the hoist end. The smaller black-edged red triangle spanning half the width of the field is superimposed on the larger white-edged yellow triangle which spans the full width of the field."},"flag":"🇬🇾","name":{"common":"Guyana","official":"Co-operative Republic of Guyana","nativeName":{"eng":{"official":"Co-operative Republic of Guyana","common":"Guyana"}}},"cca2":"GY","ccn3":"328","cca3":"GUY","region":"Americas","subregion":"South America"},{"flags":{"png":"https://flagcdn.com/w320/ae.png","svg":"https://flagcdn.com/ae.svg","alt":"The flag of United Arab Emirates features a red vertical band on its hoist side that takes up about one-fourth the width of the field and three equal horizontal bands of green, white and black adjoining the vertical band."},"flag":"🇦🇪","name":{"common":"United Arab Emirates","official":"United Arab Emirates","nativeName":{"ara":{"official":"الإمارات العربية المتحدة","common":"دولة الإمارات العربية المتحدة"}}},"cca2":"AE","ccn3":"784","cca3":"ARE","region":"Asia","subregion":"Western Asia"},{"flags":{"png":"https://flagcdn.com/w320/tr.png","svg":"https://flagcdn.com/tr.svg","alt":"The flag of Turkey has a red field bearing a large fly-side facing white crescent and a smaller five-pointed white star placed just outside the crescent opening. The white crescent and star are offset slightly towards the hoist side of center."},"flag":"🇹🇷","name":{"common":"Turkey","official":"Republic of Turkey","nativeName":{"tur":{"official":"Türkiye Cumhuriyeti","common":"Türkiye"}}},"cca2":"TR","ccn3":"792","cca3":"TUR","region":"Asia","subregion":"Western Asia"},{"flags":{"png":"https://flagcdn.com/w320/ws.png","svg":"https://flagcdn.com/ws.svg","alt":"The flag of Samoa has a red field. A blue rectangle, bearing a representation of the Southern Cross made up of five large and one smaller five-pointed white stars, is superimposed in the canton."},"flag":"🇼🇸","name":{"common":"Samoa","official":"Independent State of Samoa","nativeName":{"eng":{"official":"Independent State of Samoa","common":"Samoa"},"smo":{"official":"Malo Saʻoloto Tutoʻatasi o Sāmoa","common":"Sāmoa"}}},"cca2":"WS","ccn3":"882","cca3":"WSM","region":"Oceania","subregion":"Polynesia"},{"flags":{"png":"https://flagcdn.com/w320/de.png","svg":"https://flagcdn.com/de.svg","alt":"The flag of Germany is composed of three equal horizontal bands of black, red and gold."},"flag":"🇩🇪","name":{"common":"Germany","official":"Federal Republic of Germany","nativeName":{"deu":{"official":"Bundesrepublik Deutschland","common":"Deutschland"}}},"cca2":"DE","ccn3":"276","cca3":"DEU","region":"Europe","subregion":"Western Europe"},{"flags":{"png":"https://flagcdn.com/w320/gm.png","svg":"https://flagcdn.com/gm.svg","alt":"The flag of Gambia is composed of three equal horizontal bands of red, blue with white top and bottom edges, and green."},"flag":"🇬🇲","name":{"common":"Gambia","official":"Republic of the Gambia","nativeName":{"eng":{"official":"Republic of the Gambia","common":"Gambia"}}},"cca2":"GM","ccn3":"270","cca3":"GMB","region":"Africa","subregion":"Western Africa"},{"flags":{"png":"https://flagcdn.com/w320/mn.png","svg":"https://flagcdn.com/mn.svg","alt":"The flag of Mongolia is composed of three equal vertical bands of red, blue and red, with the national emblem — the Soyombo — in gold centered in the hoist-side red band."},"flag":"🇲🇳","name":{"common":"Mongolia","official":"Mongolia","nativeName":{"mon":{"official":"Монгол улс","common":"Монгол улс"}}},"cca2":"MN","ccn3":"496","cca3":"MNG","region":"Asia","subregion":"Eastern Asia"},{"flags":{"png":"https://flagcdn.com/w320/rs.png","svg":"https://flagcdn.com/rs.svg","alt":"The flag of Serbia is composed of three equal horizontal bands of red, blue and white. The coat of arms of Serbia is superimposed at the center of the field slightly towards the hoist side."},"flag":"🇷🇸","name":{"common":"Serbia","official":"Republic of Serbia","nativeName":{"srp":{"official":"Република Србија","common":"Србија"}}},"cca2":"RS","ccn3":"688","cca3":"SRB","region":"Europe","subregion":"Southeast Europe"},{"flags":{"png":"https://flagcdn.com/w320/cn.png","svg":"https://flagcdn.com/cn.svg","alt":"The flag of China has a red field. In the canton are five yellow five-pointed stars — a large star and four smaller stars arranged in a vertical arc on the fly side of the large star."},"flag":"🇨🇳","name":{"common":"China","official":"People's Republic of China","nativeName":{"zho":{"official":"中华人民共和国","common":"中国"}}},"cca2":"CN","ccn3":"156","cca3":"CHN","region":"Asia","subregion":"Eastern Asia"},{"flags":{"png":"https://flagcdn.com/w320/me.png","svg":"https://flagcdn.com/me.svg","alt":"The flag of Montenegro features a large red central rectangular area surrounded by a golden-yellow border. The coat of arms of Montenegro is centered in the red rectangle."},"flag":"🇲🇪","name":{"common":"Montenegro","official":"Montenegro","nativeName":{"cnr":{"official":"Црна Гора","common":"Црна Гора"}}},"cca2":"ME","ccn3":"499","cca3":"MNE","region":"Europe","subregion":"Southeast Europe"},{"flags":{"png":"https://flagcdn.com/w320/gu.png","svg":"https://flagcdn.com/gu.svg","alt":"The flag of Guam is dark blue with a narrow red border on all four sides. Centered is a red-bordered, pointed, vertical ellipse containing a beach scene, a proa (outrigger canoe with sail), and a palm tree. The country's name in red is centered in the ellipse."},"flag":"🇬🇺","name":{"common":"Guam","official":"Guam","nativeName":{"cha":{"official":"Guåhån","common":"Guåhån"},"eng":{"official":"Guam","common":"Guam"},"spa":{"official":"Guam","common":"Guam"}}},"cca2":"GU","ccn3":"316","cca3":"GUM","region":"Oceania","subregion":"Micronesia"},{"flags":{"png":"https://flagcdn.com/w320/kh.png","svg":"https://flagcdn.com/kh.svg","alt":"The flag of Cambodia features three horizontal bands of blue, red and blue, with a white depiction of the temple complex, Angkor Wat centered in the red band."},"flag":"🇰🇭","name":{"common":"Cambodia","official":"Kingdom of Cambodia","nativeName":{"khm":{"official":"ព្រះរាជាណាចក្រកម្ពុជា","common":"Kâmpŭchéa"}}},"cca2":"KH","ccn3":"116","cca3":"KHM","region":"Asia","subregion":"South-Eastern Asia"},{"flags":{"png":"https://flagcdn.com/w320/li.png","svg":"https://flagcdn.com/li.svg","alt":"The flag of Liechtenstein is composed of two equal horizontal bands of blue and red, with a golden-yellow crown on the hoist side of the blue band."},"flag":"🇱🇮","name":{"common":"Liechtenstein","official":"Principality of Liechtenstein","nativeName":{"deu":{"official":"Fürstentum Liechtenstein","common":"Liechtenstein"}}},"cca2":"LI","ccn3":"438","cca3":"LIE","region":"Europe","subregion":"Western Europe"},{"flags":{"png":"https://flagcdn.com/w320/sn.png","svg":"https://flagcdn.com/sn.svg","alt":"The flag of Senegal is composed of three equal vertical bands of green, golden-yellow and red, with a five-pointed green star centered in the golden-yellow band."},"flag":"🇸🇳","name":{"common":"Senegal","official":"Republic of Senegal","nativeName":{"fra":{"official":"République du Sénégal","common":"Sénégal"}}},"cca2":"SN","ccn3":"686","cca3":"SEN","region":"Africa","subregion":"Western Africa"},{"flags":{"png":"https://flagcdn.com/w320/gb.png","svg":"https://flagcdn.com/gb.svg","alt":"The flag of the United Kingdom — the Union Jack — has a blue field. It features the white-edged red cross of Saint George superimposed on the diagonal red cross of Saint Patrick which is superimposed on the diagonal white cross of Saint Andrew."},"flag":"🇬🇧","name":{"common":"United Kingdom","official":"United Kingdom of Great Britain and Northern Ireland","nativeName":{"eng":{"official":"United Kingdom of Great Britain and Northern Ireland","common":"United Kingdom"}}},"cca2":"GB","ccn3":"826","cca3":"GBR","region":"Europe","subregion":"Northern Europe"},{"flags":{"png":"https://flagcdn.com/w320/bq.png","svg":"https://flagcdn.com/bq.svg","alt":"The flag of the Caribbean Netherlands features a large blue triangle at the lower fly-side corner, spanning half the flag, and a smaller yellow triangle at the opposite corner, separated by a white strip containing a black compass surrounding a red six-pointed star."},"flag":"🇧🇶","name":{"common":"Caribbean Netherlands","official":"Bonaire, Sint Eustatius and Saba","nativeName":{"nld":{"official":"Bonaire, Sint Eustatius en Saba","common":"Caribisch Nederland"},"pap":{"official":"Boneiru, Sint Eustatius y Saba","common":"Boneiru, Sint Eustatius y Saba"}}},"cca2":"BQ","ccn3":"535","cca3":"BES","region":"Americas","subregion":"Caribbean"},{"flags":{"png":"https://flagcdn.com/w320/gf.png","svg":"https://flagcdn.com/gf.svg","alt":"The flag of French Guiana divides diagonally with green in the upper fly and yellow in the lower hoist and bears a red five-pointed star in the center."},"flag":"🇬🇫","name":{"common":"French Guiana","official":"Guiana","nativeName":{"fra":{"official":"Guyane","common":"Guyane française"}}},"cca2":"GF","ccn3":"254","cca3":"GUF","region":"Americas","subregion":"South America"},{"flags":{"png":"https://flagcdn.com/w320/bf.png","svg":"https://flagcdn.com/bf.svg","alt":"The flag of Burkina Faso features two equal horizontal bands of red and green, with a yellow five-pointed star in the center."},"flag":"🇧🇫","name":{"common":"Burkina Faso","official":"Burkina Faso","nativeName":{"fra":{"official":"République du Burkina","common":"Burkina Faso"}}},"cca2":"BF","ccn3":"854","cca3":"BFA","region":"Africa","subregion":"Western Africa"},{"flags":{"png":"https://flagcdn.com/w320/qa.png","svg":"https://flagcdn.com/qa.svg","alt":"The flag of Qatar has a maroon field, on the hoist side of which is a white vertical band that spans about one-third the width of the field and is separated from the rest of the field by nine adjoining fly-side pointing white isosceles triangles that serve as a serrated line."},"flag":"🇶🇦","name":{"common":"Qatar","official":"State of Qatar","nativeName":{"ara":{"official":"دولة قطر","common":"قطر"}}},"cca2":"QA","ccn3":"634","cca3":"QAT","region":"Asia","subregion":"Western Asia"},{"flags":{"png":"https://flagcdn.com/w320/ua.png","svg":"https://flagcdn.com/ua.svg","alt":"The flag of Ukraine is composed of two equal horizontal bands of blue and yellow."},"flag":"🇺🇦","name":{"common":"Ukraine","official":"Ukraine","nativeName":{"ukr":{"official":"Україна","common":"Україна"}}},"cca2":"UA","ccn3":"804","cca3":"UKR","region":"Europe","subregion":"Eastern Europe"},{"flags":{"png":"https://flagcdn.com/w320/mx.png","svg":"https://flagcdn.com/mx.svg","alt":"The flag of Mexico is composed of three equal vertical bands of green, white and red, with the national coat of arms centered in the white band."},"flag":"🇲🇽","name":{"common":"Mexico","official":"United Mexican States","nativeName":{"spa":{"official":"Estados Unidos Mexicanos","common":"México"}}},"cca2":"MX","ccn3":"484","cca3":"MEX","region":"Americas","subregion":"North America"},{"flags":{"png":"https://flagcdn.com/w320/im.png","svg":"https://flagcdn.com/im.svg","alt":"The flag of the Isle of Man is red with an emblem comprising three legs (triskelion) in the center. The three legs are joined at the thigh and bent at the knee."},"flag":"🇮🇲","name":{"common":"Isle of Man","official":"Isle of Man","nativeName":{"eng":{"official":"Isle of Man","common":"Isle of Man"},"glv":{"official":"Ellan Vannin or Mannin","common":"Mannin"}}},"cca2":"IM","ccn3":"833","cca3":"IMN","region":"Europe","subregion":"Northern Europe"},{"flags":{"png":"https://flagcdn.com/w320/aq.png","svg":"https://flagcdn.com/aq.svg","alt":"The flag of Antarctica features a plain white map of the country on a blue background."},"flag":"🇦🇶","name":{"common":"Antarctica","official":"Antarctica","nativeName":{}},"cca2":"AQ","ccn3":"010","cca3":"ATA","region":"Antarctic","subregion":""},{"flags":{"png":"https://flagcdn.com/w320/gs.png","svg":"https://flagcdn.com/gs.svg","alt":"The flag of South Georgia is blue with the UK flag in the canton. The national coat of arms is centered on the fly half and has a green shield with a golden lion holding a torch. A fur seal is to the left of the shield and a Macaroni penguin to the right. A reindeer appears above the crest, and below the shield on a scroll is the Latin motto \"LEO TERRAM PROPRIAM PROTEGAT\" (Let the Lion Protect its Own Land)."},"flag":"🇬🇸","name":{"common":"South Georgia","official":"South Georgia and the South Sandwich Islands","nativeName":{"eng":{"official":"South Georgia and the South Sandwich Islands","common":"South Georgia"}}},"cca2":"GS","ccn3":"239","cca3":"SGS","region":"Antarctic","subregion":""},{"flags":{"png":"https://flagcdn.com/w320/eh.png","svg":"https://flagcdn.com/eh.svg","alt":"The flag of Western Sahara has three equal horizontal stripes of black, white, and green overlaid by a red triangle issuing from the hoist. Centered in the white band is a red fly-facing crescent surrounding a red five-pointed star."},"flag":"🇪🇭","name":{"common":"Western Sahara","official":"Sahrawi Arab Democratic Republic","nativeName":{"ber":{"official":"Sahrawi Arab Democratic Republic","common":"Western Sahara"},"mey":{"official":"الجمهورية العربية الصحراوية الديمقراطية","common":"الصحراء الغربية"},"spa":{"official":"República Árabe Saharaui Democrática","common":"Sahara Occidental"}}},"cca2":"EH","ccn3":"732","cca3":"ESH","region":"Africa","subregion":"Northern Africa"},{"flags":{"png":"https://flagcdn.com/w320/tc.png","svg":"https://flagcdn.com/tc.svg","alt":"The flag of the Turks and Caicos Islands is blue with the UK flag in the canton and the colonial shield centered on the fly half. The shield is yellow and displays a conch shell, a spiny lobster, and a cactus."},"flag":"🇹🇨","name":{"common":"Turks and Caicos Islands","official":"Turks and Caicos Islands","nativeName":{"eng":{"official":"Turks and Caicos Islands","common":"Turks and Caicos Islands"}}},"cca2":"TC","ccn3":"796","cca3":"TCA","region":"Americas","subregion":"Caribbean"},{"flags":{"png":"https://flagcdn.com/w320/al.png","svg":"https://flagcdn.com/al.svg","alt":"The flag of Albania features a silhouetted double-headed black eagle at the center of a red field."},"flag":"🇦🇱","name":{"common":"Albania","official":"Republic of Albania","nativeName":{"sqi":{"official":"Republika e Shqipërisë","common":"Shqipëria"}}},"cca2":"AL","ccn3":"008","cca3":"ALB","region":"Europe","subregion":"Southeast Europe"},{"flags":{"png":"https://flagcdn.com/w320/cy.png","svg":"https://flagcdn.com/cy.svg","alt":"The flag of Cyprus has a white field, at the center of which is a copper-colored silhouette of the Island of Cyprus above two green olive branches crossed at the stem."},"flag":"🇨🇾","name":{"common":"Cyprus","official":"Republic of Cyprus","nativeName":{"ell":{"official":"Δημοκρατία της Κύπρος","common":"Κύπρος"},"tur":{"official":"Kıbrıs Cumhuriyeti","common":"Kıbrıs"}}},"cca2":"CY","ccn3":"196","cca3":"CYP","region":"Europe","subregion":"Southern Europe"},{"flags":{"png":"https://flagcdn.com/w320/gt.png","svg":"https://flagcdn.com/gt.svg","alt":"The flag of Guatemala is composed of three equal vertical bands of light blue, white and light blue, with the national coat of arms centered in the white band."},"flag":"🇬🇹","name":{"common":"Guatemala","official":"Republic of Guatemala","nativeName":{"spa":{"official":"República de Guatemala","common":"Guatemala"}}},"cca2":"GT","ccn3":"320","cca3":"GTM","region":"Americas","subregion":"Central America"},{"flags":{"png":"https://flagcdn.com/w320/nr.png","svg":"https://flagcdn.com/nr.svg","alt":"The flag of Nauru has a dark blue field with a thin yellow horizontal band across the center and a large white twelve-pointed star beneath the horizontal band on the hoist side of the field."},"flag":"🇳🇷","name":{"common":"Nauru","official":"Republic of Nauru","nativeName":{"eng":{"official":"Republic of Nauru","common":"Nauru"},"nau":{"official":"Republic of Nauru","common":"Nauru"}}},"cca2":"NR","ccn3":"520","cca3":"NRU","region":"Oceania","subregion":"Micronesia"},{"flags":{"png":"https://flagcdn.com/w320/sh.png","svg":"https://flagcdn.com/sh.svg","alt":"The flag of Saint Helena, Ascension and Tristan da Cunha is blue with the UK flag in the canton and the national coat of arms shield centered on the fly half. The upper third of the shield depicts a white plover, a native bird, on a yellow field. The rest of the shield depicts a rocky coastline and a three-masted sailing ship with sails furled and flying an English flag."},"flag":"🇸🇭","name":{"common":"Saint Helena, Ascension and Tristan da Cunha","official":"Saint Helena, Ascension and Tristan da Cunha","nativeName":{"eng":{"official":"Saint Helena, Ascension and Tristan da Cunha","common":"Saint Helena, Ascension and Tristan da Cunha"}}},"cca2":"SH","ccn3":"654","cca3":"SHN","region":"Africa","subregion":"Western Africa"},{"flags":{"png":"https://flagcdn.com/w320/lv.png","svg":"https://flagcdn.com/lv.svg","alt":"The flag of Latvia has a carmine-red field with a thin white horizontal band across the middle of the field."},"flag":"🇱🇻","name":{"common":"Latvia","official":"Republic of Latvia","nativeName":{"lav":{"official":"Latvijas Republikas","common":"Latvija"}}},"cca2":"LV","ccn3":"428","cca3":"LVA","region":"Europe","subregion":"Northern Europe"},{"flags":{"png":"https://flagcdn.com/w320/ck.png","svg":"https://flagcdn.com/ck.svg","alt":"The flag of the Cook Islands is blue with the UK flag in the canton and a large circle of 15 white five-pointed stars centered in the fly."},"flag":"🇨🇰","name":{"common":"Cook Islands","official":"Cook Islands","nativeName":{"eng":{"official":"Cook Islands","common":"Cook Islands"},"rar":{"official":"Kūki 'Āirani","common":"Kūki 'Āirani"}}},"cca2":"CK","ccn3":"184","cca3":"COK","region":"Oceania","subregion":"Polynesia"},{"flags":{"png":"https://flagcdn.com/w320/mt.png","svg":"https://flagcdn.com/mt.svg","alt":"The flag of Malta is composed of two equal vertical bands of white and red. A representation of the George cross edged in red is situated on the upper hoist-side corner of the white band."},"flag":"🇲🇹","name":{"common":"Malta","official":"Republic of Malta","nativeName":{"eng":{"official":"Republic of Malta","common":"Malta"},"mlt":{"official":"Repubblika ta ' Malta","common":"Malta"}}},"cca2":"MT","ccn3":"470","cca3":"MLT","region":"Europe","subregion":"Southern Europe"},{"flags":{"png":"https://flagcdn.com/w320/pn.png","svg":"https://flagcdn.com/pn.svg","alt":"The flag of the Pitcairn Islands is blue with the UK flag in the canton and the national coat of arms centered on the fly half. The coat of arms has a light-blue shield with a green field which features a yellow anchor with a Bible over it. A wheelbarrow is on the crest, with a flowering twig of miro, a local plant."},"flag":"🇵🇳","name":{"common":"Pitcairn Islands","official":"Pitcairn Group of Islands","nativeName":{"eng":{"official":"Pitcairn Group of Islands","common":"Pitcairn Islands"}}},"cca2":"PN","ccn3":"612","cca3":"PCN","region":"Oceania","subregion":"Polynesia"},{"flags":{"png":"https://flagcdn.com/w320/kr.png","svg":"https://flagcdn.com/kr.svg","alt":"The flag of South Korea has a white field, at the center of which is a red and blue Taegeuk circle surrounded by four black trigrams, one in each corner."},"flag":"🇰🇷","name":{"common":"South Korea","official":"Republic of Korea","nativeName":{"kor":{"official":"대한민국","common":"한국"}}},"cca2":"KR","ccn3":"410","cca3":"KOR","region":"Asia","subregion":"Eastern Asia"},{"flags":{"png":"https://flagcdn.com/w320/nc.png","svg":"https://flagcdn.com/nc.svg","alt":"The flag of New Caledonia has three equal horizontal bands of blue, red, and green. A large yellow disk shifted slightly to the hoist side displays a black symbol of a native rooftop adornment."},"flag":"🇳🇨","name":{"common":"New Caledonia","official":"New Caledonia","nativeName":{"fra":{"official":"Nouvelle-Calédonie","common":"Nouvelle-Calédonie"}}},"cca2":"NC","ccn3":"540","cca3":"NCL","region":"Oceania","subregion":"Melanesia"},{"flags":{"png":"https://flagcdn.com/w320/mz.png","svg":"https://flagcdn.com/mz.svg","alt":"The flag of Mozambique is composed of three equal horizontal bands of teal, black with white top and bottom edges, and yellow. A red isosceles triangle spanning about two-fifth the width of the field is superimposed on the hoist side with its base on the hoist end. This triangle bears a crossed rifle and hoe in black superimposed on an open white book which is superimposed on a five-pointed yellow star."},"flag":"🇲🇿","name":{"common":"Mozambique","official":"Republic of Mozambique","nativeName":{"por":{"official":"República de Moçambique","common":"Moçambique"}}},"cca2":"MZ","ccn3":"508","cca3":"MOZ","region":"Africa","subregion":"Eastern Africa"},{"flags":{"png":"https://flagcdn.com/w320/lu.png","svg":"https://flagcdn.com/lu.svg","alt":"The flag of Luxembourg is composed of three equal horizontal bands of red, white and light blue."},"flag":"🇱🇺","name":{"common":"Luxembourg","official":"Grand Duchy of Luxembourg","nativeName":{"deu":{"official":"Großherzogtum Luxemburg","common":"Luxemburg"},"fra":{"official":"Grand-Duché de Luxembourg","common":"Luxembourg"},"ltz":{"official":"Groussherzogtum Lëtzebuerg","common":"Lëtzebuerg"}}},"cca2":"LU","ccn3":"442","cca3":"LUX","region":"Europe","subregion":"Western Europe"},{"flags":{"png":"https://flagcdn.com/w320/ie.png","svg":"https://flagcdn.com/ie.svg","alt":"The flag of Ireland is composed of three equal vertical bands of green, white and orange."},"flag":"🇮🇪","name":{"common":"Ireland","official":"Republic of Ireland","nativeName":{"eng":{"official":"Republic of Ireland","common":"Ireland"},"gle":{"official":"Poblacht na hÉireann","common":"Éire"}}},"cca2":"IE","ccn3":"372","cca3":"IRL","region":"Europe","subregion":"Northern Europe"},{"flags":{"png":"https://flagcdn.com/w320/ng.png","svg":"https://flagcdn.com/ng.svg","alt":"The flag of Nigeria is composed of three equal vertical bands of green, white and green."},"flag":"🇳🇬","name":{"common":"Nigeria","official":"Federal Republic of Nigeria","nativeName":{"eng":{"official":"Federal Republic of Nigeria","common":"Nigeria"}}},"cca2":"NG","ccn3":"566","cca3":"NGA","region":"Africa","subregion":"Western Africa"},{"flags":{"png":"https://flagcdn.com/w320/bs.png","svg":"https://flagcdn.com/bs.svg","alt":"The flag of the Bahamas is composed of three equal horizontal bands of aquamarine, yellow and aquamarine, with a black equilateral triangle superimposed on the hoist side of the field. This triangle has its base on the hoist end and spans about one-third the width of the field."},"flag":"🇧🇸","name":{"common":"Bahamas","official":"Commonwealth of the Bahamas","nativeName":{"eng":{"official":"Commonwealth of the Bahamas","common":"Bahamas"}}},"cca2":"BS","ccn3":"044","cca3":"BHS","region":"Americas","subregion":"Caribbean"},{"flags":{"png":"https://flagcdn.com/w320/mh.png","svg":"https://flagcdn.com/mh.svg","alt":"The flag of Marshall Islands has a blue field with two broadening adjacent diagonal bands of orange and white that extend from the lower hoist-side corner to the upper fly-side corner of the field. A large white star with twenty-four rays — four large rays at the cardinal points and twenty smaller rays — is situated in the upper hoist-side corner above the diagonal bands."},"flag":"🇲🇭","name":{"common":"Marshall Islands","official":"Republic of the Marshall Islands","nativeName":{"eng":{"official":"Republic of the Marshall Islands","common":"Marshall Islands"},"mah":{"official":"Republic of the Marshall Islands","common":"M̧ajeļ"}}},"cca2":"MH","ccn3":"584","cca3":"MHL","region":"Oceania","subregion":"Micronesia"},{"flags":{"png":"https://flagcdn.com/w320/gr.png","svg":"https://flagcdn.com/gr.svg","alt":"The flag of Greece is composed of nine equal horizontal bands of blue alternating with white. A blue square bearing a white cross is superimposed in the canton."},"flag":"🇬🇷","name":{"common":"Greece","official":"Hellenic Republic","nativeName":{"ell":{"official":"Ελληνική Δημοκρατία","common":"Ελλάδα"}}},"cca2":"GR","ccn3":"300","cca3":"GRC","region":"Europe","subregion":"Southern Europe"},{"flags":{"png":"https://flagcdn.com/w320/mv.png","svg":"https://flagcdn.com/mv.svg","alt":"The flag of Maldives has a red field, at the center of which is a large green rectangle bearing a fly-side facing white crescent."},"flag":"🇲🇻","name":{"common":"Maldives","official":"Republic of the Maldives","nativeName":{"div":{"official":"ދިވެހިރާއްޖޭގެ ޖުމްހޫރިއްޔާ","common":"ދިވެހިރާއްޖޭގެ"}}},"cca2":"MV","ccn3":"462","cca3":"MDV","region":"Asia","subregion":"Southern Asia"},{"flags":{"png":"https://flagcdn.com/w320/nu.png","svg":"https://flagcdn.com/nu.svg","alt":"The flag of Niue is yellow with the UK flag in the canton. The UK flag has five yellow five-pointed stars, with a large star on a blue disk in the center and smaller stars on each arm of the red cross."},"flag":"🇳🇺","name":{"common":"Niue","official":"Niue","nativeName":{"eng":{"official":"Niue","common":"Niue"},"niu":{"official":"Niuē","common":"Niuē"}}},"cca2":"NU","ccn3":"570","cca3":"NIU","region":"Oceania","subregion":"Polynesia"},{"flags":{"png":"https://flagcdn.com/w320/sy.png","svg":"https://flagcdn.com/sy.svg","alt":"The flag of Syria is composed of three equal horizontal bands of red, white and black. At the center of the white band are two small five-pointed green stars arranged in a horizontal line."},"flag":"🇸🇾","name":{"common":"Syria","official":"Syrian Arab Republic","nativeName":{"ara":{"official":"الجمهورية العربية السورية","common":"سوريا"}}},"cca2":"SY","ccn3":"760","cca3":"SYR","region":"Asia","subregion":"Western Asia"},{"flags":{"png":"https://flagcdn.com/w320/ec.png","svg":"https://flagcdn.com/ec.svg","alt":"The flag of Ecuador is composed of the horizontal bands of yellow, blue and red, with the yellow band twice the height of the other two bands. The Ecuadorian coat of arms is superimposed in the center of the field."},"flag":"🇪🇨","name":{"common":"Ecuador","official":"Republic of Ecuador","nativeName":{"spa":{"official":"República del Ecuador","common":"Ecuador"}}},"cca2":"EC","ccn3":"218","cca3":"ECU","region":"Americas","subregion":"South America"},{"flags":{"png":"https://flagcdn.com/w320/tm.png","svg":"https://flagcdn.com/tm.svg","alt":"The flag of Turkmenistan has a green field. It features a red vertical band, bearing five carpet guls stacked above two crossed olive branches, near the hoist end of the field. Just to the fly side of the vertical band near the top edge of the field is a hoist-side facing white crescent and five small five-pointed white stars placed just outside the crescent opening."},"flag":"🇹🇲","name":{"common":"Turkmenistan","official":"Turkmenistan","nativeName":{"rus":{"official":"Туркменистан","common":"Туркмения"},"tuk":{"official":"Türkmenistan","common":"Türkmenistan"}}},"cca2":"TM","ccn3":"795","cca3":"TKM","region":"Asia","subregion":"Central Asia"},{"flags":{"png":"https://flagcdn.com/w320/wf.png","svg":"https://flagcdn.com/wf.svg","alt":"The flag of Wallis and Futuna has a red field with four white isosceles triangles in the middle. The apexes of the triangles are oriented inward and at right angles to each other. The flag of France, outlined in white on two sides, is in the canton."},"flag":"🇼🇫","name":{"common":"Wallis and Futuna","official":"Territory of the Wallis and Futuna Islands","nativeName":{"fra":{"official":"Territoire des îles Wallis et Futuna","common":"Wallis et Futuna"}}},"cca2":"WF","ccn3":"876","cca3":"WLF","region":"Oceania","subregion":"Polynesia"},{"flags":{"png":"https://flagcdn.com/w320/bn.png","svg":"https://flagcdn.com/bn.svg","alt":"The flag of Brunei has a yellow field with two adjoining diagonal bands of white and black that extend from the upper hoist side of the field to the lower fly side. The red emblem of Brunei is centered on the field."},"flag":"🇧🇳","name":{"common":"Brunei","official":"Nation of Brunei, Abode of Peace","nativeName":{"msa":{"official":"Nation of Brunei, Abode Damai","common":"Negara Brunei Darussalam"}}},"cca2":"BN","ccn3":"096","cca3":"BRN","region":"Asia","subregion":"South-Eastern Asia"},{"flags":{"png":"https://flagcdn.com/w320/uz.png","svg":"https://flagcdn.com/uz.svg","alt":"The flag of Uzbekistan is composed of three equal horizontal bands of turquoise, white with red top and bottom edges, and green. On the hoist side of the turquoise band is a fly-side facing white crescent and twelve five-pointed white stars arranged just outside the crescent opening in three rows comprising three, four and five stars."},"flag":"🇺🇿","name":{"common":"Uzbekistan","official":"Republic of Uzbekistan","nativeName":{"rus":{"official":"Республика Узбекистан","common":"Узбекистан"},"uzb":{"official":"O'zbekiston Respublikasi","common":"O‘zbekiston"}}},"cca2":"UZ","ccn3":"860","cca3":"UZB","region":"Asia","subregion":"Central Asia"},{"flags":{"png":"https://flagcdn.com/w320/bh.png","svg":"https://flagcdn.com/bh.svg","alt":"The flag of Bahrain has a red field. On the hoist side, it features a white vertical band that spans about one-third the width of the field and is separated from the rest of the field by five adjoining fly-side pointing white isosceles triangles that serve as a serrated line."},"flag":"🇧🇭","name":{"common":"Bahrain","official":"Kingdom of Bahrain","nativeName":{"ara":{"official":"مملكة البحرين","common":"‏البحرين"}}},"cca2":"BH","ccn3":"048","cca3":"BHR","region":"Asia","subregion":"Western Asia"},{"flags":{"png":"https://flagcdn.com/w320/vg.png","svg":"https://flagcdn.com/vg.svg","alt":"The flag of the British Virgin Islands is blue with the UK flag in the canton and the national coat of arms centered in the fly half. The coat of arms depicts a woman flanked by vertical columns of six oil lamps above a scroll bearing the Latin word \"VIGILATE\" (Be Watchful)."},"flag":"🇻🇬","name":{"common":"British Virgin Islands","official":"Virgin Islands","nativeName":{"eng":{"official":"Virgin Islands","common":"British Virgin Islands"}}},"cca2":"VG","ccn3":"092","cca3":"VGB","region":"Americas","subregion":"Caribbean"},{"flags":{"png":"https://flagcdn.com/w320/md.png","svg":"https://flagcdn.com/md.svg","alt":"The flag of Moldova is composed of three equal vertical bands of blue, yellow and red, with the national coat of arms centered in the yellow band."},"flag":"🇲🇩","name":{"common":"Moldova","official":"Republic of Moldova","nativeName":{"ron":{"official":"Republica Moldova","common":"Moldova"}}},"cca2":"MD","ccn3":"498","cca3":"MDA","region":"Europe","subregion":"Eastern Europe"},{"flags":{"png":"https://flagcdn.com/w320/mu.png","svg":"https://flagcdn.com/mu.svg","alt":"The flag of Mauritius is composed of four equal horizontal bands of red, blue, yellow and green."},"flag":"🇲🇺","name":{"common":"Mauritius","official":"Republic of Mauritius","nativeName":{"eng":{"official":"Republic of Mauritius","common":"Mauritius"},"fra":{"official":"République de Maurice","common":"Maurice"},"mfe":{"official":"Republik Moris","common":"Moris"}}},"cca2":"MU","ccn3":"480","cca3":"MUS","region":"Africa","subregion":"Eastern Africa"},{"flags":{"png":"https://flagcdn.com/w320/to.png","svg":"https://flagcdn.com/to.svg","alt":"The flag of Tonga has a red field. A white rectangle bearing a red Greek cross is superimposed in the canton."},"flag":"🇹🇴","name":{"common":"Tonga","official":"Kingdom of Tonga","nativeName":{"eng":{"official":"Kingdom of Tonga","common":"Tonga"},"ton":{"official":"Kingdom of Tonga","common":"Tonga"}}},"cca2":"TO","ccn3":"776","cca3":"TON","region":"Oceania","subregion":"Polynesia"},{"flags":{"png":"https://flagcdn.com/w320/sc.png","svg":"https://flagcdn.com/sc.svg","alt":"The flag of Seychelles is composed of five broadening oblique bands of blue, yellow, red, white and green, which extend from the hoist side of the bottom edge to the top and fly edges of the field."},"flag":"🇸🇨","name":{"common":"Seychelles","official":"Republic of Seychelles","nativeName":{"crs":{"official":"Repiblik Sesel","common":"Sesel"},"eng":{"official":"Republic of Seychelles","common":"Seychelles"},"fra":{"official":"République des Seychelles","common":"Seychelles"}}},"cca2":"SC","ccn3":"690","cca3":"SYC","region":"Africa","subregion":"Eastern Africa"},{"flags":{"png":"https://flagcdn.com/w320/ao.png","svg":"https://flagcdn.com/ao.svg","alt":"The flag of Angola features two equal horizontal bands of red and black, with a yellow emblem at its centre. This emblem consists of a five-pointed star within the hoist-side facing half of a cogwheel that is crossed on its lower end by a machete."},"flag":"🇦🇴","name":{"common":"Angola","official":"Republic of Angola","nativeName":{"por":{"official":"República de Angola","common":"Angola"}}},"cca2":"AO","ccn3":"024","cca3":"AGO","region":"Africa","subregion":"Middle Africa"},{"flags":{"png":"https://flagcdn.com/w320/ee.png","svg":"https://flagcdn.com/ee.svg","alt":"The flag of Estonia is composed of three equal horizontal bands of blue, black and white."},"flag":"🇪🇪","name":{"common":"Estonia","official":"Republic of Estonia","nativeName":{"est":{"official":"Eesti Vabariik","common":"Eesti"}}},"cca2":"EE","ccn3":"233","cca3":"EST","region":"Europe","subregion":"Northern Europe"},{"flags":{"png":"https://flagcdn.com/w320/bm.png","svg":"https://flagcdn.com/bm.svg","alt":"The flag of Bermuda is red, with the UK flag in the canton and the national coat of arms centered in the fly half. The coat of arms is a white shield with a red lion on a green field, holding a scrolled shield showing the sinking of a ship."},"flag":"🇧🇲","name":{"common":"Bermuda","official":"Bermuda","nativeName":{"eng":{"official":"Bermuda","common":"Bermuda"}}},"cca2":"BM","ccn3":"060","cca3":"BMU","region":"Americas","subregion":"North America"},{"flags":{"png":"https://flagcdn.com/w320/ph.png","svg":"https://flagcdn.com/ph.svg","alt":"The flag of Philippines is composed of two equal horizontal bands of blue and red, with a white equilateral triangle superimposed on the hoist side of the field. This triangle has its base on the hoist end, spans about two-fifth the width of the field and bears a central golden-yellow sun with eight rays and a five-pointed golden-yellow star at each vertex."},"flag":"🇵🇭","name":{"common":"Philippines","official":"Republic of the Philippines","nativeName":{"eng":{"official":"Republic of the Philippines","common":"Philippines"},"fil":{"official":"Republic of the Philippines","common":"Pilipinas"}}},"cca2":"PH","ccn3":"608","cca3":"PHL","region":"Asia","subregion":"South-Eastern Asia"},{"flags":{"png":"https://flagcdn.com/w320/va.png","svg":"https://flagcdn.com/va.svg","alt":"The flag of Vatican City is square shaped. It is composed of two equal vertical bands of yellow and white, with national coat of arms centered in the white band. The national coat of arms comprises the Papal Tiara superimposed on two crossed keys."},"flag":"🇻🇦","name":{"common":"Vatican City","official":"Vatican City State","nativeName":{"ita":{"official":"Stato della Città del Vaticano","common":"Vaticano"},"lat":{"official":"Status Civitatis Vaticanæ","common":"Vaticanæ"}}},"cca2":"VA","ccn3":"336","cca3":"VAT","region":"Europe","subregion":"Southern Europe"},{"flags":{"png":"https://flagcdn.com/w320/ve.png","svg":"https://flagcdn.com/ve.svg","alt":"The flag of Venezuela is composed of three equal horizontal bands of yellow, blue and red. At the center of the blue band are eight five-pointed white stars arranged in a horizontal arc."},"flag":"🇻🇪","name":{"common":"Venezuela","official":"Bolivarian Republic of Venezuela","nativeName":{"spa":{"official":"República Bolivariana de Venezuela","common":"Venezuela"}}},"cca2":"VE","ccn3":"862","cca3":"VEN","region":"Americas","subregion":"South America"},{"flags":{"png":"https://flagcdn.com/w320/bb.png","svg":"https://flagcdn.com/bb.svg","alt":"The flag of Barbados is composed of three equal vertical bands of ultramarine, gold and ultramarine. The head of a black trident is centered in the gold band."},"flag":"🇧🇧","name":{"common":"Barbados","official":"Barbados","nativeName":{"eng":{"official":"Barbados","common":"Barbados"}}},"cca2":"BB","ccn3":"052","cca3":"BRB","region":"Americas","subregion":"Caribbean"},{"flags":{"png":"https://flagcdn.com/w320/in.png","svg":"https://flagcdn.com/in.svg","alt":"The flag of India is composed of three equal horizontal bands of saffron, white and green. A navy blue wheel with twenty-four spokes — the Ashoka Chakra — is centered in the white band."},"flag":"🇮🇳","name":{"common":"India","official":"Republic of India","nativeName":{"eng":{"official":"Republic of India","common":"India"},"hin":{"official":"भारत गणराज्य","common":"भारत"},"tam":{"official":"இந்தியக் குடியரசு","common":"இந்தியா"}}},"cca2":"IN","ccn3":"356","cca3":"IND","region":"Asia","subregion":"Southern Asia"},{"flags":{"png":"https://flagcdn.com/w320/ml.png","svg":"https://flagcdn.com/ml.svg","alt":"The flag of Mali is composed of three equal vertical bands of green, yellow and red."},"flag":"🇲🇱","name":{"common":"Mali","official":"Republic of Mali","nativeName":{"fra":{"official":"République du Mali","common":"Mali"}}},"cca2":"ML","ccn3":"466","cca3":"MLI","region":"Africa","subregion":"Western Africa"},{"flags":{"png":"https://flagcdn.com/w320/nf.png","svg":"https://flagcdn.com/nf.svg","alt":"The flag of Norfolk Island has three vertical bands of green, white, and green, with a large green native pine tree centered in the white band."},"flag":"🇳🇫","name":{"common":"Norfolk Island","official":"Territory of Norfolk Island","nativeName":{"eng":{"official":"Territory of Norfolk Island","common":"Norfolk Island"},"pih":{"official":"Teratri of Norf'k Ailen","common":"Norf'k Ailen"}}},"cca2":"NF","ccn3":"574","cca3":"NFK","region":"Oceania","subregion":"Australia and New Zealand"},{"flags":{"png":"https://flagcdn.com/w320/by.png","svg":"https://flagcdn.com/by.svg","alt":"The flag of Belarus features a vertical band, with a white and red ornamental pattern, spanning about one-fifth the width of the field on the hoist side. Adjoining the vertical band are two horizontal bands of red and green, with the red band twice the height of the green band."},"flag":"🇧🇾","name":{"common":"Belarus","official":"Republic of Belarus","nativeName":{"bel":{"official":"Рэспубліка Беларусь","common":"Белару́сь"},"rus":{"official":"Республика Беларусь","common":"Беларусь"}}},"cca2":"BY","ccn3":"112","cca3":"BLR","region":"Europe","subregion":"Eastern Europe"},{"flags":{"png":"https://flagcdn.com/w320/kn.png","svg":"https://flagcdn.com/kn.svg","alt":"The flag of Saint Kitts and Nevis features two large five-pointed white stars within a yellow-edged black diagonal band that extends from the lower hoist-side corner to the upper fly-side corner of the field. Above and beneath this band are a green and red triangle respectively."},"flag":"🇰🇳","name":{"common":"Saint Kitts and Nevis","official":"Federation of Saint Christopher and Nevis","nativeName":{"eng":{"official":"Federation of Saint Christopher and Nevis","common":"Saint Kitts and Nevis"}}},"cca2":"KN","ccn3":"659","cca3":"KNA","region":"Americas","subregion":"Caribbean"},{"flags":{"png":"https://flagcdn.com/w320/gp.png","svg":"https://flagcdn.com/gp.svg","alt":"The flag of Guadeloupe has a black field with a 30-rayed yellow sun in front of a green sugarcane, as well as a blue stripe with three yellow fleurs-de-lis on the top."},"flag":"🇬🇵","name":{"common":"Guadeloupe","official":"Guadeloupe","nativeName":{"fra":{"official":"Guadeloupe","common":"Guadeloupe"}}},"cca2":"GP","ccn3":"312","cca3":"GLP","region":"Americas","subregion":"Caribbean"},{"flags":{"png":"https://flagcdn.com/w320/lt.png","svg":"https://flagcdn.com/lt.svg","alt":"The flag of Lithuania is composed of three equal horizontal bands of yellow, green and red."},"flag":"🇱🇹","name":{"common":"Lithuania","official":"Republic of Lithuania","nativeName":{"lit":{"official":"Lietuvos Respublikos","common":"Lietuva"}}},"cca2":"LT","ccn3":"440","cca3":"LTU","region":"Europe","subregion":"Northern Europe"},{"flags":{"png":"https://flagcdn.com/w320/ms.png","svg":"https://flagcdn.com/ms.svg","alt":"The flag of Montserrat is blue with the UK flag in the canton and the national coat of arms centered in the fly half. The coat of arms shows a woman in a green dress standing beside a yellow harp and embracing a large dark cross with her right arm."},"flag":"🇲🇸","name":{"common":"Montserrat","official":"Montserrat","nativeName":{"eng":{"official":"Montserrat","common":"Montserrat"}}},"cca2":"MS","ccn3":"500","cca3":"MSR","region":"Americas","subregion":"Caribbean"},{"flags":{"png":"https://flagcdn.com/w320/ne.png","svg":"https://flagcdn.com/ne.svg","alt":"The flag of Niger features three equal horizontal bands of orange, white and green, with an orange circle centered in the white band."},"flag":"🇳🇪","name":{"common":"Niger","official":"Republic of Niger","nativeName":{"fra":{"official":"République du Niger","common":"Niger"}}},"cca2":"NE","ccn3":"562","cca3":"NER","region":"Africa","subregion":"Western Africa"},{"flags":{"png":"https://flagcdn.com/w320/gq.png","svg":"https://flagcdn.com/gq.svg","alt":"The flag of Equatorial Guinea is composed of three equal horizontal bands of green, white and red with the national coat of arms centered in the white band and an isosceles triangle superimposed on the hoist side of the field. The triangle is light blue, has its base on the hoist end and spans about one-fifth the width of the field."},"flag":"🇬🇶","name":{"common":"Equatorial Guinea","official":"Republic of Equatorial Guinea","nativeName":{"fra":{"official":"République de la Guinée Équatoriale","common":"Guinée équatoriale"},"por":{"official":"República da Guiné Equatorial","common":"Guiné Equatorial"},"spa":{"official":"República de Guinea Ecuatorial","common":"Guinea Ecuatorial"}}},"cca2":"GQ","ccn3":"226","cca3":"GNQ","region":"Africa","subregion":"Middle Africa"},{"flags":{"png":"https://flagcdn.com/w320/iq.png","svg":"https://flagcdn.com/iq.svg","alt":"The flag of Iraq is composed of three equal horizontal bands of red, white and black. In the central white band are Arabic inscriptions in green."},"flag":"🇮🇶","name":{"common":"Iraq","official":"Republic of Iraq","nativeName":{"ara":{"official":"جمهورية العراق","common":"العراق"},"arc":{"official":"ܩܘܼܛܢܵܐ ܐܝܼܪܲܩ","common":"ܩܘܼܛܢܵܐ"},"ckb":{"official":"کۆماری عێراق","common":"کۆماری"}}},"cca2":"IQ","ccn3":"368","cca3":"IRQ","region":"Asia","subregion":"Western Asia"},{"flags":{"png":"https://flagcdn.com/w320/mr.png","svg":"https://flagcdn.com/mr.svg","alt":"The flag of Mauritania has a green field with a thin red horizontal band at the top and bottom of the field. At the center of the field is a five-pointed yellow star above an upward facing yellow crescent."},"flag":"🇲🇷","name":{"common":"Mauritania","official":"Islamic Republic of Mauritania","nativeName":{"ara":{"official":"الجمهورية الإسلامية الموريتانية","common":"موريتانيا"}}},"cca2":"MR","ccn3":"478","cca3":"MRT","region":"Africa","subregion":"Western Africa"},{"flags":{"png":"https://flagcdn.com/w320/tw.png","svg":"https://flagcdn.com/tw.svg","alt":"The flag of Taiwan has a red field with a dark blue rectangle in the canton, bearing a white sun with 12 triangular rays."},"flag":"🇹🇼","name":{"common":"Taiwan","official":"Republic of China (Taiwan)","nativeName":{"zho":{"official":"中華民國","common":"台灣"}}},"cca2":"TW","ccn3":"158","cca3":"TWN","region":"Asia","subregion":"Eastern Asia"},{"flags":{"png":"https://flagcdn.com/w320/ge.png","svg":"https://flagcdn.com/ge.svg","alt":"The flag of Georgia has a white field with a large centered red cross that extends to the edges and divides the field into four quarters. A small red Bolnur-Katskhuri cross is centered in each quarter."},"flag":"🇬🇪","name":{"common":"Georgia","official":"Georgia","nativeName":{"kat":{"official":"საქართველო","common":"საქართველო"}}},"cca2":"GE","ccn3":"268","cca3":"GEO","region":"Asia","subregion":"Western Asia"},{"flags":{"png":"https://flagcdn.com/w320/pr.png","svg":"https://flagcdn.com/pr.svg","alt":"The flag of Puerto Rico has five equal horizontal bands of red alternating with white. A blue isosceles triangle based on the hoist side has a large five-pointed white star in the center."},"flag":"🇵🇷","name":{"common":"Puerto Rico","official":"Commonwealth of Puerto Rico","nativeName":{"eng":{"official":"Commonwealth of Puerto Rico","common":"Puerto Rico"},"spa":{"official":"Estado Libre Asociado de Puerto Rico","common":"Puerto Rico"}}},"cca2":"PR","ccn3":"630","cca3":"PRI","region":"Americas","subregion":"Caribbean"},{"flags":{"png":"https://flagcdn.com/w320/py.png","svg":"https://flagcdn.com/py.svg","alt":"The flag of Paraguay features three equal horizontal bands of red, white and blue, with an emblem centered in the white band. On the obverse side of the flag depicted, this emblem is the national coat of arms."},"flag":"🇵🇾","name":{"common":"Paraguay","official":"Republic of Paraguay","nativeName":{"grn":{"official":"Tetã Paraguái","common":"Paraguái"},"spa":{"official":"República de Paraguay","common":"Paraguay"}}},"cca2":"PY","ccn3":"600","cca3":"PRY","region":"Americas","subregion":"South America"},{"flags":{"png":"https://flagcdn.com/w320/sl.png","svg":"https://flagcdn.com/sl.svg","alt":"The flag of Sierra Leone is composed of three equal horizontal bands of green, white and blue."},"flag":"🇸🇱","name":{"common":"Sierra Leone","official":"Republic of Sierra Leone","nativeName":{"eng":{"official":"Republic of Sierra Leone","common":"Sierra Leone"}}},"cca2":"SL","ccn3":"694","cca3":"SLE","region":"Africa","subregion":"Western Africa"},{"flags":{"png":"https://flagcdn.com/w320/bw.png","svg":"https://flagcdn.com/bw.svg","alt":"The flag of Botswana has a light blue field with a white-edged black horizontal band across its center."},"flag":"🇧🇼","name":{"common":"Botswana","official":"Republic of Botswana","nativeName":{"eng":{"official":"Republic of Botswana","common":"Botswana"},"tsn":{"official":"Lefatshe la Botswana","common":"Botswana"}}},"cca2":"BW","ccn3":"072","cca3":"BWA","region":"Africa","subregion":"Southern Africa"},{"flags":{"png":"https://flagcdn.com/w320/yt.png","svg":"https://flagcdn.com/yt.svg","alt":"The flag of Mayotte bears a white field with the national coat of arms in the center and the country's name in red above it. The coat of arms consists of a shield supported by two seahorses and the national motto in a banner below it. The shield has a white fringe and bears a blue field with an upwards-facing white crescent on the top half and a red field with two yellow ylang-ylang flowers on the bottom half."},"flag":"🇾🇹","name":{"common":"Mayotte","official":"Department of Mayotte","nativeName":{"fra":{"official":"Département de Mayotte","common":"Mayotte"}}},"cca2":"YT","ccn3":"175","cca3":"MYT","region":"Africa","subregion":"Eastern Africa"},{"flags":{"png":"https://flagcdn.com/w320/fi.png","svg":"https://flagcdn.com/fi.svg","alt":"The flag of Finland has a white field with a large blue cross that extend to the edges of the field. The vertical part of this cross is offset towards the hoist side."},"flag":"🇫🇮","name":{"common":"Finland","official":"Republic of Finland","nativeName":{"fin":{"official":"Suomen tasavalta","common":"Suomi"},"swe":{"official":"Republiken Finland","common":"Finland"}}},"cca2":"FI","ccn3":"246","cca3":"FIN","region":"Europe","subregion":"Northern Europe"},{"flags":{"png":"https://flagcdn.com/w320/je.png","svg":"https://flagcdn.com/je.svg","alt":"The flag of Jersey is white with a diagonal red cross extending to the corners of the flag. A red shield with three lions in yellow is in the upper quadrant, with a yellow crown above."},"flag":"🇯🇪","name":{"common":"Jersey","official":"Bailiwick of Jersey","nativeName":{"eng":{"official":"Bailiwick of Jersey","common":"Jersey"},"fra":{"official":"Bailliage de Jersey","common":"Jersey"},"nrf":{"official":"Bailliage dé Jèrri","common":"Jèrri"}}},"cca2":"JE","ccn3":"832","cca3":"JEY","region":"Europe","subregion":"Northern Europe"},{"flags":{"png":"https://flagcdn.com/w320/fo.png","svg":"https://flagcdn.com/fo.svg","alt":"The flag of the Faroe Islands is white with a red cross outlined in blue extending to the edges of the flag. The vertical part of the cross is shifted toward the hoist side."},"flag":"🇫🇴","name":{"common":"Faroe Islands","official":"Faroe Islands","nativeName":{"dan":{"official":"Færøerne","common":"Færøerne"},"fao":{"official":"Føroyar","common":"Føroyar"}}},"cca2":"FO","ccn3":"234","cca3":"FRO","region":"Europe","subregion":"Northern Europe"},{"flags":{"png":"https://flagcdn.com/w320/se.png","svg":"https://flagcdn.com/se.svg","alt":"The flag of Sweden has a blue field with a large golden-yellow cross that extend to the edges of the field. The vertical part of this cross is offset towards the hoist side."},"flag":"🇸🇪","name":{"common":"Sweden","official":"Kingdom of Sweden","nativeName":{"swe":{"official":"Konungariket Sverige","common":"Sverige"}}},"cca2":"SE","ccn3":"752","cca3":"SWE","region":"Europe","subregion":"Northern Europe"},{"flags":{"png":"https://flagcdn.com/w320/lk.png","svg":"https://flagcdn.com/lk.svg","alt":"The flag of Sri Lanka features two large adjacent but separate rectangular areas, centered on a golden-yellow field. The smaller hoist-side rectangle is divided into two equal vertical bands of teal and orange, and the larger fly-side rectangle is maroon with a centered golden-yellow lion holding a Kastane sword in its right fore-paw and four golden-yellow Bo leaves, one in each corner."},"flag":"🇱🇰","name":{"common":"Sri Lanka","official":"Democratic Socialist Republic of Sri Lanka","nativeName":{"sin":{"official":"ශ්‍රී ලංකා ප්‍රජාතාන්ත්‍රික සමාජවාදී ජනරජය","common":"ශ්‍රී ලංකාව"},"tam":{"official":"இலங்கை சனநாயக சோசலிசக் குடியரசு","common":"இலங்கை"}}},"cca2":"LK","ccn3":"144","cca3":"LKA","region":"Asia","subregion":"Southern Asia"},{"flags":{"png":"https://flagcdn.com/w320/th.png","svg":"https://flagcdn.com/th.svg","alt":"The flag of Thailand is composed of five horizontal bands of red, white, blue, white and red, with the central blue band twice the height of the other four bands."},"flag":"🇹🇭","name":{"common":"Thailand","official":"Kingdom of Thailand","nativeName":{"tha":{"official":"ราชอาณาจักรไทย","common":"ประเทศไทย"}}},"cca2":"TH","ccn3":"764","cca3":"THA","region":"Asia","subregion":"South-Eastern Asia"},{"flags":{"png":"https://flagcdn.com/w320/gl.png","svg":"https://flagcdn.com/gl.svg","alt":"The flag of Greenland has two equal horizontal bands of white and red, with a large disk slightly to the hoist side of center. The top half of the disk is red, and the bottom half is white."},"flag":"🇬🇱","name":{"common":"Greenland","official":"Greenland","nativeName":{"kal":{"official":"Kalaallit Nunaat","common":"Kalaallit Nunaat"}}},"cca2":"GL","ccn3":"304","cca3":"GRL","region":"Americas","subregion":"North America"},{"flags":{"png":"https://flagcdn.com/w320/bg.png","svg":"https://flagcdn.com/bg.svg","alt":"The flag of Bulgaria is composed of three equal horizontal bands of white, green and red."},"flag":"🇧🇬","name":{"common":"Bulgaria","official":"Republic of Bulgaria","nativeName":{"bul":{"official":"Република България","common":"България"}}},"cca2":"BG","ccn3":"100","cca3":"BGR","region":"Europe","subregion":"Southeast Europe"},{"flags":{"png":"https://flagcdn.com/w320/ke.png","svg":"https://flagcdn.com/ke.svg","alt":"The flag of Kenya is composed of three equal horizontal bands of black, red with white top and bottom edges, and green. An emblem comprising a red, black and white Maasai shield covering two crossed white spears is superimposed at the center of the field."},"flag":"🇰🇪","name":{"common":"Kenya","official":"Republic of Kenya","nativeName":{"eng":{"official":"Republic of Kenya","common":"Kenya"},"swa":{"official":"Republic of Kenya","common":"Kenya"}}},"cca2":"KE","ccn3":"404","cca3":"KEN","region":"Africa","subregion":"Eastern Africa"},{"flags":{"png":"https://flagcdn.com/w320/hk.png","svg":"https://flagcdn.com/hk.svg","alt":"The flag of Hong Kong is red with a stylized, five-petal, white Bauhinia flower in the center. Each petal has a small, five-pointed red star."},"flag":"🇭🇰","name":{"common":"Hong Kong","official":"Hong Kong Special Administrative Region of the People's Republic of China","nativeName":{"eng":{"official":"Hong Kong Special Administrative Region of the People's Republic of China","common":"Hong Kong"},"zho":{"official":"中华人民共和国香港特别行政区","common":"香港"}}},"cca2":"HK","ccn3":"344","cca3":"HKG","region":"Asia","subregion":"Eastern Asia"},{"flags":{"png":"https://flagcdn.com/w320/ro.png","svg":"https://flagcdn.com/ro.svg","alt":"The flag of Romania is composed of three equal vertical bands of navy blue, yellow and red."},"flag":"🇷🇴","name":{"common":"Romania","official":"Romania","nativeName":{"ron":{"official":"România","common":"România"}}},"cca2":"RO","ccn3":"642","cca3":"ROU","region":"Europe","subregion":"Southeast Europe"},{"flags":{"png":"https://flagcdn.com/w320/mw.png","svg":"https://flagcdn.com/mw.svg","alt":"The flag of Malawi is composed of three equal horizontal bands of black, red and green. The top half of a red sun with thirty-one visible rays is centered in the black band."},"flag":"🇲🇼","name":{"common":"Malawi","official":"Republic of Malawi","nativeName":{"eng":{"official":"Republic of Malawi","common":"Malawi"},"nya":{"official":"Chalo cha Malawi, Dziko la Malaŵi","common":"Malaŵi"}}},"cca2":"MW","ccn3":"454","cca3":"MWI","region":"Africa","subregion":"Eastern Africa"},{"flags":{"png":"https://flagcdn.com/w320/cg.png","svg":"https://flagcdn.com/cg.svg","alt":"The flag of the Republic of the Congo features a yellow diagonal band that extends from the lower hoist-side corner to the upper fly-side corner of the field. Above and beneath this band are a green and red triangle respectively."},"flag":"🇨🇬","name":{"common":"Republic of the Congo","official":"Republic of the Congo","nativeName":{"fra":{"official":"République du Congo","common":"République du Congo"},"kon":{"official":"Repubilika ya Kongo","common":"Repubilika ya Kongo"},"lin":{"official":"Republíki ya Kongó","common":"Republíki ya Kongó"}}},"cca2":"CG","ccn3":"178","cca3":"COG","region":"Africa","subregion":"Middle Africa"},{"flags":{"png":"https://flagcdn.com/w320/hn.png","svg":"https://flagcdn.com/hn.svg","alt":"The flag of Honduras is composed of three equal horizontal bands of turquoise, white and turquoise, with five small five-pointed turquoise stars arranged in a quincuncial pattern at the center of the white band."},"flag":"🇭🇳","name":{"common":"Honduras","official":"Republic of Honduras","nativeName":{"spa":{"official":"República de Honduras","common":"Honduras"}}},"cca2":"HN","ccn3":"340","cca3":"HND","region":"Americas","subregion":"Central America"},{"flags":{"png":"https://flagcdn.com/w320/aw.png","svg":"https://flagcdn.com/aw.svg","alt":"The flag of Aruba is blue, with two narrow, horizontal yellow stripes across the lower portion and a red four-pointed star outlined in white in the canton."},"flag":"🇦🇼","name":{"common":"Aruba","official":"Aruba","nativeName":{"nld":{"official":"Aruba","common":"Aruba"},"pap":{"official":"Aruba","common":"Aruba"}}},"cca2":"AW","ccn3":"533","cca3":"ABW","region":"Americas","subregion":"Caribbean"},{"flags":{"png":"https://flagcdn.com/w320/mc.png","svg":"https://flagcdn.com/mc.svg","alt":"The flag of Monaco is composed of two equal horizontal bands of red and white."},"flag":"🇲🇨","name":{"common":"Monaco","official":"Principality of Monaco","nativeName":{"fra":{"official":"Principauté de Monaco","common":"Monaco"}}},"cca2":"MC","ccn3":"492","cca3":"MCO","region":"Europe","subregion":"Western Europe"},{"flags":{"png":"https://flagcdn.com/w320/bv.png","svg":"https://flagcdn.com/bv.svg","alt":"The flag of Bouvet Island has a red field with a large white-edged navy blue cross that extends to the edges of the field. The vertical part of this cross is offset towards the hoist side."},"flag":"🇧🇻","name":{"common":"Bouvet Island","official":"Bouvet Island","nativeName":{"nor":{"official":"Bouvetøya","common":"Bouvetøya"}}},"cca2":"BV","ccn3":"074","cca3":"BVT","region":"Antarctic","subregion":""},{"flags":{"png":"https://flagcdn.com/w320/zm.png","svg":"https://flagcdn.com/zm.svg","alt":"The flag of Zambia has a green field, on the fly side of which is a soaring orange African fish eagle above a rectangular area divided into three equal vertical bands of red, black and orange."},"flag":"🇿🇲","name":{"common":"Zambia","official":"Republic of Zambia","nativeName":{"eng":{"official":"Republic of Zambia","common":"Zambia"}}},"cca2":"ZM","ccn3":"894","cca3":"ZMB","region":"Africa","subregion":"Eastern Africa"},{"flags":{"png":"https://flagcdn.com/w320/ad.png","svg":"https://flagcdn.com/ad.svg","alt":"The flag of Andorra features three equal vertical bands of blue, yellow and red, with the coat of arms of Andorra centered in the yellow band."},"flag":"🇦🇩","name":{"common":"Andorra","official":"Principality of Andorra","nativeName":{"cat":{"official":"Principat d'Andorra","common":"Andorra"}}},"cca2":"AD","ccn3":"020","cca3":"AND","region":"Europe","subregion":"Southern Europe"},{"flags":{"png":"https://flagcdn.com/w320/lr.png","svg":"https://flagcdn.com/lr.svg","alt":"The flag of Liberia is composed of eleven equal horizontal bands of red alternating with white. A blue square bearing a five-pointed white star is superimposed in the canton."},"flag":"🇱🇷","name":{"common":"Liberia","official":"Republic of Liberia","nativeName":{"eng":{"official":"Republic of Liberia","common":"Liberia"}}},"cca2":"LR","ccn3":"430","cca3":"LBR","region":"Africa","subregion":"Western Africa"},{"flags":{"png":"https://flagcdn.com/w320/tz.png","svg":"https://flagcdn.com/tz.svg","alt":"The flag of Tanzania features a yellow-edged black diagonal band that extends from the lower hoist-side corner to the upper fly-side corner of the field. Above and beneath this band are a green and light blue triangle respectively."},"flag":"🇹🇿","name":{"common":"Tanzania","official":"United Republic of Tanzania","nativeName":{"eng":{"official":"United Republic of Tanzania","common":"Tanzania"},"swa":{"official":"Jamhuri ya Muungano wa Tanzania","common":"Tanzania"}}},"cca2":"TZ","ccn3":"834","cca3":"TZA","region":"Africa","subregion":"Eastern Africa"},{"flags":{"png":"https://flagcdn.com/w320/pm.png","svg":"https://flagcdn.com/pm.svg","alt":"The flag of Saint Pierre and Miquelon features a yellow three-masted sailing ship facing the hoist side riding on a blue background with wavy white lines. A black-over-white wavy line divides the ship from the white wavy lines. On the hoist side, a vertical band is divided into three heraldic arms: the top is red with a green diagonal cross extending to the corners and overlaid with a white cross, the middle is white with an ermine pattern, and the bottom is red with two yellow lions outlined in black."},"flag":"🇵🇲","name":{"common":"Saint Pierre and Miquelon","official":"Saint Pierre and Miquelon","nativeName":{"fra":{"official":"Collectivité territoriale de Saint-Pierre-et-Miquelon","common":"Saint-Pierre-et-Miquelon"}}},"cca2":"PM","ccn3":"666","cca3":"SPM","region":"Americas","subregion":"North America"},{"flags":{"png":"https://flagcdn.com/w320/ir.png","svg":"https://flagcdn.com/ir.svg","alt":"The flag of Iran is composed of three equal horizontal bands of green, white and red. A red emblem of Iran is centered in the white band and Arabic inscriptions in white span the bottom edge of the green band and the top edge of the red band."},"flag":"🇮🇷","name":{"common":"Iran","official":"Islamic Republic of Iran","nativeName":{"fas":{"official":"جمهوری اسلامی ایران","common":"ایران"}}},"cca2":"IR","ccn3":"364","cca3":"IRN","region":"Asia","subregion":"Southern Asia"},{"flags":{"png":"https://flagcdn.com/w320/is.png","svg":"https://flagcdn.com/is.svg","alt":"The flag of Iceland has a blue field with a large white-edged red cross that extends to the edges of the field. The vertical part of this cross is offset towards the hoist side."},"flag":"🇮🇸","name":{"common":"Iceland","official":"Iceland","nativeName":{"isl":{"official":"Ísland","common":"Ísland"}}},"cca2":"IS","ccn3":"352","cca3":"ISL","region":"Europe","subregion":"Northern Europe"},{"flags":{"png":"https://flagcdn.com/w320/gn.png","svg":"https://flagcdn.com/gn.svg","alt":"The flag of Guinea is composed of three equal vertical bands of red, yellow and green."},"flag":"🇬🇳","name":{"common":"Guinea","official":"Republic of Guinea","nativeName":{"fra":{"official":"République de Guinée","common":"Guinée"}}},"cca2":"GN","ccn3":"324","cca3":"GIN","region":"Africa","subregion":"Western Africa"},{"flags":{"png":"https://flagcdn.com/w320/sj.png","svg":"https://flagcdn.com/sj.svg","alt":"The flag of Svalbard and Jan Mayen has a red field with a large white-edged navy blue cross that extends to the edges of the field. The vertical part of this cross is offset towards the hoist side."},"flag":"🇸🇯","name":{"common":"Svalbard and Jan Mayen","official":"Svalbard og Jan Mayen","nativeName":{"nor":{"official":"Svalbard og Jan Mayen","common":"Svalbard og Jan Mayen"}}},"cca2":"SJ","ccn3":"744","cca3":"SJM","region":"Europe","subregion":"Northern Europe"},{"flags":{"png":"https://flagcdn.com/w320/kw.png","svg":"https://flagcdn.com/kw.svg","alt":"The flag of Kuwait is composed of three equal horizontal bands of green, white and red, with a black trapezium superimposed on the hoist side of the field. This trapezium has its base on the hoist end and spans about one-fourth the width of the field."},"flag":"🇰🇼","name":{"common":"Kuwait","official":"State of Kuwait","nativeName":{"ara":{"official":"دولة الكويت","common":"الكويت"}}},"cca2":"KW","ccn3":"414","cca3":"KWT","region":"Asia","subregion":"Western Asia"},{"flags":{"png":"https://flagcdn.com/w320/ma.png","svg":"https://flagcdn.com/ma.svg","alt":"The flag of Morocco features a green pentagram — a five-pointed linear star — centered on a red field."},"flag":"🇲🇦","name":{"common":"Morocco","official":"Kingdom of Morocco","nativeName":{"ara":{"official":"المملكة المغربية","common":"المغرب"},"ber":{"official":"ⵜⴰⴳⵍⴷⵉⵜ ⵏ ⵍⵎⵖⵔⵉⴱ","common":"ⵍⵎⴰⵖⵔⵉⴱ"}}},"cca2":"MA","ccn3":"504","cca3":"MAR","region":"Africa","subregion":"Northern Africa"},{"flags":{"png":"https://flagcdn.com/w320/na.png","svg":"https://flagcdn.com/na.svg","alt":"The flag of Namibia features a white-edged red diagonal band that extends from the lower hoist-side corner to the upper fly-side corner of the field. Above and beneath this band are a blue and green triangle respectively. A gold sun with twelve triangular rays is situated on the hoist side of the upper triangle."},"flag":"🇳🇦","name":{"common":"Namibia","official":"Republic of Namibia","nativeName":{"afr":{"official":"Republiek van Namibië","common":"Namibië"},"deu":{"official":"Republik Namibia","common":"Namibia"},"eng":{"official":"Republic of Namibia","common":"Namibia"},"her":{"official":"Republic of Namibia","common":"Namibia"},"hgm":{"official":"Republic of Namibia","common":"Namibia"},"kwn":{"official":"Republic of Namibia","common":"Namibia"},"loz":{"official":"Republic of Namibia","common":"Namibia"},"ndo":{"official":"Republic of Namibia","common":"Namibia"},"tsn":{"official":"Lefatshe la Namibia","common":"Namibia"}}},"cca2":"NA","ccn3":"516","cca3":"NAM","region":"Africa","subregion":"Southern Africa"},{"flags":{"png":"https://flagcdn.com/w320/tj.png","svg":"https://flagcdn.com/tj.svg","alt":"The flag of Tajikistan is composed of three horizontal bands of red, white and green in the ratio of 2:3:2. A golden-yellow crown surmounted by an arc of seven five-pointed golden-yellow stars is centered in the white band."},"flag":"🇹🇯","name":{"common":"Tajikistan","official":"Republic of Tajikistan","nativeName":{"rus":{"official":"Республика Таджикистан","common":"Таджикистан"},"tgk":{"official":"Ҷумҳурии Тоҷикистон","common":"Тоҷикистон"}}},"cca2":"TJ","ccn3":"762","cca3":"TJK","region":"Asia","subregion":"Central Asia"},{"flags":{"png":"https://flagcdn.com/w320/vu.png","svg":"https://flagcdn.com/vu.svg","alt":"The flag of Vanuatu is composed of two equal horizontal bands of red and green, with a black isosceles triangle superimposed on the hoist side of the field. This triangle has its base on the hoist end, spans about two-fifth the width of the field and is enclosed on its sides by the arms of a thin black-edged yellow horizontally oriented Y-shaped band which extends along the boundary of the red and green bands to the fly end of the field. A yellow boar's tusk encircling two yellow crossed namele leaves is centered in the triangle."},"flag":"🇻🇺","name":{"common":"Vanuatu","official":"Republic of Vanuatu","nativeName":{"bis":{"official":"Ripablik blong Vanuatu","common":"Vanuatu"},"eng":{"official":"Republic of Vanuatu","common":"Vanuatu"},"fra":{"official":"République de Vanuatu","common":"Vanuatu"}}},"cca2":"VU","ccn3":"548","cca3":"VUT","region":"Oceania","subregion":"Melanesia"},{"flags":{"png":"https://flagcdn.com/w320/jm.png","svg":"https://flagcdn.com/jm.svg","alt":"The flag of Jamaica is divided by a gold diagonal cross into four alternating triangular areas of green at the top and bottom, and black on the hoist and fly sides."},"flag":"🇯🇲","name":{"common":"Jamaica","official":"Jamaica","nativeName":{"eng":{"official":"Jamaica","common":"Jamaica"},"jam":{"official":"Jamaica","common":"Jamaica"}}},"cca2":"JM","ccn3":"388","cca3":"JAM","region":"Americas","subregion":"Caribbean"},{"flags":{"png":"https://flagcdn.com/w320/pw.png","svg":"https://flagcdn.com/pw.svg","alt":"The flag of Palau has a light blue field with a large golden-yellow circle that is offset slightly towards the hoist side of center."},"flag":"🇵🇼","name":{"common":"Palau","official":"Republic of Palau","nativeName":{"eng":{"official":"Republic of Palau","common":"Palau"},"pau":{"official":"Beluu er a Belau","common":"Belau"}}},"cca2":"PW","ccn3":"585","cca3":"PLW","region":"Oceania","subregion":"Micronesia"},{"flags":{"png":"https://flagcdn.com/w320/uy.png","svg":"https://flagcdn.com/uy.svg","alt":"The flag of Uruguay is composed of nine equal horizontal bands of white alternating with blue, with a white square superimposed in the canton. In the white square is a yellow sun bearing a human face — the Sun of May — from which sixteen rays extend. The sun's rays alternate between triangular and wavy."},"flag":"🇺🇾","name":{"common":"Uruguay","official":"Oriental Republic of Uruguay","nativeName":{"spa":{"official":"República Oriental del Uruguay","common":"Uruguay"}}},"cca2":"UY","ccn3":"858","cca3":"URY","region":"Americas","subregion":"South America"},{"flags":{"png":"https://flagcdn.com/w320/bt.png","svg":"https://flagcdn.com/bt.svg","alt":"The flag of Bhutan is divided diagonally, from the lower hoist-side corner to the upper fly-side corner, into an upper yellow and a lower orange triangle. A fly-side facing white dragon holding four jewels in its claws is situated along the boundary of the two triangles."},"flag":"🇧🇹","name":{"common":"Bhutan","official":"Kingdom of Bhutan","nativeName":{"dzo":{"official":"འབྲུག་རྒྱལ་ཁབ་","common":"འབྲུག་ཡུལ་"}}},"cca2":"BT","ccn3":"064","cca3":"BTN","region":"Asia","subregion":"Southern Asia"},{"flags":{"png":"https://flagcdn.com/w320/cd.png","svg":"https://flagcdn.com/cd.svg","alt":"The flag of the Democratic Republic of the Congo has a sky-blue field with a yellow-edged red diagonal band that extends from the lower hoist-side corner to the upper fly-side corner of the field. A large five-pointed yellow star is situated above the diagonal band on the upper hoist side of the field."},"flag":"🇨🇩","name":{"common":"DR Congo","official":"Democratic Republic of the Congo","nativeName":{"fra":{"official":"République démocratique du Congo","common":"RD Congo"},"kon":{"official":"Repubilika ya Kongo Demokratiki","common":"Repubilika ya Kongo Demokratiki"},"lin":{"official":"Republiki ya Kongó Demokratiki","common":"Republiki ya Kongó Demokratiki"},"lua":{"official":"Ditunga dia Kongu wa Mungalaata","common":"Ditunga dia Kongu wa Mungalaata"},"swa":{"official":"Jamhuri ya Kidemokrasia ya Kongo","common":"Jamhuri ya Kidemokrasia ya Kongo"}}},"cca2":"CD","ccn3":"180","cca3":"COD","region":"Africa","subregion":"Middle Africa"},{"flags":{"png":"https://flagcdn.com/w320/kz.png","svg":"https://flagcdn.com/kz.svg","alt":"The flag of Kazakhstan has a turquoise field, at the center of which is a gold sun with thirty-two rays above a soaring golden steppe eagle. A thin vertical band displays a national ornamental pattern — koshkar-muiz — in gold near the hoist end."},"flag":"🇰🇿","name":{"common":"Kazakhstan","official":"Republic of Kazakhstan","nativeName":{"kaz":{"official":"Қазақстан Республикасы","common":"Қазақстан"},"rus":{"official":"Республика Казахстан","common":"Казахстан"}}},"cca2":"KZ","ccn3":"398","cca3":"KAZ","region":"Asia","subregion":"Central Asia"},{"flags":{"png":"https://flagcdn.com/w320/er.png","svg":"https://flagcdn.com/er.svg","alt":"The flag of Eritrea comprises three triangles — a large red isosceles triangle with its base spanning the hoist end and its apex at the midpoint on the fly end, and a green and blue right-angled triangle above and beneath the red triangle. On the hoist side of the red triangle is a golden vertical olive branch encircled by a golden olive wreath."},"flag":"🇪🇷","name":{"common":"Eritrea","official":"State of Eritrea","nativeName":{"ara":{"official":"دولة إرتريا","common":"إرتريا‎"},"eng":{"official":"State of Eritrea","common":"Eritrea"},"tir":{"official":"ሃገረ ኤርትራ","common":"ኤርትራ"}}},"cca2":"ER","ccn3":"232","cca3":"ERI","region":"Africa","subregion":"Eastern Africa"},{"flags":{"png":"https://flagcdn.com/w320/gi.png","svg":"https://flagcdn.com/gi.svg","alt":"The flag of Gibraltar has two horizontal bands of white (double-width) and red, with a three-towered red castle in the center of the white band. A gold key hangs from the castle gate and is centered in the red band."},"flag":"🇬🇮","name":{"common":"Gibraltar","official":"Gibraltar","nativeName":{"eng":{"official":"Gibraltar","common":"Gibraltar"}}},"cca2":"GI","ccn3":"292","cca3":"GIB","region":"Europe","subregion":"Southern Europe"},{"flags":{"png":"https://flagcdn.com/w320/ga.png","svg":"https://flagcdn.com/ga.svg","alt":"The flag of Gabon is composed of three equal horizontal bands of green, yellow and blue."},"flag":"🇬🇦","name":{"common":"Gabon","official":"Gabonese Republic","nativeName":{"fra":{"official":"République gabonaise","common":"Gabon"}}},"cca2":"GA","ccn3":"266","cca3":"GAB","region":"Africa","subregion":"Middle Africa"},{"flags":{"png":"https://flagcdn.com/w320/pe.png","svg":"https://flagcdn.com/pe.svg","alt":"The flag of Peru is composed of three equal vertical bands of red, white and red, with the national emblem centered in the white band."},"flag":"🇵🇪","name":{"common":"Peru","official":"Republic of Peru","nativeName":{"aym":{"official":"Piruw Suyu","common":"Piruw"},"que":{"official":"Piruw Ripuwlika","common":"Piruw"},"spa":{"official":"República del Perú","common":"Perú"}}},"cca2":"PE","ccn3":"604","cca3":"PER","region":"Americas","subregion":"South America"},{"flags":{"png":"https://flagcdn.com/w320/dk.png","svg":"https://flagcdn.com/dk.svg","alt":"The flag of Denmark has a red field with a large white cross that extend to the edges of the field. The vertical part of this cross is offset towards the hoist side."},"flag":"🇩🇰","name":{"common":"Denmark","official":"Kingdom of Denmark","nativeName":{"dan":{"official":"Kongeriget Danmark","common":"Danmark"}}},"cca2":"DK","ccn3":"208","cca3":"DNK","region":"Europe","subregion":"Northern Europe"},{"flags":{"png":"https://flagcdn.com/w320/hr.png","svg":"https://flagcdn.com/hr.svg","alt":"The flag of Croatia is composed of three equal horizontal bands of red, white and blue, with the coat of arms of Croatia superimposed in the center."},"flag":"🇭🇷","name":{"common":"Croatia","official":"Republic of Croatia","nativeName":{"hrv":{"official":"Republika Hrvatska","common":"Hrvatska"}}},"cca2":"HR","ccn3":"191","cca3":"HRV","region":"Europe","subregion":"Southeast Europe"},{"flags":{"png":"https://flagcdn.com/w320/cw.png","svg":"https://flagcdn.com/cw.svg","alt":"The flag of Curaçao shows a blue field, on which a horizontal yellow band below the center divides the flag. Two five-pointed white stars, the smaller above and to the left of the larger, appear in the canton."},"flag":"🇨🇼","name":{"common":"Curaçao","official":"Country of Curaçao","nativeName":{"eng":{"official":"Country of Curaçao","common":"Curaçao"},"nld":{"official":"Land Curaçao","common":"Curaçao"},"pap":{"official":"Pais Kòrsou","common":"Pais Kòrsou"}}},"cca2":"CW","ccn3":"531","cca3":"CUW","region":"Americas","subregion":"Caribbean"},{"flags":{"png":"https://flagcdn.com/w320/io.png","svg":"https://flagcdn.com/io.svg","alt":"The flag of the British Indian Ocean Territory is white with six blue, wavy, horizontal stripes. The UK flag is in the canton. The striped section has a palm tree and yellow crown centered in the fly half."},"flag":"🇮🇴","name":{"common":"British Indian Ocean Territory","official":"British Indian Ocean Territory","nativeName":{"eng":{"official":"British Indian Ocean Territory","common":"British Indian Ocean Territory"}}},"cca2":"IO","ccn3":"086","cca3":"IOT","region":"Africa","subregion":"Eastern Africa"},{"flags":{"png":"https://flagcdn.com/w320/no.png","svg":"https://flagcdn.com/no.svg","alt":"The flag of Norway has a red field with a large white-edged navy blue cross that extends to the edges of the field. The vertical part of this cross is offset towards the hoist side."},"flag":"🇳🇴","name":{"common":"Norway","official":"Kingdom of Norway","nativeName":{"nno":{"official":"Kongeriket Noreg","common":"Noreg"},"nob":{"official":"Kongeriket Norge","common":"Norge"},"smi":{"official":"Norgga gonagasriika","common":"Norgga"}}},"cca2":"NO","ccn3":"578","cca3":"NOR","region":"Europe","subregion":"Northern Europe"},{"flags":{"png":"https://flagcdn.com/w320/ls.png","svg":"https://flagcdn.com/ls.svg","alt":"The flag of Lesotho is composed of three horizontal bands of blue, white and green in the ratio of 3:4:3. A black mokorotlo — a Basotho hat — is centered in the white band."},"flag":"🇱🇸","name":{"common":"Lesotho","official":"Kingdom of Lesotho","nativeName":{"eng":{"official":"Kingdom of Lesotho","common":"Lesotho"},"sot":{"official":"Kingdom of Lesotho","common":"Lesotho"}}},"cca2":"LS","ccn3":"426","cca3":"LSO","region":"Africa","subregion":"Southern Africa"},{"flags":{"png":"https://flagcdn.com/w320/at.png","svg":"https://flagcdn.com/at.svg","alt":"The flag of Austria is composed of three equal horizontal bands of red, white and red."},"flag":"🇦🇹","name":{"common":"Austria","official":"Republic of Austria","nativeName":{"bar":{"official":"Republik Österreich","common":"Österreich"}}},"cca2":"AT","ccn3":"040","cca3":"AUT","region":"Europe","subregion":"Central Europe"},{"flags":{"png":"https://flagcdn.com/w320/ch.png","svg":"https://flagcdn.com/ch.svg","alt":"The flag of Switzerland is square shaped. It features a white Swiss cross centered on a red field."},"flag":"🇨🇭","name":{"common":"Switzerland","official":"Swiss Confederation","nativeName":{"fra":{"official":"Confédération suisse","common":"Suisse"},"gsw":{"official":"Schweizerische Eidgenossenschaft","common":"Schweiz"},"ita":{"official":"Confederazione Svizzera","common":"Svizzera"},"roh":{"official":"Confederaziun svizra","common":"Svizra"}}},"cca2":"CH","ccn3":"756","cca3":"CHE","region":"Europe","subregion":"Western Europe"},{"flags":{"png":"https://flagcdn.com/w320/bz.png","svg":"https://flagcdn.com/bz.svg","alt":"The flag of Belize has a royal blue field with a thin red horizontal band at the top and bottom of the field and the national coat of arms in the center."},"flag":"🇧🇿","name":{"common":"Belize","official":"Belize","nativeName":{"bjz":{"official":"Belize","common":"Belize"},"eng":{"official":"Belize","common":"Belize"},"spa":{"official":"Belice","common":"Belice"}}},"cca2":"BZ","ccn3":"084","cca3":"BLZ","region":"Americas","subregion":"Central America"},{"flags":{"png":"https://flagcdn.com/w320/be.png","svg":"https://flagcdn.com/be.svg","alt":"The flag of Belgium is composed of three equal vertical bands of black, yellow and red."},"flag":"🇧🇪","name":{"common":"Belgium","official":"Kingdom of Belgium","nativeName":{"deu":{"official":"Königreich Belgien","common":"Belgien"},"fra":{"official":"Royaume de Belgique","common":"Belgique"},"nld":{"official":"Koninkrijk België","common":"België"}}},"cca2":"BE","ccn3":"056","cca3":"BEL","region":"Europe","subregion":"Western Europe"},{"flags":{"png":"https://flagcdn.com/w320/ni.png","svg":"https://flagcdn.com/ni.svg","alt":"The flag of Nicaragua is composed of three equal horizontal bands of blue, white and blue, with the national coat of arms centered in the white band."},"flag":"🇳🇮","name":{"common":"Nicaragua","official":"Republic of Nicaragua","nativeName":{"spa":{"official":"República de Nicaragua","common":"Nicaragua"}}},"cca2":"NI","ccn3":"558","cca3":"NIC","region":"Americas","subregion":"Central America"},{"flags":{"png":"https://flagcdn.com/w320/vi.png","svg":"https://flagcdn.com/vi.svg","alt":"The flag of the United States Virgin Islands is composed of a white field with the national coat of arms in the center between the large blue initials V and I. The coat of arms shows a yellow eagle holding an olive branch in its right talon and three arrows in the left, with a shield of seven red and six white vertical stripes below a blue panel."},"flag":"🇻🇮","name":{"common":"United States Virgin Islands","official":"Virgin Islands of the United States","nativeName":{"eng":{"official":"Virgin Islands of the United States","common":"United States Virgin Islands"}}},"cca2":"VI","ccn3":"850","cca3":"VIR","region":"Americas","subregion":"Caribbean"},{"flags":{"png":"https://flagcdn.com/w320/sg.png","svg":"https://flagcdn.com/sg.svg","alt":"The flag of Singapore is composed of two equal horizontal bands of red and white. On the hoist side of the red band is a fly-side facing white crescent which partially encloses five small five-pointed white stars arranged in the shape of a pentagon."},"flag":"🇸🇬","name":{"common":"Singapore","official":"Republic of Singapore","nativeName":{"eng":{"official":"Republic of Singapore","common":"Singapore"},"zho":{"official":"新加坡共和国","common":"新加坡"},"msa":{"official":"Republik Singapura","common":"Singapura"},"tam":{"official":"சிங்கப்பூர் குடியரசு","common":"சிங்கப்பூர்"}}},"cca2":"SG","ccn3":"702","cca3":"SGP","region":"Asia","subregion":"South-Eastern Asia"},{"flags":{"png":"https://flagcdn.com/w320/do.png","svg":"https://flagcdn.com/do.svg","alt":"The flag of the Dominican Republic is divided into four rectangles by a centered white cross that extends to the edges of the field and bears the national coat of arms in its center. The upper hoist-side and lower fly-side rectangles are blue and the lower hoist-side and upper fly-side rectangles are red."},"flag":"🇩🇴","name":{"common":"Dominican Republic","official":"Dominican Republic","nativeName":{"spa":{"official":"República Dominicana","common":"República Dominicana"}}},"cca2":"DO","ccn3":"214","cca3":"DOM","region":"Americas","subregion":"Caribbean"},{"flags":{"png":"https://flagcdn.com/w320/xk.png","svg":"https://flagcdn.com/xk.svg","alt":"The flag of Kosovo has a dark blue field with a gold-colored silhouette of the country in the center, with six five-pointed white stars in a slight arc over it."},"flag":"🇽🇰","name":{"common":"Kosovo","official":"Republic of Kosovo","nativeName":{"sqi":{"official":"Republika e Kosovës","common":"Kosova"},"srp":{"official":"Република Косово","common":"Косово"}}},"cca2":"XK","ccn3":"","cca3":"UNK","region":"Europe","subregion":"Southeast Europe"},{"flags":{"png":"https://flagcdn.com/w320/la.png","svg":"https://flagcdn.com/la.svg","alt":"The flag of Laos is composed of three horizontal bands of red, blue and red. The blue band is twice the height of the red bands and bears a white circle at its center."},"flag":"🇱🇦","name":{"common":"Laos","official":"Lao People's Democratic Republic","nativeName":{"lao":{"official":"ສາທາລະນະ ຊາທິປະໄຕ ຄົນລາວ ຂອງ","common":"ສປປລາວ"}}},"cca2":"LA","ccn3":"418","cca3":"LAO","region":"Asia","subregion":"South-Eastern Asia"},{"flags":{"png":"https://flagcdn.com/w320/cl.png","svg":"https://flagcdn.com/cl.svg","alt":"The flag of Chile is composed of two equal horizontal bands of white and red, with a blue square of the same height as the white band superimposed in the canton. A white five-pointed star is centered in the blue square."},"flag":"🇨🇱","name":{"common":"Chile","official":"Republic of Chile","nativeName":{"spa":{"official":"República de Chile","common":"Chile"}}},"cca2":"CL","ccn3":"152","cca3":"CHL","region":"Americas","subregion":"South America"},{"flags":{"png":"https://flagcdn.com/w320/cr.png","svg":"https://flagcdn.com/cr.svg","alt":"The flag of Costa Rica is composed of five horizontal bands of blue, white, red, white and blue. The central red band is twice the height of the other four bands. The national coat of arms is placed in a white elliptical disk toward the hoist side of the red band."},"flag":"🇨🇷","name":{"common":"Costa Rica","official":"Republic of Costa Rica","nativeName":{"spa":{"official":"República de Costa Rica","common":"Costa Rica"}}},"cca2":"CR","ccn3":"188","cca3":"CRI","region":"Americas","subregion":"Central America"},{"flags":{"png":"https://flagcdn.com/w320/rw.png","svg":"https://flagcdn.com/rw.svg","alt":"The flag of Rwanda is composed of three horizontal bands of light blue, yellow and green. The light blue band is twice the height of the other two bands and bears a yellow sun with twenty-four rays on its fly side."},"flag":"🇷🇼","name":{"common":"Rwanda","official":"Republic of Rwanda","nativeName":{"eng":{"official":"Republic of Rwanda","common":"Rwanda"},"fra":{"official":"République rwandaise","common":"Rwanda"},"kin":{"official":"Repubulika y'u Rwanda","common":"Rwanda"}}},"cca2":"RW","ccn3":"646","cca3":"RWA","region":"Africa","subregion":"Eastern Africa"},{"flags":{"png":"https://flagcdn.com/w320/tt.png","svg":"https://flagcdn.com/tt.svg","alt":"The flag of Trinidad and Tobago has a red field with a white-edged black diagonal band that extends from the upper hoist-side corner to the lower fly-side corner of the field."},"flag":"🇹🇹","name":{"common":"Trinidad and Tobago","official":"Republic of Trinidad and Tobago","nativeName":{"eng":{"official":"Republic of Trinidad and Tobago","common":"Trinidad and Tobago"}}},"cca2":"TT","ccn3":"780","cca3":"TTO","region":"Americas","subregion":"Caribbean"},{"flags":{"png":"https://flagcdn.com/w320/my.png","svg":"https://flagcdn.com/my.svg","alt":"The flag of Malaysia is composed of fourteen equal horizontal bands of red alternating with white. A blue rectangle, bearing a fly-side facing yellow crescent and a fourteen-pointed yellow star placed just outside the crescent opening, is superimposed in the canton."},"flag":"🇲🇾","name":{"common":"Malaysia","official":"Malaysia","nativeName":{"eng":{"official":"Malaysia","common":"Malaysia"},"msa":{"official":"مليسيا","common":"مليسيا"}}},"cca2":"MY","ccn3":"458","cca3":"MYS","region":"Asia","subregion":"South-Eastern Asia"},{"flags":{"png":"https://flagcdn.com/w320/za.png","svg":"https://flagcdn.com/za.svg","alt":"The flag of South Africa is composed of two equal horizontal bands of red and blue, with a yellow-edged black isosceles triangle superimposed on the hoist side of the field. This triangle has its base centered on the hoist end, spans about two-fifth the width and two-third the height of the field, and is enclosed on its sides by the arms of a white-edged green horizontally oriented Y-shaped band which extends along the boundary of the red and blue bands to the fly end of the field."},"flag":"🇿🇦","name":{"common":"South Africa","official":"Republic of South Africa","nativeName":{"afr":{"official":"Republiek van Suid-Afrika","common":"South Africa"},"eng":{"official":"Republic of South Africa","common":"South Africa"},"nbl":{"official":"IRiphabliki yeSewula Afrika","common":"Sewula Afrika"},"nso":{"official":"Rephaboliki ya Afrika-Borwa ","common":"Afrika-Borwa"},"sot":{"official":"Rephaboliki ya Afrika Borwa","common":"Afrika Borwa"},"ssw":{"official":"IRiphabhulikhi yeNingizimu Afrika","common":"Ningizimu Afrika"},"tsn":{"official":"Rephaboliki ya Aforika Borwa","common":"Aforika Borwa"},"tso":{"official":"Riphabliki ra Afrika Dzonga","common":"Afrika Dzonga"},"ven":{"official":"Riphabuḽiki ya Afurika Tshipembe","common":"Afurika Tshipembe"},"xho":{"official":"IRiphabliki yaseMzantsi Afrika","common":"Mzantsi Afrika"},"zul":{"official":"IRiphabliki yaseNingizimu Afrika","common":"Ningizimu Afrika"}}},"cca2":"ZA","ccn3":"710","cca3":"ZAF","region":"Africa","subregion":"Southern Africa"},{"flags":{"png":"https://flagcdn.com/w320/sa.png","svg":"https://flagcdn.com/sa.svg","alt":"The flag of Saudi Arabia has a green field, at the center of which is an Arabic inscription — the Shahada — in white above a white horizontal sabre with its tip pointed to the hoist side of the field."},"flag":"🇸🇦","name":{"common":"Saudi Arabia","official":"Kingdom of Saudi Arabia","nativeName":{"ara":{"official":"المملكة العربية السعودية","common":"العربية السعودية"}}},"cca2":"SA","ccn3":"682","cca3":"SAU","region":"Asia","subregion":"Western Asia"},{"flags":{"png":"https://flagcdn.com/w320/ht.png","svg":"https://flagcdn.com/ht.svg","alt":"The flag of Haiti is composed of two equal horizontal bands of blue and red. A white square bearing the national coat of arms is superimposed at the center of the field."},"flag":"🇭🇹","name":{"common":"Haiti","official":"Republic of Haiti","nativeName":{"fra":{"official":"République d'Haïti","common":"Haïti"},"hat":{"official":"Repiblik Ayiti","common":"Ayiti"}}},"cca2":"HT","ccn3":"332","cca3":"HTI","region":"Americas","subregion":"Caribbean"},{"flags":{"png":"https://flagcdn.com/w320/tf.png","svg":"https://flagcdn.com/tf.svg","alt":"The flag of the French Southern and Antarctic Lands is blue and features the French tricolor in the canton, often displayed with a white border. In the lower fly, the letters T.A.A.F. form a monogram in white, which is stylized to resemble an anchor. The monogram is surrounded by five white stars."},"flag":"🇹🇫","name":{"common":"French Southern and Antarctic Lands","official":"Territory of the French Southern and Antarctic Lands","nativeName":{"fra":{"official":"Territoire des Terres australes et antarctiques françaises","common":"Terres australes et antarctiques françaises"}}},"cca2":"TF","ccn3":"260","cca3":"ATF","region":"Antarctic","subregion":""},{"flags":{"png":"https://flagcdn.com/w320/tk.png","svg":"https://flagcdn.com/tk.svg","alt":"The flag of Tokelau features a yellow stylized canoe on a dark blue field sailing toward the Southern Cross constellation with four white five-pointed stars at the hoist side."},"flag":"🇹🇰","name":{"common":"Tokelau","official":"Tokelau","nativeName":{"eng":{"official":"Tokelau","common":"Tokelau"},"smo":{"official":"Tokelau","common":"Tokelau"},"tkl":{"official":"Tokelau","common":"Tokelau"}}},"cca2":"TK","ccn3":"772","cca3":"TKL","region":"Oceania","subregion":"Polynesia"},{"flags":{"png":"https://flagcdn.com/w320/so.png","svg":"https://flagcdn.com/so.svg","alt":"The flag of Somalia features a large five-pointed white star centered on a light blue field."},"flag":"🇸🇴","name":{"common":"Somalia","official":"Federal Republic of Somalia","nativeName":{"ara":{"official":"جمهورية الصومال‎‎","common":"الصومال‎‎"},"som":{"official":"Jamhuuriyadda Federaalka Soomaaliya","common":"Soomaaliya"}}},"cca2":"SO","ccn3":"706","cca3":"SOM","region":"Africa","subregion":"Eastern Africa"},{"flags":{"png":"https://flagcdn.com/w320/jp.png","svg":"https://flagcdn.com/jp.svg","alt":"The flag of Japan features a crimson-red circle at the center of a white field."},"flag":"🇯🇵","name":{"common":"Japan","official":"Japan","nativeName":{"jpn":{"official":"日本","common":"日本"}}},"cca2":"JP","ccn3":"392","cca3":"JPN","region":"Asia","subregion":"Eastern Asia"},{"flags":{"png":"https://flagcdn.com/w320/mo.png","svg":"https://flagcdn.com/mo.svg","alt":"The flag of Macau is green with a lotus flower above a stylized bridge and water in white, under an arc of five five-pointed gold stars."},"flag":"🇲🇴","name":{"common":"Macau","official":"Macao Special Administrative Region of the People's Republic of China","nativeName":{"por":{"official":"Região Administrativa Especial de Macau da República Popular da China","common":"Macau"},"zho":{"official":"中华人民共和国澳门特别行政区","common":"澳门"}}},"cca2":"MO","ccn3":"446","cca3":"MAC","region":"Asia","subregion":"Eastern Asia"},{"flags":{"png":"https://flagcdn.com/w320/sd.png","svg":"https://flagcdn.com/sd.svg","alt":"The flag of Sudan is composed of three equal horizontal bands of red, white and black, with a green isosceles triangle superimposed on the hoist side. The green triangle spans about two-fifth the width of the field with its base on the hoist end."},"flag":"🇸🇩","name":{"common":"Sudan","official":"Republic of the Sudan","nativeName":{"ara":{"official":"جمهورية السودان","common":"السودان"},"eng":{"official":"Republic of the Sudan","common":"Sudan"}}},"cca2":"SD","ccn3":"729","cca3":"SDN","region":"Africa","subregion":"Northern Africa"},{"flags":{"png":"https://flagcdn.com/w320/dz.png","svg":"https://flagcdn.com/dz.svg","alt":"The flag of Algeria features two equal vertical bands of green and white. A five-pointed red star within a fly-side facing red crescent is centered over the two-color boundary."},"flag":"🇩🇿","name":{"common":"Algeria","official":"People's Democratic Republic of Algeria","nativeName":{"ara":{"official":"الجمهورية الديمقراطية الشعبية الجزائرية","common":"الجزائر"}}},"cca2":"DZ","ccn3":"012","cca3":"DZA","region":"Africa","subregion":"Northern Africa"},{"flags":{"png":"https://flagcdn.com/w320/sz.png","svg":"https://flagcdn.com/sz.svg","alt":"The flag of Eswatini is composed of three horizontal bands — a large central yellow-edged red band, and a light blue band above and beneath the red band. The red band is three times the height of the blue bands and bears a centered emblem made up of a large black and white Nguni shield covering two spears and a staff decorated with feather tassels, all placed horizontally."},"flag":"🇸🇿","name":{"common":"Eswatini","official":"Kingdom of Eswatini","nativeName":{"eng":{"official":"Kingdom of Eswatini","common":"Eswatini"},"ssw":{"official":"Umbuso weSwatini","common":"eSwatini"}}},"cca2":"SZ","ccn3":"748","cca3":"SWZ","region":"Africa","subregion":"Southern Africa"},{"flags":{"png":"https://flagcdn.com/w320/fr.png","svg":"https://flagcdn.com/fr.svg","alt":"The flag of France is composed of three equal vertical bands of blue, white and red."},"flag":"🇫🇷","name":{"common":"France","official":"French Republic","nativeName":{"fra":{"official":"République française","common":"France"}}},"cca2":"FR","ccn3":"250","cca3":"FRA","region":"Europe","subregion":"Western Europe"},{"flags":{"png":"https://flagcdn.com/w320/sx.png","svg":"https://flagcdn.com/sx.svg","alt":"The flag of Sint Maarten features two equal horizontal bands of red and blue, with a white isosceles triangle based on the hoist side. The center of the triangle displays the national coat of arms with an orange-bordered blue shield that prominently displays a white courthouse, as well as yellow sage in the upper left and the silhouette of a monument in the upper right. Over the shield is a yellow rising sun and a brown pelican in flight. A yellow scroll below the shield has the Latin motto \"SEMPER PROGREDIENS\" (Always Progressing)."},"flag":"🇸🇽","name":{"common":"Sint Maarten","official":"Sint Maarten","nativeName":{"eng":{"official":"Sint Maarten","common":"Sint Maarten"},"fra":{"official":"Saint-Martin","common":"Saint-Martin"},"nld":{"official":"Sint Maarten","common":"Sint Maarten"}}},"cca2":"SX","ccn3":"534","cca3":"SXM","region":"Americas","subregion":"Caribbean"},{"flags":{"png":"https://flagcdn.com/w320/bd.png","svg":"https://flagcdn.com/bd.svg","alt":"The flag of Bangladesh has a dark green field bearing a large red circle that is offset slightly towards the hoist side of center."},"flag":"🇧🇩","name":{"common":"Bangladesh","official":"People's Republic of Bangladesh","nativeName":{"ben":{"official":"বাংলাদেশ গণপ্রজাতন্ত্রী","common":"বাংলাদেশ"}}},"cca2":"BD","ccn3":"050","cca3":"BGD","region":"Asia","subregion":"Southern Asia"},{"flags":{"png":"https://flagcdn.com/w320/cf.png","svg":"https://flagcdn.com/cf.svg","alt":"The flag of Central African Republic is composed of four equal horizontal bands of blue, white, green and yellow intersected at the center by a vertical red band of equal size as the horizontal bands. A yellow five-pointed star is situated on the hoist side of the blue band."},"flag":"🇨🇫","name":{"common":"Central African Republic","official":"Central African Republic","nativeName":{"fra":{"official":"République centrafricaine","common":"République centrafricaine"},"sag":{"official":"Ködörösêse tî Bêafrîka","common":"Bêafrîka"}}},"cca2":"CF","ccn3":"140","cca3":"CAF","region":"Africa","subregion":"Middle Africa"},{"flags":{"png":"https://flagcdn.com/w320/ca.png","svg":"https://flagcdn.com/ca.svg","alt":"The flag of Canada is composed of a red vertical band on the hoist and fly sides and a central white square that is twice the width of the vertical bands. A large eleven-pointed red maple leaf is centered in the white square."},"flag":"🇨🇦","name":{"common":"Canada","official":"Canada","nativeName":{"eng":{"official":"Canada","common":"Canada"},"fra":{"official":"Canada","common":"Canada"}}},"cca2":"CA","ccn3":"124","cca3":"CAN","region":"Americas","subregion":"North America"},{"flags":{"png":"https://flagcdn.com/w320/re.png","svg":"https://flagcdn.com/re.svg","alt":"The flag of Réunion has a blue field, with a red triangle spanning the bottom and extending to the center, as well as five yellow beams emanating from the center, spread equally apart."},"flag":"🇷🇪","name":{"common":"Réunion","official":"Réunion Island","nativeName":{"fra":{"official":"Ile de la Réunion","common":"La Réunion"}}},"cca2":"RE","ccn3":"638","cca3":"REU","region":"Africa","subregion":"Eastern Africa"},{"flags":{"png":"https://flagcdn.com/w320/vc.png","svg":"https://flagcdn.com/vc.svg","alt":"The flag of Saint Vincent and the Grenadines is composed of three vertical bands of blue, gold and green. The gold band is twice as wide as the other two bands and bears three green diamonds arranged to form the letter V at its center."},"flag":"🇻🇨","name":{"common":"Saint Vincent and the Grenadines","official":"Saint Vincent and the Grenadines","nativeName":{"eng":{"official":"Saint Vincent and the Grenadines","common":"Saint Vincent and the Grenadines"}}},"cca2":"VC","ccn3":"670","cca3":"VCT","region":"Americas","subregion":"Caribbean"},{"flags":{"png":"https://flagcdn.com/w320/fm.png","svg":"https://flagcdn.com/fm.svg","alt":"The flag of Micronesia has a light blue field, at the center of which are four five-pointed white stars arranged in the shape of a diamond."},"flag":"🇫🇲","name":{"common":"Micronesia","official":"Federated States of Micronesia","nativeName":{"eng":{"official":"Federated States of Micronesia","common":"Micronesia"}}},"cca2":"FM","ccn3":"583","cca3":"FSM","region":"Oceania","subregion":"Micronesia"},{"flags":{"png":"https://flagcdn.com/w320/cv.png","svg":"https://flagcdn.com/cv.svg","alt":"The flag of Cape Verde is composed of five horizontal bands of blue, white, red, white and blue in the ratio of 6:1:1:1:3. A ring of ten five-pointed yellow stars is centered at three-eighth of the height from the bottom edge and three-eighth of the width from the hoist end of the field."},"flag":"🇨🇻","name":{"common":"Cape Verde","official":"Republic of Cabo Verde","nativeName":{"por":{"official":"República de Cabo Verde","common":"Cabo Verde"}}},"cca2":"CV","ccn3":"132","cca3":"CPV","region":"Africa","subregion":"Western Africa"},{"flags":{"png":"https://flagcdn.com/w320/gd.png","svg":"https://flagcdn.com/gd.svg","alt":"The flag of Grenada features a large central rectangular area surrounded by a red border, with three five-pointed yellow stars centered on the top and bottom borders. The central rectangle is divided diagonally into four alternating triangular areas of yellow at the top and bottom and green on the hoist and fly sides, and a five-pointed yellow star on a red circle is superimposed at its center. A symbolic nutmeg pod is situated on the green hoist-side triangle."},"flag":"🇬🇩","name":{"common":"Grenada","official":"Grenada","nativeName":{"eng":{"official":"Grenada","common":"Grenada"}}},"cca2":"GD","ccn3":"308","cca3":"GRD","region":"Americas","subregion":"Caribbean"},{"flags":{"png":"https://flagcdn.com/w320/pk.png","svg":"https://flagcdn.com/pk.svg","alt":"The flag of Pakistan is composed of a white vertical band on its hoist side that takes up about one-fourth the width of the field and a dark green rectangular area that spans the rest of the field. A white fly-side facing crescent and five-pointed star are centered in the dark green area."},"flag":"🇵🇰","name":{"common":"Pakistan","official":"Islamic Republic of Pakistan","nativeName":{"eng":{"official":"Islamic Republic of Pakistan","common":"Pakistan"},"urd":{"official":"اسلامی جمہوریۂ پاكستان","common":"پاكستان"}}},"cca2":"PK","ccn3":"586","cca3":"PAK","region":"Asia","subregion":"Southern Asia"},{"flags":{"png":"https://flagcdn.com/w320/np.png","svg":"https://flagcdn.com/np.svg","alt":"The flag of Nepal is the world's only non-quadrilateral flag of a sovereign country. It takes the shape of two adjoining right-angled triangles and has a crimson red field with deep blue edges. Within the smaller upper triangle is an emblem of the upper half of a white sun resting on an upward facing white crescent. The lower triangle bears a white sun with twelve rays."},"flag":"🇳🇵","name":{"common":"Nepal","official":"Federal Democratic Republic of Nepal","nativeName":{"nep":{"official":"नेपाल संघीय लोकतान्त्रिक गणतन्त्र","common":"नेपाल"}}},"cca2":"NP","ccn3":"524","cca3":"NPL","region":"Asia","subregion":"Southern Asia"},{"flags":{"png":"https://flagcdn.com/w320/ba.png","svg":"https://flagcdn.com/ba.svg","alt":"The flag of Bosnia and Herzegovina has a blue field, at the center of which is a large yellow hoist-side facing right-angled triangle that is based on the top edge and spans the height of the field. Adjacent to the hypotenuse of this triangle are nine adjoining five-pointed white stars with the top and bottom stars cut in half by the edges of the field."},"flag":"🇧🇦","name":{"common":"Bosnia and Herzegovina","official":"Bosnia and Herzegovina","nativeName":{"bos":{"official":"Bosna i Hercegovina","common":"Bosna i Hercegovina"},"hrv":{"official":"Bosna i Hercegovina","common":"Bosna i Hercegovina"},"srp":{"official":"Босна и Херцеговина","common":"Босна и Херцеговина"}}},"cca2":"BA","ccn3":"070","cca3":"BIH","region":"Europe","subregion":"Southeast Europe"},{"flags":{"png":"https://flagcdn.com/w320/ss.png","svg":"https://flagcdn.com/ss.svg","alt":"The flag of South Sudan is composed of three equal horizontal bands of black, red with white top and bottom edges, and green. A blue equilateral triangle which spans about two-fifth the width of the field is superimposed on the hoist side with its base on the hoist end of the field. At the center of this triangle is a five-pointed yellow star."},"flag":"🇸🇸","name":{"common":"South Sudan","official":"Republic of South Sudan","nativeName":{"eng":{"official":"Republic of South Sudan","common":"South Sudan"}}},"cca2":"SS","ccn3":"728","cca3":"SSD","region":"Africa","subregion":"Middle Africa"},{"flags":{"png":"https://flagcdn.com/w320/gw.png","svg":"https://flagcdn.com/gw.svg","alt":"The flag of Guinea-Bissau features a red vertical band on its hoist side that takes up about two-fifth the width of the field, and two equal horizontal bands of yellow and green adjoining the vertical band. A five-pointed black star is centered in the vertical band."},"flag":"🇬🇼","name":{"common":"Guinea-Bissau","official":"Republic of Guinea-Bissau","nativeName":{"por":{"official":"República da Guiné-Bissau","common":"Guiné-Bissau"},"pov":{"official":"República da Guiné-Bissau","common":"Guiné-Bissau"}}},"cca2":"GW","ccn3":"624","cca3":"GNB","region":"Africa","subregion":"Western Africa"},{"flags":{"png":"https://flagcdn.com/w320/bj.png","svg":"https://flagcdn.com/bj.svg","alt":"The flag of Benin features a green vertical band on its hoist side that takes up about two-fifth the width of the field and two equal horizontal bands of yellow and red adjoining the vertical band."},"flag":"🇧🇯","name":{"common":"Benin","official":"Republic of Benin","nativeName":{"fra":{"official":"République du Bénin","common":"Bénin"}}},"cca2":"BJ","ccn3":"204","cca3":"BEN","region":"Africa","subregion":"Western Africa"},{"flags":{"png":"https://flagcdn.com/w320/lb.png","svg":"https://flagcdn.com/lb.svg","alt":"The flag of Lebanon is composed of three horizontal bands of red, white and red. The white band is twice the height of the red bands and bears a green Lebanese Cedar tree at its center."},"flag":"🇱🇧","name":{"common":"Lebanon","official":"Lebanese Republic","nativeName":{"ara":{"official":"الجمهورية اللبنانية","common":"لبنان"},"fra":{"official":"République libanaise","common":"Liban"}}},"cca2":"LB","ccn3":"422","cca3":"LBN","region":"Asia","subregion":"Western Asia"},{"flags":{"png":"https://flagcdn.com/w320/ax.png","svg":"https://flagcdn.com/ax.svg","alt":"The flag of the Åland Islands has a blue field with a large golden-yellow-edged red cross that extends to the edges of the field. The vertical part of this cross is offset towards the hoist side."},"flag":"🇦🇽","name":{"common":"Åland Islands","official":"Åland Islands","nativeName":{"swe":{"official":"Landskapet Åland","common":"Åland"}}},"cca2":"AX","ccn3":"248","cca3":"ALA","region":"Europe","subregion":"Northern Europe"}] \ No newline at end of file diff --git a/database/factories/CountryFactory.php b/database/factories/CountryFactory.php new file mode 100644 index 00000000..4cecc9c1 --- /dev/null +++ b/database/factories/CountryFactory.php @@ -0,0 +1,38 @@ + + */ +class CountryFactory extends Factory +{ + protected $model = Country::class; + + public function definition(): array + { + $iso2 = fake()->unique()->lexify('??'); + $iso3 = fake()->unique()->lexify('???'); + + return [ + 'iso2' => strtoupper($iso2), + 'iso3' => strtoupper($iso3), + 'numeric_code' => str_pad((string) fake()->numberBetween(1, 999), 3, '0', STR_PAD_LEFT), + 'name_common' => fake()->unique()->country(), + 'name_official' => fake()->company().' Republic', + 'region' => fake()->randomElement(['Europe', 'Americas', 'Africa', 'Asia', 'Oceania']), + 'subregion' => fake()->randomElement(['Central Europe', 'North America', 'Western Africa', 'Eastern Asia']), + 'flag_svg_url' => 'https://example.test/flags/'.strtolower($iso2).'.svg', + 'flag_png_url' => 'https://example.test/flags/'.strtolower($iso2).'.png', + 'flag_emoji' => null, + 'active' => true, + 'sort_order' => 1000, + 'is_featured' => false, + ]; + } +} diff --git a/database/migrations/2026_03_09_000005_upgrade_stories_for_creator_module.php b/database/migrations/2026_03_09_000005_upgrade_stories_for_creator_module.php index cc4048c8..614db5db 100644 --- a/database/migrations/2026_03_09_000005_upgrade_stories_for_creator_module.php +++ b/database/migrations/2026_03_09_000005_upgrade_stories_for_creator_module.php @@ -40,13 +40,18 @@ return new class extends Migration }); if (Schema::hasColumn('stories', 'author_id') && Schema::hasTable('stories_authors')) { - DB::statement(<<<'SQL' - UPDATE stories s - INNER JOIN stories_authors sa ON sa.id = s.author_id - SET s.creator_id = sa.user_id - WHERE s.creator_id IS NULL - AND sa.user_id IS NOT NULL - SQL); + DB::table('stories') + ->join('stories_authors', 'stories_authors.id', '=', 'stories.author_id') + ->whereNull('stories.creator_id') + ->whereNotNull('stories_authors.user_id') + ->select(['stories.id as story_id', 'stories_authors.user_id as creator_id']) + ->orderBy('stories.id') + ->get() + ->each(function ($row): void { + DB::table('stories') + ->where('id', (int) $row->story_id) + ->update(['creator_id' => (int) $row->creator_id]); + }); } } diff --git a/database/migrations/2026_03_17_090000_add_xp_fields_to_users_table.php b/database/migrations/2026_03_17_090000_add_xp_fields_to_users_table.php new file mode 100644 index 00000000..2661c347 --- /dev/null +++ b/database/migrations/2026_03_17_090000_add_xp_fields_to_users_table.php @@ -0,0 +1,26 @@ +unsignedInteger('xp')->default(0)->after('allow_messages_from'); + $table->unsignedInteger('level')->default(1)->after('xp'); + $table->string('rank', 50)->default('Newbie')->after('level'); + }); + } + + public function down(): void + { + Schema::table('users', function (Blueprint $table): void { + $table->dropColumn(['xp', 'level', 'rank']); + }); + } +}; diff --git a/database/migrations/2026_03_17_090100_create_user_xp_logs_table.php b/database/migrations/2026_03_17_090100_create_user_xp_logs_table.php new file mode 100644 index 00000000..d1b7bdf8 --- /dev/null +++ b/database/migrations/2026_03_17_090100_create_user_xp_logs_table.php @@ -0,0 +1,31 @@ +id(); + $table->foreignId('user_id')->constrained('users')->cascadeOnDelete(); + $table->string('action', 100); + $table->unsignedInteger('xp'); + $table->unsignedBigInteger('reference_id')->nullable(); + $table->timestamp('created_at')->nullable(); + + $table->index(['user_id', 'created_at']); + $table->index(['user_id', 'action']); + $table->index(['user_id', 'reference_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('user_xp_logs'); + } +}; diff --git a/database/migrations/2026_03_17_100000_create_achievements_table.php b/database/migrations/2026_03_17_100000_create_achievements_table.php new file mode 100644 index 00000000..5c925619 --- /dev/null +++ b/database/migrations/2026_03_17_100000_create_achievements_table.php @@ -0,0 +1,181 @@ +id(); + $table->string('name'); + $table->string('slug')->unique(); + $table->string('description'); + $table->string('icon', 80); + $table->unsignedInteger('xp_reward')->default(0); + $table->string('type', 40); + $table->string('condition_type', 40); + $table->unsignedInteger('condition_value'); + $table->timestamps(); + + $table->index(['type', 'condition_type']); + }); + + DB::table('achievements')->insert([ + [ + 'name' => 'First Upload', + 'slug' => 'first-upload', + 'description' => 'Publish your first artwork.', + 'icon' => 'fa-image', + 'xp_reward' => 100, + 'type' => 'Uploads', + 'condition_type' => 'upload_count', + 'condition_value' => 1, + 'created_at' => now(), + 'updated_at' => now(), + ], + [ + 'name' => '10 Uploads', + 'slug' => 'ten-uploads', + 'description' => 'Publish 10 artworks.', + 'icon' => 'fa-layer-group', + 'xp_reward' => 250, + 'type' => 'Uploads', + 'condition_type' => 'upload_count', + 'condition_value' => 10, + 'created_at' => now(), + 'updated_at' => now(), + ], + [ + 'name' => '100 Uploads', + 'slug' => 'hundred-uploads', + 'description' => 'Publish 100 artworks.', + 'icon' => 'fa-fire', + 'xp_reward' => 1200, + 'type' => 'Uploads', + 'condition_type' => 'upload_count', + 'condition_value' => 100, + 'created_at' => now(), + 'updated_at' => now(), + ], + [ + 'name' => 'First Like Received', + 'slug' => 'first-like-received', + 'description' => 'Receive your first artwork like.', + 'icon' => 'fa-heart', + 'xp_reward' => 75, + 'type' => 'Engagement', + 'condition_type' => 'likes_received', + 'condition_value' => 1, + 'created_at' => now(), + 'updated_at' => now(), + ], + [ + 'name' => '100 Likes', + 'slug' => 'hundred-likes-received', + 'description' => 'Receive 100 artwork likes.', + 'icon' => 'fa-heart-circle-check', + 'xp_reward' => 300, + 'type' => 'Engagement', + 'condition_type' => 'likes_received', + 'condition_value' => 100, + 'created_at' => now(), + 'updated_at' => now(), + ], + [ + 'name' => '1000 Likes', + 'slug' => 'thousand-likes-received', + 'description' => 'Receive 1000 artwork likes.', + 'icon' => 'fa-heart-circle-bolt', + 'xp_reward' => 1500, + 'type' => 'Engagement', + 'condition_type' => 'likes_received', + 'condition_value' => 1000, + 'created_at' => now(), + 'updated_at' => now(), + ], + [ + 'name' => 'First Follower', + 'slug' => 'first-follower', + 'description' => 'Earn your first follower.', + 'icon' => 'fa-user-plus', + 'xp_reward' => 100, + 'type' => 'Social', + 'condition_type' => 'followers_count', + 'condition_value' => 1, + 'created_at' => now(), + 'updated_at' => now(), + ], + [ + 'name' => '100 Followers', + 'slug' => 'hundred-followers', + 'description' => 'Earn 100 followers.', + 'icon' => 'fa-users', + 'xp_reward' => 500, + 'type' => 'Social', + 'condition_type' => 'followers_count', + 'condition_value' => 100, + 'created_at' => now(), + 'updated_at' => now(), + ], + [ + 'name' => 'First Story', + 'slug' => 'first-story', + 'description' => 'Publish your first story.', + 'icon' => 'fa-feather-pointed', + 'xp_reward' => 100, + 'type' => 'Stories', + 'condition_type' => 'stories_published', + 'condition_value' => 1, + 'created_at' => now(), + 'updated_at' => now(), + ], + [ + 'name' => '10 Stories', + 'slug' => 'ten-stories', + 'description' => 'Publish 10 stories.', + 'icon' => 'fa-book-open-reader', + 'xp_reward' => 350, + 'type' => 'Stories', + 'condition_type' => 'stories_published', + 'condition_value' => 10, + 'created_at' => now(), + 'updated_at' => now(), + ], + [ + 'name' => 'Reached Level 5', + 'slug' => 'level-five', + 'description' => 'Reach level 5 in the XP system.', + 'icon' => 'fa-star', + 'xp_reward' => 400, + 'type' => 'Milestones', + 'condition_type' => 'level_reached', + 'condition_value' => 5, + 'created_at' => now(), + 'updated_at' => now(), + ], + [ + 'name' => 'Reached Level 7', + 'slug' => 'level-seven', + 'description' => 'Reach level 7 in the XP system.', + 'icon' => 'fa-crown', + 'xp_reward' => 1200, + 'type' => 'Milestones', + 'condition_type' => 'level_reached', + 'condition_value' => 7, + 'created_at' => now(), + 'updated_at' => now(), + ], + ]); + } + + public function down(): void + { + Schema::dropIfExists('achievements'); + } +}; diff --git a/database/migrations/2026_03_17_100100_create_user_achievements_table.php b/database/migrations/2026_03_17_100100_create_user_achievements_table.php new file mode 100644 index 00000000..2437efdc --- /dev/null +++ b/database/migrations/2026_03_17_100100_create_user_achievements_table.php @@ -0,0 +1,28 @@ +id(); + $table->foreignId('user_id')->constrained('users')->cascadeOnDelete(); + $table->foreignId('achievement_id')->constrained('achievements')->cascadeOnDelete(); + $table->timestamp('unlocked_at'); + + $table->unique(['user_id', 'achievement_id']); + $table->index(['user_id', 'unlocked_at']); + }); + } + + public function down(): void + { + Schema::dropIfExists('user_achievements'); + } +}; \ No newline at end of file diff --git a/database/migrations/2026_03_18_120000_create_leaderboards_table.php b/database/migrations/2026_03_18_120000_create_leaderboards_table.php new file mode 100644 index 00000000..b24526b0 --- /dev/null +++ b/database/migrations/2026_03_18_120000_create_leaderboards_table.php @@ -0,0 +1,30 @@ +id(); + $table->string('type', 20); + $table->unsignedBigInteger('entity_id'); + $table->decimal('score', 14, 2)->default(0); + $table->string('period', 20); + $table->timestamps(); + + $table->unique(['type', 'period', 'entity_id']); + $table->index(['type', 'period', 'score']); + }); + } + + public function down(): void + { + Schema::dropIfExists('leaderboards'); + } +}; diff --git a/database/migrations/2026_03_18_150000_create_story_comments_and_bookmarks_tables.php b/database/migrations/2026_03_18_150000_create_story_comments_and_bookmarks_tables.php new file mode 100644 index 00000000..c5372e12 --- /dev/null +++ b/database/migrations/2026_03_18_150000_create_story_comments_and_bookmarks_tables.php @@ -0,0 +1,48 @@ +id(); + $table->foreignId('story_id')->constrained('stories')->cascadeOnDelete(); + $table->foreignId('user_id')->constrained('users')->cascadeOnDelete(); + $table->foreignId('parent_id')->nullable()->constrained('story_comments')->nullOnDelete(); + $table->text('content')->nullable(); + $table->longText('raw_content')->nullable(); + $table->longText('rendered_content')->nullable(); + $table->boolean('is_approved')->default(true); + $table->timestamps(); + $table->softDeletes(); + + $table->index(['story_id', 'created_at']); + $table->index(['story_id', 'parent_id', 'is_approved']); + $table->index(['user_id', 'created_at']); + }); + } + + if (! Schema::hasTable('story_bookmarks')) { + Schema::create('story_bookmarks', function (Blueprint $table): void { + $table->id(); + $table->foreignId('story_id')->constrained('stories')->cascadeOnDelete(); + $table->foreignId('user_id')->constrained('users')->cascadeOnDelete(); + $table->timestamps(); + + $table->unique(['story_id', 'user_id']); + $table->index(['user_id', 'created_at']); + }); + } + } + + public function down(): void + { + Schema::dropIfExists('story_bookmarks'); + Schema::dropIfExists('story_comments'); + } +}; \ No newline at end of file diff --git a/database/migrations/2026_03_19_000000_create_artwork_bookmarks_table.php b/database/migrations/2026_03_19_000000_create_artwork_bookmarks_table.php new file mode 100644 index 00000000..b7184781 --- /dev/null +++ b/database/migrations/2026_03_19_000000_create_artwork_bookmarks_table.php @@ -0,0 +1,28 @@ +id(); + $table->foreignId('artwork_id')->constrained('artworks')->cascadeOnDelete(); + $table->foreignId('user_id')->constrained('users')->cascadeOnDelete(); + $table->timestamps(); + + $table->unique(['artwork_id', 'user_id']); + $table->index(['user_id', 'created_at']); + }); + } + } + + public function down(): void + { + Schema::dropIfExists('artwork_bookmarks'); + } +}; \ No newline at end of file diff --git a/database/migrations/2026_03_19_120000_create_countries_table.php b/database/migrations/2026_03_19_120000_create_countries_table.php new file mode 100644 index 00000000..1c2b6097 --- /dev/null +++ b/database/migrations/2026_03_19_120000_create_countries_table.php @@ -0,0 +1,90 @@ +createCountriesTable(); + + return; + } + + if (Schema::hasColumn('countries', 'id') && Schema::hasColumn('countries', 'iso2')) { + return; + } + + $legacyCountries = DB::table('countries')->get(); + Schema::drop('countries'); + $this->createCountriesTable(); + + foreach ($legacyCountries as $country) { + $iso2 = strtoupper(trim((string) ($country->iso ?? ''))); + + if ($iso2 === '' || ! preg_match('/^[A-Z]{2}$/', $iso2)) { + continue; + } + + DB::table('countries')->insert([ + 'iso' => $iso2, + 'iso2' => $iso2, + 'name' => $country->name ?? null, + 'native' => $country->native ?? null, + 'phone' => $country->phone ?? null, + 'continent' => $country->continent ?? null, + 'capital' => $country->capital ?? null, + 'currency' => $country->currency ?? null, + 'languages' => $country->languages ?? null, + 'name_common' => $country->name ?? $iso2, + 'name_official' => $country->native ?? null, + 'active' => true, + 'sort_order' => 1000, + 'is_featured' => false, + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + } + + public function down(): void + { + Schema::dropIfExists('countries'); + } + + private function createCountriesTable(): void + { + Schema::create('countries', function (Blueprint $table): void { + $table->id(); + $table->char('iso', 2)->nullable()->unique(); + $table->char('iso2', 2)->unique(); + $table->char('iso3', 3)->nullable()->index(); + $table->string('numeric_code', 3)->nullable(); + $table->string('name', 255)->nullable(); + $table->string('native', 255)->nullable(); + $table->string('phone', 50)->nullable(); + $table->char('continent', 2)->nullable(); + $table->string('capital', 255)->nullable(); + $table->string('currency', 32)->nullable(); + $table->string('languages', 255)->nullable(); + $table->string('name_common', 255); + $table->string('name_official', 255)->nullable(); + $table->string('region', 120)->nullable(); + $table->string('subregion', 120)->nullable(); + $table->text('flag_svg_url')->nullable(); + $table->text('flag_png_url')->nullable(); + $table->string('flag_emoji', 16)->nullable(); + $table->boolean('active')->default(true); + $table->unsignedInteger('sort_order')->default(1000); + $table->boolean('is_featured')->default(false); + $table->timestamps(); + + $table->index('active'); + $table->index('name_common'); + $table->index(['is_featured', 'sort_order']); + }); + } +}; diff --git a/database/migrations/2026_03_19_120100_add_country_id_to_users_table.php b/database/migrations/2026_03_19_120100_add_country_id_to_users_table.php new file mode 100644 index 00000000..ad9c858b --- /dev/null +++ b/database/migrations/2026_03_19_120100_add_country_id_to_users_table.php @@ -0,0 +1,29 @@ +foreignId('country_id')->nullable()->constrained('countries')->nullOnDelete(); + }); + } + + public function down(): void + { + if (! Schema::hasColumn('users', 'country_id')) { + return; + } + + Schema::table('users', function (Blueprint $table): void { + $table->dropConstrainedForeignId('country_id'); + }); + } +}; diff --git a/database/migrations/2026_03_19_130000_add_last_received_comment_read_at_to_users_table.php b/database/migrations/2026_03_19_130000_add_last_received_comment_read_at_to_users_table.php new file mode 100644 index 00000000..eebd4eb5 --- /dev/null +++ b/database/migrations/2026_03_19_130000_add_last_received_comment_read_at_to_users_table.php @@ -0,0 +1,26 @@ +timestamp('last_received_comment_read_at')->nullable()->after('allow_messages_from'); + } + }); + } + + public function down(): void + { + Schema::table('users', function (Blueprint $table): void { + if (Schema::hasColumn('users', 'last_received_comment_read_at')) { + $table->dropColumn('last_received_comment_read_at'); + } + }); + } +}; diff --git a/database/migrations/2026_03_19_130100_create_user_received_comment_reads_table.php b/database/migrations/2026_03_19_130100_create_user_received_comment_reads_table.php new file mode 100644 index 00000000..d8e29bdb --- /dev/null +++ b/database/migrations/2026_03_19_130100_create_user_received_comment_reads_table.php @@ -0,0 +1,27 @@ +id(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->foreignId('artwork_comment_id')->constrained('artwork_comments')->cascadeOnDelete(); + $table->timestamp('read_at'); + $table->timestamps(); + + $table->unique(['user_id', 'artwork_comment_id'], 'ucr_user_comment_unique'); + $table->index(['user_id', 'read_at'], 'ucr_user_read_at_index'); + }); + } + + public function down(): void + { + Schema::dropIfExists('user_received_comment_reads'); + } +}; diff --git a/database/migrations/2026_03_19_130200_drop_last_received_comment_read_at_from_users_table.php b/database/migrations/2026_03_19_130200_drop_last_received_comment_read_at_from_users_table.php new file mode 100644 index 00000000..088d956d --- /dev/null +++ b/database/migrations/2026_03_19_130200_drop_last_received_comment_read_at_from_users_table.php @@ -0,0 +1,26 @@ +dropColumn('last_received_comment_read_at'); + } + }); + } + + public function down(): void + { + Schema::table('users', function (Blueprint $table): void { + if (! Schema::hasColumn('users', 'last_received_comment_read_at')) { + $table->timestamp('last_received_comment_read_at')->nullable()->after('allow_messages_from'); + } + }); + } +}; diff --git a/database/migrations/2026_03_19_180000_create_dashboard_preferences_table.php b/database/migrations/2026_03_19_180000_create_dashboard_preferences_table.php new file mode 100644 index 00000000..e5d15099 --- /dev/null +++ b/database/migrations/2026_03_19_180000_create_dashboard_preferences_table.php @@ -0,0 +1,25 @@ +unsignedBigInteger('user_id')->primary(); + $table->json('pinned_spaces')->nullable(); + $table->timestamps(); + + $table->foreign('user_id')->references('id')->on('users')->cascadeOnDelete(); + }); + } + + public function down(): void + { + Schema::dropIfExists('dashboard_preferences'); + } +}; diff --git a/docs/legacy-routes-inventory.md b/docs/legacy-routes-inventory.md index e91c331c..1f31e7aa 100644 --- a/docs/legacy-routes-inventory.md +++ b/docs/legacy-routes-inventory.md @@ -10,50 +10,43 @@ Companion execution guide: [docs/legacy-routes-removal-checklist.md](docs/legacy ## Avatar and Artwork | Method | Path | Route Name | Handler / Target | -|---|---|---|---| +| --- | --- | --- | --- | | GET | /avatar/{id}/{name?} | legacy.avatar | LegacyAvatarController@show | | GET, POST | /art/{id}/comment | - | ArtController@show | ## Categories and Browse | Method | Path | Route Name | Handler / Target | -|---|---|---|---| -| GET | /categories | legacy.categories | CategoryController@index | -| GET | /sections | sections | SectionsController@index | -| GET | /browse-categories | browse.categories | BrowseCategoriesController@index | -| GET | /category/{group}/{slug?}/{id?} | legacy.category | BrowseGalleryController@legacyCategory | -| GET | /browse | legacy.browse | BrowseGalleryController@browse | -| GET | /browse-redirect | legacy.browse.redirect | 301 -> /explore | -| GET | /wallpapers-redirect | legacy.wallpapers.redirect | 301 -> /explore/wallpapers | -| GET | /featured | legacy.featured | FeaturedArtworksController@index | -| GET | /featured-artworks | legacy.featured_artworks | FeaturedArtworksController@index | -| GET | /daily-uploads | legacy.daily_uploads | DailyUploadsController@index | +| --- | --- | --- | --- | +| GET | /sections | sections | 301 -> /categories | +| GET | /browse-categories | browse.categories | 301 -> /categories | +| GET | /{group}/{slug}/{id} | legacy.category.short | CategoryRedirectController | +| GET | /category/{group}/{slug?}/{id?} | legacy.category | CategoryRedirectController | +| GET | /browse | legacy.browse | 301 -> /explore | +| GET | /featured-artworks | legacy.featured_artworks | 301 -> /featured | +| GET | /daily-uploads | legacy.daily_uploads | 301 -> /uploads/daily | ## Community and Listings | Method | Path | Route Name | Handler / Target | -|---|---|---|---| -| GET | /chat | legacy.chat | 301 -> /community/chat | -| POST | /chat_post | legacy.chat.post | ChatController@post | -| GET | /uploads/latest | uploads.latest | LatestController@index | -| GET | /uploads/daily | uploads.daily | DailyUploadsController@index | -| GET | /members/photos | members.photos | MembersController@photos | -| GET | /authors/top | authors.top | TopAuthorsController@index | -| GET | /comments/latest | comments.latest | LatestCommentsController@index | -| GET | /comments/monthly | comments.monthly | MonthlyCommentatorsController@index | -| GET | /downloads/today | downloads.today | TodayDownloadsController@index | -| GET | /latest | legacy.latest | LatestController@index | -| GET | /latest-comments | legacy.latest_comments | LatestCommentsController@index | -| GET | /today-in-history | legacy.today_in_history | TodayInHistoryController@index | -| GET | /today-downloads | legacy.today_downloads | TodayDownloadsController@index | -| GET | /monthly-commentators | legacy.monthly_commentators | MonthlyCommentatorsController@index | -| GET | /members | legacy.members | MembersController@index | -| GET | /top-favourites | legacy.top_favourites | TopFavouritesController@index | +| --- | --- | --- | --- | +| GET, POST | /chat | - | 301 -> /messages | +| GET, POST | /community/chat | community.chat | 301 -> /messages | +| GET | /latest | legacy.latest | 301 -> /uploads/latest | +| GET | /authors/top | authors.top | 301 -> /creators/top | +| GET | /latest-artworks | legacy.latest_artworks | 301 -> /discover/fresh | +| GET | /latest-comments | legacy.latest_comments | 301 -> /community/activity | +| GET | /comments/latest | comments.latest | 301 -> /community/activity | +| GET | /today-in-history | legacy.today_in_history | 301 -> /discover/on-this-day | +| GET | /today-downloads | legacy.today_downloads | 301 -> /downloads/today | +| GET | /monthly-commentators | legacy.monthly_commentators | 301 -> /comments/monthly | +| GET | /members | legacy.members | 301 -> /creators/top | +| GET | /top-favourites | legacy.top_favourites | 301 -> /discover/top-rated | ## Legacy Redirect Endpoints | Method | Path | Route Name | Handler / Target | -|---|---|---|---| +| --- | --- | --- | --- | | GET | /top-authors | legacy.top_authors | 301 -> /creators/top | | GET | /interviews | legacy.interviews | 301 -> /stories | | GET | /apply | legacy.apply.redirect | 301 -> /contact | @@ -62,22 +55,24 @@ Companion execution guide: [docs/legacy-routes-removal-checklist.md](docs/legacy ## Auth-Only Legacy Routes | Method | Path | Route Name | Handler / Target | -|---|---|---|---| -| GET | /mybuddies.php | legacy.mybuddies.php | MyBuddiesController@index | -| GET | /mybuddies | legacy.mybuddies | MyBuddiesController@index | -| DELETE | /mybuddies/{id} | legacy.mybuddies.delete | MyBuddiesController@destroy | -| GET | /buddies.php | legacy.buddies.php | BuddiesController@index | -| GET | /buddies | legacy.buddies | BuddiesController@index | +| --- | --- | --- | --- | +| GET | /mybuddies.php | legacy.mybuddies.php | 301 -> dashboard.following | +| GET | /mybuddies | legacy.mybuddies | 301 -> dashboard.following | +| DELETE | /mybuddies/{id} | legacy.mybuddies.delete | 302 -> dashboard.following | +| GET | /buddies.php | legacy.buddies.php | 301 -> dashboard.followers | +| GET | /buddies | legacy.buddies | 301 -> dashboard.followers | | GET | /statistics | legacy.statistics | StatisticsController@index | | GET, POST | /user | legacy.user.redirect | 301 -> dashboard.profile | +| GET | /recieved-comments | legacy.received_comments | 301 -> dashboard.comments.received | +| GET | /received-comments | legacy.received_comments.corrected | 301 -> dashboard.comments.received | ## Favourites, Gallery, and Profile Legacy URLs | Method | Path | Route Name | Handler / Target | -|---|---|---|---| +| --- | --- | --- | --- | | GET | /favourites/{id?}/{username?} | legacy.favourites | FavouritesController@index | | POST | /favourites/{userId}/delete/{artworkId} | legacy.favourites.delete | FavouritesController@destroy | -| GET | /gallery/{id}/{username?} | legacy.gallery | GalleryController@show (ensure.onboarding.complete) | +| GET | /gallery/{id}/{username?} | legacy.gallery | 301 -> /@username/gallery via GalleryController | | GET | /user/{username} | legacy.user.profile | ProfileController@legacyByUsername | | GET | /profile/{id}/{username?} | legacy.profile.id | ProfileController@legacyById | | GET | /profile/{username} | legacy.profile | ProfileController@legacyByUsername | @@ -85,7 +80,7 @@ Companion execution guide: [docs/legacy-routes-removal-checklist.md](docs/legacy ## Legacy RSS Feed URLs | Method | Path | Route Name | Handler / Target | -|---|---|---|---| +| --- | --- | --- | --- | | GET | /rss/latest-uploads.xml | rss.uploads | RssFeedController@latestUploads | | GET | /rss/latest-skins.xml | rss.skins | RssFeedController@latestSkins | | GET | /rss/latest-wallpapers.xml | rss.wallpapers | RssFeedController@latestWallpapers | @@ -131,12 +126,10 @@ These endpoints are not simple redirects and may have old templates, old query s - /members/photos - /authors/top - /top-favourites -- /today-in-history - /chat - /chat_post - /favourites/{id?}/{username?} - /favourites/{userId}/delete/{artworkId} -- /gallery/{id}/{username?} ### Phase 4: Legacy Profile URL Surface (requires 301 mapping checks) diff --git a/package-lock.json b/package-lock.json index f94c606f..df5b0cbf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,7 @@ "@tiptap/suggestion": "^3.20.0", "emoji-mart": "^5.6.0", "framer-motion": "^12.34.0", + "highlight.js": "^11.11.1", "lowlight": "^3.3.0", "react": "^19.2.4", "react-dom": "^19.2.4", diff --git a/package.json b/package.json index c0c3582e..3f159207 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "@tiptap/suggestion": "^3.20.0", "emoji-mart": "^5.6.0", "framer-motion": "^12.34.0", + "highlight.js": "^11.11.1", "lowlight": "^3.3.0", "react": "^19.2.4", "react-dom": "^19.2.4", diff --git a/playwright-report/data/7950e4b7b5a0f6db4890ef75bfee3280ae544e8e.md b/playwright-report/data/7950e4b7b5a0f6db4890ef75bfee3280ae544e8e.md deleted file mode 100644 index 8335b713..00000000 --- a/playwright-report/data/7950e4b7b5a0f6db4890ef75bfee3280ae544e8e.md +++ /dev/null @@ -1,289 +0,0 @@ -# Page snapshot - -```yaml -- generic [active] [ref=e1]: - - generic [ref=e2]: - - generic [ref=e4]: - - generic [ref=e5]: - - img [ref=e7] - - generic [ref=e10]: Internal Server Error - - button "Copy as Markdown" [ref=e11] [cursor=pointer]: - - img [ref=e12] - - generic [ref=e15]: Copy as Markdown - - generic [ref=e18]: - - generic [ref=e19]: - - heading "Symfony\\Component\\Routing\\Exception\\RouteNotFoundException" [level=1] [ref=e20] - - generic [ref=e22]: vendor\laravel\framework\src\Illuminate\Routing\UrlGenerator.php:528 - - paragraph [ref=e23]: Route [artwork.show] not defined. - - generic [ref=e24]: - - generic [ref=e25]: - - generic [ref=e26]: - - generic [ref=e27]: LARAVEL - - generic [ref=e28]: 12.53.0 - - generic [ref=e29]: - - generic [ref=e30]: PHP - - generic [ref=e31]: 8.4.12 - - generic [ref=e32]: - - img [ref=e33] - - text: UNHANDLED - - generic [ref=e36]: CODE 0 - - generic [ref=e38]: - - generic [ref=e39]: - - img [ref=e40] - - text: "500" - - generic [ref=e43]: - - img [ref=e44] - - text: GET - - generic [ref=e47]: http://skinbase26.test/explore - - button [ref=e48] [cursor=pointer]: - - img [ref=e49] - - generic [ref=e53]: - - generic [ref=e54]: - - generic [ref=e55]: - - img [ref=e57] - - heading "Exception trace" [level=3] [ref=e60] - - generic [ref=e61]: - - generic [ref=e63] [cursor=pointer]: - - img [ref=e64] - - generic [ref=e68]: 2 vendor frames - - button [ref=e69]: - - img [ref=e70] - - generic [ref=e74]: - - generic [ref=e75] [cursor=pointer]: - - generic [ref=e78]: - - code [ref=e82]: - - generic [ref=e83]: route(string, string) - - generic [ref=e85]: resources\views\web\explore\index.blade.php:18 - - button [ref=e87]: - - img [ref=e88] - - code [ref=e96]: - - generic [ref=e97]: 13 ✦ Featured Today - - generic [ref=e98]: 14 - - generic [ref=e99]: 15 - - generic [ref=e100]: 16
    - - generic [ref=e101]: 17 @foreach($spotlight as $item) - - generic [ref=e102]: "18 slug ? route('artwork.show', $item->slug) : '#' }}\"" - - generic [ref=e103]: 19 class="group relative flex-none w-44 md:w-52 rounded-xl overflow-hidden - - generic [ref=e104]: 20 bg-neutral-800 border border-white/10 hover:border-amber-400/40 - - generic [ref=e105]: 21 hover:shadow-lg hover:shadow-amber-500/10 transition-all duration-200" - - generic [ref=e106]: "22 title=\"{{ $item->name ?? '' }}\">" - - generic [ref=e107]: "23" - - generic [ref=e108]: "24 {{-- Thumbnail --}}" - - generic [ref=e109]: 25
    - - generic [ref=e110]: 26 thumb_url ?? '' }}\"" - - generic [ref=e112]: "28 @if(!empty($item->thumb_srcset)) srcset=\"{{ $item->thumb_srcset }}\" @endif" - - generic [ref=e113]: "29 alt=\"{{ $item->name ?? 'Featured artwork' }}\"" - - generic [ref=e114]: "30" - - generic [ref=e116] [cursor=pointer]: - - img [ref=e117] - - generic [ref=e121]: 15 vendor frames - - button [ref=e122]: - - img [ref=e123] - - generic [ref=e128] [cursor=pointer]: - - generic [ref=e131]: - - code [ref=e135]: - - generic [ref=e136]: "Illuminate\\Pipeline\\Pipeline->{closure:{closure:Illuminate\\Pipeline\\Pipeline::carry():194}:195}(object(Illuminate\\Http\\Request))" - - generic [ref=e138]: app\Http\Middleware\EnsureOnboardingComplete.php:27 - - button [ref=e140]: - - img [ref=e141] - - generic [ref=e146] [cursor=pointer]: - - img [ref=e147] - - generic [ref=e151]: 45 vendor frames - - button [ref=e152]: - - img [ref=e153] - - generic [ref=e158] [cursor=pointer]: - - generic [ref=e161]: - - code [ref=e165]: - - generic [ref=e166]: Illuminate\Foundation\Application->handleRequest(object(Illuminate\Http\Request)) - - generic [ref=e168]: public\index.php:20 - - button [ref=e170]: - - img [ref=e171] - - generic [ref=e175]: - - generic [ref=e176]: - - generic [ref=e177]: - - img [ref=e179] - - heading "Queries" [level=3] [ref=e181] - - generic [ref=e183]: 1-10 of 90 - - generic [ref=e184]: - - generic [ref=e185]: - - generic [ref=e186]: - - generic [ref=e187]: - - img [ref=e188] - - generic [ref=e190]: mysql - - code [ref=e194]: - - generic [ref=e195]: "select exists (select 1 from information_schema.tables where table_schema = schema() and table_name = 'cpad' and table_type in ('BASE TABLE', 'SYSTEM VERSIONED')) as `exists`" - - generic [ref=e196]: 13.96ms - - generic [ref=e197]: - - generic [ref=e198]: - - generic [ref=e199]: - - img [ref=e200] - - generic [ref=e202]: mysql - - code [ref=e206]: - - generic [ref=e207]: "select exists (select 1 from information_schema.tables where table_schema = schema() and table_name = 'cpad' and table_type in ('BASE TABLE', 'SYSTEM VERSIONED')) as `exists`" - - generic [ref=e208]: 1.1ms - - generic [ref=e209]: - - generic [ref=e210]: - - generic [ref=e211]: - - img [ref=e212] - - generic [ref=e214]: mysql - - code [ref=e218]: - - generic [ref=e219]: "select * from `cache` where `key` in ('skinbasenova-cache-service:ConfigService:config:plugins')" - - generic [ref=e220]: 0.53ms - - generic [ref=e221]: - - generic [ref=e222]: - - generic [ref=e223]: - - img [ref=e224] - - generic [ref=e226]: mysql - - code [ref=e230]: - - generic [ref=e231]: "select * from `cpad` where `keycode` = 'plugins' limit 1" - - generic [ref=e232]: 0.69ms - - generic [ref=e233]: - - generic [ref=e234]: - - generic [ref=e235]: - - img [ref=e236] - - generic [ref=e238]: mysql - - code [ref=e242]: - - generic [ref=e243]: "select * from `cache` where `key` in ('skinbasenova-cache-cpad_config_tabs_registry')" - - generic [ref=e244]: 0.51ms - - generic [ref=e245]: - - generic [ref=e246]: - - generic [ref=e247]: - - img [ref=e248] - - generic [ref=e250]: mysql - - code [ref=e254]: - - generic [ref=e255]: "insert into `cache` (`expiration`, `key`, `value`) values (1773088441, 'skinbasenova-cache-cpad_config_tabs_registry', 'a:1:{s:14:\"config.plugins\";O:50:\"Klevze\\ControlPanel\\Configuration\\PluginsConfigTab\":2:{s:14:\"*serviceName\";s:16:\"PluginsConfigTab\";s:11:\"*cacheTtl\";i:3600;}}') on duplicate key update `expiration` = values(`expiration`), `key` = values(`key`), `value` = values(`value`)" - - generic [ref=e256]: 3.31ms - - generic [ref=e257]: - - generic [ref=e258]: - - generic [ref=e259]: - - img [ref=e260] - - generic [ref=e262]: mysql - - code [ref=e266]: - - generic [ref=e267]: "delete from `cache` where `key` in ('skinbasenova-cache-cpad_config_tabs_registry', 'skinbasenova-cache-illuminate:cache:flexible:created:cpad_config_tabs_registry')" - - generic [ref=e268]: 2.8ms - - generic [ref=e269]: - - generic [ref=e270]: - - generic [ref=e271]: - - img [ref=e272] - - generic [ref=e274]: mysql - - code [ref=e278]: - - generic [ref=e279]: "select * from `sessions` where `id` = '9JQSo5DrgARJAXNMelWZeiOWRA88DskBb5LukhVI' limit 1" - - generic [ref=e280]: 1.28ms - - generic [ref=e281]: - - generic [ref=e282]: - - generic [ref=e283]: - - img [ref=e284] - - generic [ref=e286]: mysql - - code [ref=e290]: - - generic [ref=e291]: "select * from `cache` where `key` in ('skinbasenova-cache-explore.all.trending.1')" - - generic [ref=e292]: 0.58ms - - generic [ref=e293]: - - generic [ref=e294]: - - generic [ref=e295]: - - img [ref=e296] - - generic [ref=e298]: mysql - - code [ref=e302]: - - generic [ref=e303]: "select * from `artworks` where `artworks`.`id` in (69610, 69611, 69606, 69597, 69599, 69601, 69417, 9517, 9518, 9523, 9524, 9494, 9496, 9497, 9500, 9501, 9502, 9504, 9505, 9506, 9507, 9508, 9509, 9511)" - - generic [ref=e304]: 3.13ms - - generic [ref=e305]: - - button [disabled] [ref=e306]: - - img [ref=e307] - - button [disabled] [ref=e310]: - - img [ref=e311] - - button "1" [ref=e314] [cursor=pointer] - - button "2" [ref=e316] [cursor=pointer] - - button "3" [ref=e318] [cursor=pointer] - - button "4" [ref=e320] [cursor=pointer] - - button "5" [ref=e322] [cursor=pointer] - - generic [ref=e324]: ... - - button "9" [ref=e326] [cursor=pointer] - - button [ref=e327] [cursor=pointer]: - - img [ref=e328] - - button [ref=e330] [cursor=pointer]: - - img [ref=e331] - - generic [ref=e335]: - - generic [ref=e336]: - - heading "Headers" [level=2] [ref=e337] - - generic [ref=e338]: - - generic [ref=e339]: - - generic [ref=e340]: cookie - - generic [ref=e342]: XSRF-TOKEN=eyJpdiI6IjB5YWlxRFhOOXMzMFZKNVo2anlvV0E9PSIsInZhbHVlIjoibnlXOStINjhmdmhTRUF2VTlFdHpXL3V4cDNwaFdpNnRYU0NhTUVNa0tublNvUVM0UUtlQ010UGFMOG1FRFpSVDNBZGhOUmR5c1VlQnJTbjJ2cGRJZzZYWEI2M2ZkMTh3M0hKNkhLKzJGR1VCUEJoUFpJUEd5YkhTMTJjdXcvQS8iLCJtYWMiOiIzN2M0NTViYTEyMWIxNTA3MTM3YmU4MjgyZjY3NTQxN2QyMTljNzY3Mzg3ZTk4OGVmMjA4MWQ5Zjg2ZGMyNDUxIiwidGFnIjoiIn0%3D; skinbasenova-session=eyJpdiI6IjZ6OHJOSTF1YlFhUG5DaEZmK0R5UGc9PSIsInZhbHVlIjoiSXBwOEFWT25RRlBpaXVKdzZNWWRySE96NUJwOHF6SUc1RVdsR2pEblhYQ1c4N0lTNHFSY1ZtRDY2MmxzVjFXT2RwSkVWSG9SUWNweDNLdkxHM1NmcXhJNllUNEpxeGZVN3JxQmZJM1plb3BZQ3BTTVd4Z05YV0VYb0g0UnBIKzMiLCJtYWMiOiJkNDQ3MDlhNmQ1OTdkNjI1MDliZTBlZTkzNTdkZmQ0ZDQwNTU1ZjcwNmRiZjIxMThjNmVjMjNhMGE1YTI2Nzk1IiwidGFnIjoiIn0%3D; PHPDEBUGBAR_STACK_DATA=%7B%2201KKA1F4SZKZ192GVC1Q09NG2K%22%3Anull%7D - - generic [ref=e343]: - - generic [ref=e344]: accept-encoding - - generic [ref=e346]: gzip, deflate - - generic [ref=e347]: - - generic [ref=e348]: accept - - generic [ref=e350]: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7 - - generic [ref=e351]: - - generic [ref=e352]: accept-language - - generic [ref=e354]: en-US - - generic [ref=e355]: - - generic [ref=e356]: user-agent - - generic [ref=e358]: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.7632.6 Safari/537.36 - - generic [ref=e359]: - - generic [ref=e360]: upgrade-insecure-requests - - generic [ref=e362]: "1" - - generic [ref=e363]: - - generic [ref=e364]: connection - - generic [ref=e366]: close - - generic [ref=e367]: - - generic [ref=e368]: host - - generic [ref=e370]: skinbase26.test - - generic [ref=e371]: - - heading "Body" [level=2] [ref=e372] - - generic [ref=e373]: // No request body - - generic [ref=e374]: - - heading "Routing" [level=2] [ref=e375] - - generic [ref=e376]: - - generic [ref=e377]: - - generic [ref=e378]: controller - - generic [ref=e380]: App\Http\Controllers\Web\ExploreController@index - - generic [ref=e381]: - - generic [ref=e382]: route name - - generic [ref=e384]: explore.index - - generic [ref=e385]: - - generic [ref=e386]: middleware - - generic [ref=e388]: web - - generic [ref=e389]: - - heading "Routing parameters" [level=2] [ref=e390] - - generic [ref=e391]: // No routing parameters - - generic [ref=e394]: - - img [ref=e396] - - img [ref=e3434] - - generic [ref=e6474]: - - generic [ref=e6476]: - - generic [ref=e6477] [cursor=pointer]: - - generic: Request - - generic [ref=e6478]: "500" - - generic [ref=e6479] [cursor=pointer]: - - generic: Exceptions - - generic [ref=e6480]: "2" - - generic [ref=e6481] [cursor=pointer]: - - generic: Messages - - generic [ref=e6482]: "5" - - generic [ref=e6483] [cursor=pointer]: - - generic: Timeline - - generic [ref=e6484] [cursor=pointer]: - - generic: Views - - generic [ref=e6485]: "513" - - generic [ref=e6486] [cursor=pointer]: - - generic: Queries - - generic [ref=e6487]: "91" - - generic [ref=e6488] [cursor=pointer]: - - generic: Models - - generic [ref=e6489]: "156" - - generic [ref=e6490] [cursor=pointer]: - - generic: Cache - - generic [ref=e6491]: "8" - - generic [ref=e6492]: - - generic [ref=e6499] [cursor=pointer]: - - generic [ref=e6500]: "2" - - generic [ref=e6501]: GET /explore - - generic [ref=e6502] [cursor=pointer]: - - generic: 4.91s - - generic [ref=e6504] [cursor=pointer]: - - generic: 51MB - - generic [ref=e6506] [cursor=pointer]: - - generic: 12.x -``` \ No newline at end of file diff --git a/playwright-report/data/9d5a1466f1e38aa2c03107e0514741f25d3d7870.png b/playwright-report/data/9d5a1466f1e38aa2c03107e0514741f25d3d7870.png deleted file mode 100644 index ca70637f..00000000 Binary files a/playwright-report/data/9d5a1466f1e38aa2c03107e0514741f25d3d7870.png and /dev/null differ diff --git a/playwright-report/data/f305649af771e1e81ba0e2383e00326f52793e8b.webm b/playwright-report/data/f305649af771e1e81ba0e2383e00326f52793e8b.webm deleted file mode 100644 index 4294ca8f..00000000 Binary files a/playwright-report/data/f305649af771e1e81ba0e2383e00326f52793e8b.webm and /dev/null differ diff --git a/playwright-report/index.html b/playwright-report/index.html index 55c6b1cd..1650ea00 100644 --- a/playwright-report/index.html +++ b/playwright-report/index.html @@ -82,4 +82,4 @@ Error generating stack: `+a.message+`
    - \ No newline at end of file + \ No newline at end of file diff --git a/resources/css/app.css b/resources/css/app.css index 5d0ed804..fe71afc4 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -107,6 +107,7 @@ } .tiptap pre { + position: relative; background: theme('colors.white / 4%'); border: 1px solid theme('colors.white / 6%'); border-radius: 0.75rem; @@ -115,6 +116,7 @@ } .tiptap pre code { + display: block; background: none; border: none; padding: 0; @@ -122,6 +124,65 @@ color: theme('colors.zinc.300'); } +.tiptap .hljs { + color: theme('colors.zinc.200'); + background: transparent; +} + +.tiptap .hljs-comment, +.tiptap .hljs-quote { + color: theme('colors.zinc.500'); + font-style: italic; +} + +.tiptap .hljs-keyword, +.tiptap .hljs-selector-tag, +.tiptap .hljs-subst { + color: theme('colors.fuchsia.300'); + font-weight: 600; +} + +.tiptap .hljs-string, +.tiptap .hljs-attr, +.tiptap .hljs-template-tag, +.tiptap .hljs-template-variable { + color: theme('colors.sky.300'); +} + +.tiptap .hljs-title, +.tiptap .hljs-section, +.tiptap .hljs-name, +.tiptap .hljs-selector-id, +.tiptap .hljs-selector-class { + color: theme('colors.blue.300'); + font-weight: 600; +} + +.tiptap .hljs-number, +.tiptap .hljs-literal, +.tiptap .hljs-symbol, +.tiptap .hljs-bullet { + color: theme('colors.amber.300'); +} + +.tiptap .hljs-built_in, +.tiptap .hljs-type, +.tiptap .hljs-class { + color: theme('colors.rose.300'); +} + +.tiptap .hljs-variable, +.tiptap .hljs-property, +.tiptap .hljs-params, +.tiptap .hljs-operator { + color: theme('colors.zinc.200'); +} + +.tiptap .hljs-meta, +.tiptap .hljs-doctag { + color: theme('colors.zinc.400'); +} + .tiptap blockquote { border-left: 3px solid theme('colors.sky.500 / 40%'); padding-left: 1rem; @@ -160,6 +221,204 @@ color: theme('colors.sky.200'); } +.story-prose pre { + position: relative; + overflow: hidden; + border-color: rgba(51, 65, 85, 0.95) !important; + background: + linear-gradient(180deg, rgba(15, 23, 42, 0.98) 0, rgba(15, 23, 42, 0.98) 3rem, rgba(2, 6, 23, 0.98) 3rem, rgba(2, 6, 23, 0.98) 100%) !important; + box-shadow: + 0 26px 75px rgba(2, 6, 23, 0.5), + inset 0 1px 0 rgba(56, 189, 248, 0.08); + padding: 4rem 1.5rem 1.5rem !important; +} + +.story-prose pre[data-language]::before { + content: attr(data-language); + position: absolute; + z-index: 2; + top: 0.7rem; + left: 1.25rem; + border: 1px solid rgba(56, 189, 248, 0.22); + border-radius: 999px; + background: rgba(15, 23, 42, 0.92); + padding: 0.18rem 0.55rem; + font-size: 0.7rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: rgb(125 211 252); +} + +.story-prose pre::after { + content: ''; + position: absolute; + inset: 3rem 0 auto 0; + height: 1px; + background: linear-gradient(90deg, rgba(56, 189, 248, 0), rgba(56, 189, 248, 0.28), rgba(56, 189, 248, 0)); +} + +.story-code-copy-button { + position: absolute; + z-index: 2; + top: 0.7rem; + right: 1.25rem; + border: 1px solid rgba(148, 163, 184, 0.32); + border-radius: 999px; + background: rgba(15, 23, 42, 0.9); + padding: 0.35rem 0.78rem; + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0.04em; + color: rgb(241 245 249); + display: inline-flex; + align-items: center; + gap: 0.4rem; + transition: background-color 120ms ease, border-color 120ms ease, color 120ms ease, transform 120ms ease, box-shadow 120ms ease; +} + +.story-code-copy-button:hover { + background: rgb(14 165 233); + border-color: rgb(56 189 248); + color: rgb(2 6 23); + box-shadow: 0 10px 24px rgba(14, 165, 233, 0.22); +} + +.story-code-copy-button:active { + transform: translateY(1px); +} + +.story-code-copy-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1rem; + height: 1rem; + font-size: 0.9rem; + line-height: 1; + transform-origin: center; +} + +.story-code-copy-label { + line-height: 1; +} + +.story-code-copy-button[data-copied='true'] { + background: rgb(14 116 144); + border-color: rgb(34 211 238); + color: white; + box-shadow: 0 12px 28px rgba(14, 116, 144, 0.22); +} + +.story-code-copy-button[data-copied='false'] { + background: rgb(153 27 27); + border-color: rgb(248 113 113); + color: white; +} + +.story-code-copy-button[data-copied='true'] .story-code-copy-icon { + animation: story-code-copy-check 320ms cubic-bezier(.2,.9,.25,1.2); +} + +.story-code-copy-button[data-copied='true'] .story-code-copy-label { + animation: story-code-copy-label 220ms ease-out; +} + +@keyframes story-code-copy-check { + 0% { + opacity: 0; + transform: scale(0.4) rotate(-24deg); + } + 55% { + opacity: 1; + transform: scale(1.2) rotate(6deg); + } + 100% { + opacity: 1; + transform: scale(1) rotate(0deg); + } +} + +@keyframes story-code-copy-label { + 0% { + opacity: 0.15; + transform: translateX(-3px); + } + 100% { + opacity: 1; + transform: translateX(0); + } +} + +.story-prose pre code.hljs, +.story-prose pre code[class*='language-'] { + display: block; + overflow-x: auto; + background: transparent; + padding: 0; +} + +.story-prose .hljs { + color: rgb(226 232 240); + background: transparent; + font-size: 0.94rem; + line-height: 1.9; +} + +.story-prose .hljs-comment, +.story-prose .hljs-quote { + color: rgb(100 116 139); + font-style: italic; +} + +.story-prose .hljs-keyword, +.story-prose .hljs-selector-tag, +.story-prose .hljs-subst { + color: rgb(125 211 252); + font-weight: 600; +} + +.story-prose .hljs-string, +.story-prose .hljs-attr, +.story-prose .hljs-template-tag, +.story-prose .hljs-template-variable { + color: rgb(134 239 172); +} + +.story-prose .hljs-title, +.story-prose .hljs-section, +.story-prose .hljs-name, +.story-prose .hljs-selector-id, +.story-prose .hljs-selector-class { + color: rgb(196 181 253); + font-weight: 600; +} + +.story-prose .hljs-number, +.story-prose .hljs-literal, +.story-prose .hljs-symbol, +.story-prose .hljs-bullet { + color: rgb(251 191 36); +} + +.story-prose .hljs-built_in, +.story-prose .hljs-type, +.story-prose .hljs-class { + color: rgb(244 114 182); +} + +.story-prose .hljs-variable, +.story-prose .hljs-property, +.story-prose .hljs-params, +.story-prose .hljs-operator { + color: rgb(15 23 42); +} + +.story-prose .hljs-meta, +.story-prose .hljs-doctag { + color: rgb(71 85 105); +} + /* ─── @mention pills ─── */ .tiptap .mention, .mention { diff --git a/resources/js/Pages/Community/CommunityActivityPage.jsx b/resources/js/Pages/Community/CommunityActivityPage.jsx index 0cef05d1..68ad18bd 100644 --- a/resources/js/Pages/Community/CommunityActivityPage.jsx +++ b/resources/js/Pages/Community/CommunityActivityPage.jsx @@ -108,7 +108,7 @@ function CommunityActivityPage({ const params = new URLSearchParams({ filter, page: String(page) }) if (initialUserId) params.set('user_id', String(initialUserId)) - const response = await fetch(`/api/community/activity?${params.toString()}`, { + const response = await fetch(`/api/activity?${params.toString()}`, { headers: { Accept: 'application/json', 'X-Requested-With': 'XMLHttpRequest' }, credentials: 'same-origin', }) diff --git a/resources/js/Pages/Leaderboard/LeaderboardPage.jsx b/resources/js/Pages/Leaderboard/LeaderboardPage.jsx new file mode 100644 index 00000000..4651c3f0 --- /dev/null +++ b/resources/js/Pages/Leaderboard/LeaderboardPage.jsx @@ -0,0 +1,108 @@ +import React, { useEffect, useState } from 'react' +import { Head, usePage } from '@inertiajs/react' +import LeaderboardTabs from '../../components/leaderboard/LeaderboardTabs' +import LeaderboardList from '../../components/leaderboard/LeaderboardList' + +const TYPE_TABS = [ + { value: 'creator', label: 'Creators' }, + { value: 'artwork', label: 'Artworks' }, + { value: 'story', label: 'Stories' }, +] + +const PERIOD_TABS = [ + { value: 'daily', label: 'Daily' }, + { value: 'weekly', label: 'Weekly' }, + { value: 'monthly', label: 'Monthly' }, + { value: 'all_time', label: 'All-time' }, +] + +const API_BY_TYPE = { + creator: '/api/leaderboard/creators', + artwork: '/api/leaderboard/artworks', + story: '/api/leaderboard/stories', +} + +export default function LeaderboardPage() { + const { props } = usePage() + const { initialType = 'creator', initialPeriod = 'weekly', initialData = { items: [] }, meta = {} } = props + + const [type, setType] = useState(initialType) + const [period, setPeriod] = useState(initialPeriod) + const [data, setData] = useState(initialData) + const [loading, setLoading] = useState(false) + + useEffect(() => { + if (type === initialType && period === initialPeriod) { + return + } + + let cancelled = false + + async function load() { + setLoading(true) + try { + const response = await window.axios.get(`${API_BY_TYPE[type]}?period=${period}`) + if (!cancelled && response.data) { + setData(response.data) + } + } finally { + if (!cancelled) { + setLoading(false) + } + } + } + + load() + + try { + const url = new URL(window.location.href) + url.searchParams.set('type', type === 'creator' ? 'creators' : `${type}s`) + url.searchParams.set('period', period === 'all_time' ? 'all' : period) + window.history.replaceState({}, '', url.toString()) + } catch (_) {} + + return () => { + cancelled = true + } + }, [type, period, initialType, initialPeriod]) + + const items = Array.isArray(data?.items) ? data.items : [] + + return ( + <> + + {meta?.title || 'Leaderboard | Skinbase'} + + + +
    +
    +
    +

    Skinbase Competition Board

    +

    + Top creators, standout artworks, and stories with momentum. +

    +

    + Switch between creators, artworks, and stories, then filter by daily, weekly, monthly, or all-time performance. +

    +
    + +
    + + +
    + + {loading ? ( +
    + Refreshing leaderboard... +
    + ) : null} + +
    + +
    +
    +
    + + ) +} diff --git a/resources/js/Pages/Profile/ProfileGallery.jsx b/resources/js/Pages/Profile/ProfileGallery.jsx new file mode 100644 index 00000000..077a89ef --- /dev/null +++ b/resources/js/Pages/Profile/ProfileGallery.jsx @@ -0,0 +1,78 @@ +import React from 'react' +import { usePage } from '@inertiajs/react' +import ProfileHero from '../../components/profile/ProfileHero' +import ProfileGalleryPanel from '../../components/profile/ProfileGalleryPanel' + +export default function ProfileGallery() { + const { props } = usePage() + const { + user, + profile, + artworks, + featuredArtworks, + followerCount, + viewerIsFollowing, + heroBgUrl, + leaderboardRank, + countryName, + isOwner, + profileUrl, + } = props + + const username = user.username || user.name + const displayName = user.name || user.username || 'Creator' + + return ( +
    + + + View Profile + + ) : null} + /> + +
    +
    +
    +

    Public Gallery

    +

    + {displayName}'s artworks +

    +

    + Browse published work with the same infinite-scroll gallery used across the profile experience. +

    +
    + + + + Back to profile + +
    +
    + +
    + +
    +
    + ) +} diff --git a/resources/js/Pages/Profile/ProfileShow.jsx b/resources/js/Pages/Profile/ProfileShow.jsx index dbcf1768..f19bf529 100644 --- a/resources/js/Pages/Profile/ProfileShow.jsx +++ b/resources/js/Pages/Profile/ProfileShow.jsx @@ -1,18 +1,19 @@ import React, { useState, useEffect, useCallback } from 'react' import { usePage } from '@inertiajs/react' -import ProfileHero from '../../Components/Profile/ProfileHero' -import ProfileStatsRow from '../../Components/Profile/ProfileStatsRow' -import ProfileTabs from '../../Components/Profile/ProfileTabs' -import TabArtworks from '../../Components/Profile/tabs/TabArtworks' -import TabAbout from '../../Components/Profile/tabs/TabAbout' -import TabStats from '../../Components/Profile/tabs/TabStats' -import TabFavourites from '../../Components/Profile/tabs/TabFavourites' -import TabCollections from '../../Components/Profile/tabs/TabCollections' -import TabActivity from '../../Components/Profile/tabs/TabActivity' -import TabPosts from '../../Components/Profile/tabs/TabPosts' -import TabStories from '../../Components/Profile/tabs/TabStories' +import ProfileHero from '../../components/profile/ProfileHero' +import ProfileStatsRow from '../../components/profile/ProfileStatsRow' +import ProfileTabs from '../../components/profile/ProfileTabs' +import TabArtworks from '../../components/profile/tabs/TabArtworks' +import TabAchievements from '../../components/profile/tabs/TabAchievements' +import TabAbout from '../../components/profile/tabs/TabAbout' +import TabStats from '../../components/profile/tabs/TabStats' +import TabFavourites from '../../components/profile/tabs/TabFavourites' +import TabCollections from '../../components/profile/tabs/TabCollections' +import TabActivity from '../../components/profile/tabs/TabActivity' +import TabPosts from '../../components/profile/tabs/TabPosts' +import TabStories from '../../components/profile/tabs/TabStories' -const VALID_TABS = ['artworks', 'stories', 'posts', 'collections', 'about', 'stats', 'favourites', 'activity'] +const VALID_TABS = ['artworks', 'stories', 'achievements', 'posts', 'collections', 'about', 'stats', 'favourites', 'activity'] function getInitialTab() { try { @@ -46,9 +47,13 @@ export default function ProfileShow() { heroBgUrl, profileComments, creatorStories, + achievements, + leaderboardRank, countryName, isOwner, auth, + profileUrl, + galleryUrl, } = props const [activeTab, setActiveTab] = useState(getInitialTab) @@ -83,6 +88,10 @@ export default function ProfileShow() { ? artworks : (artworks?.data ?? []) const artworkNextCursor = artworks?.next_cursor ?? null + const favouriteList = Array.isArray(favourites) + ? favourites + : (favourites?.data ?? []) + const favouriteNextCursor = favourites?.next_cursor ?? null // Normalise social links (may be object keyed by platform, or array) const socialLinksObj = Array.isArray(socialLinks) @@ -100,6 +109,16 @@ export default function ProfileShow() { followerCount={followerCount} heroBgUrl={heroBgUrl} countryName={countryName} + leaderboardRank={leaderboardRank} + extraActions={galleryUrl ? ( + + + View Gallery + + ) : null} /> {/* Stats pills row */} @@ -146,6 +165,9 @@ export default function ProfileShow() { username={user.username || user.name} /> )} + {activeTab === 'achievements' && ( + + )} {activeTab === 'collections' && ( )} @@ -166,7 +188,7 @@ export default function ProfileShow() { )} {activeTab === 'favourites' && ( diff --git a/resources/js/Pages/Settings/ProfileEdit.jsx b/resources/js/Pages/Settings/ProfileEdit.jsx index fbbe3264..fe677e71 100644 --- a/resources/js/Pages/Settings/ProfileEdit.jsx +++ b/resources/js/Pages/Settings/ProfileEdit.jsx @@ -6,6 +6,7 @@ import Textarea from '../../components/ui/Textarea' import Button from '../../components/ui/Button' import Toggle from '../../components/ui/Toggle' import Select from '../../components/ui/Select' +import NovaSelect from '../../components/ui/NovaSelect' import Modal from '../../components/ui/Modal' import { RadioGroup } from '../../components/ui/Radio' import { buildBotFingerprint } from '../../lib/security/botFingerprint' @@ -183,7 +184,7 @@ export default function ProfileEdit() { month: fromUser.month || fromProps.month || '', year: fromUser.year || fromProps.year || '', gender: String(user?.gender || '').toLowerCase() || '', - country: user?.country_code || '', + country_id: user?.country_id ? String(user.country_id) : '', } }) const [notificationForm, setNotificationForm] = useState({ @@ -349,8 +350,12 @@ export default function ProfileEdit() { } const countryOptions = (countries || []).map((c) => ({ - value: c.country_code || c.code || c.id || '', - label: c.country_name || c.name || '', + value: String(c.id || ''), + label: c.name || '', + iso2: c.iso2 || '', + flagEmoji: c.flag_emoji || '', + flagPath: c.flag_path || '', + group: c.is_featured ? 'Featured' : 'All countries', })) const yearOptions = useMemo(() => { @@ -685,7 +690,7 @@ export default function ProfileEdit() { body: JSON.stringify(applyCaptchaPayload({ birthday: toIsoDate(personalForm.day, personalForm.month, personalForm.year) || null, gender: personalForm.gender || null, - country: personalForm.country || null, + country_id: personalForm.country_id || null, homepage_url: '', })), }) @@ -1158,28 +1163,41 @@ export default function ProfileEdit() { /> {countryOptions.length > 0 ? ( - setTitle(event.target.value)} - placeholder="Title" - className="w-full rounded-xl border border-gray-700 bg-gray-900 px-4 py-3 text-2xl font-semibold text-gray-100" - /> +
    +
    +
    +
    + {mode === 'create' ? 'New story' : 'Editing draft'} + {wordCount.toLocaleString()} words + {readMinutes} min read + {saveStatus} +
    +

    Write in the main column, keep the sidebar for story settings, and only surface captcha when protection actually asks for it.

    +
    -
    - setExcerpt(event.target.value)} placeholder="Excerpt" className="rounded-xl border border-gray-700 bg-gray-900 px-3 py-2 text-sm text-gray-200" /> - - setTagsCsv(event.target.value)} placeholder="Tags (comma separated)" className="rounded-xl border border-gray-700 bg-gray-900 px-3 py-2 text-sm text-gray-200" /> - - setCoverImage(event.target.value)} placeholder="Cover image URL" className="rounded-xl border border-gray-700 bg-gray-900 px-3 py-2 text-sm text-gray-200" /> - setScheduledFor(event.target.value)} className="rounded-xl border border-gray-700 bg-gray-900 px-3 py-2 text-sm text-gray-200" /> - setMetaTitle(event.target.value)} placeholder="Meta title" className="rounded-xl border border-gray-700 bg-gray-900 px-3 py-2 text-sm text-gray-200" /> - setMetaDescription(event.target.value)} placeholder="Meta description" className="rounded-xl border border-gray-700 bg-gray-900 px-3 py-2 text-sm text-gray-200" /> - setCanonicalUrl(event.target.value)} placeholder="Canonical URL" className="rounded-xl border border-gray-700 bg-gray-900 px-3 py-2 text-sm text-gray-200" /> - setOgImage(event.target.value)} placeholder="OG image URL" className="rounded-xl border border-gray-700 bg-gray-900 px-3 py-2 text-sm text-gray-200" /> +
    + + + + +
    -
    -
    - - - - - - - {saveStatus} +
    +
    +
    + {coverImage ? ( +
    + Story cover +
    +
    Cover preview
    +
    + ) : null} + +
    +
    + {storyTypes.find((type) => type.slug === storyType)?.name || 'Story'} + {status.replace(/_/g, ' ')} + {scheduledFor ? Scheduled {scheduledFor} : null} +
    + +
    + setTitle(event.target.value)} + placeholder="Give the story a title worth opening" + className="w-full border-0 bg-transparent px-0 text-4xl font-semibold tracking-tight text-white placeholder:text-white/25 focus:outline-none md:text-5xl" + /> + {titleError ?

    {titleError}

    : null} +
    + +
    + - - - @endif - @endauth -
    +
    diff --git a/resources/views/web/uploads/latest.blade.php b/resources/views/web/uploads/latest.blade.php index acd88505..cd29b302 100644 --- a/resources/views/web/uploads/latest.blade.php +++ b/resources/views/web/uploads/latest.blade.php @@ -1,55 +1,185 @@ @extends('layouts.nova') +@php + $latestBreadcrumbs = collect([ + (object) ['name' => 'Uploads', 'url' => route('uploads.latest')], + (object) ['name' => $page_title ?? 'Latest Artworks', 'url' => route('uploads.latest')], + ]); + $cursorStateLabel = request()->filled('cursor') ? 'Browsing archive' : 'Latest slice'; + $cursorStateCopy = request()->filled('cursor') + ? 'You are viewing an older slice of the cursor-based feed.' + : 'You are viewing the newest public uploads first.'; + $galleryArtworks = collect($artworks->items())->map(fn ($art) => [ + 'id' => $art->id, + 'name' => $art->name ?? null, + 'thumb' => $art->thumb_url ?? $art->thumb ?? null, + 'thumb_url' => $art->thumb_url ?? $art->thumb ?? null, + 'thumb_srcset' => $art->thumb_srcset ?? null, + 'uname' => $art->uname ?? '', + 'username' => $art->username ?? '', + 'avatar_url' => $art->avatar_url ?? null, + 'content_type_name' => $art->content_type_name ?? '', + 'content_type_slug' => $art->content_type_slug ?? '', + 'category_name' => $art->category_name ?? '', + 'category_slug' => $art->category_slug ?? '', + 'slug' => $art->slug ?? '', + 'width' => $art->width ?? null, + 'height' => $art->height ?? null, + 'published_at' => optional($art->published_at)?->toIsoString() ?? null, + 'url' => isset($art->id) ? '/art/' . $art->id . '/' . ($art->slug ?: \Illuminate\Support\Str::slug($art->name ?? 'artwork')) : '#', + ])->values(); + $galleryNextPageUrl = method_exists($artworks, 'nextPageUrl') ? $artworks->nextPageUrl() : null; +@endphp + @section('content') -{{-- ── Hero header ── --}} -
    -
    -
    -

    Skinbase

    -

    Latest Artworks

    -

    Recently uploaded Skins, Photography and Wallpapers.

    -
    -
    - - - - + + + + + + +
    +
    +
    +
    + + + Live feed + + + Ordered by newest first + +
    + +
    +
    +

    This page

    +

    {{ number_format($artworks->count()) }}

    +

    Artworks loaded in the current slice.

    +
    +
    +

    Position

    +

    {{ $cursorStateLabel }}

    +

    {{ $cursorStateCopy }}

    +
    +
    +

    Per page

    +

    {{ number_format($artworks->perPage()) }}

    +

    Balanced for a fast modern gallery load.

    +
    +
    +
    + +
    -
    + -{{-- ── Artwork grid ── --}} -
    - @if ($artworks && $artworks->isNotEmpty()) -
    - @foreach ($artworks as $art) - @php - $card = (object)[ - 'id' => $art->id, - 'name' => $art->name, - 'thumb' => $art->thumb_url ?? $art->thumb ?? null, - 'thumb_srcset' => $art->thumb_srcset ?? null, - 'uname' => $art->uname ?? '', - 'category_name' => $art->category_name ?? '', - ]; - @endphp - - @endforeach -
    - - {{-- Pagination --}} -
    - {{ $artworks->withQueryString()->links() }} -
    - @else -
    -

    No artworks found.

    -
    - @endif -
    +
    +
    +
    @endsection + +@push('styles') + +@endpush + +@push('scripts') +@vite('resources/js/entry-masonry-gallery.jsx') +@endpush diff --git a/routes/api.php b/routes/api.php index 7c29c622..4b80cce6 100644 --- a/routes/api.php +++ b/routes/api.php @@ -12,6 +12,21 @@ Route::middleware(['web', 'auth'])->prefix('dashboard')->name('api.dashboard.')- Route::get('analytics', [DashboardController::class, 'analytics'])->name('analytics'); Route::get('trending-artworks', [DashboardController::class, 'trendingArtworks'])->name('trending-artworks'); Route::get('recommended-creators', [DashboardController::class, 'recommendedCreators'])->name('recommended-creators'); + Route::put('preferences/shortcuts', [\App\Http\Controllers\Dashboard\DashboardPreferenceController::class, 'updateShortcuts'])->name('preferences.shortcuts'); +}); + +Route::middleware(['web', 'auth']) + ->get('user/xp', \App\Http\Controllers\Api\UserXpController::class) + ->name('api.user.xp'); + +Route::middleware(['web', 'auth']) + ->get('user/achievements', \App\Http\Controllers\Api\UserAchievementsController::class) + ->name('api.user.achievements'); + +Route::middleware(['web', 'throttle:60,1'])->prefix('leaderboard')->name('api.leaderboard.')->group(function () { + Route::get('creators', [\App\Http\Controllers\Api\LeaderboardController::class, 'creators'])->name('creators'); + Route::get('artworks', [\App\Http\Controllers\Api\LeaderboardController::class, 'artworks'])->name('artworks'); + Route::get('stories', [\App\Http\Controllers\Api\LeaderboardController::class, 'stories'])->name('stories'); }); Route::middleware(['web', 'auth', 'creator.access'])->prefix('stories')->name('api.stories.')->group(function () { @@ -56,6 +71,14 @@ Route::middleware(['web', 'throttle:reactions-read']) ->get('community/activity', [\App\Http\Controllers\Api\CommunityActivityController::class, 'index']) ->name('api.community.activity'); +Route::middleware(['web', 'throttle:social-read']) + ->get('activity', [\App\Http\Controllers\Api\SocialActivityController::class, 'index']) + ->name('api.activity'); + +Route::middleware(['web', 'throttle:social-read']) + ->get('comments', [\App\Http\Controllers\Api\SocialCompatibilityController::class, 'comments']) + ->name('api.social.comments.index'); + // ── Ranking lists (public, throttled, Redis-cached) ───────────────────────── // GET /api/rank/global?type=trending|new_hot|best // GET /api/rank/category/{id}?type=trending|new_hot|best @@ -308,6 +331,22 @@ Route::middleware(['web']) }); Route::middleware(['web', 'auth', 'normalize.username'])->group(function () { + Route::match(['post', 'delete'], 'like', [\App\Http\Controllers\Api\SocialCompatibilityController::class, 'like']) + ->name('api.social.like'); + + Route::post('comments', [\App\Http\Controllers\Api\SocialCompatibilityController::class, 'comments']) + ->name('api.social.comments.store'); + + Route::match(['post', 'delete'], 'bookmark', [\App\Http\Controllers\Api\SocialCompatibilityController::class, 'bookmark']) + ->name('api.social.bookmark'); + + Route::get('bookmarks', [\App\Http\Controllers\Api\SocialCompatibilityController::class, 'bookmarks']) + ->name('api.social.bookmarks'); + + Route::match(['post', 'delete'], 'artworks/{id}/bookmark', [\App\Http\Controllers\Api\ArtworkInteractionController::class, 'bookmark']) + ->whereNumber('id') + ->name('api.artworks.bookmark'); + Route::post('artworks/{id}/favorite', [\App\Http\Controllers\Api\ArtworkInteractionController::class, 'favorite']) ->whereNumber('id') ->name('api.artworks.favorite'); @@ -320,9 +359,13 @@ Route::middleware(['web', 'auth', 'normalize.username'])->group(function () { ->whereNumber('id') ->name('api.artworks.report'); - Route::post('users/{id}/follow', [\App\Http\Controllers\Api\ArtworkInteractionController::class, 'follow']) + Route::match(['post', 'delete'], 'users/{id}/follow', [\App\Http\Controllers\Api\ArtworkInteractionController::class, 'follow']) ->whereNumber('id') ->name('api.users.follow'); + + Route::match(['post', 'delete'], 'follow/{id}', [\App\Http\Controllers\Api\ArtworkInteractionController::class, 'follow']) + ->whereNumber('id') + ->name('api.follow.alias'); }); // ── Share tracking (public, throttled) ──────────────────────────────────────── @@ -615,6 +658,38 @@ Route::middleware(['web', 'auth']) Route::post('{id}/read', [\App\Http\Controllers\Api\NotificationController::class, 'markRead'])->name('mark-read'); }); +Route::middleware(['web', 'throttle:social-read']) + ->prefix('stories') + ->name('api.stories.comments.') + ->group(function () { + Route::get('{id}/comments', [\App\Http\Controllers\Api\StoryCommentController::class, 'index']) + ->whereNumber('id') + ->name('index'); + }); + +Route::middleware(['web', 'auth']) + ->prefix('stories') + ->name('api.stories.social.') + ->group(function () { + Route::post('{id}/like', [\App\Http\Controllers\Api\StoryInteractionController::class, 'like']) + ->whereNumber('id') + ->middleware(['throttle:social-write', 'forum.bot.protection:api_write']) + ->name('like'); + Route::post('{id}/bookmark', [\App\Http\Controllers\Api\StoryInteractionController::class, 'bookmark']) + ->whereNumber('id') + ->middleware(['throttle:social-write', 'forum.bot.protection:api_write']) + ->name('bookmark'); + Route::post('{id}/comments', [\App\Http\Controllers\Api\StoryCommentController::class, 'store']) + ->whereNumber('id') + ->middleware(['throttle:social-write', 'forum.bot.protection:api_write']) + ->name('comments.store'); + Route::delete('{id}/comments/{commentId}', [\App\Http\Controllers\Api\StoryCommentController::class, 'destroy']) + ->whereNumber('id') + ->whereNumber('commentId') + ->middleware('throttle:social-write') + ->name('comments.destroy'); + }); + // ── Artwork search for share modal (public, throttled) ──────────────────────── // GET /api/search/artworks?q=...&shareable=1 → reuses existing ArtworkSearchController diff --git a/routes/legacy.php b/routes/legacy.php index 6051f64f..9cfda538 100644 --- a/routes/legacy.php +++ b/routes/legacy.php @@ -12,31 +12,15 @@ */ use Illuminate\Support\Facades\Route; -//use App\Http\Controllers\Web\ArtController; use App\Http\Controllers\Legacy\AvatarController; -use App\Http\Controllers\Web\FeaturedArtworksController; -use App\Http\Controllers\Web\DailyUploadsController; -use App\Http\Controllers\Community\ChatController; -use App\Http\Controllers\Community\LatestController; -use App\Http\Controllers\User\TopFavouritesController; +use App\Http\Controllers\Legacy\CategoryRedirectController; use App\Http\Controllers\User\FavouritesController; -use App\Http\Controllers\User\TopAuthorsController; -use App\Http\Controllers\User\TodayInHistoryController; -use App\Http\Controllers\User\TodayDownloadsController; -use App\Http\Controllers\User\MonthlyCommentatorsController; -use App\Http\Controllers\User\MembersController; use App\Http\Controllers\User\StatisticsController; use App\Http\Controllers\User\ProfileController; -use App\Http\Controllers\Web\BrowseGalleryController; use App\Http\Controllers\Web\GalleryController; -use App\Http\Controllers\Web\RssFeedController; -//use App\Http\Controllers\Dashboard\ManageController; -use App\Http\Controllers\Legacy\ReceivedCommentsController; // ── AVATARS ─────────────────────────────────────────────────────────────────── -Route::get('/avatar/{id}/{name?}', [AvatarController::class, 'show']) - ->where('id', '\d+') - ->name('legacy.avatar'); +Route::get('/avatar/{id}/{name?}', [AvatarController::class, 'show'])->where('id', '\d+')->name('legacy.avatar'); // ── ARTWORK (legacy comment URL) ────────────────────────────────────────────── //Route::match(['get','post'], '/art/{id}/comment', [ArtController::class, 'show'])->where('id', '\d+'); @@ -45,41 +29,45 @@ Route::get('/avatar/{id}/{name?}', [AvatarController::class, 'show']) Route::redirect('/sections', '/categories', 301)->name('sections'); Route::redirect('/browse-categories', '/categories', 301)->name('browse.categories'); +// Legacy mixed-case category URL patterns: +// /Skins/BrowserBob/210 +// /Skins/BrowserBob/sdsdsdsd/210 +Route::get('/{group}/{slug}/{id}', CategoryRedirectController::class) + ->where('group', '(?i:skins|wallpapers|photography|other|members)') + ->where('slug', '[^/]+(?:/[^/]+)*') + ->whereNumber('id') + ->name('legacy.category.short'); + // Legacy category URL pattern: /category/group/slug/id -Route::get('/category/{group}/{slug?}/{id?}', [BrowseGalleryController::class, 'legacyCategory']) - ->name('legacy.category'); +Route::get('/category/{group}/{slug?}/{id?}', CategoryRedirectController::class)->name('legacy.category'); // ── BROWSE / FEATURED / DAILY ───────────────────────────────────────────────── //Route::get('/browse', [BrowseGalleryController::class, 'browse'])->name('legacy.browse'); Route::get('/browse', fn () => redirect('/explore', 301))->name('legacy.browse'); -Route::get('/featured', [FeaturedArtworksController::class, 'index'])->name('legacy.featured'); -Route::get('/featured-artworks',[FeaturedArtworksController::class, 'index'])->name('legacy.featured_artworks'); -Route::get('/daily-uploads', [DailyUploadsController::class, 'index'])->name('legacy.daily_uploads'); +Route::get('/featured-artworks', fn () => redirect('/featured', 301))->name('legacy.featured_artworks'); +Route::get('/daily-uploads', fn () => redirect()->route('uploads.daily', request()->query(), 301))->name('legacy.daily_uploads'); // ── CHAT ────────────────────────────────────────────────────────────────────── -Route::get('/chat', fn () => redirect()->route('community.chat', [], 301))->name('legacy.chat'); -Route::post('/chat_post', [ChatController::class, 'post'])->name('legacy.chat.post'); +Route::match(['get', 'post'], '/chat', fn () => redirect('/messages', 301)); +Route::match(['get', 'post'], '/community/chat', fn () => redirect('/messages', 301))->name('community.chat'); // ── UPLOADS / COMMENTS / DOWNLOADS (SEO alias pages) ───────────────────────── -Route::get('/uploads/latest', [LatestController::class, 'index'])->name('uploads.latest'); -Route::get('/uploads/daily', [DailyUploadsController::class, 'index'])->name('uploads.daily'); -Route::get('/members/photos', [MembersController::class, 'photos'])->name('members.photos'); -Route::get('/authors/top', [TopAuthorsController::class, 'index'])->name('authors.top'); -Route::get('/comments/latest', function () { - return redirect()->route('community.activity', request()->query(), 301); -})->name('comments.latest'); -Route::get('/comments/monthly', [MonthlyCommentatorsController::class, 'index'])->name('comments.monthly'); -Route::get('/downloads/today', [TodayDownloadsController::class, 'index'])->name('downloads.today'); +Route::get('/latest', fn () => redirect('/uploads/latest', 301))->name('legacy.latest'); -Route::get('/latest', [LatestController::class, 'index'])->name('legacy.latest'); -Route::get('/latest-comments', function () { - return redirect()->route('community.activity', request()->query(), 301); -})->name('legacy.latest_comments'); -Route::get('/today-in-history', [TodayInHistoryController::class, 'index'])->name('legacy.today_in_history'); -Route::get('/today-downloads', [TodayDownloadsController::class, 'index'])->name('legacy.today_downloads'); -Route::get('/monthly-commentators', [MonthlyCommentatorsController::class, 'index'])->name('legacy.monthly_commentators'); -Route::get('/members', [MembersController::class, 'index'])->name('legacy.members'); -Route::get('/top-favourites', [TopFavouritesController::class, 'index'])->name('legacy.top_favourites'); +Route::get('/authors/top', fn () => redirect('/creators/top', 301))->name('authors.top'); +Route::get('/latest-artworks', fn () => redirect()->route('discover.fresh', request()->query(), 301))->name('legacy.latest_artworks'); + +Route::get('/latest-comments', fn () => redirect()->route('community.activity', request()->query(), 301))->name('legacy.latest_comments'); +Route::get('/comments/latest', fn () => redirect()->route('community.activity', request()->query(), 301))->name('comments.latest'); + +Route::get('/today-in-history', fn () => redirect()->route('discover.on-this-day', request()->query(), 301))->name('legacy.today_in_history'); + +Route::get('/today-downloads', fn () => redirect()->route('downloads.today', request()->query(), 301))->name('legacy.today_downloads'); + +Route::get('/monthly-commentators', fn () => redirect()->route('comments.monthly', request()->query(), 301))->name('legacy.monthly_commentators'); + +Route::get('/members', fn () => redirect()->route('creators.top', request()->query(), 301))->name('legacy.members'); +Route::get('/top-favourites', fn () => redirect()->route('discover.top-rated', request()->query(), 301))->name('legacy.top_favourites'); // ── REDIRECTS: top-authors, interviews, apply, bug-report ──────────────────── Route::get('/top-authors', fn () => redirect('/creators/top', 301))->name('legacy.top_authors'); @@ -88,12 +76,12 @@ Route::get('/apply', fn () => redirect('/contact', 301))->name('legacy.app Route::match(['get','post'], '/bug-report', fn () => redirect('/contact', 301))->name('bug-report.redirect'); // ── BUDDIES / MYBUDDIES ─────────────────────────────────────────────────────── -Route::middleware('auth')->get('/mybuddies.php', [\App\Http\Controllers\User\MyBuddiesController::class, 'index'])->name('legacy.mybuddies.php'); -Route::middleware('auth')->get('/mybuddies', [\App\Http\Controllers\User\MyBuddiesController::class, 'index'])->name('legacy.mybuddies'); -Route::middleware('auth')->delete('/mybuddies/{id}', [\App\Http\Controllers\User\MyBuddiesController::class, 'destroy'])->name('legacy.mybuddies.delete'); +Route::middleware('auth')->get('/mybuddies.php', fn () => redirect()->route('dashboard.following', [], 301))->name('legacy.mybuddies.php'); +Route::middleware('auth')->get('/mybuddies', fn () => redirect()->route('dashboard.following', [], 301))->name('legacy.mybuddies'); +Route::middleware('auth')->delete('/mybuddies/{id}', fn () => redirect()->route('dashboard.following'))->name('legacy.mybuddies.delete'); -Route::middleware('auth')->get('/buddies.php', [\App\Http\Controllers\User\BuddiesController::class, 'index'])->name('legacy.buddies.php'); -Route::middleware('auth')->get('/buddies', [\App\Http\Controllers\User\BuddiesController::class, 'index'])->name('legacy.buddies'); +Route::middleware('auth')->get('/buddies.php', fn () => redirect()->route('dashboard.followers', [], 301))->name('legacy.buddies.php'); +Route::middleware('auth')->get('/buddies', fn () => redirect()->route('dashboard.followers', [], 301))->name('legacy.buddies'); // ── FAVOURITES / GALLERY ────────────────────────────────────────────────────── Route::get('/favourites/{id?}/{username?}', [FavouritesController::class, 'index'])->name('legacy.favourites'); @@ -104,57 +92,17 @@ Route::middleware('ensure.onboarding.complete') ->name('legacy.gallery'); // We need to fix to a new gallery // ── PROFILE (legacy URL patterns) ──────────────────────────────────────────── -Route::get('/user/{username}', [ProfileController::class, 'legacyByUsername']) - ->where('username', '[A-Za-z0-9_-]{3,20}') - ->name('legacy.user.profile'); - -Route::get('/profile/{id}/{username?}', [ProfileController::class, 'legacyById']) - ->where('id', '\d+') - ->name('legacy.profile.id'); - -Route::get('/profile/{username}', [ProfileController::class, 'legacyByUsername']) - ->where('username', '[A-Za-z0-9_-]{3,20}') - ->name('legacy.profile'); +Route::get('/user/{username}', [ProfileController::class, 'legacyByUsername'])->where('username', '[A-Za-z0-9_-]{3,20}')->name('legacy.user.profile'); +Route::get('/profile/{id}/{username?}', [ProfileController::class, 'legacyById'])->where('id', '\d+')->name('legacy.profile.id'); +Route::get('/profile/{username}', [ProfileController::class, 'legacyByUsername'])->where('username', '[A-Za-z0-9_-]{3,20}')->name('legacy.profile'); // Keep legacy `/user` as a permanent redirect to the canonical dashboard path. -Route::middleware(['auth'])->match(['get','post'], '/user', function () { - return redirect()->route('dashboard.profile', [], 301); -})->name('legacy.user.redirect'); +Route::middleware(['auth'])->match(['get','post'], '/user', function () {return redirect()->route('dashboard.profile', [], 301);})->name('legacy.user.redirect'); // ── COMMENTS / STATISTICS ───────────────────────────────────────────────────── -Route::middleware('auth')->get('/recieved-comments', [ReceivedCommentsController::class, 'index'])->name('legacy.received_comments'); +Route::middleware('auth')->get('/recieved-comments', fn () => redirect()->route('dashboard.comments.received', request()->query(), 301))->name('legacy.received_comments'); +Route::middleware('auth')->get('/received-comments', fn () => redirect()->route('dashboard.comments.received', request()->query(), 301))->name('legacy.received_comments.corrected'); Route::middleware(['auth'])->group(function () { Route::get('/statistics', [StatisticsController::class, 'index'])->name('legacy.statistics'); }); - -// ── MANAGE (old artwork management pages) ───────────────────────────────────── -/* -Route::middleware(['auth'])->group(function () { - Route::get('/manage', [ManageController::class, 'index'])->name('manage'); - Route::get('/manage/edit/{id}', [ManageController::class, 'edit'])->name('manage.edit'); - Route::post('/manage/update/{id}', [ManageController::class, 'update'])->name('manage.update'); - Route::post('/manage/delete/{id}', [ManageController::class, 'destroy'])->name('manage.destroy'); -}); -*/ - -// ── LEGACY FORUM REDIRECT (/forum.php?topic=X) ─────────────────────────────── -/* -Route::middleware('ensure.onboarding.complete') - ->get('/forum.php', function (\Illuminate\Http\Request $request) { - $threadId = (int) ($request->query('topic') ?? $request->query('tid') ?? 0); - if ($threadId < 1) { - return redirect()->route('forum.index', [], 301); - } - - $thread = \App\Models\ForumThread::query()->find($threadId); - $slug = $thread?->slug ?: ('thread-' . $threadId); - - return redirect()->route('forum.thread.show', ['thread' => $threadId, 'slug' => $slug], 301); - })->name('forum.legacy.redirect'); -*/ -// ── LEGACY RSS (.xml feeds — old site compatibility) ────────────────────────── -Route::get('/rss/latest-uploads.xml', [RssFeedController::class, 'latestUploads'])->name('rss.uploads'); -Route::get('/rss/latest-skins.xml', [RssFeedController::class, 'latestSkins'])->name('rss.skins'); -Route::get('/rss/latest-wallpapers.xml', [RssFeedController::class, 'latestWallpapers'])->name('rss.wallpapers'); -Route::get('/rss/latest-photos.xml', [RssFeedController::class, 'latestPhotos'])->name('rss.photos'); diff --git a/routes/web.php b/routes/web.php index ed8f48de..bd396689 100644 --- a/routes/web.php +++ b/routes/web.php @@ -8,6 +8,8 @@ use App\Http\Controllers\Dashboard\ArtworkController as DashboardArtworkControll use App\Http\Controllers\Web\ArtworkPageController; use App\Http\Controllers\ArtworkDownloadController; use App\Http\Controllers\Web\BrowseGalleryController; +use App\Http\Controllers\Web\FeaturedArtworksController; +use App\Http\Controllers\Web\DailyUploadsController; use App\Http\Controllers\Web\DiscoverController; use App\Http\Controllers\Web\ExploreController; use App\Http\Controllers\Web\BlogController; @@ -27,15 +29,26 @@ use App\Http\Controllers\RSS\ExploreFeedController; use App\Http\Controllers\RSS\TagFeedController; use App\Http\Controllers\RSS\CreatorFeedController; use App\Http\Controllers\RSS\BlogFeedController; -use App\Http\Controllers\Web\StaffApplicationAdminController; use App\Http\Controllers\Studio\StudioController; use App\Http\Controllers\DashboardController; +use App\Http\Controllers\Community\LatestController; +use App\Http\Controllers\User\MembersController; +use App\Http\Controllers\User\TodayDownloadsController; +use App\Http\Controllers\User\MonthlyCommentatorsController; use App\Models\Artwork; use Inertia\Inertia; Route::get('/', [HomeController::class, 'index'])->name('index'); Route::get('/home', [HomeController::class, 'index']); +// ── PUBLIC GALLERIES / LISTS ───────────────────────────────────────────────── +Route::get('/featured', [FeaturedArtworksController::class, 'index'])->name('featured'); +Route::get('/uploads/latest', [LatestController::class, 'index'])->name('uploads.latest'); +Route::get('/uploads/daily', [DailyUploadsController::class, 'index'])->name('uploads.daily'); +Route::get('/members/photos', [MembersController::class, 'photos'])->name('members.photos'); +Route::get('/downloads/today', [TodayDownloadsController::class, 'index'])->name('downloads.today'); +Route::get('/comments/monthly', [MonthlyCommentatorsController::class, 'index'])->name('comments.monthly'); + // ── DISCOVER (/discover/*) ──────────────────────────────────────────────────── Route::prefix('discover')->name('discover.')->group(function () { Route::get('/', fn () => redirect('/discover/trending', 301)); @@ -54,8 +67,8 @@ Route::prefix('discover')->name('discover.')->group(function () { // ── EXPLORE (/explore/*) ────────────────────────────────────────────────────── Route::prefix('explore')->name('explore.')->group(function () { Route::get('/', [ExploreController::class, 'index'])->name('index'); - Route::get('/members', fn () => redirect('/members', 301))->name('members.redirect'); - Route::get('/memebers', fn () => redirect('/members', 301))->name('memebers.redirect'); + Route::get('/members', fn () => redirect()->route('creators.top', request()->query(), 301))->name('members.redirect'); + Route::get('/memebers', fn () => redirect()->route('creators.top', request()->query(), 301))->name('memebers.redirect'); Route::get('/{type}', [ExploreController::class, 'byType']) ->where('type', 'artworks|wallpapers|skins|photography|other') ->name('type'); @@ -95,11 +108,13 @@ Route::get('/staff', [StaffController::class, 'index'])->name('st Route::get('/contact', [ApplicationController::class, 'show'])->name('contact.show'); Route::post('/contact', [ApplicationController::class, 'submit'])->middleware('throttle:6,1')->name('contact.submit'); -// ── ADMIN: Staff applications ───────────────────────────────────────────────── -Route::middleware(['auth'])->prefix('admin')->name('admin.')->group(function () { - Route::get('/applications', [StaffApplicationAdminController::class, 'index'])->name('applications.index'); - Route::get('/applications/{staffApplication}', [StaffApplicationAdminController::class, 'show'])->name('applications.show'); -}); +$cpPrefix = trim((string) config('cpad.webroot', config('cp.webroot', 'cp')), '/'); + +// ── LEGACY RSS (.xml feeds — old site compatibility) ────────────────────────── +Route::get('/rss/latest-uploads.xml', [RssFeedController::class, 'latestUploads'])->name('rss.uploads'); +Route::get('/rss/latest-skins.xml', [RssFeedController::class, 'latestSkins'])->name('rss.skins'); +Route::get('/rss/latest-wallpapers.xml', [RssFeedController::class, 'latestWallpapers'])->name('rss.wallpapers'); +Route::get('/rss/latest-photos.xml', [RssFeedController::class, 'latestPhotos'])->name('rss.photos'); // ── RSS 2.0 feeds (/rss/*) ──────────────────────────────────────────────────── Route::middleware('throttle:60,1')->group(function () { @@ -139,6 +154,9 @@ Route::prefix('creators')->name('creators.')->group(function () { Route::get('/rising', [DiscoverController::class, 'risingCreators'])->name('rising'); }); +Route::get('/leaderboard', \App\Http\Controllers\Web\LeaderboardPageController::class) + ->name('leaderboard'); + // ── STORIES (/stories/*) ────────────────────────────────────────────────────── Route::prefix('stories')->name('stories.')->group(function () { Route::get('/', [StoryController::class, 'index'])->name('index'); @@ -214,6 +232,10 @@ Route::prefix('news')->name('news.')->group(function () { Route::get('/rss/news', [NewsRssController::class, 'feed'])->name('news.rss'); // ── PROFILES (@username) ────────────────────────────────────────────────────── +Route::get('/@{username}/gallery', [ProfileController::class, 'showGalleryByUsername']) + ->where('username', '[A-Za-z0-9_-]{3,20}') + ->name('profile.gallery'); + Route::get('/@{username}', [ProfileController::class, 'showByUsername']) ->where('username', '[A-Za-z0-9_-]{3,20}') ->name('profile.show'); @@ -247,7 +269,9 @@ Route::middleware(['auth', \App\Http\Middleware\NoIndexDashboard::class])->prefi Route::get('/followers', [\App\Http\Controllers\Dashboard\FollowerController::class, 'index'])->name('followers'); Route::get('/following', [\App\Http\Controllers\Dashboard\FollowingController::class, 'index'])->name('following'); - Route::get('/comments', [\App\Http\Controllers\Dashboard\CommentController::class, 'index'])->name('comments'); + Route::get('/comments', fn () => redirect()->route('dashboard.comments.received', request()->query(), 302))->name('comments'); + Route::get('/comments/received', [\App\Http\Controllers\Dashboard\CommentController::class, 'received'])->name('comments.received'); + Route::get('/notifications', [\App\Http\Controllers\Dashboard\NotificationController::class, 'index'])->name('notifications'); Route::get('/gallery', [\App\Http\Controllers\Dashboard\DashboardGalleryController::class, 'index'])->name('gallery'); Route::get('/awards', [\App\Http\Controllers\Dashboard\DashboardAwardsController::class, 'index'])->name('awards'); }); @@ -270,8 +294,8 @@ Route::middleware(['auth', 'ensure.onboarding.complete'])->prefix('studio')->nam // ── SETTINGS / PROFILE EDIT ─────────────────────────────────────────────────── Route::middleware(['auth', 'normalize.username', 'ensure.onboarding.complete'])->group(function () { Route::get('/profile', fn () => redirect()->route('dashboard.profile', [], 301))->name('legacy.profile.redirect'); - Route::get('/settings', [ProfileController::class, 'edit'])->name('settings'); - Route::get('/profile/edit', [ProfileController::class, 'edit'])->name('profile.edit'); + Route::get('/settings', fn () => redirect()->route('dashboard.profile', [], 302))->name('settings'); + Route::get('/profile/edit', fn () => redirect()->route('dashboard.profile', [], 302))->name('profile.edit'); Route::match(['post','put','patch'], '/profile', [ProfileController::class, 'update'])->name('profile.update'); Route::delete('/profile', [ProfileController::class, 'destroy'])->name('profile.destroy'); Route::match(['post', 'put'], '/profile/password', [ProfileController::class, 'password'])->name('profile.password'); @@ -365,48 +389,6 @@ Route::get('/search', [\App\Http\Controllers\Web\SearchController::class, 'index Route::view('/data-deletion', 'privacy.data-deletion')->name('privacy.data_deletion'); Route::view('/blank', 'blank')->name('blank'); -// ── ADMIN ───────────────────────────────────────────────────────────────────── -Route::middleware(['auth'])->prefix('admin')->name('admin.')->group(function () { - Route::get('uploads/moderation', fn () => Inertia::render('Admin/UploadQueue')) - ->middleware('admin.moderation') - ->name('uploads.moderation'); - - Route::get('usernames/moderation', fn () => Inertia::render('Admin/UsernameQueue')) - ->middleware('admin.moderation') - ->name('usernames.moderation'); - - Route::resource('artworks', \App\Http\Controllers\Admin\ArtworkController::class)->except(['show']); - - Route::get('reports', fn () => view('admin.reports.queue')) - ->middleware('admin.moderation') - ->name('reports.queue'); - - Route::get('reports/tags', [\App\Http\Controllers\Admin\TagInteractionReportController::class, 'index']) - ->middleware('admin.moderation') - ->name('reports.tags'); - - Route::middleware('admin.moderation')->prefix('early-growth')->name('early-growth.')->group(function () { - Route::get('/', [\App\Http\Controllers\Admin\EarlyGrowthAdminController::class, 'index'])->name('index'); - Route::delete('/cache', [\App\Http\Controllers\Admin\EarlyGrowthAdminController::class, 'flushCache'])->name('cache.flush'); - Route::get('/status', [\App\Http\Controllers\Admin\EarlyGrowthAdminController::class, 'status'])->name('status'); - }); - - Route::middleware('admin.moderation')->prefix('stories')->name('stories.')->group(function () { - Route::get('/review', [\App\Http\Controllers\Admin\StoryAdminController::class, 'review'])->name('review'); - Route::get('/', [\App\Http\Controllers\Admin\StoryAdminController::class, 'index'])->name('index'); - Route::get('/create', [\App\Http\Controllers\Admin\StoryAdminController::class, 'create'])->name('create'); - Route::post('/', [\App\Http\Controllers\Admin\StoryAdminController::class, 'store'])->name('store'); - Route::get('/{story}', [\App\Http\Controllers\Admin\StoryAdminController::class, 'show'])->name('show'); - Route::post('/{story}/approve', [\App\Http\Controllers\Admin\StoryAdminController::class, 'approve'])->name('approve'); - Route::post('/{story}/reject', [\App\Http\Controllers\Admin\StoryAdminController::class, 'reject'])->name('reject'); - Route::get('/{story}/edit', [\App\Http\Controllers\Admin\StoryAdminController::class, 'edit'])->name('edit'); - Route::put('/{story}', [\App\Http\Controllers\Admin\StoryAdminController::class, 'update'])->name('update'); - Route::delete('/{story}', [\App\Http\Controllers\Admin\StoryAdminController::class, 'destroy'])->name('destroy'); - Route::post('/{story}/publish', [\App\Http\Controllers\Admin\StoryAdminController::class, 'publish'])->name('publish'); - Route::get('/moderation/comments', [\App\Http\Controllers\Admin\StoryAdminController::class, 'moderateComments'])->name('comments.moderation'); - }); -}); - // ── MESSAGES ────────────────────────────────────────────────────────────────── Route::middleware(['auth', 'ensure.onboarding.complete']) ->get('/messages/attachments/{id}', [\App\Http\Controllers\Api\Messaging\AttachmentController::class, 'show']) @@ -419,8 +401,6 @@ Route::middleware(['auth', 'ensure.onboarding.complete'])->prefix('messages')->n }); // ── COMMUNITY ACTIVITY ──────────────────────────────────────────────────────── -Route::match(['get', 'post'], '/community/chat', [\App\Http\Controllers\Community\ChatController::class, 'index']) - ->name('community.chat'); Route::get('/community/activity', [\App\Http\Controllers\Web\CommunityActivityController::class, 'index']) ->name('community.activity'); diff --git a/tests/Feature/Api/SocialCompatibilityEndpointsTest.php b/tests/Feature/Api/SocialCompatibilityEndpointsTest.php new file mode 100644 index 00000000..fab5dc46 --- /dev/null +++ b/tests/Feature/Api/SocialCompatibilityEndpointsTest.php @@ -0,0 +1,229 @@ +create(); + $actor = User::factory()->create(); + $artwork = Artwork::factory()->create(['user_id' => $owner->id]); + + $this->actingAs($actor) + ->postJson('/api/like', [ + 'entity_type' => 'artwork', + 'entity_id' => $artwork->id, + 'state' => true, + ]) + ->assertOk() + ->assertJsonPath('is_liked', true) + ->assertJsonPath('stats.likes', 1); + + $this->assertDatabaseHas('artwork_likes', [ + 'artwork_id' => $artwork->id, + 'user_id' => $actor->id, + ]); + + $notification = $owner->fresh() + ->notifications() + ->where('type', 'artwork_liked') + ->latest() + ->first(); + + expect($notification)->not->toBeNull(); + expect($notification->data['type'] ?? null)->toBe('artwork_liked'); + expect($notification->data['actor_id'] ?? null)->toBe($actor->id); +}); + +test('authenticated user can comment on artwork through generic social endpoint and send owner and mention notifications', function () { + $owner = User::factory()->create(); + $actor = User::factory()->create(); + $mentioned = User::factory()->create(); + $artwork = Artwork::factory()->create(['user_id' => $owner->id]); + + $this->actingAs($actor) + ->postJson('/api/comments', [ + 'entity_type' => 'artwork', + 'entity_id' => $artwork->id, + 'content' => 'Great work @' . $mentioned->username, + ]) + ->assertCreated() + ->assertJsonPath('data.user.id', $actor->id); + + $comment = ArtworkComment::query()->latest('id')->first(); + + expect($comment)->not->toBeNull(); + expect($comment->artwork_id)->toBe($artwork->id); + + $ownerNotification = $owner->fresh() + ->notifications() + ->where('type', 'artwork_commented') + ->latest() + ->first(); + + $mentionedNotification = $mentioned->fresh() + ->notifications() + ->where('type', 'artwork_mentioned') + ->latest() + ->first(); + + expect($ownerNotification)->not->toBeNull(); + expect($ownerNotification->data['type'] ?? null)->toBe('artwork_commented'); + expect($mentionedNotification)->not->toBeNull(); + expect($mentionedNotification->data['type'] ?? null)->toBe('artwork_mentioned'); + + $this->assertDatabaseHas('user_mentions', [ + 'comment_id' => $comment->id, + 'mentioned_user_id' => $mentioned->id, + ]); +}); + +test('generic comments endpoint lists artwork comments', function () { + $artwork = Artwork::factory()->create(); + $comment = ArtworkComment::factory()->create(['artwork_id' => $artwork->id]); + + $this->getJson('/api/comments?entity_type=artwork&entity_id=' . $artwork->id) + ->assertOk() + ->assertJsonCount(1, 'data') + ->assertJsonPath('data.0.id', $comment->id); +}); + +test('authenticated user can bookmark artwork through generic endpoint and see it in bookmarks list', function () { + $user = User::factory()->create(); + $artwork = Artwork::factory()->create(); + + $this->actingAs($user) + ->postJson('/api/bookmark', [ + 'entity_type' => 'artwork', + 'entity_id' => $artwork->id, + 'state' => true, + ]) + ->assertOk() + ->assertJsonPath('is_bookmarked', true) + ->assertJsonPath('stats.bookmarks', 1); + + $this->assertDatabaseHas('artwork_bookmarks', [ + 'artwork_id' => $artwork->id, + 'user_id' => $user->id, + ]); + + $this->actingAs($user) + ->getJson('/api/bookmarks?entity_type=artwork') + ->assertOk() + ->assertJsonCount(1, 'data') + ->assertJsonPath('data.0.type', 'artwork') + ->assertJsonPath('data.0.id', $artwork->id); +}); + +test('authenticated user can like and bookmark a story through generic social endpoints', function () { + $owner = User::factory()->create(); + $actor = User::factory()->create(); + $story = Story::query()->create([ + 'creator_id' => $owner->id, + 'title' => 'Published Story', + 'slug' => 'published-story-' . strtolower((string) \Illuminate\Support\Str::random(6)), + 'content' => '

    Story body

    ', + 'story_type' => 'creator_story', + 'status' => 'published', + 'published_at' => now()->subMinute(), + ]); + + $this->actingAs($actor) + ->postJson('/api/like', [ + 'entity_type' => 'story', + 'entity_id' => $story->id, + 'state' => true, + ]) + ->assertOk() + ->assertJsonPath('is_liked', true) + ->assertJsonPath('stats.likes', 1); + + $this->assertDatabaseHas('story_likes', [ + 'story_id' => $story->id, + 'user_id' => $actor->id, + ]); + + $likeNotification = $owner->fresh() + ->notifications() + ->where('type', 'story_liked') + ->latest() + ->first(); + + expect($likeNotification)->not->toBeNull(); + expect($likeNotification->data['type'] ?? null)->toBe('story_liked'); + + $this->actingAs($actor) + ->postJson('/api/bookmark', [ + 'entity_type' => 'story', + 'entity_id' => $story->id, + 'state' => true, + ]) + ->assertOk() + ->assertJsonPath('is_bookmarked', true) + ->assertJsonPath('stats.bookmarks', 1); + + $this->assertDatabaseHas('story_bookmarks', [ + 'story_id' => $story->id, + 'user_id' => $actor->id, + ]); + + $this->actingAs($actor) + ->getJson('/api/bookmarks?entity_type=story') + ->assertOk() + ->assertJsonCount(1, 'data') + ->assertJsonPath('data.0.type', 'story') + ->assertJsonPath('data.0.id', $story->id); +}); + +test('authenticated user can comment on a story through generic social endpoint and send owner and mention notifications', function () { + $owner = User::factory()->create(); + $actor = User::factory()->create(); + $mentioned = User::factory()->create(); + $story = Story::query()->create([ + 'creator_id' => $owner->id, + 'title' => 'Commentable Story', + 'slug' => 'commentable-story-' . strtolower((string) \Illuminate\Support\Str::random(6)), + 'content' => '

    Story body

    ', + 'story_type' => 'creator_story', + 'status' => 'published', + 'published_at' => now()->subMinute(), + ]); + + $this->actingAs($actor) + ->postJson('/api/comments', [ + 'entity_type' => 'story', + 'entity_id' => $story->id, + 'content' => 'Great story @' . $mentioned->username, + ]) + ->assertCreated() + ->assertJsonPath('data.user.id', $actor->id); + + $this->assertDatabaseHas('story_comments', [ + 'story_id' => $story->id, + 'user_id' => $actor->id, + ]); + + $ownerNotification = $owner->fresh() + ->notifications() + ->where('type', 'story_commented') + ->latest() + ->first(); + + $mentionedNotification = $mentioned->fresh() + ->notifications() + ->where('type', 'story_mentioned') + ->latest() + ->first(); + + expect($ownerNotification)->not->toBeNull(); + expect($ownerNotification->data['type'] ?? null)->toBe('story_commented'); + expect($mentionedNotification)->not->toBeNull(); + expect($mentionedNotification->data['type'] ?? null)->toBe('story_mentioned'); + + $this->actingAs($actor) + ->getJson('/api/comments?entity_type=story&entity_id=' . $story->id) + ->assertOk() + ->assertJsonCount(1, 'data') + ->assertJsonPath('data.0.user.id', $actor->id); +}); \ No newline at end of file diff --git a/tests/Feature/Auth/EnsureOnboardingCompleteTest.php b/tests/Feature/Auth/EnsureOnboardingCompleteTest.php index bcd8f648..a52a8302 100644 --- a/tests/Feature/Auth/EnsureOnboardingCompleteTest.php +++ b/tests/Feature/Auth/EnsureOnboardingCompleteTest.php @@ -46,7 +46,8 @@ it('allows complete onboarding user to access profile and upload', function () { $this->actingAs($user) ->get('/profile') - ->assertOk(); + ->assertRedirect('/dashboard/profile') + ->assertStatus(301); $this->actingAs($user) ->get('/upload') diff --git a/tests/Feature/Countries/CountryMigrationTest.php b/tests/Feature/Countries/CountryMigrationTest.php new file mode 100644 index 00000000..8f65e202 --- /dev/null +++ b/tests/Feature/Countries/CountryMigrationTest.php @@ -0,0 +1,28 @@ +toBeTrue(); + expect(Schema::hasColumns('countries', [ + 'id', + 'iso2', + 'iso3', + 'numeric_code', + 'name_common', + 'name_official', + 'region', + 'subregion', + 'flag_svg_url', + 'flag_png_url', + 'flag_emoji', + 'active', + 'sort_order', + 'is_featured', + 'created_at', + 'updated_at', + ]))->toBeTrue(); + expect(Schema::hasColumn('users', 'country_id'))->toBeTrue(); +}); diff --git a/tests/Feature/Countries/CountrySyncServiceTest.php b/tests/Feature/Countries/CountrySyncServiceTest.php new file mode 100644 index 00000000..ade8258f --- /dev/null +++ b/tests/Feature/Countries/CountrySyncServiceTest.php @@ -0,0 +1,90 @@ +set('skinbase-countries.deactivate_missing', true); + + Country::query()->where('iso2', 'SI')->update([ + 'iso' => 'SI', + 'iso3' => 'SVN', + 'name' => 'Old Slovenia', + 'name_common' => 'Old Slovenia', + 'active' => true, + ]); + + $user = User::factory()->create(); + + DB::table('user_profiles')->insert([ + 'user_id' => $user->id, + 'country_code' => 'SI', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $catalog = app(CountryCatalogService::class); + expect(collect($catalog->profileSelectOptions())->firstWhere('iso2', 'SI')['name'])->toBe('Old Slovenia'); + + app()->instance(CountryRemoteProviderInterface::class, new class implements CountryRemoteProviderInterface { + public function fetchAll(): array + { + return [ + [ + 'iso2' => 'SI', + 'iso3' => 'SVN', + 'numeric_code' => '705', + 'name_common' => 'Slovenia', + 'name_official' => 'Republic of Slovenia', + 'region' => 'Europe', + 'subregion' => 'Central Europe', + 'flag_svg_url' => 'https://flags.test/si.svg', + 'flag_png_url' => 'https://flags.test/si.png', + 'flag_emoji' => '🇸🇮', + ], + [ + 'iso2' => '', + 'name_common' => 'Invalid', + ], + [ + 'iso2' => 'SI', + 'name_common' => 'Duplicate Slovenia', + ], + [ + 'iso2' => 'ZZ', + 'iso3' => 'ZZZ', + 'numeric_code' => '999', + 'name_common' => 'Zedland', + 'name_official' => 'Republic of Zedland', + 'region' => 'Europe', + 'subregion' => 'Nowhere', + 'flag_svg_url' => 'https://flags.test/zz.svg', + 'flag_png_url' => 'https://flags.test/zz.png', + 'flag_emoji' => '🏳️', + ], + ]; + } + + public function normalizePayload(array $payload): array + { + return $payload; + } + }); + + $summary = app(CountrySyncService::class)->sync(allowFallback: false, deactivateMissing: true); + + expect($summary['updated'])->toBe(1) + ->and($summary['inserted'])->toBe(1) + ->and($summary['invalid'])->toBe(1) + ->and($summary['skipped'])->toBe(1) + ->and($summary['backfilled_users'])->toBe(1); + + expect(collect($catalog->profileSelectOptions())->firstWhere('iso2', 'SI')['name'])->toBe('Slovenia'); + expect($user->fresh()->country_id)->toBe(Country::query()->where('iso2', 'SI')->value('id')); +}); diff --git a/tests/Feature/Countries/ProfileCountryPersistenceTest.php b/tests/Feature/Countries/ProfileCountryPersistenceTest.php new file mode 100644 index 00000000..b2f2dabf --- /dev/null +++ b/tests/Feature/Countries/ProfileCountryPersistenceTest.php @@ -0,0 +1,86 @@ +withoutMiddleware(ForumBotProtectionMiddleware::class); + + $user = User::factory()->create(); + $country = Country::query()->where('iso2', 'SI')->firstOrFail(); + $country->update([ + 'iso' => 'SI', + 'iso3' => 'SVN', + 'name' => 'Slovenia', + 'name_common' => 'Slovenia', + ]); + + $response = $this->actingAs($user)->postJson('/settings/personal/update', [ + 'birthday' => '1990-01-02', + 'gender' => 'm', + 'country_id' => $country->id, + ]); + + $response->assertOk(); + + $user->refresh(); + + expect($user->country_id)->toBe($country->id); + expect(optional($user->profile)->country_code)->toBe('SI'); +}); + +it('rejects invalid country identifiers on the personal settings endpoint', function (): void { + $this->withoutMiddleware(ForumBotProtectionMiddleware::class); + + $user = User::factory()->create(); + + $response = $this->actingAs($user)->postJson('/settings/personal/update', [ + 'country_id' => 999999, + ]); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['country_id']); +}); + +it('loads countries on the dashboard profile settings page', function (): void { + $this->withoutMiddleware(ForumBotProtectionMiddleware::class); + + $user = User::factory()->create(); + Country::query()->where('iso2', 'SI')->update([ + 'iso' => 'SI', + 'iso3' => 'SVN', + 'name' => 'Slovenia', + 'name_common' => 'Slovenia', + 'flag_emoji' => '🇸🇮', + ]); + + $response = $this->actingAs($user)->get('/dashboard/profile'); + + $response->assertOk()->assertSee('Slovenia'); +}); + +it('supports country persistence through the legacy profile update endpoint', function (): void { + $user = User::factory()->create(); + $country = Country::query()->where('iso2', 'DE')->firstOrFail(); + $country->update([ + 'iso' => 'DE', + 'iso3' => 'DEU', + 'name' => 'Germany', + 'name_common' => 'Germany', + ]); + + $response = $this->actingAs($user) + ->from('/profile/edit') + ->patch('/profile', [ + 'name' => 'Updated User', + 'email' => $user->email, + 'country_id' => $country->id, + ]); + + $response->assertSessionHasNoErrors(); + expect($user->fresh()->country_id)->toBe($country->id); + expect(optional($user->fresh()->profile)->country_code)->toBe('DE'); +}); diff --git a/tests/Feature/Countries/SyncCountriesCommandTest.php b/tests/Feature/Countries/SyncCountriesCommandTest.php new file mode 100644 index 00000000..68d78c9d --- /dev/null +++ b/tests/Feature/Countries/SyncCountriesCommandTest.php @@ -0,0 +1,56 @@ +set('skinbase-countries.endpoint', 'https://countries.test/all'); + config()->set('skinbase-countries.fallback_seed_enabled', false); + + Http::fake([ + 'https://countries.test/all' => Http::response([ + [ + 'cca2' => 'SI', + 'cca3' => 'SVN', + 'ccn3' => '705', + 'name' => ['common' => 'Slovenia', 'official' => 'Republic of Slovenia'], + 'region' => 'Europe', + 'subregion' => 'Central Europe', + 'flags' => ['svg' => 'https://flags.test/si.svg', 'png' => 'https://flags.test/si.png'], + 'flag' => '🇸🇮', + ], + ], 200), + ]); + + $this->artisan('skinbase:sync-countries')->assertSuccessful(); + + expect(Country::query()->where('iso2', 'SI')->value('name_common'))->toBe('Slovenia'); +}); + +it('sync command fails when the remote source errors and fallback is disabled', function (): void { + config()->set('skinbase-countries.endpoint', 'https://countries.test/all'); + config()->set('skinbase-countries.fallback_seed_enabled', false); + + Http::fake([ + 'https://countries.test/all' => Http::response(['message' => 'server error'], 500), + ]); + + $this->artisan('skinbase:sync-countries') + ->assertExitCode(1); +}); + +it('sync command fails gracefully when the payload contains no valid country records', function (): void { + config()->set('skinbase-countries.endpoint', 'https://countries.test/all'); + config()->set('skinbase-countries.fallback_seed_enabled', false); + + Http::fake([ + 'https://countries.test/all' => Http::response([ + ['bad' => 'payload'], + ], 200), + ]); + + $this->artisan('skinbase:sync-countries') + ->assertExitCode(1); +}); diff --git a/tests/Feature/Dashboard/DashboardOverviewTest.php b/tests/Feature/Dashboard/DashboardOverviewTest.php new file mode 100644 index 00000000..c8351f1c --- /dev/null +++ b/tests/Feature/Dashboard/DashboardOverviewTest.php @@ -0,0 +1,111 @@ +create([ + 'email_verified_at' => now(), + ]); + $follower = User::factory()->create(); + $followed = User::factory()->create(); + $commenter = User::factory()->create(); + + $artwork = Artwork::factory()->create([ + 'user_id' => $user->id, + 'is_approved' => true, + 'is_public' => true, + 'published_at' => now()->subDay(), + ]); + + DB::table('user_followers')->insert([ + [ + 'user_id' => $user->id, + 'follower_id' => $follower->id, + 'created_at' => now(), + ], + [ + 'user_id' => $followed->id, + 'follower_id' => $user->id, + 'created_at' => now(), + ], + ]); + + DB::table('artwork_favourites')->insert([ + 'user_id' => $user->id, + 'artwork_id' => $artwork->id, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + Notification::query()->create([ + 'user_id' => $user->id, + 'type' => 'comment', + 'data' => [ + 'message' => 'Unread dashboard notification', + 'url' => '/dashboard/notifications', + ], + 'read_at' => null, + ]); + + ArtworkComment::factory()->create([ + 'artwork_id' => $artwork->id, + 'user_id' => $commenter->id, + 'content' => 'Unread dashboard comment', + 'raw_content' => 'Unread dashboard comment', + 'rendered_content' => '

    Unread dashboard comment

    ', + 'is_approved' => true, + ]); + + $response = $this->actingAs($user)->get('/dashboard'); + + $response->assertOk()->assertSee('data-overview=', false); + + $overview = $response->viewData('dashboard_overview'); + $preferences = $response->viewData('dashboard_preferences'); + + expect($overview)->toMatchArray([ + 'artworks' => 1, + 'stories' => 0, + 'followers' => 1, + 'following' => 1, + 'favorites' => 1, + 'notifications' => $user->unreadNotifications()->count(), + 'received_comments' => 1, + ]); + + expect($preferences)->toMatchArray([ + 'pinned_spaces' => [], + ]); +}); + +it('embeds saved pinned dashboard spaces for the authenticated user', function () { + $user = User::factory()->create([ + 'email_verified_at' => now(), + ]); + + DashboardPreference::query()->create([ + 'user_id' => $user->id, + 'pinned_spaces' => [ + '/dashboard/notifications', + '/studio', + ], + ]); + + $response = $this->actingAs($user)->get('/dashboard'); + + $response->assertOk(); + + expect($response->viewData('dashboard_preferences'))->toMatchArray([ + 'pinned_spaces' => [ + '/dashboard/notifications', + '/studio', + ], + ]); +}); diff --git a/tests/Feature/Dashboard/DashboardPinnedSpacesPreferenceTest.php b/tests/Feature/Dashboard/DashboardPinnedSpacesPreferenceTest.php new file mode 100644 index 00000000..e0730d4e --- /dev/null +++ b/tests/Feature/Dashboard/DashboardPinnedSpacesPreferenceTest.php @@ -0,0 +1,67 @@ +create([ + 'email_verified_at' => now(), + ]); + + $response = $this->actingAs($user)->putJson('/api/dashboard/preferences/shortcuts', [ + 'pinned_spaces' => [ + '/dashboard/notifications', + '/dashboard/notifications', + '/dashboard/comments/received', + '/not-allowed', + '/studio', + ], + ]); + + $response + ->assertOk() + ->assertJson([ + 'data' => [ + 'pinned_spaces' => [ + '/dashboard/notifications', + '/dashboard/comments/received', + '/studio', + ], + ], + ]); + + expect(DashboardPreference::query()->find($user->id)?->pinned_spaces)->toBe([ + '/dashboard/notifications', + '/dashboard/comments/received', + '/studio', + ]); +}); + +it('allows clearing all pinned dashboard spaces for the authenticated user', function () { + $user = User::factory()->create([ + 'email_verified_at' => now(), + ]); + + DashboardPreference::query()->create([ + 'user_id' => $user->id, + 'pinned_spaces' => [ + '/dashboard/notifications', + ], + ]); + + $response = $this->actingAs($user)->putJson('/api/dashboard/preferences/shortcuts', [ + 'pinned_spaces' => [], + ]); + + $response + ->assertOk() + ->assertJson([ + 'data' => [ + 'pinned_spaces' => [], + ], + ]); + + expect(DashboardPreference::query()->find($user->id)?->pinned_spaces)->toBe([]); +}); \ No newline at end of file diff --git a/tests/Feature/Dashboard/NotificationsPageTest.php b/tests/Feature/Dashboard/NotificationsPageTest.php new file mode 100644 index 00000000..d1955f76 --- /dev/null +++ b/tests/Feature/Dashboard/NotificationsPageTest.php @@ -0,0 +1,29 @@ +create(); + + Notification::query()->create([ + 'user_id' => $user->id, + 'type' => 'comment', + 'data' => [ + 'type' => 'comment', + 'message' => 'Someone commented on your artwork', + 'url' => '/dashboard/comments/received', + ], + 'read_at' => null, + ]); + + $response = $this->actingAs($user)->get('/dashboard/notifications'); + + $response + ->assertOk() + ->assertSee('Notifications', false) + ->assertSee('Someone commented on your artwork', false) + ->assertSee('Unread', false); +}); diff --git a/tests/Feature/Dashboard/ReceivedCommentsUnreadStateTest.php b/tests/Feature/Dashboard/ReceivedCommentsUnreadStateTest.php new file mode 100644 index 00000000..c9fad9cf --- /dev/null +++ b/tests/Feature/Dashboard/ReceivedCommentsUnreadStateTest.php @@ -0,0 +1,66 @@ +create(); + $commenter = User::factory()->create(); + + $artwork = Artwork::factory()->create([ + 'user_id' => $owner->id, + 'is_approved' => true, + 'is_public' => true, + 'published_at' => now()->subDay(), + ]); + + $firstComment = ArtworkComment::factory()->create([ + 'artwork_id' => $artwork->id, + 'user_id' => $commenter->id, + 'content' => 'First unread comment', + 'raw_content' => 'First unread comment', + 'rendered_content' => '

    First unread comment

    ', + 'is_approved' => true, + 'created_at' => now()->subMinute(), + 'updated_at' => now()->subMinute(), + ]); + + $service = app(ReceivedCommentsInboxService::class); + + expect($service->unreadCountForUser($owner))->toBe(1); + + $this->actingAs($owner) + ->get('/dashboard/comments/received') + ->assertOk() + ->assertSee('Marked 1 new comment as read', false); + + $this->assertDatabaseHas('user_received_comment_reads', [ + 'user_id' => $owner->id, + 'artwork_comment_id' => $firstComment->id, + ]); + expect($service->unreadCountForUser($owner))->toBe(0); + + Carbon::setTestNow('2026-03-19 12:05:00'); + + ArtworkComment::factory()->create([ + 'artwork_id' => $artwork->id, + 'user_id' => $commenter->id, + 'content' => 'Second unread comment', + 'raw_content' => 'Second unread comment', + 'rendered_content' => '

    Second unread comment

    ', + 'is_approved' => true, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + expect($service->unreadCountForUser($owner))->toBe(1); + + Carbon::setTestNow(); +}); diff --git a/tests/Feature/Discovery/ActivityEventRecordingTest.php b/tests/Feature/Discovery/ActivityEventRecordingTest.php index 7cfd46d8..c8e500e3 100644 --- a/tests/Feature/Discovery/ActivityEventRecordingTest.php +++ b/tests/Feature/Discovery/ActivityEventRecordingTest.php @@ -105,7 +105,7 @@ it('following tab returns 200 for users with no follows', function () { $user = User::factory()->create(); $this->actingAs($user) - ->get('/community/activity?type=following') + ->get('/community/activity?filter=following') ->assertStatus(200); }); @@ -127,10 +127,12 @@ it('following tab shows only events from followed users', function () { // Event from non-followed user (should not appear) ActivityEvent::record($other->id, ActivityEvent::TYPE_UPLOAD, ActivityEvent::TARGET_ARTWORK, $artwork->id); - $response = $this->actingAs($user)->get('/community/activity?type=following'); + $response = $this->actingAs($user)->get('/community/activity?filter=following'); $response->assertStatus(200); - $events = $response->original->gatherData()['events']; - expect($events->total())->toBe(1); - expect($events->first()->actor_id)->toBe($creator->id); + $props = $response->viewData('props'); + $events = collect($props['initialActivities'] ?? []); + + expect($events)->toHaveCount(1); + expect(data_get($events->first(), 'user.id'))->toBe($creator->id); }); diff --git a/tests/Feature/Legacy/ReceivedCommentsPageTest.php b/tests/Feature/Legacy/ReceivedCommentsPageTest.php new file mode 100644 index 00000000..cb1ecc9d --- /dev/null +++ b/tests/Feature/Legacy/ReceivedCommentsPageTest.php @@ -0,0 +1,50 @@ +create(); + + $this->actingAs($owner) + ->get('/recieved-comments') + ->assertRedirect('/dashboard/comments/received') + ->assertStatus(301); + + $this->actingAs($owner) + ->get('/received-comments') + ->assertRedirect('/dashboard/comments/received') + ->assertStatus(301); +}); + +it('renders the canonical received comments dashboard page for an authenticated user', function () { + $owner = User::factory()->create(); + $commenter = User::factory()->create(); + + $artwork = Artwork::factory()->create([ + 'user_id' => $owner->id, + 'is_approved' => true, + 'is_public' => true, + 'published_at' => now()->subDay(), + ]); + + ArtworkComment::factory()->create([ + 'artwork_id' => $artwork->id, + 'user_id' => $commenter->id, + 'content' => 'Legacy comment regression test', + 'raw_content' => 'Legacy comment regression test', + 'rendered_content' => '

    Legacy comment regression test

    ', + 'is_approved' => true, + ]); + + $response = $this->actingAs($owner)->get('/dashboard/comments/received'); + + $response + ->assertOk() + ->assertSee('Received Comments', false) + ->assertSee('Total comments', false) + ->assertSee('Legacy comment regression test', false); +}); diff --git a/tests/Feature/LegacyProfileSubdomainRedirectTest.php b/tests/Feature/LegacyProfileSubdomainRedirectTest.php new file mode 100644 index 00000000..2c637449 --- /dev/null +++ b/tests/Feature/LegacyProfileSubdomainRedirectTest.php @@ -0,0 +1,65 @@ +set('app.url', 'http://skinbase26.test'); +}); + +it('redirects a username subdomain root to the canonical profile URL', function () { + User::factory()->create([ + 'username' => 'gregor', + ]); + + $response = app(Kernel::class)->handle( + Request::create('/', 'GET', ['tab' => 'favourites'], [], [], ['HTTP_HOST' => 'gregor.skinbase26.test']) + ); + + expect($response->getStatusCode())->toBe(301); + expect($response->headers->get('location'))->toBe('http://skinbase26.test/@gregor?tab=favourites'); +}); + +it('redirects the legacy username subdomain gallery path to the canonical profile gallery URL', function () { + User::factory()->create([ + 'username' => 'gregor', + ]); + + $response = app(Kernel::class)->handle( + Request::create('/gallery', 'GET', [], [], [], ['HTTP_HOST' => 'gregor.skinbase26.test']) + ); + + expect($response->getStatusCode())->toBe(301); + expect($response->headers->get('location'))->toBe('http://skinbase26.test/@gregor/gallery'); +}); + +it('redirects an old username subdomain to the canonical profile URL for the renamed user', function () { + $user = User::factory()->create([ + 'username' => 'gregor', + ]); + + DB::table('username_redirects')->insert([ + 'old_username' => 'oldgregor', + 'new_username' => 'gregor', + 'user_id' => $user->id, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $response = app(Kernel::class)->handle( + Request::create('/', 'GET', [], [], [], ['HTTP_HOST' => 'oldgregor.skinbase26.test']) + ); + + expect($response->getStatusCode())->toBe(301); + expect($response->headers->get('location'))->toBe('http://skinbase26.test/@gregor'); +}); + +it('does not treat reserved subdomains as profile hosts', function () { + $this->call('GET', '/sections', [], [], [], ['HTTP_HOST' => 'www.skinbase26.test']) + ->assertRedirect('/categories') + ->assertStatus(301); +}); \ No newline at end of file diff --git a/tests/Feature/ProfileGalleryPageTest.php b/tests/Feature/ProfileGalleryPageTest.php new file mode 100644 index 00000000..6959b4d5 --- /dev/null +++ b/tests/Feature/ProfileGalleryPageTest.php @@ -0,0 +1,45 @@ +create([ + 'username' => 'gregor', + ]); + + Artwork::factory()->create([ + 'user_id' => $user->id, + 'title' => 'Gallery Artwork', + 'is_public' => true, + 'is_approved' => true, + 'published_at' => now()->subDay(), + ]); + + $this->get('/@gregor/gallery') + ->assertOk() + ->assertSee('http://skinbase26.test/@gregor/gallery', false) + ->assertSee('Profile\\/ProfileGallery', false); +}); + +it('redirects the legacy gallery route to the canonical profile gallery', function () { + $user = User::factory()->create([ + 'username' => 'gregor', + ]); + + $this->get('/gallery/' . $user->id . '/Gregor%20Klev%C5%BEe') + ->assertRedirect('/@gregor/gallery') + ->assertStatus(301); +}); + +it('redirects mixed-case profile gallery usernames to the lowercase canonical route', function () { + User::factory()->create([ + 'username' => 'gregor', + ]); + + $this->get('/@Gregor/gallery') + ->assertRedirect('/@gregor/gallery') + ->assertStatus(301); +}); diff --git a/tests/Feature/RoutingUnificationTest.php b/tests/Feature/RoutingUnificationTest.php index 645b5aeb..45a0f4b2 100644 --- a/tests/Feature/RoutingUnificationTest.php +++ b/tests/Feature/RoutingUnificationTest.php @@ -7,6 +7,9 @@ declare(strict_types=1); * Routing Unification spec (§3.2 Explore, §4 Blog/Pages, §6.1 redirects). */ +use App\Models\Category; +use App\Models\ContentType; + // ── /explore routes ────────────────────────────────────────────────────────── it('GET /explore returns 200', function () { @@ -78,6 +81,116 @@ it('GET /discover redirects to /discover/trending with 301', function () { $this->get('/discover')->assertRedirect('/discover/trending')->assertStatus(301); }); +it('GET /sections redirects to /categories with 301', function () { + $this->get('/sections')->assertRedirect('/categories')->assertStatus(301); +}); + +it('GET /browse-categories redirects to /categories with 301', function () { + $this->get('/browse-categories')->assertRedirect('/categories')->assertStatus(301); +}); + +it('legacy mixed-case category route redirects to the canonical category URL with 301', function () { + $contentType = ContentType::query()->create([ + 'name' => 'Skins', + 'slug' => 'skins', + ]); + + $category = Category::query()->create([ + 'content_type_id' => $contentType->id, + 'name' => 'BrowserBob', + 'slug' => 'browserbob', + ]); + + $this->get('/Skins/BrowserBob/' . $category->id . '?ref=legacy') + ->assertRedirect('/skins/browserbob?ref=legacy') + ->assertStatus(301); +}); + +it('legacy nested category route redirects to the canonical nested category URL with 301', function () { + $contentType = ContentType::query()->create([ + 'name' => 'Skins', + 'slug' => 'skins', + ]); + + $parent = Category::query()->create([ + 'content_type_id' => $contentType->id, + 'name' => 'BrowserBob', + 'slug' => 'browserbob', + ]); + + $child = Category::query()->create([ + 'content_type_id' => $contentType->id, + 'parent_id' => $parent->id, + 'name' => 'sdsdsdsd', + 'slug' => 'sdsdsdsd', + ]); + + $this->get('/Skins/BrowserBob/sdsdsdsd/' . $child->id) + ->assertRedirect('/skins/browserbob/sdsdsdsd') + ->assertStatus(301); +}); + +it('legacy category route falls back to /categories and preserves query string when no category matches', function () { + $this->get('/Skins/does-not-exist/999999?source=old-site') + ->assertRedirect('/categories?source=old-site') + ->assertStatus(301); +}); + +it('legacy /category route redirects to the canonical category URL with 301', function () { + $contentType = ContentType::query()->create([ + 'name' => 'Skins', + 'slug' => 'skins', + ]); + + $category = Category::query()->create([ + 'content_type_id' => $contentType->id, + 'name' => 'BrowserBob', + 'slug' => 'browserbob', + ]); + + $this->get('/category/skins/browserbob/' . $category->id . '?ref=legacy-category') + ->assertRedirect('/skins/browserbob?ref=legacy-category') + ->assertStatus(301); +}); + +it('legacy /category route falls back to /categories and preserves query string when no category matches', function () { + $this->get('/category/skins/does-not-exist/999999?source=legacy-category') + ->assertRedirect('/categories?source=legacy-category') + ->assertStatus(301); +}); + +it('GET /today-in-history redirects to /discover/on-this-day with 301', function () { + $this->get('/today-in-history')->assertRedirect('/discover/on-this-day')->assertStatus(301); +}); + +it('GET /members redirects to /creators/top with 301', function () { + $this->get('/members')->assertRedirect('/creators/top')->assertStatus(301); +}); + +it('GET /explore/members redirects to /creators/top with 301', function () { + $this->get('/explore/members')->assertRedirect('/creators/top')->assertStatus(301); +}); + +it('GET /latest-artworks redirects to /discover/fresh with 301', function () { + $this->get('/latest-artworks')->assertRedirect('/discover/fresh')->assertStatus(301); +}); + +it('GET /today-downloads redirects to /downloads/today with 301', function () { + $this->get('/today-downloads')->assertRedirect('/downloads/today')->assertStatus(301); +}); + +it('GET /monthly-commentators redirects to /comments/monthly with 301', function () { + $this->get('/monthly-commentators')->assertRedirect('/comments/monthly')->assertStatus(301); +}); + +it('GET /top-favourites redirects to /discover/top-rated with 301', function () { + $this->get('/top-favourites')->assertRedirect('/discover/top-rated')->assertStatus(301); +}); + +it('GET /downloads/today returns 200', function () { + $this->get('/downloads/today')->assertOk(); +}); + // ── /blog routes ───────────────────────────────────────────────────────────── it('GET /blog returns 200', function () { diff --git a/tests/Feature/Stories/AdminStoryModerationWorkflowTest.php b/tests/Feature/Stories/AdminStoryModerationWorkflowTest.php index 2be46792..22691ffe 100644 --- a/tests/Feature/Stories/AdminStoryModerationWorkflowTest.php +++ b/tests/Feature/Stories/AdminStoryModerationWorkflowTest.php @@ -1,5 +1,6 @@ create(['role' => 'admin']); + $creator = User::factory()->create(); + + $story = createPendingReviewStory($creator); + + $this->actingAs($admin) + ->post(route('admin.stories.approve', ['story' => $story->id])) + ->assertRedirect(); + + $this->assertDatabaseHas('activity_events', [ + 'actor_id' => $creator->id, + 'type' => ActivityEvent::TYPE_UPLOAD, + 'target_type' => ActivityEvent::TARGET_STORY, + 'target_id' => $story->id, + ]); +}); diff --git a/tests/Feature/Stories/CreatorStoryWorkflowTest.php b/tests/Feature/Stories/CreatorStoryWorkflowTest.php index 1be85101..da38aebd 100644 --- a/tests/Feature/Stories/CreatorStoryWorkflowTest.php +++ b/tests/Feature/Stories/CreatorStoryWorkflowTest.php @@ -1,5 +1,7 @@ status)->toBe('pending_review'); expect($story->submitted_for_review_at)->not->toBeNull(); }); + +it('creator publish records story activity and stores a story_published notification', function () { + $creator = User::factory()->create(); + + $story = Story::query()->create([ + 'creator_id' => $creator->id, + 'title' => 'Publish Me', + 'slug' => 'publish-me-' . Str::lower(Str::random(6)), + 'content' => '

    Publish content

    ', + 'story_type' => 'creator_story', + 'status' => 'draft', + ]); + + $response = $this->actingAs($creator) + ->post(route('creator.stories.publish-now', ['story' => $story->id])); + + $response->assertRedirect(route('stories.show', ['slug' => $story->slug])); + + $story->refresh(); + + expect($story->status)->toBe('published'); + expect($story->published_at)->not->toBeNull(); + + $this->assertDatabaseHas('activity_events', [ + 'actor_id' => $creator->id, + 'type' => ActivityEvent::TYPE_UPLOAD, + 'target_type' => ActivityEvent::TARGET_STORY, + 'target_id' => $story->id, + ]); + + $notification = $creator->fresh() + ->notifications() + ->where('type', 'story_published') + ->latest() + ->first(); + + expect($notification)->toBeInstanceOf(Notification::class); + expect($notification->data['type'] ?? null)->toBe('story_published'); +}); + +it('published story page renders successfully', function () { + $creator = User::factory()->create(); + + $story = Story::query()->create([ + 'creator_id' => $creator->id, + 'title' => 'Renderable Story', + 'slug' => 'renderable-story-' . Str::lower(Str::random(6)), + 'excerpt' => 'Renderable excerpt', + 'content' => json_encode([ + 'type' => 'doc', + 'content' => [ + ['type' => 'paragraph', 'content' => [['type' => 'text', 'text' => 'Renderable story content.']]], + ['type' => 'codeBlock', 'attrs' => ['language' => 'bash'], 'content' => [['type' => 'text', 'text' => 'git clone https://github.com/klevze/sqlBackup.git']]], + ], + ], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE), + 'story_type' => 'creator_story', + 'status' => 'published', + 'published_at' => now()->subMinute(), + ]); + + $this->get(route('stories.show', ['slug' => $story->slug])) + ->assertOk() + ->assertSee('Renderable Story', false) + ->assertSee('language-bash', false) + ->assertDontSee('{"type":"doc"', false); +}); + +it('creator can publish through story editor api create endpoint', function () { + $creator = User::factory()->create(); + + $response = $this->actingAs($creator) + ->postJson(route('api.stories.create'), [ + 'title' => 'API Publish Story', + 'story_type' => 'creator_story', + 'content' => [ + 'type' => 'doc', + 'content' => [ + ['type' => 'paragraph', 'content' => [['type' => 'text', 'text' => 'Story body for API publishing.']]], + ], + ], + 'submit_action' => 'publish_now', + 'status' => 'published', + ]); + + $response->assertOk() + ->assertJsonPath('status', 'published') + ->assertJsonPath('public_url', fn (string $url) => str_contains($url, '/stories/')); + + $storyId = (int) $response->json('story_id'); + $story = Story::query()->findOrFail($storyId); + + expect($story->status)->toBe('published'); + expect($story->published_at)->not->toBeNull(); +}); + +it('creator can publish through story editor api update endpoint', function () { + $creator = User::factory()->create(); + + $story = Story::query()->create([ + 'creator_id' => $creator->id, + 'title' => 'API Draft Story', + 'slug' => 'api-draft-story-' . Str::lower(Str::random(6)), + 'content' => '

    Draft content

    ', + 'story_type' => 'creator_story', + 'status' => 'draft', + ]); + + $response = $this->actingAs($creator) + ->putJson(route('api.stories.update'), [ + 'story_id' => $story->id, + 'title' => 'API Published Story', + 'story_type' => 'creator_story', + 'content' => [ + 'type' => 'doc', + 'content' => [ + ['type' => 'paragraph', 'content' => [['type' => 'text', 'text' => 'Updated story content for publication.']]], + ], + ], + 'submit_action' => 'publish_now', + 'status' => 'published', + ]); + + $response->assertOk() + ->assertJsonPath('status', 'published') + ->assertJsonPath('preview_url', fn (string $url) => str_contains($url, '/preview')); + + $story->refresh(); + + expect($story->title)->toBe('API Published Story'); + expect($story->status)->toBe('published'); + expect($story->published_at)->not->toBeNull(); +}); diff --git a/tests/artifacts/.last-run.json b/tests/artifacts/.last-run.json index 3e518567..cbcc1fba 100644 --- a/tests/artifacts/.last-run.json +++ b/tests/artifacts/.last-run.json @@ -1,6 +1,4 @@ { - "status": "failed", - "failedTests": [ - "598fdabf36083b33787e-d0e56fbd27a2103ba5b0" - ] + "status": "passed", + "failedTests": [] } \ No newline at end of file diff --git a/tests/artifacts/routes-Public-routes-—-200-no-errors-Browse-chromium/error-context.md b/tests/artifacts/routes-Public-routes-—-200-no-errors-Browse-chromium/error-context.md deleted file mode 100644 index 8335b713..00000000 --- a/tests/artifacts/routes-Public-routes-—-200-no-errors-Browse-chromium/error-context.md +++ /dev/null @@ -1,289 +0,0 @@ -# Page snapshot - -```yaml -- generic [active] [ref=e1]: - - generic [ref=e2]: - - generic [ref=e4]: - - generic [ref=e5]: - - img [ref=e7] - - generic [ref=e10]: Internal Server Error - - button "Copy as Markdown" [ref=e11] [cursor=pointer]: - - img [ref=e12] - - generic [ref=e15]: Copy as Markdown - - generic [ref=e18]: - - generic [ref=e19]: - - heading "Symfony\\Component\\Routing\\Exception\\RouteNotFoundException" [level=1] [ref=e20] - - generic [ref=e22]: vendor\laravel\framework\src\Illuminate\Routing\UrlGenerator.php:528 - - paragraph [ref=e23]: Route [artwork.show] not defined. - - generic [ref=e24]: - - generic [ref=e25]: - - generic [ref=e26]: - - generic [ref=e27]: LARAVEL - - generic [ref=e28]: 12.53.0 - - generic [ref=e29]: - - generic [ref=e30]: PHP - - generic [ref=e31]: 8.4.12 - - generic [ref=e32]: - - img [ref=e33] - - text: UNHANDLED - - generic [ref=e36]: CODE 0 - - generic [ref=e38]: - - generic [ref=e39]: - - img [ref=e40] - - text: "500" - - generic [ref=e43]: - - img [ref=e44] - - text: GET - - generic [ref=e47]: http://skinbase26.test/explore - - button [ref=e48] [cursor=pointer]: - - img [ref=e49] - - generic [ref=e53]: - - generic [ref=e54]: - - generic [ref=e55]: - - img [ref=e57] - - heading "Exception trace" [level=3] [ref=e60] - - generic [ref=e61]: - - generic [ref=e63] [cursor=pointer]: - - img [ref=e64] - - generic [ref=e68]: 2 vendor frames - - button [ref=e69]: - - img [ref=e70] - - generic [ref=e74]: - - generic [ref=e75] [cursor=pointer]: - - generic [ref=e78]: - - code [ref=e82]: - - generic [ref=e83]: route(string, string) - - generic [ref=e85]: resources\views\web\explore\index.blade.php:18 - - button [ref=e87]: - - img [ref=e88] - - code [ref=e96]: - - generic [ref=e97]: 13 ✦ Featured Today - - generic [ref=e98]: 14 - - generic [ref=e99]: 15
    - - generic [ref=e100]: 16
    - - generic [ref=e101]: 17 @foreach($spotlight as $item) - - generic [ref=e102]: "18 slug ? route('artwork.show', $item->slug) : '#' }}\"" - - generic [ref=e103]: 19 class="group relative flex-none w-44 md:w-52 rounded-xl overflow-hidden - - generic [ref=e104]: 20 bg-neutral-800 border border-white/10 hover:border-amber-400/40 - - generic [ref=e105]: 21 hover:shadow-lg hover:shadow-amber-500/10 transition-all duration-200" - - generic [ref=e106]: "22 title=\"{{ $item->name ?? '' }}\">" - - generic [ref=e107]: "23" - - generic [ref=e108]: "24 {{-- Thumbnail --}}" - - generic [ref=e109]: 25
    - - generic [ref=e110]: 26 thumb_url ?? '' }}\"" - - generic [ref=e112]: "28 @if(!empty($item->thumb_srcset)) srcset=\"{{ $item->thumb_srcset }}\" @endif" - - generic [ref=e113]: "29 alt=\"{{ $item->name ?? 'Featured artwork' }}\"" - - generic [ref=e114]: "30" - - generic [ref=e116] [cursor=pointer]: - - img [ref=e117] - - generic [ref=e121]: 15 vendor frames - - button [ref=e122]: - - img [ref=e123] - - generic [ref=e128] [cursor=pointer]: - - generic [ref=e131]: - - code [ref=e135]: - - generic [ref=e136]: "Illuminate\\Pipeline\\Pipeline->{closure:{closure:Illuminate\\Pipeline\\Pipeline::carry():194}:195}(object(Illuminate\\Http\\Request))" - - generic [ref=e138]: app\Http\Middleware\EnsureOnboardingComplete.php:27 - - button [ref=e140]: - - img [ref=e141] - - generic [ref=e146] [cursor=pointer]: - - img [ref=e147] - - generic [ref=e151]: 45 vendor frames - - button [ref=e152]: - - img [ref=e153] - - generic [ref=e158] [cursor=pointer]: - - generic [ref=e161]: - - code [ref=e165]: - - generic [ref=e166]: Illuminate\Foundation\Application->handleRequest(object(Illuminate\Http\Request)) - - generic [ref=e168]: public\index.php:20 - - button [ref=e170]: - - img [ref=e171] - - generic [ref=e175]: - - generic [ref=e176]: - - generic [ref=e177]: - - img [ref=e179] - - heading "Queries" [level=3] [ref=e181] - - generic [ref=e183]: 1-10 of 90 - - generic [ref=e184]: - - generic [ref=e185]: - - generic [ref=e186]: - - generic [ref=e187]: - - img [ref=e188] - - generic [ref=e190]: mysql - - code [ref=e194]: - - generic [ref=e195]: "select exists (select 1 from information_schema.tables where table_schema = schema() and table_name = 'cpad' and table_type in ('BASE TABLE', 'SYSTEM VERSIONED')) as `exists`" - - generic [ref=e196]: 13.96ms - - generic [ref=e197]: - - generic [ref=e198]: - - generic [ref=e199]: - - img [ref=e200] - - generic [ref=e202]: mysql - - code [ref=e206]: - - generic [ref=e207]: "select exists (select 1 from information_schema.tables where table_schema = schema() and table_name = 'cpad' and table_type in ('BASE TABLE', 'SYSTEM VERSIONED')) as `exists`" - - generic [ref=e208]: 1.1ms - - generic [ref=e209]: - - generic [ref=e210]: - - generic [ref=e211]: - - img [ref=e212] - - generic [ref=e214]: mysql - - code [ref=e218]: - - generic [ref=e219]: "select * from `cache` where `key` in ('skinbasenova-cache-service:ConfigService:config:plugins')" - - generic [ref=e220]: 0.53ms - - generic [ref=e221]: - - generic [ref=e222]: - - generic [ref=e223]: - - img [ref=e224] - - generic [ref=e226]: mysql - - code [ref=e230]: - - generic [ref=e231]: "select * from `cpad` where `keycode` = 'plugins' limit 1" - - generic [ref=e232]: 0.69ms - - generic [ref=e233]: - - generic [ref=e234]: - - generic [ref=e235]: - - img [ref=e236] - - generic [ref=e238]: mysql - - code [ref=e242]: - - generic [ref=e243]: "select * from `cache` where `key` in ('skinbasenova-cache-cpad_config_tabs_registry')" - - generic [ref=e244]: 0.51ms - - generic [ref=e245]: - - generic [ref=e246]: - - generic [ref=e247]: - - img [ref=e248] - - generic [ref=e250]: mysql - - code [ref=e254]: - - generic [ref=e255]: "insert into `cache` (`expiration`, `key`, `value`) values (1773088441, 'skinbasenova-cache-cpad_config_tabs_registry', 'a:1:{s:14:\"config.plugins\";O:50:\"Klevze\\ControlPanel\\Configuration\\PluginsConfigTab\":2:{s:14:\"*serviceName\";s:16:\"PluginsConfigTab\";s:11:\"*cacheTtl\";i:3600;}}') on duplicate key update `expiration` = values(`expiration`), `key` = values(`key`), `value` = values(`value`)" - - generic [ref=e256]: 3.31ms - - generic [ref=e257]: - - generic [ref=e258]: - - generic [ref=e259]: - - img [ref=e260] - - generic [ref=e262]: mysql - - code [ref=e266]: - - generic [ref=e267]: "delete from `cache` where `key` in ('skinbasenova-cache-cpad_config_tabs_registry', 'skinbasenova-cache-illuminate:cache:flexible:created:cpad_config_tabs_registry')" - - generic [ref=e268]: 2.8ms - - generic [ref=e269]: - - generic [ref=e270]: - - generic [ref=e271]: - - img [ref=e272] - - generic [ref=e274]: mysql - - code [ref=e278]: - - generic [ref=e279]: "select * from `sessions` where `id` = '9JQSo5DrgARJAXNMelWZeiOWRA88DskBb5LukhVI' limit 1" - - generic [ref=e280]: 1.28ms - - generic [ref=e281]: - - generic [ref=e282]: - - generic [ref=e283]: - - img [ref=e284] - - generic [ref=e286]: mysql - - code [ref=e290]: - - generic [ref=e291]: "select * from `cache` where `key` in ('skinbasenova-cache-explore.all.trending.1')" - - generic [ref=e292]: 0.58ms - - generic [ref=e293]: - - generic [ref=e294]: - - generic [ref=e295]: - - img [ref=e296] - - generic [ref=e298]: mysql - - code [ref=e302]: - - generic [ref=e303]: "select * from `artworks` where `artworks`.`id` in (69610, 69611, 69606, 69597, 69599, 69601, 69417, 9517, 9518, 9523, 9524, 9494, 9496, 9497, 9500, 9501, 9502, 9504, 9505, 9506, 9507, 9508, 9509, 9511)" - - generic [ref=e304]: 3.13ms - - generic [ref=e305]: - - button [disabled] [ref=e306]: - - img [ref=e307] - - button [disabled] [ref=e310]: - - img [ref=e311] - - button "1" [ref=e314] [cursor=pointer] - - button "2" [ref=e316] [cursor=pointer] - - button "3" [ref=e318] [cursor=pointer] - - button "4" [ref=e320] [cursor=pointer] - - button "5" [ref=e322] [cursor=pointer] - - generic [ref=e324]: ... - - button "9" [ref=e326] [cursor=pointer] - - button [ref=e327] [cursor=pointer]: - - img [ref=e328] - - button [ref=e330] [cursor=pointer]: - - img [ref=e331] - - generic [ref=e335]: - - generic [ref=e336]: - - heading "Headers" [level=2] [ref=e337] - - generic [ref=e338]: - - generic [ref=e339]: - - generic [ref=e340]: cookie - - generic [ref=e342]: XSRF-TOKEN=eyJpdiI6IjB5YWlxRFhOOXMzMFZKNVo2anlvV0E9PSIsInZhbHVlIjoibnlXOStINjhmdmhTRUF2VTlFdHpXL3V4cDNwaFdpNnRYU0NhTUVNa0tublNvUVM0UUtlQ010UGFMOG1FRFpSVDNBZGhOUmR5c1VlQnJTbjJ2cGRJZzZYWEI2M2ZkMTh3M0hKNkhLKzJGR1VCUEJoUFpJUEd5YkhTMTJjdXcvQS8iLCJtYWMiOiIzN2M0NTViYTEyMWIxNTA3MTM3YmU4MjgyZjY3NTQxN2QyMTljNzY3Mzg3ZTk4OGVmMjA4MWQ5Zjg2ZGMyNDUxIiwidGFnIjoiIn0%3D; skinbasenova-session=eyJpdiI6IjZ6OHJOSTF1YlFhUG5DaEZmK0R5UGc9PSIsInZhbHVlIjoiSXBwOEFWT25RRlBpaXVKdzZNWWRySE96NUJwOHF6SUc1RVdsR2pEblhYQ1c4N0lTNHFSY1ZtRDY2MmxzVjFXT2RwSkVWSG9SUWNweDNLdkxHM1NmcXhJNllUNEpxeGZVN3JxQmZJM1plb3BZQ3BTTVd4Z05YV0VYb0g0UnBIKzMiLCJtYWMiOiJkNDQ3MDlhNmQ1OTdkNjI1MDliZTBlZTkzNTdkZmQ0ZDQwNTU1ZjcwNmRiZjIxMThjNmVjMjNhMGE1YTI2Nzk1IiwidGFnIjoiIn0%3D; PHPDEBUGBAR_STACK_DATA=%7B%2201KKA1F4SZKZ192GVC1Q09NG2K%22%3Anull%7D - - generic [ref=e343]: - - generic [ref=e344]: accept-encoding - - generic [ref=e346]: gzip, deflate - - generic [ref=e347]: - - generic [ref=e348]: accept - - generic [ref=e350]: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7 - - generic [ref=e351]: - - generic [ref=e352]: accept-language - - generic [ref=e354]: en-US - - generic [ref=e355]: - - generic [ref=e356]: user-agent - - generic [ref=e358]: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.7632.6 Safari/537.36 - - generic [ref=e359]: - - generic [ref=e360]: upgrade-insecure-requests - - generic [ref=e362]: "1" - - generic [ref=e363]: - - generic [ref=e364]: connection - - generic [ref=e366]: close - - generic [ref=e367]: - - generic [ref=e368]: host - - generic [ref=e370]: skinbase26.test - - generic [ref=e371]: - - heading "Body" [level=2] [ref=e372] - - generic [ref=e373]: // No request body - - generic [ref=e374]: - - heading "Routing" [level=2] [ref=e375] - - generic [ref=e376]: - - generic [ref=e377]: - - generic [ref=e378]: controller - - generic [ref=e380]: App\Http\Controllers\Web\ExploreController@index - - generic [ref=e381]: - - generic [ref=e382]: route name - - generic [ref=e384]: explore.index - - generic [ref=e385]: - - generic [ref=e386]: middleware - - generic [ref=e388]: web - - generic [ref=e389]: - - heading "Routing parameters" [level=2] [ref=e390] - - generic [ref=e391]: // No routing parameters - - generic [ref=e394]: - - img [ref=e396] - - img [ref=e3434] - - generic [ref=e6474]: - - generic [ref=e6476]: - - generic [ref=e6477] [cursor=pointer]: - - generic: Request - - generic [ref=e6478]: "500" - - generic [ref=e6479] [cursor=pointer]: - - generic: Exceptions - - generic [ref=e6480]: "2" - - generic [ref=e6481] [cursor=pointer]: - - generic: Messages - - generic [ref=e6482]: "5" - - generic [ref=e6483] [cursor=pointer]: - - generic: Timeline - - generic [ref=e6484] [cursor=pointer]: - - generic: Views - - generic [ref=e6485]: "513" - - generic [ref=e6486] [cursor=pointer]: - - generic: Queries - - generic [ref=e6487]: "91" - - generic [ref=e6488] [cursor=pointer]: - - generic: Models - - generic [ref=e6489]: "156" - - generic [ref=e6490] [cursor=pointer]: - - generic: Cache - - generic [ref=e6491]: "8" - - generic [ref=e6492]: - - generic [ref=e6499] [cursor=pointer]: - - generic [ref=e6500]: "2" - - generic [ref=e6501]: GET /explore - - generic [ref=e6502] [cursor=pointer]: - - generic: 4.91s - - generic [ref=e6504] [cursor=pointer]: - - generic: 51MB - - generic [ref=e6506] [cursor=pointer]: - - generic: 12.x -``` \ No newline at end of file diff --git a/tests/artifacts/routes-Public-routes-—-200-no-errors-Browse-chromium/test-failed-1.png b/tests/artifacts/routes-Public-routes-—-200-no-errors-Browse-chromium/test-failed-1.png deleted file mode 100644 index ca70637f..00000000 Binary files a/tests/artifacts/routes-Public-routes-—-200-no-errors-Browse-chromium/test-failed-1.png and /dev/null differ diff --git a/tests/artifacts/routes-Public-routes-—-200-no-errors-Browse-chromium/video.webm b/tests/artifacts/routes-Public-routes-—-200-no-errors-Browse-chromium/video.webm deleted file mode 100644 index 4294ca8f..00000000 Binary files a/tests/artifacts/routes-Public-routes-—-200-no-errors-Browse-chromium/video.webm and /dev/null differ diff --git a/tests/e2e/dashboard-shortcuts.spec.ts b/tests/e2e/dashboard-shortcuts.spec.ts new file mode 100644 index 00000000..c6dd1fa5 --- /dev/null +++ b/tests/e2e/dashboard-shortcuts.spec.ts @@ -0,0 +1,396 @@ +import { test, expect, type Browser, type Page } from '@playwright/test' +import { execFileSync } from 'node:child_process' +import { existsSync } from 'node:fs' +import path from 'node:path' + +type DashboardFixture = { + email: string + password: string + username: string +} + +const RECENT_VISITS_STORAGE_KEY = 'skinbase.dashboard.recent-visits' +const BASE_URL = process.env.PLAYWRIGHT_BASE_URL || 'http://skinbase26.test' +const VITE_MANIFEST_PATH = path.join(process.cwd(), 'public', 'build', 'manifest.json') + +function ensureCompiledAssets() { + if (existsSync(VITE_MANIFEST_PATH)) { + return + } + + const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm' + + execFileSync(npmCommand, ['run', 'build'], { + cwd: process.cwd(), + stdio: 'inherit', + }) +} + +function seedDashboardFixture(): DashboardFixture { + const token = `${Date.now().toString().slice(-6)}${Math.floor(Math.random() * 1000).toString().padStart(3, '0')}` + const email = `e2e-dashboard-${token}@example.test` + const username = `e2ed${token}`.slice(0, 20) + + const script = [ + "use App\\Models\\User;", + "use Illuminate\\Support\\Facades\\Hash;", + `$user = User::updateOrCreate(['email' => '${email}'], [`, + " 'name' => 'E2E Dashboard User',", + ` 'username' => '${username}',`, + " 'onboarding_step' => 'complete',", + " 'email_verified_at' => now(),", + " 'is_active' => 1,", + " 'password' => Hash::make('password'),", + "]);", + "echo json_encode(['email' => $user->email, 'password' => 'password', 'username' => $user->username]);", + ].join(' ') + + const raw = execFileSync('php', ['artisan', 'tinker', `--execute=${script}`], { + cwd: process.cwd(), + encoding: 'utf8', + }) + + const lines = raw + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean) + + const jsonLine = [...lines].reverse().find((line) => line.startsWith('{') && line.endsWith('}')) + if (!jsonLine) { + throw new Error(`Unable to parse fixture JSON from tinker output: ${raw}`) + } + + return JSON.parse(jsonLine) as DashboardFixture +} + +function setPinnedSpacesForFixture(fixture: DashboardFixture, pinnedSpaces: string[] = []) { + const encodedPinnedSpaces = JSON.stringify(pinnedSpaces) + const script = [ + "use App\\Models\\User;", + "use App\\Models\\DashboardPreference;", + `$user = User::where('email', '${fixture.email}')->firstOrFail();`, + `DashboardPreference::updateOrCreate(['user_id' => $user->id], ['pinned_spaces' => json_decode('${encodedPinnedSpaces}', true)]);`, + "echo 'ok';", + ].join(' ') + + execFileSync('php', ['artisan', 'tinker', `--execute=${script}`], { + cwd: process.cwd(), + encoding: 'utf8', + }) +} + +function resetBotProtectionState() { + const script = [ + "use Illuminate\\Support\\Facades\\DB;", + "use Illuminate\\Support\\Facades\\Schema;", + "foreach (['forum_bot_logs', 'forum_bot_ip_blacklist', 'forum_bot_device_fingerprints', 'forum_bot_behavior_profiles'] as $table) {", + " if (Schema::hasTable($table)) {", + " DB::table($table)->delete();", + ' }', + '}', + "echo 'ok';", + ].join(' ') + + execFileSync('php', ['artisan', 'tinker', `--execute=${script}`], { + cwd: process.cwd(), + encoding: 'utf8', + }) +} + +async function login(page: Page, fixture: DashboardFixture) { + for (let attempt = 0; attempt < 2; attempt += 1) { + await page.goto('/login') + const emailField = page.locator('input[name="email"]') + const viteManifestError = page.getByText(/Vite manifest not found/i) + const internalServerError = page.getByText('Internal Server Error') + + await Promise.race([ + emailField.waitFor({ state: 'visible', timeout: 8000 }), + viteManifestError.waitFor({ state: 'visible', timeout: 8000 }), + internalServerError.waitFor({ state: 'visible', timeout: 8000 }), + ]) + + if (await viteManifestError.isVisible().catch(() => false)) { + throw new Error('Dashboard Playwright login failed because the Vite manifest is missing. Run the frontend build before running this spec.') + } + + if ((await internalServerError.isVisible().catch(() => false)) && !(await emailField.isVisible().catch(() => false))) { + throw new Error('Dashboard Playwright login failed because the login page returned an internal server error before the form loaded.') + } + + await emailField.fill(fixture.email) + await page.locator('input[name="password"]').fill(fixture.password) + await page.getByRole('button', { name: 'Sign In' }).click() + + try { + await page.waitForURL((url) => url.pathname !== '/login', { timeout: 8000, waitUntil: 'domcontentloaded' }) + await expect(page.getByRole('button', { name: /E2E Dashboard User/i })).toBeVisible() + return + } catch { + const suspiciousActivity = page.getByText('Suspicious activity detected.') + if (attempt === 0 && (await suspiciousActivity.isVisible().catch(() => false))) { + resetBotProtectionState() + continue + } + + throw new Error('Dashboard Playwright login failed before reaching an authenticated page.') + } + } +} + +async function openDashboard(page: Page, fixture: DashboardFixture) { + await login(page, fixture) + await page.goto('/dashboard') + await expect(page.getByRole('heading', { name: /welcome back/i })).toBeVisible() +} + +async function openDashboardInFreshContext(browser: Browser, fixture: DashboardFixture) { + const context = await browser.newContext({ baseURL: BASE_URL, ignoreHTTPSErrors: true }) + const page = await context.newPage() + + await openDashboard(page, fixture) + + return { context, page } +} + +async function saveShortcutUpdate(page: Page, action: () => Promise) { + const responsePromise = page.waitForResponse( + (response) => response.url().includes('/api/dashboard/preferences/shortcuts') && response.request().method() === 'PUT' + ) + + await action() + + const response = await responsePromise + expect(response.ok()).toBeTruthy() +} + +async function performShortcutUpdate(page: Page, action: () => Promise) { + const responsePromise = page.waitForResponse( + (response) => response.url().includes('/api/dashboard/preferences/shortcuts') && response.request().method() === 'PUT' + ) + + await action() + + return responsePromise +} + +function shortcutToast(page: Page) { + return page.getByText(/Saving dashboard shortcuts|Dashboard shortcuts saved\./i).first() +} + +async function seedRecentVisits(page: Page, items: Array<{ href: string; label: string; pinned?: boolean; lastVisitedAt?: string | null }>) { + await page.evaluate( + ({ storageKey, recentItems }) => { + window.localStorage.setItem(storageKey, JSON.stringify(recentItems)) + }, + { storageKey: RECENT_VISITS_STORAGE_KEY, recentItems: items } + ) +} + +function recentVisitsHeading(page: Page) { + return page.getByRole('heading', { name: 'Recently visited dashboard spaces' }) +} + +function pinnedSection(page: Page) { + return page + .locator('section') + .filter({ has: page.getByRole('heading', { name: 'Your fastest dashboard shortcuts' }) }) + .first() +} + +async function pinnedShortcutLabels(page: Page): Promise { + return pinnedSection(page).locator('article h3').allTextContents() +} + +async function pinShortcut(page: Page, name: string) { + await saveShortcutUpdate(page, async () => { + await page.getByRole('button', { name: `Pin ${name}` }).click() + }) +} + +async function unpinShortcut(page: Page, name: string) { + await saveShortcutUpdate(page, async () => { + await pinnedSection(page).getByRole('button', { name: `Unpin ${name}` }).click() + }) +} + +async function pinRecentShortcut(page: Page, name: string) { + await saveShortcutUpdate(page, async () => { + await page.getByRole('button', { name: `Pin ${name}` }).first().click() + }) +} + +test.describe('Dashboard pinned shortcuts', () => { + test.describe.configure({ mode: 'serial' }) + + let fixture: DashboardFixture + + test.beforeAll(() => { + ensureCompiledAssets() + fixture = seedDashboardFixture() + }) + + test.beforeEach(() => { + resetBotProtectionState() + setPinnedSpacesForFixture(fixture, []) + }) + + test('pins shortcuts, preserves explicit order, and survives reload without local recents', async ({ page }) => { + await openDashboard(page, fixture) + + await pinShortcut(page, 'Notifications') + await expect(shortcutToast(page)).toBeVisible() + await pinShortcut(page, 'Favorites') + + await expect(pinnedSection(page)).toBeVisible() + await expect.poll(() => pinnedShortcutLabels(page)).toEqual(['Notifications', 'Favorites']) + + await saveShortcutUpdate(page, async () => { + await pinnedSection(page).getByRole('button', { name: 'Move Favorites earlier' }).click() + }) + + await expect.poll(() => pinnedShortcutLabels(page)).toEqual(['Favorites', 'Notifications']) + + await page.evaluate((storageKey) => { + window.localStorage.removeItem(storageKey) + }, RECENT_VISITS_STORAGE_KEY) + + await page.reload({ waitUntil: 'domcontentloaded' }) + await expect(page.getByRole('heading', { name: /welcome back/i })).toBeVisible() + await expect(pinnedSection(page)).toBeVisible() + await expect.poll(() => pinnedShortcutLabels(page)).toEqual(['Favorites', 'Notifications']) + }) + + test('pinned strip matches the visual baseline', async ({ page }) => { + await openDashboard(page, fixture) + + await pinShortcut(page, 'Notifications') + await pinShortcut(page, 'Favorites') + + await expect(pinnedSection(page)).toBeVisible() + await expect.poll(() => pinnedShortcutLabels(page)).toEqual(['Notifications', 'Favorites']) + await expect(pinnedSection(page)).toHaveScreenshot('dashboard-pinned-strip.png', { + animations: 'disabled', + caret: 'hide', + maxDiffPixels: 50, + }) + }) + + test('pinned strip matches the mobile visual baseline', async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }) + await openDashboard(page, fixture) + + await pinShortcut(page, 'Notifications') + await pinShortcut(page, 'Favorites') + + await expect(pinnedSection(page)).toBeVisible() + await expect.poll(() => pinnedShortcutLabels(page)).toEqual(['Notifications', 'Favorites']) + await expect(pinnedSection(page)).toHaveScreenshot('dashboard-pinned-strip-mobile.png', { + animations: 'disabled', + caret: 'hide', + maxDiffPixels: 50, + }) + }) + + test('shows an error toast when shortcut persistence fails', async ({ page }) => { + await openDashboard(page, fixture) + + await page.route('**/api/dashboard/preferences/shortcuts', async (route) => { + await route.fulfill({ + status: 500, + contentType: 'application/json', + body: JSON.stringify({ message: 'Server error' }), + }) + }) + + const response = await performShortcutUpdate(page, async () => { + await page.getByRole('button', { name: 'Pin Notifications' }).click() + }) + + expect(response.ok()).toBeFalsy() + await expect(page.getByText('Could not save dashboard shortcuts. Refresh and try again.')).toBeVisible() + await expect(pinnedSection(page)).toBeVisible() + await expect.poll(() => pinnedShortcutLabels(page)).toEqual(['Notifications']) + }) + + test('unpinning the last shortcut removes the pinned strip', async ({ page }) => { + await openDashboard(page, fixture) + + await pinShortcut(page, 'Notifications') + + await expect(pinnedSection(page)).toBeVisible() + await expect.poll(() => pinnedShortcutLabels(page)).toEqual(['Notifications']) + + await unpinShortcut(page, 'Notifications') + + await expect(pinnedSection(page)).toHaveCount(0) + await expect(page.getByRole('button', { name: 'Pin Notifications' }).first()).toBeVisible() + }) + + test('saved pinned order is restored in a fresh browser context', async ({ browser, page }) => { + await openDashboard(page, fixture) + + await pinShortcut(page, 'Notifications') + await pinShortcut(page, 'Favorites') + + await saveShortcutUpdate(page, async () => { + await pinnedSection(page).getByRole('button', { name: 'Move Favorites earlier' }).click() + }) + + await expect.poll(() => pinnedShortcutLabels(page)).toEqual(['Favorites', 'Notifications']) + + const fresh = await openDashboardInFreshContext(browser, fixture) + + try { + await fresh.page.evaluate((storageKey) => { + window.localStorage.removeItem(storageKey) + }, RECENT_VISITS_STORAGE_KEY) + + await fresh.page.reload({ waitUntil: 'domcontentloaded' }) + await expect(fresh.page.getByRole('heading', { name: /welcome back/i })).toBeVisible() + await expect(pinnedSection(fresh.page)).toBeVisible() + await expect.poll(() => pinnedShortcutLabels(fresh.page)).toEqual(['Favorites', 'Notifications']) + } finally { + await fresh.context.close() + } + }) + + test('pinning from recent cards feeds the same persisted pinned order', async ({ page }) => { + await openDashboard(page, fixture) + + const now = new Date().toISOString() + await seedRecentVisits(page, [ + { + href: '/dashboard/notifications', + label: 'Notifications', + pinned: false, + lastVisitedAt: now, + }, + { + href: '/dashboard/favorites', + label: 'Favorites', + pinned: false, + lastVisitedAt: now, + }, + ]) + + await page.reload({ waitUntil: 'domcontentloaded' }) + await expect(page.getByRole('heading', { name: /welcome back/i })).toBeVisible() + await expect(recentVisitsHeading(page)).toBeVisible() + + await pinRecentShortcut(page, 'Notifications') + await pinRecentShortcut(page, 'Favorites') + + await expect(pinnedSection(page)).toBeVisible() + await expect.poll(() => pinnedShortcutLabels(page)).toEqual(['Notifications', 'Favorites']) + + await page.evaluate((storageKey) => { + window.localStorage.removeItem(storageKey) + }, RECENT_VISITS_STORAGE_KEY) + + await page.reload({ waitUntil: 'domcontentloaded' }) + await expect(page.getByRole('heading', { name: /welcome back/i })).toBeVisible() + await expect(pinnedSection(page)).toBeVisible() + await expect.poll(() => pinnedShortcutLabels(page)).toEqual(['Notifications', 'Favorites']) + }) +}) diff --git a/tests/e2e/dashboard-shortcuts.spec.ts-snapshots/dashboard-pinned-strip-chromium-win32.png b/tests/e2e/dashboard-shortcuts.spec.ts-snapshots/dashboard-pinned-strip-chromium-win32.png new file mode 100644 index 00000000..143531b8 Binary files /dev/null and b/tests/e2e/dashboard-shortcuts.spec.ts-snapshots/dashboard-pinned-strip-chromium-win32.png differ diff --git a/tests/e2e/dashboard-shortcuts.spec.ts-snapshots/dashboard-pinned-strip-mobile-chromium-win32.png b/tests/e2e/dashboard-shortcuts.spec.ts-snapshots/dashboard-pinned-strip-mobile-chromium-win32.png new file mode 100644 index 00000000..02a5c4d9 Binary files /dev/null and b/tests/e2e/dashboard-shortcuts.spec.ts-snapshots/dashboard-pinned-strip-mobile-chromium-win32.png differ diff --git a/tests/e2e/routes.spec.ts b/tests/e2e/routes.spec.ts index 4a8347ae..be7c6016 100644 --- a/tests/e2e/routes.spec.ts +++ b/tests/e2e/routes.spec.ts @@ -68,9 +68,9 @@ const PUBLIC_ROUTES: RouteFixture[] = [ { url: '/comments/monthly', label: 'Monthly commentators (new)' }, { url: '/downloads/today', label: 'Today downloads (new)' }, { url: '/top-authors', label: 'Top authors (legacy)', expectUrlContains: '/creators/top' }, - { url: '/top-favourites', label: 'Top favourites (legacy)' }, + { url: '/top-favourites', label: 'Top favourites (legacy)', expectUrlContains: '/discover/top-rated' }, { url: '/today-downloads', label: 'Today downloads (legacy)' }, - { url: '/today-in-history', label: 'Today in history' }, + { url: '/today-in-history', label: 'Today in history', expectUrlContains: '/discover/on-this-day' }, { url: '/monthly-commentators',label: 'Monthly commentators (legacy)' }, { url: '/latest-comments', label: 'Latest comments (legacy)' }, { url: '/interviews', label: 'Interviews', expectUrlContains: '/stories' }, diff --git a/vite.config.mjs b/vite.config.mjs index d0190fd9..b4a232a7 100644 --- a/vite.config.mjs +++ b/vite.config.mjs @@ -25,6 +25,7 @@ export default defineConfig({ 'resources/js/Pages/Messages/Index.jsx', 'resources/js/profile.jsx', 'resources/js/feed.jsx', + 'resources/js/leaderboard.jsx', 'resources/js/settings.jsx', 'resources/js/entry-forum.jsx', ],