login update

This commit is contained in:
2026-03-05 11:24:37 +01:00
parent 5a33ca55a1
commit f6772f673b
67 changed files with 10640 additions and 116 deletions

View File

@@ -0,0 +1,188 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Story;
use App\Models\StoryTag;
use App\Models\StoryAuthor;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
/**
* Stories API JSON endpoints for React frontend.
*
* GET /api/stories list published stories (paginated)
* GET /api/stories/{slug} single story detail
* GET /api/stories/tag/{tag} stories by tag
* GET /api/stories/author/{author} stories by author
* GET /api/stories/featured featured stories
*/
final class StoriesApiController extends Controller
{
/**
* List published stories (paginated).
* GET /api/stories?page=1&per_page=12
*/
public function index(Request $request): JsonResponse
{
$perPage = min((int) $request->get('per_page', 12), 50);
$page = (int) $request->get('page', 1);
$cacheKey = "stories:api:list:{$perPage}:{$page}";
$stories = Cache::remember($cacheKey, 300, fn () =>
Story::published()
->with('author', 'tags')
->orderByDesc('published_at')
->paginate($perPage, ['*'], 'page', $page)
);
return response()->json([
'data' => $stories->getCollection()->map(fn (Story $s) => $this->formatCard($s)),
'meta' => [
'current_page' => $stories->currentPage(),
'last_page' => $stories->lastPage(),
'per_page' => $stories->perPage(),
'total' => $stories->total(),
],
]);
}
/**
* Single story detail.
* GET /api/stories/{slug}
*/
public function show(string $slug): JsonResponse
{
$story = Cache::remember('stories:api:' . $slug, 600, fn () =>
Story::published()
->with('author', 'tags')
->where('slug', $slug)
->firstOrFail()
);
return response()->json($this->formatFull($story));
}
/**
* Featured story.
* GET /api/stories/featured
*/
public function featured(): JsonResponse
{
$story = Cache::remember('stories:api:featured', 300, fn () =>
Story::published()->featured()
->with('author', 'tags')
->orderByDesc('published_at')
->first()
);
if (! $story) {
return response()->json(null);
}
return response()->json($this->formatFull($story));
}
/**
* Stories by tag.
* GET /api/stories/tag/{tag}?page=1
*/
public function byTag(Request $request, string $tag): JsonResponse
{
$storyTag = StoryTag::where('slug', $tag)->firstOrFail();
$page = (int) $request->get('page', 1);
$stories = Cache::remember("stories:api:tag:{$tag}:{$page}", 300, fn () =>
Story::published()
->with('author', 'tags')
->whereHas('tags', fn ($q) => $q->where('stories_tags.id', $storyTag->id))
->orderByDesc('published_at')
->paginate(12, ['*'], 'page', $page)
);
return response()->json([
'tag' => ['id' => $storyTag->id, 'slug' => $storyTag->slug, 'name' => $storyTag->name],
'data' => $stories->getCollection()->map(fn (Story $s) => $this->formatCard($s)),
'meta' => [
'current_page' => $stories->currentPage(),
'last_page' => $stories->lastPage(),
'per_page' => $stories->perPage(),
'total' => $stories->total(),
],
]);
}
/**
* Stories by author.
* GET /api/stories/author/{username}?page=1
*/
public function byAuthor(Request $request, string $username): JsonResponse
{
$author = StoryAuthor::whereHas('user', fn ($q) => $q->where('username', $username))->first()
?? StoryAuthor::where('name', $username)->firstOrFail();
$page = (int) $request->get('page', 1);
$stories = Cache::remember("stories:api:author:{$author->id}:{$page}", 300, fn () =>
Story::published()
->with('author', 'tags')
->where('author_id', $author->id)
->orderByDesc('published_at')
->paginate(12, ['*'], 'page', $page)
);
return response()->json([
'author' => $this->formatAuthor($author),
'data' => $stories->getCollection()->map(fn (Story $s) => $this->formatCard($s)),
'meta' => [
'current_page' => $stories->currentPage(),
'last_page' => $stories->lastPage(),
'per_page' => $stories->perPage(),
'total' => $stories->total(),
],
]);
}
// ── Private formatters ────────────────────────────────────────────────
private function formatCard(Story $story): array
{
return [
'id' => $story->id,
'slug' => $story->slug,
'url' => $story->url,
'title' => $story->title,
'excerpt' => $story->excerpt,
'cover_image' => $story->cover_url,
'author' => $story->author ? $this->formatAuthor($story->author) : null,
'tags' => $story->tags->map(fn ($t) => ['id' => $t->id, 'slug' => $t->slug, 'name' => $t->name, 'url' => $t->url]),
'views' => $story->views,
'featured' => $story->featured,
'reading_time' => $story->reading_time,
'published_at' => $story->published_at?->toIso8601String(),
];
}
private function formatFull(Story $story): array
{
return array_merge($this->formatCard($story), [
'content' => $story->content,
]);
}
private function formatAuthor(StoryAuthor $author): array
{
return [
'id' => $author->id,
'name' => $author->name,
'avatar_url' => $author->avatar_url,
'bio' => $author->bio,
'profile_url' => $author->profile_url,
];
}
}

