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
This commit is contained in:
2026-02-26 10:25:35 +01:00
parent d3fd32b004
commit d0aefc5ddc
78 changed files with 1046 additions and 221 deletions

View File

@@ -0,0 +1,304 @@
<?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(),
];
}
}