Files
SkinbaseNova/app/Services/HomepageService.php
Gregor Klevze d0aefc5ddc feat: Nova homepage, profile redesign, and legacy view system overhaul
Homepage
- Add HomepageService with hero, trending (award-weighted), fresh uploads,
  popular tags, creator spotlight (weekly uploads ranking), and news sections
- Add React components: HomePage, HomeHero, HomeTrending, HomeFresh,
  HomeTags, HomeCreators, HomeNews (lazy-loaded below the fold)
- Wire home.blade.php with JSON props, SEO meta, JSON-LD, and hero preload
- Add HomePage.jsx to vite.config.js inputs

Profile page
- Hero banner with random user artwork as background + dark gradient overlay
- Favourites section uses real Artwork models + <x-artwork-card> for CDN URLs
- Newest artworks grid: gallery-grid → grid grid-cols-2 gap-4

Edit Profile page (user.blade.php)
- Add hero banner (featured wallpaper/photography via artwork_features,
  content_type_id IN [2,3]) sourced in UserController
- Remove bg-deep from outer wrapper; card backgrounds: bg-panel → bg-nova-800
- Remove stray AI-generated tag fragment from template

Author profile links
- Fix all /@username routes in: HomepageService, MonthlyCommentatorsController,
  LatestCommentsController, MyBuddiesController and corresponding blade views

Legacy view namespace
- Register View::addNamespace('legacy', resource_path('views/_legacy'))
  in AppServiceProvider::boot()
- Convert all view('legacy.x') and @include('legacy.x') calls to legacy::x
- Migrate legacy views to resources/views/_legacy/ with namespace support
2026-02-26 10:25:35 +01:00