View File

@@ -0,0 +1,252 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Models\SocialAccount;
use App\Models\User;
use Illuminate\Database\UniqueConstraintViolationException;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
use Laravel\Socialite\Contracts\User as SocialiteUser;
use Laravel\Socialite\Facades\Socialite;
use Throwable;
class OAuthController extends Controller
{
/** Providers enabled for OAuth login. */
private const ALLOWED_PROVIDERS = ['google', 'discord'];
/**
* Redirect the user to the provider's OAuth page.
*/
public function redirectToProvider(string $provider): RedirectResponse
{
$this->abortIfInvalidProvider($provider);
return Socialite::driver($provider)->redirect();
}
/**
* Handle the provider callback and authenticate the user.
*/
public function handleProviderCallback(string $provider): RedirectResponse
{
$this->abortIfInvalidProvider($provider);
try {
/** @var SocialiteUser $socialUser */
$socialUser = Socialite::driver($provider)->user();
} catch (Throwable) {
return redirect()->route('login')
->withErrors(['oauth' => 'Authentication failed. Please try again.']);
}
$providerId = (string) $socialUser->getId();
$providerEmail = $this->resolveEmail($socialUser);
$verified = $this->isEmailVerifiedByProvider($provider, $socialUser);
// ── 1. Provider account already linked → login ───────────────────────
$existing = SocialAccount::query()
->where('provider', $provider)
->where('provider_id', $providerId)
->with('user')
->first();
if ($existing !== null && $existing->user !== null) {
return $this->loginAndRedirect($existing->user);
}
// ── 2. Email match → link to existing account ────────────────────────
// Covers both verified and unverified users: if the OAuth provider
// has confirmed this email we can safely link it and mark it verified,
// preventing a duplicate-email insert when the user had started
// registration via email but never finished verification.
if ($providerEmail !== null && $verified) {
$userByEmail = User::query()
->where('email', strtolower($providerEmail))
->first();
if ($userByEmail !== null) {
// If their email was not yet verified, promote it now — the
// OAuth provider has already verified it on our behalf.
if ($userByEmail->email_verified_at === null) {
$userByEmail->forceFill([
'email_verified_at' => now(),
'is_active' => true,
// Keep their onboarding step unless already complete
'onboarding_step' => $userByEmail->onboarding_step === 'email'
? 'username'
: ($userByEmail->onboarding_step ?? 'username'),
])->save();
}
$this->createSocialAccount($userByEmail, $provider, $providerId, $providerEmail, $socialUser->getAvatar());
return $this->loginAndRedirect($userByEmail);
}
}
// ── 3. Provider email not verified → reject auto-link ────────────────
if ($providerEmail !== null && ! $verified) {
return redirect()->route('login')
->withErrors(['oauth' => 'Your ' . ucfirst($provider) . ' email is not verified. Please verify it and try again.']);
}
// ── 4. No email at all → cannot proceed ──────────────────────────────
if ($providerEmail === null) {
return redirect()->route('login')
->withErrors(['oauth' => 'Could not retrieve your email address from ' . ucfirst($provider) . '. Please register with email.']);
}
// ── 5. New user creation ──────────────────────────────────────────────
try {
$user = $this->createOAuthUser($socialUser, $provider, $providerId, $providerEmail);
} catch (UniqueConstraintViolationException) {
// Race condition: another request inserted the same email between
// the lookup above and this insert. Fetch and link instead.
$user = User::query()->where('email', strtolower($providerEmail))->first();
if ($user === null) {
return redirect()->route('login')
->withErrors(['oauth' => 'An error occurred during sign in. Please try again.']);
}
$this->createSocialAccount($user, $provider, $providerId, $providerEmail, $socialUser->getAvatar());
}
return $this->loginAndRedirect($user);
}
// ─── Private helpers ─────────────────────────────────────────────────────
private function abortIfInvalidProvider(string $provider): void
{
abort_unless(in_array($provider, self::ALLOWED_PROVIDERS, true), 404);
}
/**
* Create social_accounts row linked to a user.
*/
private function createSocialAccount(
User $user,
string $provider,
string $providerId,
?string $providerEmail,
?string $avatar
): void {
SocialAccount::query()->updateOrCreate(
['provider' => $provider, 'provider_id' => $providerId],
[
'user_id' => $user->id,
'provider_email' => $providerEmail !== null ? strtolower($providerEmail) : null,
'avatar' => $avatar,
]
);
}
/**
* Create a brand-new user from OAuth data.
*/
private function createOAuthUser(
SocialiteUser $socialUser,
string $provider,
string $providerId,
string $providerEmail
): User {
$user = DB::transaction(function () use ($socialUser, $provider, $providerId, $providerEmail): User {
$name = $this->resolveDisplayName($socialUser, $providerEmail);
$user = User::query()->create([
'username' => null,
'name' => $name,
'email' => strtolower($providerEmail),
'email_verified_at' => now(),
'password' => Hash::make(Str::random(64)),
'is_active' => true,
'onboarding_step' => 'username',
'username_changed_at' => now(),
]);
$this->createSocialAccount(
$user,
$provider,
$providerId,
$providerEmail,
$socialUser->getAvatar()
);
return $user;
});
return $user;
}
/**
* Login the user and redirect appropriately.
*/
private function loginAndRedirect(User $user): RedirectResponse
{
Auth::login($user, remember: true);
request()->session()->regenerate();
$step = strtolower((string) ($user->onboarding_step ?? ''));
if (in_array($step, ['username', 'password'], true)) {
return redirect()->route('setup.username.create');
}
return redirect()->intended(route('dashboard', absolute: false));
}
/**
* Resolve a usable display name from the social user.
*/
private function resolveDisplayName(SocialiteUser $socialUser, string $email): string
{
$name = trim((string) ($socialUser->getName() ?? ''));
if ($name !== '') {
return $name;
}
return Str::before($email, '@');
}
/**
* Best-effort email resolution. Apple can return null email on repeat logins.
*/
private function resolveEmail(SocialiteUser $socialUser): ?string
{
$email = $socialUser->getEmail();
if ($email === null || $email === '') {
return null;
}
return strtolower(trim($email));
}
/**
* Determine whether the provider has verified the user's email.
*
* - Google: returns email_verified flag in raw data
* - Discord: returns verified flag in raw data
* - Apple: only issues tokens for verified Apple IDs
*/
private function isEmailVerifiedByProvider(string $provider, SocialiteUser $socialUser): bool
{
$raw = (array) ($socialUser->getRaw() ?? []);
return match ($provider) {
'google' => filter_var($raw['email_verified'] ?? false, FILTER_VALIDATE_BOOLEAN),
'discord' => (bool) ($raw['verified'] ?? false),
'apple' => true, // Apple only issues tokens for verified Apple IDs
default => false,
};
}
}

