minor fixes

This commit is contained in:
2026-04-09 08:50:36 +02:00
parent 23d363a50c
commit a2457f4e49
75 changed files with 3848 additions and 387 deletions

View File

@@ -64,6 +64,18 @@ class DashboardGalleryController extends Controller
{
$primary = $artwork->categories->sortBy('sort_order')->first();
$present = ThumbnailPresenter::present($artwork, 'md');
$group = $artwork->group;
$isGroupPublisher = $group !== null;
$displayName = $isGroupPublisher ? ($group->name ?? 'Skinbase') : ($artwork->user?->name ?? 'Skinbase');
$username = $isGroupPublisher ? '' : ($artwork->user?->username ?? '');
$avatarUrl = $isGroupPublisher
? $group->avatarUrl()
: AvatarUrl::forUser(
(int) ($artwork->user_id ?? 0),
$artwork->user?->profile?->avatar_hash ?? null,
64
);
$profileUrl = $isGroupPublisher ? $group->publicUrl() : ($username !== '' ? '/@' . $username : null);
return (object) [
'id' => $artwork->id,
@@ -74,13 +86,18 @@ class DashboardGalleryController extends Controller
'category_slug' => $primary?->slug ?? '',
'thumb_url' => $present['url'],
'thumb_srcset' => $present['srcset'] ?? $present['url'],
'uname' => $artwork->user?->name ?? 'Skinbase',
'username' => $artwork->user?->username ?? '',
'avatar_url' => AvatarUrl::forUser(
(int) ($artwork->user_id ?? 0),
$artwork->user?->profile?->avatar_hash ?? null,
64
),
'uname' => $displayName,
'username' => $username,
'avatar_url' => $avatarUrl,
'profile_url' => $profileUrl,
'published_as_type' => $isGroupPublisher ? 'group' : 'user',
'publisher' => [
'type' => $isGroupPublisher ? 'group' : 'user',
'name' => $displayName,
'username' => $username,
'avatar_url' => $avatarUrl,
'profile_url' => $profileUrl,
],
'published_at' => $artwork->published_at,
'slug' => $artwork->slug ?? '',
'width' => $artwork->width ?? null,

View File

@@ -6,9 +6,12 @@ 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
@@ -22,7 +25,10 @@ use Illuminate\Support\Facades\Cache;
*/
final class DiscoverFeedController extends Controller
{
public function __construct(private readonly RSSFeedBuilder $builder) {}
public function __construct(
private readonly RSSFeedBuilder $builder,
private readonly AdaptiveTimeWindow $timeWindow,
) {}
/** /rss/discover → redirect to fresh */
public function index(): Response
@@ -77,15 +83,19 @@ final class DiscoverFeedController extends Controller
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()
$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(
@@ -95,4 +105,76 @@ final class DiscoverFeedController extends Controller
$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

@@ -280,11 +280,18 @@ class BrowseGalleryController extends \App\Http\Controllers\Controller
{
$primaryCategory = $artwork->categories->sortBy('sort_order')->first();
$present = ThumbnailPresenter::present($artwork, 'md');
$avatarUrl = \App\Support\AvatarUrl::forUser(
(int) ($artwork->user_id ?? 0),
$artwork->user?->profile?->avatar_hash ?? null,
64
);
$group = $artwork->group;
$isGroupPublisher = $group !== null;
$avatarUrl = $isGroupPublisher
? $group->avatarUrl()
: \App\Support\AvatarUrl::forUser(
(int) ($artwork->user_id ?? 0),
$artwork->user?->profile?->avatar_hash ?? null,
64
);
$displayName = $isGroupPublisher ? ($group->name ?? 'Skinbase') : ($artwork->user?->name ?? 'Skinbase');
$username = $isGroupPublisher ? '' : ($artwork->user?->username ?? '');
$profileUrl = $isGroupPublisher ? $group->publicUrl() : ($username !== '' ? '/@' . $username : null);
return (object) [
'id' => $artwork->id,
@@ -295,9 +302,18 @@ class BrowseGalleryController extends \App\Http\Controllers\Controller
'category_slug' => $primaryCategory->slug ?? '',
'thumb_url' => $present['url'],
'thumb_srcset' => $present['srcset'] ?? $present['url'],
'uname' => $artwork->user?->name ?? 'Skinbase',
'username' => $artwork->user?->username ?? '',
'uname' => $displayName,
'username' => $username,
'avatar_url' => $avatarUrl,
'profile_url' => $profileUrl,
'published_as_type' => $isGroupPublisher ? 'group' : 'user',
'publisher' => [
'type' => $isGroupPublisher ? 'group' : 'user',
'name' => $displayName,
'username' => $username,
'avatar_url' => $avatarUrl,
'profile_url' => $profileUrl,
],
'published_at' => $artwork->published_at,
'width' => $artwork->width ?? null,
'height' => $artwork->height ?? null,

View File

@@ -7,7 +7,7 @@ use App\Models\Artwork;
use App\Services\CommunityActivityService;
use App\Services\ArtworkSearchService;
use App\Services\ArtworkService;
use App\Services\EarlyGrowth\FeedBlender;
use App\Services\EarlyGrowth\AdaptiveTimeWindow;
use App\Services\EarlyGrowth\GridFiller;
use App\Services\Recommendations\RecommendationFeedResolver;
use App\Services\UserSuggestionService;
@@ -33,8 +33,8 @@ final class DiscoverController extends Controller
public function __construct(
private readonly ArtworkService $artworkService,
private readonly ArtworkSearchService $searchService,
private readonly AdaptiveTimeWindow $timeWindow,
private readonly RecommendationFeedResolver $feedResolver,
private readonly FeedBlender $feedBlender,
private readonly GridFiller $gridFiller,
private readonly CommunityActivityService $communityActivity,
private readonly UserSuggestionService $userSuggestions,
@@ -45,9 +45,18 @@ final class DiscoverController extends Controller
public function trending(Request $request)
{
$perPage = 24;
$page = max(1, (int) $request->query('page', 1));
$results = $this->searchService->discoverTrending($perPage);
$results = $this->gridFiller->fill($results, 0, $page);
$windowDays = $this->timeWindow->getTrendingWindowDays(30);
try {
$results = $this->searchService->discoverTrending($perPage);
} catch (\Throwable) {
$results = $this->fallbackTrendingFromDatabase($perPage, $windowDays);
}
if ($this->paginatorIsEmpty($results)) {
$results = $this->fallbackTrendingFromDatabase($perPage, $windowDays);
}
$this->hydrateDiscoverSearchResults($results);
return view('web.discover.index', [
@@ -64,9 +73,22 @@ final class DiscoverController extends Controller
public function rising(Request $request)
{
$perPage = 24;
$page = max(1, (int) $request->query('page', 1));
$results = $this->searchService->discoverRising($perPage);
$results = $this->gridFiller->fill($results, 0, $page);
$windowDays = $this->timeWindow->getTrendingWindowDays(30);
try {
$results = $this->searchService->discoverRising($perPage);
} catch (\Throwable) {
$results = $this->fallbackRisingFromDatabase($perPage, $windowDays);
}
if ($this->paginatorIsEmpty($results)) {
$results = $this->fallbackRisingFromDatabase($perPage, $windowDays);
}
if ($this->paginatorHasNoRisingMomentum($results)) {
$results = $this->fallbackRisingLowSignalFromDatabase($perPage, $windowDays);
}
$this->hydrateDiscoverSearchResults($results);
return view('web.discover.index', [
@@ -83,11 +105,12 @@ final class DiscoverController extends Controller
public function fresh(Request $request)
{
$perPage = 24;
$page = max(1, (int) $request->query('page', 1));
$results = $this->searchService->discoverFresh($perPage);
// EGS: blend fresh feed with curated + spotlight on page 1
$results = $this->feedBlender->blend($results, $perPage, $page);
$results = $this->gridFiller->fill($results, 0, $page);
if ($this->paginatorIsEmpty($results)) {
$results = $this->fallbackFreshFromDatabase($perPage);
}
$this->hydrateDiscoverSearchResults($results);
return view('web.discover.index', [
@@ -351,6 +374,152 @@ final class DiscoverController extends Controller
// ─── Helpers ─────────────────────────────────────────────────────────────
private function paginatorIsEmpty($paginator): bool
{
if (! is_object($paginator) || ! method_exists($paginator, 'getCollection')) {
return true;
}
$items = $paginator->getCollection();
return ! $items || $items->isEmpty();
}
private function paginatorHasNoRisingMomentum($paginator): bool
{
if (! is_object($paginator) || ! method_exists($paginator, 'getCollection')) {
return true;
}
$items = $paginator->getCollection();
if (! $items || $items->isEmpty()) {
return true;
}
return $items->every(function ($item): bool {
$heat = (float) ($item->heat_score ?? $item->stats?->heat_score ?? 0);
$velocity = (float) ($item->engagement_velocity ?? $item->stats?->engagement_velocity ?? 0);
return $heat <= 0.0 && $velocity <= 0.0;
});
}
private function fallbackFreshFromDatabase(int $perPage)
{
return Artwork::query()
->public()
->published()
->with([
'user:id,name,username',
'user.profile:user_id,avatar_hash',
'categories:id,name,slug,content_type_id,parent_id,sort_order',
'categories.contentType:id,slug,name',
])
->orderByDesc('published_at')
->orderByDesc('id')
->paginate($perPage)
->withQueryString();
}
private function fallbackTrendingFromDatabase(int $perPage, int $windowDays)
{
$cutoff = now()->subDays($windowDays)->startOfDay();
return Artwork::query()
->public()
->published()
->with([
'user:id,name,username',
'user.profile:user_id,avatar_hash',
'categories:id,name,slug,content_type_id,parent_id,sort_order',
'categories.contentType:id,slug,name',
])
->leftJoin('artwork_stats as discover_stats', 'discover_stats.artwork_id', '=', 'artworks.id')
->select('artworks.*')
->where('artworks.published_at', '>=', $cutoff)
->orderByDesc('discover_stats.ranking_score')
->orderByDesc('discover_stats.engagement_velocity')
->orderByDesc('discover_stats.views')
->orderByDesc('artworks.published_at')
->orderByDesc('artworks.id')
->paginate($perPage)
->withQueryString();
}
private function fallbackRisingFromDatabase(int $perPage, int $windowDays)
{
$cutoff = now()->subDays($windowDays)->startOfDay();
return Artwork::query()
->public()
->published()
->with([
'user:id,name,username',
'user.profile:user_id,avatar_hash',
'categories:id,name,slug,content_type_id,parent_id,sort_order',
'categories.contentType:id,slug,name',
])
->leftJoin('artwork_stats as discover_stats', 'discover_stats.artwork_id', '=', 'artworks.id')
->select('artworks.*')
->selectRaw('COALESCE(discover_stats.heat_score, 0) as heat_score')
->selectRaw('COALESCE(discover_stats.engagement_velocity, 0) as engagement_velocity')
->where('artworks.published_at', '>=', $cutoff)
->orderByDesc('discover_stats.heat_score')
->orderByDesc('discover_stats.engagement_velocity')
->orderByDesc('artworks.published_at')
->orderByDesc('artworks.id')
->paginate($perPage)
->withQueryString();
}
private function fallbackRisingLowSignalFromDatabase(int $perPage, int $windowDays)
{
$cutoff = now()->subDays($windowDays)->startOfDay();
$recentActivity = $this->risingRecentActivitySubquery();
return Artwork::query()
->public()
->published()
->with([
'user:id,name,username',
'user.profile:user_id,avatar_hash',
'categories:id,name,slug,content_type_id,parent_id,sort_order',
'categories.contentType:id,slug,name',
])
->leftJoin('artwork_stats as discover_stats', 'discover_stats.artwork_id', '=', 'artworks.id')
->leftJoinSub($recentActivity, 'recent_rising_activity', function ($join): void {
$join->on('recent_rising_activity.artwork_id', '=', 'artworks.id');
})
->select('artworks.*')
->selectRaw('COALESCE(discover_stats.heat_score, 0) as heat_score')
->selectRaw('COALESCE(discover_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')
->paginate($perPage)
->withQueryString();
}
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');
}
private function hydrateDiscoverSearchResults($paginator): void
{
if (!is_object($paginator) || !method_exists($paginator, 'getCollection') || !method_exists($paginator, 'setCollection')) {
@@ -377,6 +546,7 @@ final class DiscoverController extends Controller
->with([
'user:id,name,username',
'user.profile:user_id,avatar_hash',
'group:id,name,slug,avatar_path',
'categories:id,name,slug,content_type_id,parent_id,sort_order',
])
->get()
@@ -398,9 +568,12 @@ final class DiscoverController extends Controller
'category_slug' => $item->category_slug ?? '',
'thumb_url' => $item->thumbnail_url ?? $item->thumb_url ?? $item->thumb ?? null,
'thumb_srcset' => $item->thumb_srcset ?? null,
'uname' => $item->author ?? $item->uname ?? 'Skinbase',
'username' => $item->username ?? '',
'uname' => $item->author_name ?? $item->author ?? $item->uname ?? 'Skinbase',
'username' => (($item->published_as_type ?? null) === 'group') ? '' : ($item->username ?? ''),
'avatar_url' => \App\Support\AvatarUrl::forUser((int) ($item->user_id ?? $item->author_id ?? 0), null, 64),
'profile_url' => $item->profile_url ?? null,
'published_as_type' => $item->published_as_type ?? null,
'publisher' => $item->publisher ?? null,
'published_at' => $item->published_at ?? null,
'width' => isset($item->width) && $item->width ? (int) $item->width : null,
'height' => isset($item->height) && $item->height ? (int) $item->height : null,
@@ -413,11 +586,18 @@ final class DiscoverController extends Controller
{
$primaryCategory = $artwork->categories->sortBy('sort_order')->first();
$present = ThumbnailPresenter::present($artwork, 'md');
$avatarUrl = \App\Support\AvatarUrl::forUser(
(int) ($artwork->user_id ?? 0),
$artwork->user?->profile?->avatar_hash ?? null,
64
);
$group = $artwork->group;
$isGroupPublisher = $group !== null;
$avatarUrl = $isGroupPublisher
? $group->avatarUrl()
: \App\Support\AvatarUrl::forUser(
(int) ($artwork->user_id ?? 0),
$artwork->user?->profile?->avatar_hash ?? null,
64
);
$displayName = $isGroupPublisher ? ($group->name ?? 'Skinbase') : ($artwork->user?->name ?? 'Skinbase');
$username = $isGroupPublisher ? '' : ($artwork->user?->username ?? '');
$profileUrl = $isGroupPublisher ? $group->publicUrl() : ($username !== '' ? '/@' . $username : null);
return (object) [
'id' => $artwork->id,
@@ -429,8 +609,18 @@ final class DiscoverController extends Controller
'gid_num' => $primaryCategory ? ((int) $primaryCategory->id % 5) * 5 : 0,
'thumb_url' => $present['url'],
'thumb_srcset' => $present['srcset'] ?? $present['url'],
'uname' => $artwork->user->name ?? 'Skinbase',
'uname' => $displayName,
'username' => $username,
'avatar_url' => $avatarUrl,
'profile_url' => $profileUrl,
'published_as_type' => $isGroupPublisher ? 'group' : 'user',
'publisher' => [
'type' => $isGroupPublisher ? 'group' : 'user',
'name' => $displayName,
'username' => $username,
'avatar_url' => $avatarUrl,
'profile_url' => $profileUrl,
],
'published_at' => $artwork->published_at,
'width' => $artwork->width ?? null,
'height' => $artwork->height ?? null,

View File

@@ -276,11 +276,18 @@ final class ExploreController extends Controller
{
$primary = $artwork->categories->sortBy('sort_order')->first();
$present = ThumbnailPresenter::present($artwork, 'md');
$avatarUrl = \App\Support\AvatarUrl::forUser(
(int) ($artwork->user_id ?? 0),
$artwork->user?->profile?->avatar_hash ?? null,
64
);
$group = $artwork->group;
$isGroupPublisher = $group !== null;
$avatarUrl = $isGroupPublisher
? $group->avatarUrl()
: \App\Support\AvatarUrl::forUser(
(int) ($artwork->user_id ?? 0),
$artwork->user?->profile?->avatar_hash ?? null,
64
);
$displayName = $isGroupPublisher ? ($group->name ?? 'Skinbase') : ($artwork->user?->name ?? 'Skinbase');
$username = $isGroupPublisher ? '' : ($artwork->user?->username ?? '');
$profileUrl = $isGroupPublisher ? $group->publicUrl() : ($username !== '' ? '/@' . $username : null);
return (object) [
'id' => $artwork->id,
@@ -291,9 +298,18 @@ final class ExploreController extends Controller
'category_slug' => $primary->slug ?? '',
'thumb_url' => $present['url'],
'thumb_srcset' => $present['srcset'] ?? $present['url'],
'uname' => $artwork->user?->name ?? 'Skinbase',
'username' => $artwork->user?->username ?? '',
'uname' => $displayName,
'username' => $username,
'avatar_url' => $avatarUrl,
'profile_url' => $profileUrl,
'published_as_type' => $isGroupPublisher ? 'group' : 'user',
'publisher' => [
'type' => $isGroupPublisher ? 'group' : 'user',
'name' => $displayName,
'username' => $username,
'avatar_url' => $avatarUrl,
'profile_url' => $profileUrl,
],
'published_at' => $artwork->published_at,
'slug' => $artwork->slug ?? '',
'width' => $artwork->width ?? null,

View File

@@ -290,11 +290,18 @@ final class SimilarArtworksPageController extends Controller
{
$primary = $artwork->categories->sortBy('sort_order')->first();
$present = ThumbnailPresenter::present($artwork, 'md');
$avatarUrl = AvatarUrl::forUser(
(int) ($artwork->user_id ?? 0),
$artwork->user?->profile?->avatar_hash ?? null,
64
);
$group = $artwork->group;
$isGroupPublisher = $group !== null;
$avatarUrl = $isGroupPublisher
? $group->avatarUrl()
: AvatarUrl::forUser(
(int) ($artwork->user_id ?? 0),
$artwork->user?->profile?->avatar_hash ?? null,
64
);
$displayName = $isGroupPublisher ? ($group->name ?? 'Skinbase') : ($artwork->user?->name ?? 'Skinbase');
$username = $isGroupPublisher ? '' : ($artwork->user?->username ?? '');
$profileUrl = $isGroupPublisher ? $group->publicUrl() : ($username !== '' ? '/@' . $username : null);
return (object) [
'id' => $artwork->id,
@@ -305,9 +312,18 @@ final class SimilarArtworksPageController extends Controller
'category_slug' => $primary?->slug ?? '',
'thumb_url' => $present['url'],
'thumb_srcset' => $present['srcset'] ?? $present['url'],
'uname' => $artwork->user?->name ?? 'Skinbase',
'username' => $artwork->user?->username ?? '',
'uname' => $displayName,
'username' => $username,
'avatar_url' => $avatarUrl,
'profile_url' => $profileUrl,
'published_as_type' => $isGroupPublisher ? 'group' : 'user',
'publisher' => [
'type' => $isGroupPublisher ? 'group' : 'user',
'name' => $displayName,
'username' => $username,
'avatar_url' => $avatarUrl,
'profile_url' => $profileUrl,
],
'published_at' => $artwork->published_at,
'slug' => $artwork->slug ?? '',
'width' => $artwork->width ?? null,

View File

@@ -8,7 +8,6 @@ use App\Http\Controllers\Controller;
use App\Models\ContentType;
use App\Models\Tag;
use App\Services\ArtworkSearchService;
use App\Services\EarlyGrowth\GridFiller;
use App\Services\Tags\TagDiscoveryService;
use App\Services\ThumbnailPresenter;
use Illuminate\Http\Request;
@@ -18,7 +17,6 @@ final class TagController extends Controller
{
public function __construct(
private readonly ArtworkSearchService $search,
private readonly GridFiller $gridFiller,
private readonly TagDiscoveryService $tagDiscovery,
) {}
@@ -45,29 +43,10 @@ final class TagController extends Controller
public function show(Tag $tag, Request $request): View
{
$sort = $request->query('sort', 'popular'); // popular | latest | downloads
$sort = $request->query('sort', 'popular'); // popular | likes | latest | downloads
$perPage = min((int) $request->query('per_page', 24), 100);
// Convert sort param to Meili sort expression
$sortMap = [
'popular' => 'views:desc',
'likes' => 'likes:desc',
'latest' => 'created_at:desc',
'downloads' => 'downloads:desc',
];
$meiliSort = $sortMap[$sort] ?? 'views:desc';
$artworks = \App\Models\Artwork::search('')
->options([
'filter' => 'is_public = true AND is_approved = true AND tags = "' . addslashes($tag->slug) . '"',
'sort' => [$meiliSort],
])
->paginate($perPage)
->appends(['sort' => $sort]);
// EGS: ensure tag pages never show a half-empty grid on page 1
$page = max(1, (int) $request->query('page', 1));
$artworks = $this->gridFiller->fill($artworks, 0, $page);
$artworks = $this->search->byTag($tag->slug, $perPage, $sort);
// Eager-load relations used by the gallery presenter and thumbnails.
$artworks->getCollection()->each(fn($m) => $m->loadMissing(['user.profile', 'categories']));

View File

@@ -7,10 +7,24 @@ namespace App\Http\Requests\Uploads;
use App\Repositories\Uploads\UploadSessionRepository;
use App\Services\Uploads\UploadTokenService;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
final class UploadChunkRequest extends FormRequest
{
protected function prepareForValidation(): void
{
$uploadError = $this->detectChunkUploadError();
if ($uploadError !== null && $uploadError !== UPLOAD_ERR_OK) {
$this->logChunkUploadFailure($uploadError);
throw ValidationException::withMessages([
'chunk' => [$this->messageForUploadError($uploadError)],
]);
}
}
public function authorize(): bool
{
$user = $this->user();
@@ -79,6 +93,63 @@ final class UploadChunkRequest extends FormRequest
throw new NotFoundHttpException();
}
private function detectChunkUploadError(): ?int
{
$uploadedFile = $this->file('chunk');
if ($uploadedFile !== null) {
return (int) $uploadedFile->getError();
}
$rawError = data_get($_FILES, 'chunk.error');
if ($rawError === null || $rawError === '') {
return null;
}
return (int) $rawError;
}
private function messageForUploadError(int $error): string
{
return match ($error) {
UPLOAD_ERR_INI_SIZE => 'The upload chunk exceeded PHP upload_max_filesize. Lower UPLOAD_CHUNK_MAX_BYTES or raise upload_max_filesize/post_max_size.',
UPLOAD_ERR_FORM_SIZE => 'The upload chunk exceeded the allowed form upload size.',
UPLOAD_ERR_PARTIAL => 'The upload chunk was only partially received. Check Nginx/PHP-FPM request handling and network stability.',
UPLOAD_ERR_NO_FILE => 'No upload chunk file was received by PHP.',
UPLOAD_ERR_NO_TMP_DIR => 'PHP upload_tmp_dir is missing or unavailable. Check the configured temporary upload directory on the server.',
UPLOAD_ERR_CANT_WRITE => 'PHP could not write the upload chunk to the temporary directory. Check upload_tmp_dir permissions and free disk space.',
UPLOAD_ERR_EXTENSION => 'A PHP extension stopped the upload chunk before Laravel could process it.',
default => 'The upload chunk failed before Laravel could read it. Check PHP temporary upload storage and request size limits.',
};
}
private function logChunkUploadFailure(int $error): void
{
$uploadTmpDir = (string) (ini_get('upload_tmp_dir') ?: sys_get_temp_dir() ?: '');
$tmpExists = $uploadTmpDir !== '' ? is_dir($uploadTmpDir) : false;
$tmpWritable = $tmpExists ? is_writable($uploadTmpDir) : false;
logger()->warning('Upload chunk failed before validation completed', [
'session_id' => (string) $this->input('session_id'),
'user_id' => $this->user()?->id,
'ip' => $this->ip(),
'upload_error' => $error,
'upload_error_message' => $this->messageForUploadError($error),
'content_length' => $this->server('CONTENT_LENGTH'),
'post_max_size' => ini_get('post_max_size'),
'upload_max_filesize' => ini_get('upload_max_filesize'),
'upload_tmp_dir' => $uploadTmpDir,
'tmp_exists' => $tmpExists,
'tmp_writable' => $tmpWritable,
'raw_files' => isset($_FILES['chunk']) ? [
'name' => $_FILES['chunk']['name'] ?? null,
'type' => $_FILES['chunk']['type'] ?? null,
'size' => $_FILES['chunk']['size'] ?? null,
'tmp_name' => $_FILES['chunk']['tmp_name'] ?? null,
'error' => $_FILES['chunk']['error'] ?? null,
] : null,
]);
}
private function logUnauthorized(string $reason): void
{
logger()->warning('Upload chunk unauthorized access', [

View File

@@ -61,6 +61,22 @@ class ArtworkListResource extends JsonResource
$decode = static fn (?string $v): string => html_entity_decode((string) ($v ?? ''), ENT_QUOTES | ENT_HTML5, 'UTF-8');
$group = $this->relationLoaded('group') ? $this->group : null;
$user = $this->relationLoaded('user') ? $this->user : null;
$isGroupPublisher = $group !== null;
$publisher = ($group || $user)
? [
'type' => $isGroupPublisher ? 'group' : 'user',
'id' => (int) ($isGroupPublisher ? $group?->id : $user?->id),
'name' => $decode($isGroupPublisher ? $group?->name : $user?->name),
'username' => $isGroupPublisher ? '' : (string) ($user?->username ?? ''),
'avatar_url' => $isGroupPublisher ? $group?->avatarUrl() : $user?->profile?->avatar_url,
'profile_url' => $isGroupPublisher
? $group?->publicUrl()
: (! empty($user?->username) ? '/@' . $user->username : null),
]
: null;
return [
'id' => $artId,
'slug' => $slugVal,
@@ -71,12 +87,12 @@ class ArtworkListResource extends JsonResource
'height' => $get('height'),
],
'thumbnail_url' => $this->when(! empty($hash) && ! empty($thumbExt), fn() => ThumbnailService::fromHash($hash, $thumbExt, 'md')),
'author' => $this->whenLoaded('user', function () use ($decode) {
return [
'name' => $decode($this->user->name ?? null),
'avatar_url' => $this->user?->profile?->avatar_url,
];
}),
'author' => $publisher,
'publisher' => $publisher,
'author_name' => $publisher['name'] ?? '',
'avatar_url' => $publisher['avatar_url'] ?? null,
'profile_url' => $publisher['profile_url'] ?? null,
'published_as_type' => $publisher['type'] ?? null,
'category' => $primaryCategory ? [
'slug' => $primaryCategory->slug ?? null,
'name' => $decode($primaryCategory->name ?? null),