Save workspace changes

This commit is contained in:
2026-04-18 17:02:56 +02:00
parent f02ea9a711
commit 87d60af5a9
4220 changed files with 1388603 additions and 1554 deletions

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,180 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\RSS;
use App\Http\Controllers\Controller;
use App\Models\Artwork;
use App\Services\EarlyGrowth\AdaptiveTimeWindow;
use App\Services\RSS\RSSFeedBuilder;
use Illuminate\Http\Response;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
/**
* 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,
private readonly AdaptiveTimeWindow $timeWindow,
) {}
/** /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');
$windowDays = $this->timeWindow->getTrendingWindowDays(30);
$artworks = Cache::remember(
"rss:discover:rising.{$windowDays}d",
600,
function () use ($windowDays) {
$artworks = $this->risingArtworks($windowDays);
if ($this->collectionHasNoRisingMomentum($artworks)) {
return $this->risingLowSignalArtworks($windowDays);
}
return $artworks;
}
);
return $this->builder->buildFromArtworks(
'Rising Artworks',
'Fastest-growing artworks gaining momentum on Skinbase right now.',
$feedUrl,
$artworks,
);
}
private function risingArtworks(int $windowDays): Collection
{
$cutoff = now()->subDays($windowDays)->startOfDay();
return Artwork::public()
->published()
->with(['user:id,username', 'categories:id,name,slug,content_type_id'])
->leftJoin('artwork_stats', 'artwork_stats.artwork_id', '=', 'artworks.id')
->select('artworks.*')
->selectRaw('COALESCE(artwork_stats.heat_score, 0) as heat_score')
->selectRaw('COALESCE(artwork_stats.engagement_velocity, 0) as engagement_velocity')
->where('artworks.published_at', '>=', $cutoff)
->orderByDesc('artwork_stats.heat_score')
->orderByDesc('artwork_stats.engagement_velocity')
->orderByDesc('artworks.published_at')
->orderByDesc('artworks.id')
->limit(RSSFeedBuilder::FEED_LIMIT)
->get();
}
private function risingLowSignalArtworks(int $windowDays): Collection
{
$cutoff = now()->subDays($windowDays)->startOfDay();
return Artwork::public()
->published()
->with(['user:id,username', 'categories:id,name,slug,content_type_id'])
->leftJoin('artwork_stats', 'artwork_stats.artwork_id', '=', 'artworks.id')
->leftJoinSub($this->risingRecentActivitySubquery(), 'recent_rising_activity', function ($join): void {
$join->on('recent_rising_activity.artwork_id', '=', 'artworks.id');
})
->select('artworks.*')
->selectRaw('COALESCE(artwork_stats.heat_score, 0) as heat_score')
->selectRaw('COALESCE(artwork_stats.engagement_velocity, 0) as engagement_velocity')
->selectRaw('COALESCE(recent_rising_activity.recent_signal_24h, 0) as recent_signal_24h')
->where('artworks.published_at', '>=', $cutoff)
->orderByDesc('recent_signal_24h')
->orderByDesc('artworks.published_at')
->orderByDesc('artworks.id')
->limit(RSSFeedBuilder::FEED_LIMIT)
->get();
}
private function collectionHasNoRisingMomentum(Collection $artworks): bool
{
if ($artworks->isEmpty()) {
return false;
}
return $artworks->every(function (Artwork $artwork): bool {
return (float) ($artwork->heat_score ?? 0) <= 0
&& (float) ($artwork->engagement_velocity ?? 0) <= 0;
});
}
private function risingRecentActivitySubquery()
{
$since = now()->startOfHour()->subHours(24);
return DB::table('artwork_metric_snapshots_hourly as rising_snapshots')
->selectRaw('rising_snapshots.artwork_id')
->selectRaw('(
COALESCE(MAX(rising_snapshots.views_count) - MIN(rising_snapshots.views_count), 0)
+ (COALESCE(MAX(rising_snapshots.downloads_count) - MIN(rising_snapshots.downloads_count), 0) * 3)
+ (COALESCE(MAX(rising_snapshots.favourites_count) - MIN(rising_snapshots.favourites_count), 0) * 4)
+ (COALESCE(MAX(rising_snapshots.comments_count) - MIN(rising_snapshots.comments_count), 0) * 5)
+ (COALESCE(MAX(rising_snapshots.shares_count) - MIN(rising_snapshots.shares_count), 0) * 6)
) as recent_signal_24h')
->where('rising_snapshots.bucket_hour', '>=', $since)
->groupBy('rising_snapshots.artwork_id');
}
}

View File

@@ -0,0 +1,124 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\RSS;
use App\Http\Controllers\Controller;
use App\Models\Artwork;
use App\Services\ContentTypes\ContentTypeSlugResolver;
use App\Services\RSS\RSSFeedBuilder;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
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,
private readonly ContentTypeSlugResolver $contentTypeResolver,
) {}
/** /rss/explore/{type} — defaults to latest */
public function byType(Request $request, string $type): Response|RedirectResponse
{
return $this->feed($request, $type, 'latest');
}
/** /rss/explore/{type}/{mode} */
public function byTypeMode(Request $request, string $type, string $mode): Response|RedirectResponse
{
return $this->feed($request, $type, $mode);
}
// ─────────────────────────────────────────────────────────────────────────
private function feed(Request $request, string $type, string $mode): Response|RedirectResponse
{
$resolution = $this->contentTypeResolver->resolve($type, allowVirtual: true);
if (! $resolution->found()) {
abort(404);
}
$mode = in_array($mode, ['trending', 'latest', 'best'], true) ? $mode : 'latest';
$resolvedType = $resolution->isVirtual ? 'artworks' : strtolower((string) $resolution->contentType?->slug);
if ($resolution->requiresRedirect()) {
return redirect()->to(url('/rss/explore/' . $resolvedType . ($mode !== 'latest' ? '/' . $mode : '')) . ($request->getQueryString() ? ('?' . $request->getQueryString()) : ''), 301);
}
$ttl = self::SORT_TTL[$mode] ?? 300;
$feedUrl = url('/rss/explore/' . $resolvedType . ($mode !== 'latest' ? '/' . $mode : ''));
$label = $resolution->isVirtual
? 'All Artworks'
: ($resolution->contentType?->name ?? ucfirst(str_replace('-', ' ', $resolvedType)));
$artworks = Cache::remember("rss:explore:{$resolvedType}:{$mode}", $ttl, function () use ($resolution, $mode) {
$contentType = $resolution->contentType;
$query = Artwork::public()->published()
->with(['user:id,username', 'categories:id,name,slug,content_type_id']);
if (! $resolution->isVirtual && $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,
);
}
}