View File

@@ -34,8 +34,9 @@ class FollowingController extends Controller
->through(fn ($row) => (object) [
'id' => $row->id,
'username' => $row->username,
'name' => $row->name,
'uname' => $row->username ?? $row->name,
'avatar_url' => AvatarUrl::forUser((int) $row->id, $row->avatar_hash, 50),
'avatar_url' => AvatarUrl::forUser((int) $row->id, $row->avatar_hash, 64),
'profile_url' => '/@' . strtolower((string) ($row->username ?? $row->id)),
'uploads' => $row->uploads_count ?? 0,
'followers_count'=> $row->followers_count ?? 0,

View File

@@ -36,7 +36,8 @@ class TopAuthorsController extends Controller
$query = DB::table(DB::raw('(' . $sub->toSql() . ') as t'))
->mergeBindings($sub->getQuery())
->join('users as u', 'u.id', '=', 't.user_id')
->select('u.id as user_id', 'u.name as uname', 'u.username', 't.total_metric', 't.latest_published')
->leftJoin('user_profiles as up', 'up.user_id', '=', 'u.id')
->select('u.id as user_id', 'u.name as uname', 'u.username', 'up.avatar_hash', 't.total_metric', 't.latest_published')
->orderByDesc('t.total_metric')
->orderByDesc('t.latest_published');
@@ -48,6 +49,7 @@ class TopAuthorsController extends Controller
'user_id' => $row->user_id,
'uname' => $row->uname,
'username' => $row->username,
'avatar_hash' => $row->avatar_hash,
'total' => (int) $row->total_metric,
'metric' => $metric,
];

View File

@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\RSS;
use App\Http\Controllers\Controller;
use App\Models\BlogPost;
use App\Services\RSS\RSSFeedBuilder;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Cache;
/**
* BlogFeedController
*
* GET /rss/blog latest blog posts feed (spec §3.6)
*/
final class BlogFeedController extends Controller
{
public function __construct(private readonly RSSFeedBuilder $builder) {}
public function __invoke(): Response
{
$feedUrl = url('/rss/blog');
$posts = Cache::remember('rss:blog', 600, fn () =>
BlogPost::published()
->with('author:id,username')
->latest('published_at')
->limit(RSSFeedBuilder::FEED_LIMIT)
->get()
);
return $this->builder->buildFromBlogPosts(
'Blog',
'Latest posts from the Skinbase blog.',
$feedUrl,
$posts,
);
}
}

View File

@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\RSS;
use App\Http\Controllers\Controller;
use App\Models\Artwork;
use App\Models\User;
use App\Services\RSS\RSSFeedBuilder;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Cache;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
/**
* CreatorFeedController
*
* GET /rss/creator/{username} latest artworks by a given creator (spec §3.5)
*/
final class CreatorFeedController extends Controller
{
public function __construct(private readonly RSSFeedBuilder $builder) {}
public function __invoke(string $username): Response
{
$user = User::where('username', $username)->first();
if (! $user) {
throw new NotFoundHttpException("Creator [{$username}] not found.");
}
$feedUrl = url('/rss/creator/' . $username);
$artworks = Cache::remember('rss:creator:' . strtolower($username), 300, fn () =>
Artwork::public()->published()
->with(['user:id,username', 'categories:id,name,slug,content_type_id'])
->where('artworks.user_id', $user->id)
->latest('artworks.published_at')
->limit(RSSFeedBuilder::FEED_LIMIT)
->get()
);
return $this->builder->buildFromArtworks(
$user->username . '\'s Artworks',
'Latest artworks by ' . $user->username . ' on Skinbase.',
$feedUrl,
$artworks,
);
}
}

View File

@@ -0,0 +1,98 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\RSS;
use App\Http\Controllers\Controller;
use App\Models\Artwork;
use App\Services\RSS\RSSFeedBuilder;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Cache;
/**
* DiscoverFeedController
*
* Powers the /rss/discover/* feeds (spec §3.2).
*
* GET /rss/discover fresh/latest (default)
* GET /rss/discover/trending trending by trending_score_7d
* GET /rss/discover/fresh latest published
* GET /rss/discover/rising rising by heat_score
*/
final class DiscoverFeedController extends Controller
{
public function __construct(private readonly RSSFeedBuilder $builder) {}
/** /rss/discover → redirect to fresh */
public function index(): Response
{
return $this->fresh();
}
/** /rss/discover/trending */
public function trending(): Response
{
$feedUrl = url('/rss/discover/trending');
$artworks = Cache::remember('rss:discover:trending', 600, fn () =>
Artwork::public()->published()
->with(['user:id,username', 'categories:id,name,slug,content_type_id'])
->leftJoin('artwork_stats', 'artwork_stats.artwork_id', '=', 'artworks.id')
->orderByDesc('artwork_stats.trending_score_7d')
->orderByDesc('artworks.published_at')
->select('artworks.*')
->limit(RSSFeedBuilder::FEED_LIMIT)
->get()
);
return $this->builder->buildFromArtworks(
'Trending Artworks',
'The most-viewed and trending artworks on Skinbase over the past 7 days.',
$feedUrl,
$artworks,
);
}
/** /rss/discover/fresh */
public function fresh(): Response
{
$feedUrl = url('/rss/discover/fresh');
$artworks = Cache::remember('rss:discover:fresh', 300, fn () =>
Artwork::public()->published()
->with(['user:id,username', 'categories:id,name,slug,content_type_id'])
->latest('published_at')
->limit(RSSFeedBuilder::FEED_LIMIT)
->get()
);
return $this->builder->buildFromArtworks(
'Fresh Uploads',
'The latest artworks just published on Skinbase.',
$feedUrl,
$artworks,
);
}
/** /rss/discover/rising */
public function rising(): Response
{
$feedUrl = url('/rss/discover/rising');
$artworks = Cache::remember('rss:discover:rising', 600, fn () =>
Artwork::public()->published()
->with(['user:id,username', 'categories:id,name,slug,content_type_id'])
->leftJoin('artwork_stats', 'artwork_stats.artwork_id', '=', 'artworks.id')
->orderByDesc('artwork_stats.heat_score')
->orderByDesc('artworks.published_at')
->select('artworks.*')
->limit(RSSFeedBuilder::FEED_LIMIT)
->get()
);
return $this->builder->buildFromArtworks(
'Rising Artworks',
'Fastest-growing artworks gaining momentum on Skinbase right now.',
$feedUrl,
$artworks,
);
}
}

View File

@@ -0,0 +1,105 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\RSS;
use App\Http\Controllers\Controller;
use App\Models\Artwork;
use App\Models\ContentType;
use App\Services\RSS\RSSFeedBuilder;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Cache;
/**
* ExploreFeedController
*
* Powers the /rss/explore/* feeds (spec §3.3).
*
* GET /rss/explore/{type} latest by content type
* GET /rss/explore/{type}/{mode} sorted by mode (trending|latest|best)
*
* Valid types: artworks | wallpapers | skins | photography | other
* Valid modes: trending | latest | best
*/
final class ExploreFeedController extends Controller
{
private const SORT_TTL = [
'trending' => 600,
'best' => 600,
'latest' => 300,
];
public function __construct(private readonly RSSFeedBuilder $builder) {}
/** /rss/explore/{type} — defaults to latest */
public function byType(string $type): Response
{
return $this->feed($type, 'latest');
}
/** /rss/explore/{type}/{mode} */
public function byTypeMode(string $type, string $mode): Response
{
return $this->feed($type, $mode);
}
// ─────────────────────────────────────────────────────────────────────────
private function feed(string $type, string $mode): Response
{
$mode = in_array($mode, ['trending', 'latest', 'best'], true) ? $mode : 'latest';
$ttl = self::SORT_TTL[$mode] ?? 300;
$feedUrl = url('/rss/explore/' . $type . ($mode !== 'latest' ? '/' . $mode : ''));
$label = ucfirst(str_replace('-', ' ', $type));
$artworks = Cache::remember("rss:explore:{$type}:{$mode}", $ttl, function () use ($type, $mode) {
$contentType = ContentType::where('slug', $type)->first();
$query = Artwork::public()->published()
->with(['user:id,username', 'categories:id,name,slug,content_type_id']);
if ($contentType) {
$query->whereHas('categories', fn ($q) =>
$q->where('content_type_id', $contentType->id)
);
}
return match ($mode) {
'trending' => $query
->leftJoin('artwork_stats', 'artwork_stats.artwork_id', '=', 'artworks.id')
->orderByDesc('artwork_stats.trending_score_7d')
->orderByDesc('artworks.published_at')
->select('artworks.*')
->limit(RSSFeedBuilder::FEED_LIMIT)
->get(),
'best' => $query
->leftJoin('artwork_stats', 'artwork_stats.artwork_id', '=', 'artworks.id')
->orderByDesc('artwork_stats.favorites')
->orderByDesc('artwork_stats.downloads')
->select('artworks.*')
->limit(RSSFeedBuilder::FEED_LIMIT)
->get(),
default => $query
->latest('artworks.published_at')
->limit(RSSFeedBuilder::FEED_LIMIT)
->get(),
};
});
$modeLabel = match ($mode) {
'trending' => 'Trending',
'best' => 'Best',
default => 'Latest',
};
return $this->builder->buildFromArtworks(
"{$modeLabel} {$label}",
"{$modeLabel} {$label} artworks on Skinbase.",
$feedUrl,
$artworks,
);
}
}

View File

@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\RSS;
use App\Http\Controllers\Controller;
use App\Models\Artwork;
use App\Services\RSS\RSSFeedBuilder;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Cache;
/**
* GlobalFeedController
*
* GET /rss global latest-artworks feed (spec §3.1)
*/
final class GlobalFeedController extends Controller
{
public function __construct(private readonly RSSFeedBuilder $builder) {}
public function __invoke(): Response
{
$feedUrl = url('/rss');
$artworks = Cache::remember('rss:global', 300, fn () =>
Artwork::public()->published()
->with(['user:id,username', 'categories:id,name,slug,content_type_id'])
->latest('published_at')
->limit(RSSFeedBuilder::FEED_LIMIT)
->get()
);
return $this->builder->buildFromArtworks(
'Latest Artworks',
'The newest artworks published on Skinbase.',
$feedUrl,
$artworks,
);
}
}

View File

@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\RSS;
use App\Http\Controllers\Controller;
use App\Models\Artwork;
use App\Models\Tag;
use App\Services\RSS\RSSFeedBuilder;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Cache;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
/**
* TagFeedController
*
* GET /rss/tag/{slug} artworks tagged with given slug (spec §3.4)
*/
final class TagFeedController extends Controller
{
public function __construct(private readonly RSSFeedBuilder $builder) {}
public function __invoke(string $slug): Response
{
$tag = Tag::where('slug', $slug)->first();
if (! $tag) {
throw new NotFoundHttpException("Tag [{$slug}] not found.");
}
$feedUrl = url('/rss/tag/' . $slug);
$artworks = Cache::remember('rss:tag:' . $slug, 600, fn () =>
Artwork::public()->published()
->with(['user:id,username', 'categories:id,name,slug,content_type_id'])
->whereHas('tags', fn ($q) => $q->where('tags.id', $tag->id))
->latest('artworks.published_at')
->limit(RSSFeedBuilder::FEED_LIMIT)
->get()
);
return $this->builder->buildFromArtworks(
ucwords(str_replace('-', ' ', $slug)) . ' Artworks',
'Latest Skinbase artworks tagged "' . $tag->name . '".',
$feedUrl,
$artworks,
);
}
}

View File

@@ -33,7 +33,8 @@ class TopAuthorsController extends Controller
$query = DB::table(DB::raw('(' . $sub->toSql() . ') as t'))
->mergeBindings($sub->getQuery())
->join('users as u', 'u.id', '=', 't.user_id')
->select('u.id as user_id', 'u.name as uname', 'u.username', 't.total_metric', 't.latest_published')
->leftJoin('user_profiles as up', 'up.user_id', '=', 'u.id')
->select('u.id as user_id', 'u.name as uname', 'u.username', 'up.avatar_hash', 't.total_metric', 't.latest_published')
->orderByDesc('t.total_metric')
->orderByDesc('t.latest_published');
@@ -44,6 +45,7 @@ class TopAuthorsController extends Controller
'user_id' => $row->user_id,
'uname' => $row->uname,
'username' => $row->username,
'avatar_hash' => $row->avatar_hash,
'total' => (int) $row->total_metric,
'metric' => $metric,
];

