login update
This commit is contained in:
188
app/Http/Controllers/Api/StoriesApiController.php
Normal file
188
app/Http/Controllers/Api/StoriesApiController.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
252
app/Http/Controllers/Auth/OAuthController.php
Normal file
252
app/Http/Controllers/Auth/OAuthController.php
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
40
app/Http/Controllers/RSS/BlogFeedController.php
Normal file
40
app/Http/Controllers/RSS/BlogFeedController.php
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
49
app/Http/Controllers/RSS/CreatorFeedController.php
Normal file
49
app/Http/Controllers/RSS/CreatorFeedController.php
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
98
app/Http/Controllers/RSS/DiscoverFeedController.php
Normal file
98
app/Http/Controllers/RSS/DiscoverFeedController.php
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
105
app/Http/Controllers/RSS/ExploreFeedController.php
Normal file
105
app/Http/Controllers/RSS/ExploreFeedController.php
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
40
app/Http/Controllers/RSS/GlobalFeedController.php
Normal file
40
app/Http/Controllers/RSS/GlobalFeedController.php
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
49
app/Http/Controllers/RSS/TagFeedController.php
Normal file
49
app/Http/Controllers/RSS/TagFeedController.php
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
@@ -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',
|
||||
]);
|
||||
|
||||
59
app/Http/Controllers/Web/StoriesAuthorController.php
Normal file
59
app/Http/Controllers/Web/StoriesAuthorController.php
Normal 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],
|
||||
]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
47
app/Http/Controllers/Web/StoriesController.php
Normal file
47
app/Http/Controllers/Web/StoriesController.php
Normal 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'],
|
||||
]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
45
app/Http/Controllers/Web/StoriesTagController.php
Normal file
45
app/Http/Controllers/Web/StoriesTagController.php
Normal 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],
|
||||
]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
86
app/Http/Controllers/Web/StoryController.php
Normal file
86
app/Http/Controllers/Web/StoryController.php
Normal 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],
|
||||
]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user