305 lines
13 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\Artwork;
use App\Models\Tag;
use App\Support\AvatarUrl;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Database\QueryException;
/**
* HomepageService
*
* Aggregates all data sections needed for the Nova homepage.
* All results are cached for CACHE_TTL seconds.
* Controllers stay thin — only call the aggregator.
*/
final class HomepageService
{
private const CACHE_TTL = 300; // 5 minutes
public function __construct(private readonly ArtworkService $artworks) {}
// ─────────────────────────────────────────────────────────────────────────
// Public aggregator
// ─────────────────────────────────────────────────────────────────────────
/**
* Return all homepage section data as a single array ready to JSON-encode.
*/
public function all(): array
{
return [
'hero' => $this->getHeroArtwork(),
'trending' => $this->getTrending(),
'fresh' => $this->getFreshUploads(),
'tags' => $this->getPopularTags(),
'creators' => $this->getCreatorSpotlight(),
'news' => $this->getNews(),
];
}
// ─────────────────────────────────────────────────────────────────────────
// Sections
// ─────────────────────────────────────────────────────────────────────────
/**
* Hero artwork: first item from the featured list.
*/
public function getHeroArtwork(): ?array
{
return Cache::remember('homepage.hero', self::CACHE_TTL, function (): ?array {
$result = $this->artworks->getFeaturedArtworks(null, 1);
/** @var \Illuminate\Database\Eloquent\Model|\null $artwork */
if ($result instanceof \Illuminate\Pagination\LengthAwarePaginator) {
$artwork = $result->getCollection()->first();
} elseif ($result instanceof \Illuminate\Support\Collection) {
$artwork = $result->first();
} elseif (is_array($result)) {
$artwork = $result[0] ?? null;
} else {
$artwork = null;
}
return $artwork ? $this->serializeArtwork($artwork, 'lg') : null;
});
}
/**
* Trending: up to 12 artworks ordered by award score, views, downloads, recent activity.
*
* Award score = SUM(weight × medal_value) where gold=3, silver=2, bronze=1.
* Uses correlated subqueries to avoid GROUP BY issues with MySQL strict mode.
*/
public function getTrending(int $limit = 12): array
{
return Cache::remember("homepage.trending.{$limit}", self::CACHE_TTL, function () use ($limit): array {
$ids = DB::table('artworks')
->select('id')
->selectRaw(
'(SELECT COALESCE(SUM(weight * CASE medal'
. ' WHEN \'gold\' THEN 3'
. ' WHEN \'silver\' THEN 2'
. ' ELSE 1 END), 0)'
. ' FROM artwork_awards WHERE artwork_awards.artwork_id = artworks.id) AS award_score'
)
->selectRaw('COALESCE((SELECT views FROM artwork_stats WHERE artwork_stats.artwork_id = artworks.id), 0) AS stat_views')
->selectRaw('COALESCE((SELECT downloads FROM artwork_stats WHERE artwork_stats.artwork_id = artworks.id), 0) AS stat_downloads')
->where('is_public', true)
->where('is_approved', true)
->whereNull('deleted_at')
->whereNotNull('published_at')
->where('published_at', '>=', now()->subDays(30))
->orderByDesc('award_score')
->orderByDesc('stat_views')
->orderByDesc('stat_downloads')
->orderByDesc('published_at')
->limit($limit)
->pluck('id');
if ($ids->isEmpty()) {
return [];
}
$indexed = Artwork::with(['user:id,name,username', 'user.profile:user_id,avatar_hash'])
->whereIn('id', $ids)
->get()
->keyBy('id');
return $ids
->filter(fn ($id) => $indexed->has($id))
->map(fn ($id) => $this->serializeArtwork($indexed[$id]))
->values()
->all();
});
}
/**
* Fresh uploads: latest 12 approved public artworks.
*/
public function getFreshUploads(int $limit = 12): array
{
return Cache::remember("homepage.fresh.{$limit}", self::CACHE_TTL, function () use ($limit): array {
$artworks = Artwork::public()
->published()
->with(['user:id,name,username', 'user.profile:user_id,avatar_hash'])
->orderByDesc('published_at')
->limit($limit)
->get();
return $artworks->map(fn ($a) => $this->serializeArtwork($a))->values()->all();
});
}
/**
* Top 12 popular tags by usage_count.
*/
public function getPopularTags(int $limit = 12): array
{
return Cache::remember("homepage.tags.{$limit}", self::CACHE_TTL, function () use ($limit): array {
return Tag::query()
->where('is_active', true)
->orderByDesc('usage_count')
->limit($limit)
->get(['id', 'name', 'slug', 'usage_count'])
->map(fn ($t) => [
'id' => $t->id,
'name' => $t->name,
'slug' => $t->slug,
'count' => (int) $t->usage_count,
])
->values()
->all();
});
}
/**
* Creator spotlight: top 6 creators by weekly uploads, awards, and engagement.
* "Weekly uploads" drives ranking per spec; ties broken by total awards then views.
*/
public function getCreatorSpotlight(int $limit = 6): array
{
return Cache::remember("homepage.creators.{$limit}", self::CACHE_TTL, function () use ($limit): array {
try {
$since = now()->subWeek();
$rows = DB::table('artworks')
->join('users as u', 'u.id', '=', 'artworks.user_id')
->leftJoin('user_profiles as up', 'up.user_id', '=', 'u.id')
->leftJoin('artwork_awards as aw', 'aw.artwork_id', '=', 'artworks.id')
->leftJoin('artwork_stats as s', 's.artwork_id', '=', 'artworks.id')
->select(
'u.id',
'u.name',
'u.username',
'up.avatar_hash',
DB::raw('COUNT(DISTINCT artworks.id) as upload_count'),
DB::raw('SUM(CASE WHEN artworks.published_at >= \'' . $since->toDateTimeString() . '\' THEN 1 ELSE 0 END) as weekly_uploads'),
DB::raw('COALESCE(SUM(s.views), 0) as total_views'),
DB::raw('COUNT(DISTINCT aw.id) as total_awards')
)
->where('artworks.is_public', true)
->where('artworks.is_approved', true)
->whereNull('artworks.deleted_at')
->whereNotNull('artworks.published_at')
->groupBy('u.id', 'u.name', 'u.username', 'up.avatar_hash')
->orderByDesc('weekly_uploads')
->orderByDesc('total_awards')
->orderByDesc('total_views')
->limit($limit)
->get();
$userIds = $rows->pluck('id')->all();
// Pick one random artwork thumbnail per creator for the card background.
$thumbsByUser = Artwork::public()
->published()
->whereIn('user_id', $userIds)
->whereNotNull('hash')
->whereNotNull('thumb_ext')
->inRandomOrder()
->get(['id', 'user_id', 'hash', 'thumb_ext'])
->groupBy('user_id');
return $rows->map(function ($u) use ($thumbsByUser) {
$artworkForBg = $thumbsByUser->get($u->id)?->first();
$bgThumb = $artworkForBg ? $artworkForBg->thumbUrl('md') : null;
return [
'id' => $u->id,
'name' => $u->name,
'uploads' => (int) $u->upload_count,
'weekly_uploads' => (int) $u->weekly_uploads,
'views' => (int) $u->total_views,
'awards' => (int) $u->total_awards,
'url' => $u->username ? '/@' . $u->username : '/profile/' . $u->id,
'avatar' => AvatarUrl::forUser((int) $u->id, $u->avatar_hash ?: null, 128),
'bg_thumb' => $bgThumb,
];
})->values()->all();
} catch (QueryException $e) {
Log::warning('HomepageService::getCreatorSpotlight DB error', [
'exception' => $e->getMessage(),
]);
return [];
}
});
}
/**
* Latest 5 news posts from the forum news category.
*/
public function getNews(int $limit = 5): array
{
return Cache::remember("homepage.news.{$limit}", self::CACHE_TTL, function () use ($limit): array {
try {
$items = DB::table('forum_threads as t')
->leftJoin('forum_categories as c', 'c.id', '=', 't.category_id')
->select('t.id', 't.title', 't.created_at', 't.slug as thread_slug')
->where(function ($q) {
$q->where('t.category_id', 2876)
->orWhereIn('c.slug', ['news', 'forum-news']);
})
->whereNull('t.deleted_at')
->orderByDesc('t.created_at')
->limit($limit)
->get();
return $items->map(fn ($row) => [
'id' => $row->id,
'title' => $row->title,
'date' => $row->created_at,
'url' => '/forum/thread/' . $row->id . '-' . ($row->thread_slug ?? 'post'),
])->values()->all();
} catch (QueryException $e) {
Log::warning('HomepageService::getNews DB error', [
'exception' => $e->getMessage(),
]);
return [];
}
});
}
// ─────────────────────────────────────────────────────────────────────────
// Helpers
// ─────────────────────────────────────────────────────────────────────────
private function serializeArtwork(Artwork $artwork, string $preferSize = 'md'): array
{
$thumbMd = $artwork->thumbUrl('md');
$thumbLg = $artwork->thumbUrl('lg');
$thumb = $preferSize === 'lg' ? ($thumbLg ?? $thumbMd) : ($thumbMd ?? $thumbLg);
$authorId = $artwork->user_id;
$authorName = $artwork->user?->name ?? 'Artist';
$authorUsername = $artwork->user?->username ?? '';
$avatarHash = $artwork->user?->profile?->avatar_hash ?? null;
$authorAvatar = AvatarUrl::forUser((int) $authorId, $avatarHash, 40);
return [
'id' => $artwork->id,
'title' => $artwork->title ?? 'Untitled',
'slug' => $artwork->slug,
'author' => $authorName,
'author_id' => $authorId,
'author_username' => $authorUsername,
'author_avatar' => $authorAvatar,
'thumb' => $thumb,
'thumb_md' => $thumbMd,
'thumb_lg' => $thumbLg,
'url' => '/art/' . $artwork->id . '/' . ($artwork->slug ?? ''),
'width' => $artwork->width,
'height' => $artwork->height,
'published_at' => $artwork->published_at?->toIso8601String(),
];
}
}