View File

@@ -13,18 +13,66 @@ use Illuminate\View\View;
/**
* RssFeedController
*
* GET /rss-feeds info page listing available feeds
* GET /rss/latest-uploads.xml all published artworks
* GET /rss/latest-skins.xml skins only
* GET /rss/latest-wallpapers.xml wallpapers only
* GET /rss/latest-photos.xml photography only
* GET /rss-feeds info page listing all available feeds
* GET /rss/latest-uploads.xml all published artworks (legacy)
* GET /rss/latest-skins.xml skins only (legacy)
* GET /rss/latest-wallpapers.xml wallpapers only (legacy)
* GET /rss/latest-photos.xml photography only (legacy)
*
* Nova feeds live in App\Http\Controllers\RSS\*.
*/
final class RssFeedController extends Controller
{
/** Number of items per feed. */
/** Number of items per legacy feed. */
private const FEED_LIMIT = 25;
/** Feed definitions shown on the info page. */
/**
* Grouped feed definitions shown on the /rss-feeds info page.
* Each group has a 'label' and an array of 'feeds' with title + url.
*/
public const FEED_GROUPS = [
'global' => [
'label' => 'Global',
'feeds' => [
['title' => 'Latest Artworks', 'url' => '/rss', 'description' => 'All new artworks across the platform.'],
],
],
'discover' => [
'label' => 'Discover',
'feeds' => [
['title' => 'Fresh Uploads', 'url' => '/rss/discover/fresh', 'description' => 'The newest artworks just published.'],
['title' => 'Trending', 'url' => '/rss/discover/trending', 'description' => 'Most-viewed artworks over the past 7 days.'],
['title' => 'Rising', 'url' => '/rss/discover/rising', 'description' => 'Artworks gaining momentum right now.'],
],
],
'explore' => [
'label' => 'Explore',
'feeds' => [
['title' => 'All Artworks', 'url' => '/rss/explore/artworks', 'description' => 'Latest artworks of all types.'],
['title' => 'Wallpapers', 'url' => '/rss/explore/wallpapers', 'description' => 'Latest wallpapers.'],
['title' => 'Skins', 'url' => '/rss/explore/skins', 'description' => 'Latest skins.'],
['title' => 'Photography', 'url' => '/rss/explore/photography', 'description' => 'Latest photography.'],
['title' => 'Trending Wallpapers', 'url' => '/rss/explore/wallpapers/trending', 'description' => 'Trending wallpapers this week.'],
],
],
'blog' => [
'label' => 'Blog',
'feeds' => [
['title' => 'Blog Posts', 'url' => '/rss/blog', 'description' => 'Latest posts from the Skinbase blog.'],
],
],
'legacy' => [
'label' => 'Legacy Feeds',
'feeds' => [
['title' => 'Latest Uploads (XML)', 'url' => '/rss/latest-uploads.xml', 'description' => 'Legacy XML feed.'],
['title' => 'Latest Skins (XML)', 'url' => '/rss/latest-skins.xml', 'description' => 'Legacy XML feed.'],
['title' => 'Latest Wallpapers (XML)', 'url' => '/rss/latest-wallpapers.xml', 'description' => 'Legacy XML feed.'],
['title' => 'Latest Photos (XML)', 'url' => '/rss/latest-photos.xml', 'description' => 'Legacy XML feed.'],
],
],
];
/** Flat feed list kept for backward-compatibility (old view logic). */
public const FEEDS = [
'uploads' => ['title' => 'Latest Uploads', 'url' => '/rss/latest-uploads.xml'],
'skins' => ['title' => 'Latest Skins', 'url' => '/rss/latest-skins.xml'],
@@ -45,7 +93,8 @@ final class RssFeedController extends Controller
(object) ['name' => 'Home', 'url' => '/'],
(object) ['name' => 'RSS Feeds', 'url' => '/rss-feeds'],
]),
'feeds' => self::FEEDS,
'feeds' => self::FEEDS,
'feed_groups' => self::FEED_GROUPS,
'center_content' => true,
'center_max' => '3xl',
]);

View File

@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Web;
use App\Http\Controllers\Controller;
use App\Models\Story;
use App\Models\StoryAuthor;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\View\View;
/**
* Stories filtered by author /stories/author/{username}
*/
final class StoriesAuthorController extends Controller
{
public function show(Request $request, string $username): View
{
// Resolve by linked user username first, then by author name slug
$author = StoryAuthor::whereHas('user', fn ($q) => $q->where('username', $username))
->with('user')
->first();
if (! $author) {
// Fallback: author name matches slug-style
$author = StoryAuthor::where('name', $username)->first();
}
if (! $author) {
abort(404);
}
$stories = Cache::remember('stories:author:' . $author->id . ':page:' . ($request->get('page', 1)), 300, fn () =>
Story::published()
->with('author', 'tags')
->where('author_id', $author->id)
->orderByDesc('published_at')
->paginate(12)
->withQueryString()
);
$authorName = $author->user?->username ?? $author->name;
return view('web.stories.author', [
'author' => $author,
'stories' => $stories,
'page_title' => 'Stories by ' . $authorName . ' — Skinbase',
'page_meta_description' => 'All stories and interviews by ' . $authorName . ' on Skinbase.',
'page_canonical' => url('/stories/author/' . $username),
'page_robots' => 'index,follow',
'breadcrumbs' => collect([
(object) ['name' => 'Stories', 'url' => '/stories'],
(object) ['name' => $authorName, 'url' => '/stories/author/' . $username],
]),
]);
}
}

View File

@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Web;
use App\Http\Controllers\Controller;
use App\Models\Story;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\View\View;
/**
* Stories listing page /stories
*/
final class StoriesController extends Controller
{
public function index(Request $request): View
{
$featured = Cache::remember('stories:featured', 300, fn () =>
Story::published()->featured()
->with('author', 'tags')
->orderByDesc('published_at')
->first()
);
$stories = Cache::remember('stories:list:page:' . ($request->get('page', 1)), 300, fn () =>
Story::published()
->with('author', 'tags')
->orderByDesc('published_at')
->paginate(12)
->withQueryString()
);
return view('web.stories.index', [
'featured' => $featured,
'stories' => $stories,
'page_title' => 'Stories — Skinbase',
'page_meta_description' => 'Artist interviews, community spotlights, tutorials and announcements from Skinbase.',
'page_canonical' => url('/stories'),
'page_robots' => 'index,follow',
'breadcrumbs' => collect([
(object) ['name' => 'Stories', 'url' => '/stories'],
]),
]);
}
}

View File

@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Web;
use App\Http\Controllers\Controller;
use App\Models\Story;
use App\Models\StoryTag;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\View\View;
/**
* Stories filtered by tag /stories/tag/{tag}
*/
final class StoriesTagController extends Controller
{
public function show(Request $request, string $tag): View
{
$storyTag = StoryTag::where('slug', $tag)->firstOrFail();
$stories = Cache::remember('stories:tag:' . $tag . ':page:' . ($request->get('page', 1)), 300, fn () =>
Story::published()
->with('author', 'tags')
->whereHas('tags', fn ($q) => $q->where('stories_tags.id', $storyTag->id))
->orderByDesc('published_at')
->paginate(12)
->withQueryString()
);
return view('web.stories.tag', [
'storyTag' => $storyTag,
'stories' => $stories,
'page_title' => '#' . $storyTag->name . ' Stories — Skinbase',
'page_meta_description' => 'Stories tagged with "' . $storyTag->name . '" on Skinbase.',
'page_canonical' => url('/stories/tag/' . $storyTag->slug),
'page_robots' => 'index,follow',
'breadcrumbs' => collect([
(object) ['name' => 'Stories', 'url' => '/stories'],
(object) ['name' => '#' . $storyTag->name, 'url' => '/stories/tag/' . $storyTag->slug],
]),
]);
}
}

View File

@@ -0,0 +1,86 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Web;
use App\Http\Controllers\Controller;
use App\Models\Story;
use Illuminate\Support\Facades\Cache;
use Illuminate\View\View;
/**
* Single story page /stories/{slug}
*/
final class StoryController extends Controller
{
public function show(string $slug): View
{
$story = Cache::remember('stories:' . $slug, 600, fn () =>
Story::published()
->with('author', 'tags')
->where('slug', $slug)
->firstOrFail()
);
// Increment view counter (fire-and-forget, no cache invalidation needed)
Story::where('id', $story->id)->increment('views');
// Related stories: shared tags → same author → newest
$related = Cache::remember('stories:related:' . $story->id, 600, function () use ($story) {
$tagIds = $story->tags->pluck('id');
$related = collect();
if ($tagIds->isNotEmpty()) {
$related = Story::published()
->with('author', 'tags')
->whereHas('tags', fn ($q) => $q->whereIn('stories_tags.id', $tagIds))
->where('id', '!=', $story->id)
->orderByDesc('published_at')
->limit(6)
->get();
}
if ($related->count() < 3 && $story->author_id) {
$byAuthor = Story::published()
->with('author', 'tags')
->where('author_id', $story->author_id)
->where('id', '!=', $story->id)
->whereNotIn('id', $related->pluck('id'))
->orderByDesc('published_at')
->limit(6 - $related->count())
->get();
$related = $related->merge($byAuthor);
}
if ($related->count() < 3) {
$newest = Story::published()
->with('author', 'tags')
->where('id', '!=', $story->id)
->whereNotIn('id', $related->pluck('id'))
->orderByDesc('published_at')
->limit(6 - $related->count())
->get();
$related = $related->merge($newest);
}
return $related->take(6);
});
return view('web.stories.show', [
'story' => $story,
'related' => $related,
'page_title' => $story->title . ' — Skinbase Stories',
'page_meta_description' => $story->meta_excerpt,
'page_canonical' => $story->url,
'page_robots' => 'index,follow',
'breadcrumbs' => collect([
(object) ['name' => 'Stories', 'url' => '/stories'],
(object) ['name' => $story->title, 'url' => $story->url],
]),
]);
}
}

View File

@@ -9,6 +9,7 @@ use App\Models\ContentType;
use App\Models\Tag;
use App\Services\ArtworkSearchService;
use App\Services\EarlyGrowth\GridFiller;
use App\Services\ThumbnailPresenter;
use Illuminate\Http\Request;
use Illuminate\View\View;
@@ -60,11 +61,10 @@ final class TagController extends Controller
$page = max(1, (int) $request->query('page', 1));
$artworks = $this->gridFiller->fill($artworks, 0, $page);
// Eager-load relations needed by the artwork-card component.
// Scout returns bare Eloquent models; without this, each card triggers N+1 queries.
$artworks->getCollection()->loadMissing(['user.profile']);
// Eager-load relations used by the gallery presenter and thumbnails.
$artworks->getCollection()->each(fn($m) => $m->loadMissing(['user.profile', 'categories']));
// Sidebar: content type links (same as browse gallery)
// Sidebar: main content type links (same as browse gallery)
$mainCategories = ContentType::orderBy('id')->get(['name', 'slug'])
->map(fn ($type) => (object) [
'id' => $type->id,
@@ -73,15 +73,76 @@ final class TagController extends Controller
'url' => '/' . strtolower($type->slug),
]);
return view('tags.show', [
'tag' => $tag,
'artworks' => $artworks,
'sort' => $sort,
'ogImage' => null,
'page_title' => 'Artworks tagged "' . $tag->name . '" — Skinbase',
'page_meta_description' => 'Browse all Skinbase artworks tagged "' . $tag->name . '". Discover photography, wallpapers and skins.',
'page_canonical' => route('tags.show', $tag->slug),
'page_robots' => 'index,follow',
// Map artworks into the lightweight shape expected by the gallery React component.
$galleryCollection = $artworks->getCollection()->map(function ($a) {
$primaryCategory = $a->categories->sortBy('sort_order')->first();
$present = ThumbnailPresenter::present($a, 'md');
$avatarUrl = \App\Support\AvatarUrl::forUser((int) ($a->user_id ?? 0), $a->user?->profile?->avatar_hash ?? null, 64);
return (object) [
'id' => $a->id,
'name' => $a->title ?? ($a->name ?? null),
'category_name' => $primaryCategory->name ?? '',
'category_slug' => $primaryCategory->slug ?? '',
'thumb_url' => $present['url'] ?? ($a->thumbUrl('md') ?? null),
'thumb_srcset' => $present['srcset'] ?? null,
'uname' => $a->user?->name ?? '',
'username' => $a->user?->username ?? '',
'avatar_url' => $avatarUrl,
'published_at' => $a->published_at ?? null,
'width' => $a->width ?? null,
'height' => $a->height ?? null,
'slug' => $a->slug ?? null,
];
})->values();
// Replace paginator collection with the gallery-shaped collection so
// the gallery.index blade will generate the expected JSON payload.
if (method_exists($artworks, 'setCollection')) {
$artworks->setCollection($galleryCollection);
}
// Determine gallery sort mapping so the gallery UI highlights the right tab.
$sortMapToGallery = [
'popular' => 'trending',
'latest' => 'latest',
'likes' => 'top-rated',
'downloads' => 'downloaded',
];
$gallerySort = $sortMapToGallery[$sort] ?? 'trending';
// Build simple pagination SEO links
$prev = method_exists($artworks, 'previousPageUrl') ? $artworks->previousPageUrl() : null;
$next = method_exists($artworks, 'nextPageUrl') ? $artworks->nextPageUrl() : null;
return view('gallery.index', [
'gallery_type' => 'tag',
'mainCategories' => $mainCategories,
'subcategories' => collect(),
'contentType' => null,
'category' => null,
'artworks' => $artworks,
'current_sort' => $gallerySort,
'sort_options' => [
['value' => 'trending', 'label' => '🔥 Trending'],
['value' => 'fresh', 'label' => '🆕 New & Hot'],
['value' => 'top-rated', 'label' => '⭐ Top Rated'],
['value' => 'latest', 'label' => '🕐 Latest'],
],
'hero_title' => $tag->name,
'hero_description' => 'Artworks tagged "' . $tag->name . '"',
'breadcrumbs' => collect([
(object) ['name' => 'Home', 'url' => '/'],
(object) ['name' => 'Tags', 'url' => route('tags.index')],
(object) ['name' => $tag->name, 'url' => route('tags.show', $tag->slug)],
]),
'page_title' => 'Artworks tagged "' . $tag->name . '" — Skinbase',
'page_meta_description' => 'Browse all Skinbase artworks tagged "' . $tag->name . '".',
'page_meta_keywords' => $tag->slug . ', skinbase, artworks, tag',
'page_canonical' => route('tags.show', $tag->slug),
'page_rel_prev' => $prev,
'page_rel_next' => $next,
'page_robots' => 'index,follow',
]);
}
}