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:
@@ -33,7 +33,7 @@ class CategoryPageController extends Controller
|
|||||||
$perPage = 40;
|
$perPage = 40;
|
||||||
$artworks = $this->artworkService->getArtworksByContentType($contentType->slug, $perPage, $sort);
|
$artworks = $this->artworkService->getArtworksByContentType($contentType->slug, $perPage, $sort);
|
||||||
|
|
||||||
return view('legacy.content-type', compact(
|
return view('legacy::content-type', compact(
|
||||||
'contentType',
|
'contentType',
|
||||||
'rootCategories',
|
'rootCategories',
|
||||||
'artworks',
|
'artworks',
|
||||||
@@ -92,7 +92,7 @@ class CategoryPageController extends Controller
|
|||||||
|
|
||||||
// resolved category and breadcrumbs are used by the view
|
// resolved category and breadcrumbs are used by the view
|
||||||
|
|
||||||
return view('legacy.category-slug', compact(
|
return view('legacy::category-slug', compact(
|
||||||
'contentType',
|
'contentType',
|
||||||
'category',
|
'category',
|
||||||
'subcategories',
|
'subcategories',
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ class InterviewController extends Controller
|
|||||||
$interviews = collect();
|
$interviews = collect();
|
||||||
}
|
}
|
||||||
|
|
||||||
return view('legacy.interviews', [
|
return view('legacy::interviews', [
|
||||||
'interviews' => $interviews,
|
'interviews' => $interviews,
|
||||||
'page_title' => 'Interviews',
|
'page_title' => 'Interviews',
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ class LatestCommentsController extends Controller
|
|||||||
'comment_id' => $c->getKey(),
|
'comment_id' => $c->getKey(),
|
||||||
'comment_description' => $c->content,
|
'comment_description' => $c->content,
|
||||||
'commenter_id' => $c->user_id,
|
'commenter_id' => $c->user_id,
|
||||||
|
'commenter_username' => $user?->username ?? null,
|
||||||
'country' => $user->country ?? null,
|
'country' => $user->country ?? null,
|
||||||
'icon' => $user ? DB::table('user_profiles')->where('user_id', $user->id)->value('avatar_hash') : null,
|
'icon' => $user ? DB::table('user_profiles')->where('user_id', $user->id)->value('avatar_hash') : null,
|
||||||
'uname' => $user->username ?? $user->name ?? 'User',
|
'uname' => $user->username ?? $user->name ?? 'User',
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ class GalleryController extends Controller
|
|||||||
|
|
||||||
$artworks = $query->skip(($page - 1) * $hits)->take($hits)->get();
|
$artworks = $query->skip(($page - 1) * $hits)->take($hits)->get();
|
||||||
|
|
||||||
return view('legacy.gallery', [
|
return view('legacy::gallery', [
|
||||||
'user' => $user,
|
'user' => $user,
|
||||||
'artworks' => $artworks,
|
'artworks' => $artworks,
|
||||||
'page' => $page,
|
'page' => $page,
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ class InterviewController extends Controller
|
|||||||
|
|
||||||
$page_title = 'Interview with ' . ($ar->username ?? '');
|
$page_title = 'Interview with ' . ($ar->username ?? '');
|
||||||
|
|
||||||
return view('legacy.interview', [
|
return view('legacy::interview', [
|
||||||
'ar' => $ar,
|
'ar' => $ar,
|
||||||
'artworks' => $artworks,
|
'artworks' => $artworks,
|
||||||
'comments' => $comments,
|
'comments' => $comments,
|
||||||
|
|||||||
@@ -23,6 +23,6 @@ class InterviewsController extends Controller
|
|||||||
|
|
||||||
$page_title = 'Interviews';
|
$page_title = 'Interviews';
|
||||||
|
|
||||||
return view('legacy.interviews', compact('interviews', 'page_title'));
|
return view('legacy::interviews', compact('interviews', 'page_title'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ class ArtController extends Controller
|
|||||||
$data = $this->legacy->getArtwork((int) $id);
|
$data = $this->legacy->getArtwork((int) $id);
|
||||||
|
|
||||||
if (! $data || empty($data['artwork'])) {
|
if (! $data || empty($data['artwork'])) {
|
||||||
return view('legacy.placeholder', ['title' => 'Artwork Not Found']);
|
return view('legacy::placeholder', ['title' => 'Artwork Not Found']);
|
||||||
}
|
}
|
||||||
|
|
||||||
// load comments for artwork (legacy schema)
|
// load comments for artwork (legacy schema)
|
||||||
@@ -57,6 +57,6 @@ class ArtController extends Controller
|
|||||||
|
|
||||||
$data['comments'] = $comments;
|
$data['comments'] = $comments;
|
||||||
|
|
||||||
return view('legacy.art', $data);
|
return view('legacy::art', $data);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ class BrowseController extends Controller
|
|||||||
|
|
||||||
$page_canonical = url('/browse');
|
$page_canonical = url('/browse');
|
||||||
|
|
||||||
return view('legacy.browse', compact('page_title', 'page_meta_description', 'page_meta_keywords', 'page_canonical', 'artworks', 'rootCategories'));
|
return view('legacy::browse', compact('page_title', 'page_meta_description', 'page_meta_keywords', 'page_canonical', 'artworks', 'rootCategories'));
|
||||||
}
|
}
|
||||||
|
|
||||||
private function mapArtwork(Artwork $artwork): object
|
private function mapArtwork(Artwork $artwork): object
|
||||||
|
|||||||
@@ -32,6 +32,6 @@ class BuddiesController extends Controller
|
|||||||
|
|
||||||
$page_title = ($user->name ?? $user->username ?? 'User') . ': Followers';
|
$page_title = ($user->name ?? $user->username ?? 'User') . ': Followers';
|
||||||
|
|
||||||
return view('legacy.buddies', compact('followers', 'page_title'));
|
return view('legacy::buddies', compact('followers', 'page_title'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ class CategoryController extends Controller
|
|||||||
|
|
||||||
// Expecting segments like ['category', '{contentType}', '{...categorySlugs}']
|
// Expecting segments like ['category', '{contentType}', '{...categorySlugs}']
|
||||||
if (count($segments) < 2 || strtolower($segments[0]) !== 'category') {
|
if (count($segments) < 2 || strtolower($segments[0]) !== 'category') {
|
||||||
return view('legacy.placeholder');
|
return view('legacy::placeholder');
|
||||||
}
|
}
|
||||||
|
|
||||||
$parts = array_slice($segments, 1);
|
$parts = array_slice($segments, 1);
|
||||||
@@ -90,7 +90,7 @@ class CategoryController extends Controller
|
|||||||
$page_meta_description = $category->description ?? ($category->contentType->name . ' artworks on Skinbase');
|
$page_meta_description = $category->description ?? ($category->contentType->name . ' artworks on Skinbase');
|
||||||
$page_meta_keywords = strtolower($category->contentType->slug) . ', skinbase, artworks, wallpapers, skins, photography';
|
$page_meta_keywords = strtolower($category->contentType->slug) . ', skinbase, artworks, wallpapers, skins, photography';
|
||||||
|
|
||||||
return view('legacy.category', compact(
|
return view('legacy::category', compact(
|
||||||
'page_title',
|
'page_title',
|
||||||
'page_meta_description',
|
'page_meta_description',
|
||||||
'page_meta_keywords',
|
'page_meta_keywords',
|
||||||
@@ -104,6 +104,6 @@ class CategoryController extends Controller
|
|||||||
public function browseCategories()
|
public function browseCategories()
|
||||||
{
|
{
|
||||||
$data = $this->legacy->browseCategories();
|
$data = $this->legacy->browseCategories();
|
||||||
return view('legacy.categories', $data);
|
return view('legacy::categories', $data);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,6 +43,6 @@ class ChatController extends Controller
|
|||||||
$smileys = collect();
|
$smileys = collect();
|
||||||
}
|
}
|
||||||
|
|
||||||
return view('legacy.chat', compact('page_title', 'adHtml', 'chatHtml', 'smileys'));
|
return view('legacy::chat', compact('page_title', 'adHtml', 'chatHtml', 'smileys'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ class DailyUploadsController extends Controller
|
|||||||
if ($isAjax && $datum) {
|
if ($isAjax && $datum) {
|
||||||
// Return partial gallery for the given date
|
// Return partial gallery for the given date
|
||||||
$arts = $this->fetchByDate($datum);
|
$arts = $this->fetchByDate($datum);
|
||||||
return view('legacy.partials.daily-uploads-grid', ['arts' => $arts])->render();
|
return view('legacy::partials.daily-uploads-grid', ['arts' => $arts])->render();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build date tabs (today .. -14 days)
|
// Build date tabs (today .. -14 days)
|
||||||
@@ -41,7 +41,7 @@ class DailyUploadsController extends Controller
|
|||||||
// initial content: recent (last 7 days)
|
// initial content: recent (last 7 days)
|
||||||
$recent = $this->fetchRecent();
|
$recent = $this->fetchRecent();
|
||||||
|
|
||||||
return view('legacy.daily-uploads', [
|
return view('legacy::daily-uploads', [
|
||||||
'dates' => $dates,
|
'dates' => $dates,
|
||||||
'recent' => $recent,
|
'recent' => $recent,
|
||||||
'page_title' => 'Daily Uploads',
|
'page_title' => 'Daily Uploads',
|
||||||
|
|||||||
@@ -120,7 +120,7 @@ class FavouritesController extends Controller
|
|||||||
|
|
||||||
$page_title = ($username ?: ($userNameCol ? DB::table('users')->where($userIdCol, $userId)->value($userNameCol) : '')) . ' Favourites';
|
$page_title = ($username ?: ($userNameCol ? DB::table('users')->where($userIdCol, $userId)->value($userNameCol) : '')) . ' Favourites';
|
||||||
|
|
||||||
return view('legacy.favourites', [
|
return view('legacy::favourites', [
|
||||||
'results' => $results,
|
'results' => $results,
|
||||||
'page_title' => $page_title,
|
'page_title' => $page_title,
|
||||||
'user_id' => $userId,
|
'user_id' => $userId,
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ class FeaturedArtworksController extends Controller
|
|||||||
|
|
||||||
$pageTitle = $artworkTypes[$type] ?? 'Featured Artworks';
|
$pageTitle = $artworkTypes[$type] ?? 'Featured Artworks';
|
||||||
|
|
||||||
return view('legacy.featured-artworks', [
|
return view('legacy::featured-artworks', [
|
||||||
'artworks' => $artworks,
|
'artworks' => $artworks,
|
||||||
'type' => $type,
|
'type' => $type,
|
||||||
'artworkTypes' => $artworkTypes,
|
'artworkTypes' => $artworkTypes,
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ class HomeController extends Controller
|
|||||||
$ourNews = [];
|
$ourNews = [];
|
||||||
$latestForumActivity = [];
|
$latestForumActivity = [];
|
||||||
|
|
||||||
return view('legacy.home', compact(
|
return view('legacy::home', compact(
|
||||||
'page_title',
|
'page_title',
|
||||||
'page_meta_description',
|
'page_meta_description',
|
||||||
'page_meta_keywords',
|
'page_meta_keywords',
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ class InterviewController extends Controller
|
|||||||
|
|
||||||
$page_title = 'Interview with ' . ($ar->username ?? '');
|
$page_title = 'Interview with ' . ($ar->username ?? '');
|
||||||
|
|
||||||
return view('legacy.interview', [
|
return view('legacy::interview', [
|
||||||
'ar' => $ar,
|
'ar' => $ar,
|
||||||
'artworks' => $artworks,
|
'artworks' => $artworks,
|
||||||
'comments' => $comments,
|
'comments' => $comments,
|
||||||
|
|||||||
@@ -23,6 +23,6 @@ class InterviewsController extends Controller
|
|||||||
|
|
||||||
$page_title = 'Interviews';
|
$page_title = 'Interviews';
|
||||||
|
|
||||||
return view('legacy.interviews', compact('interviews', 'page_title'));
|
return view('legacy::interviews', compact('interviews', 'page_title'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,6 +52,6 @@ class LatestCommentsController extends Controller
|
|||||||
|
|
||||||
$page_title = 'Latest Comments';
|
$page_title = 'Latest Comments';
|
||||||
|
|
||||||
return view('legacy.latest-comments', compact('page_title', 'comments'));
|
return view('legacy::latest-comments', compact('page_title', 'comments'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ class LatestController extends Controller
|
|||||||
];
|
];
|
||||||
});
|
});
|
||||||
|
|
||||||
return view('legacy.latest-artworks', [
|
return view('legacy::latest-artworks', [
|
||||||
'artworks' => $artworks,
|
'artworks' => $artworks,
|
||||||
'page_title' => 'Latest Artworks',
|
'page_title' => 'Latest Artworks',
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -46,6 +46,6 @@ class MembersController extends Controller
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return view('legacy.browse', compact('page_title', 'artworks'));
|
return view('legacy::browse', compact('page_title', 'artworks'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,6 @@ class MonthlyCommentatorsController extends Controller
|
|||||||
|
|
||||||
$page_title = 'Monthly Top Commentators';
|
$page_title = 'Monthly Top Commentators';
|
||||||
|
|
||||||
return view('legacy.monthly-commentators', compact('page_title', 'rows'));
|
return view('legacy::monthly-commentators', compact('page_title', 'rows'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ class MyBuddiesController extends Controller
|
|||||||
|
|
||||||
$page_title = ($user->name ?? $user->username ?? 'User') . ': Following List';
|
$page_title = ($user->name ?? $user->username ?? 'User') . ': Following List';
|
||||||
|
|
||||||
return view('legacy.mybuddies', compact('buddies', 'page_title'));
|
return view('legacy::mybuddies', compact('buddies', 'page_title'));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function destroy(Request $request, $id)
|
public function destroy(Request $request, $id)
|
||||||
|
|||||||
@@ -39,6 +39,6 @@ class NewsController extends Controller
|
|||||||
|
|
||||||
$page_title = ($news->headline ?? 'News') . ' - SkinBase News';
|
$page_title = ($news->headline ?? 'News') . ' - SkinBase News';
|
||||||
|
|
||||||
return view('legacy.news', compact('news', 'comments', 'page_title'));
|
return view('legacy::news', compact('news', 'comments', 'page_title'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -107,6 +107,6 @@ class PhotographyController extends Controller
|
|||||||
|
|
||||||
$page_meta_description = $tidy;
|
$page_meta_description = $tidy;
|
||||||
|
|
||||||
return view('legacy.content-type', compact('contentType','rootCategories','artworks','page_title','page_meta_description','subcategories','id'));
|
return view('legacy::content-type', compact('contentType','rootCategories','artworks','page_title','page_meta_description','subcategories','id'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ class ProfileController extends Controller
|
|||||||
'about_me' => $user->bio ?? null,
|
'about_me' => $user->bio ?? null,
|
||||||
];
|
];
|
||||||
|
|
||||||
return view('legacy.profile', [
|
return view('legacy::profile', [
|
||||||
'user' => $legacyUser,
|
'user' => $legacyUser,
|
||||||
'artworks' => $artworks,
|
'artworks' => $artworks,
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ class ReceivedCommentsController extends Controller
|
|||||||
|
|
||||||
$comments = $base->paginate($hits);
|
$comments = $base->paginate($hits);
|
||||||
|
|
||||||
return view('legacy.received-comments', [
|
return view('legacy::received-comments', [
|
||||||
'comments' => $comments,
|
'comments' => $comments,
|
||||||
'page' => $page,
|
'page' => $page,
|
||||||
'hits' => $hits,
|
'hits' => $hits,
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ class StatisticsController extends Controller
|
|||||||
return $row;
|
return $row;
|
||||||
});
|
});
|
||||||
|
|
||||||
return view('legacy.statistics', [
|
return view('legacy::statistics', [
|
||||||
'artworks' => $artworks,
|
'artworks' => $artworks,
|
||||||
'sort' => $sort,
|
'sort' => $sort,
|
||||||
'page_title' => 'Artwork Statistics',
|
'page_title' => 'Artwork Statistics',
|
||||||
|
|||||||
@@ -63,6 +63,6 @@ class TodayDownloadsController extends Controller
|
|||||||
|
|
||||||
$page_title = 'Today Downloaded Artworks';
|
$page_title = 'Today Downloaded Artworks';
|
||||||
|
|
||||||
return view('legacy.browse', ['page_title' => $page_title, 'artworks' => $paginator]);
|
return view('legacy::browse', ['page_title' => $page_title, 'artworks' => $paginator]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ class TodayInHistoryController extends Controller
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return view('legacy.today-in-history', [
|
return view('legacy::today-in-history', [
|
||||||
'artworks' => $artworks,
|
'artworks' => $artworks,
|
||||||
'page_title' => 'Popular on this day in history',
|
'page_title' => 'Popular on this day in history',
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -55,6 +55,6 @@ class TopAuthorsController extends Controller
|
|||||||
|
|
||||||
$page_title = 'Top Authors';
|
$page_title = 'Top Authors';
|
||||||
|
|
||||||
return view('legacy.top-authors', compact('page_title', 'authors', 'metric'));
|
return view('legacy::top-authors', compact('page_title', 'authors', 'metric'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,6 +52,6 @@ class TopFavouritesController extends Controller
|
|||||||
|
|
||||||
$page_title = 'Top Favourites';
|
$page_title = 'Top Favourites';
|
||||||
|
|
||||||
return view('legacy.top-favourites', ['page_title' => $page_title, 'artworks' => $paginator]);
|
return view('legacy::top-favourites', ['page_title' => $page_title, 'artworks' => $paginator]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -173,7 +173,7 @@ class UserController extends Controller
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return view('legacy.user', [
|
return view('legacy::user', [
|
||||||
'user' => $user,
|
'user' => $user,
|
||||||
'birthDay' => $birthDay,
|
'birthDay' => $birthDay,
|
||||||
'birthMonth' => $birthMonth,
|
'birthMonth' => $birthMonth,
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ class LegacyController extends Controller
|
|||||||
$ourNews = $this->ourNews();
|
$ourNews = $this->ourNews();
|
||||||
$latestForumActivity = $this->latestForumActivity();
|
$latestForumActivity = $this->latestForumActivity();
|
||||||
|
|
||||||
return view('legacy.home', compact(
|
return view('legacy::home', compact(
|
||||||
'page_title',
|
'page_title',
|
||||||
'page_meta_description',
|
'page_meta_description',
|
||||||
'page_meta_keywords',
|
'page_meta_keywords',
|
||||||
@@ -75,7 +75,7 @@ class LegacyController extends Controller
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return view('legacy.browse', compact('page_title', 'page_meta_description', 'page_meta_keywords', 'artworks'));
|
return view('legacy::browse', compact('page_title', 'page_meta_description', 'page_meta_keywords', 'artworks'));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function category(Request $request, string $group, ?string $slug = null, ?int $id = null)
|
public function category(Request $request, string $group, ?string $slug = null, ?int $id = null)
|
||||||
@@ -172,7 +172,7 @@ class LegacyController extends Controller
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return view('legacy.category', compact(
|
return view('legacy::category', compact(
|
||||||
'group',
|
'group',
|
||||||
'category',
|
'category',
|
||||||
'artworks',
|
'artworks',
|
||||||
@@ -246,7 +246,7 @@ class LegacyController extends Controller
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return view('legacy.categories', compact(
|
return view('legacy::categories', compact(
|
||||||
'categories',
|
'categories',
|
||||||
'subgroups',
|
'subgroups',
|
||||||
'page_title',
|
'page_title',
|
||||||
|
|||||||
@@ -96,6 +96,6 @@ class PhotographyController extends Controller
|
|||||||
|
|
||||||
$page_meta_description = $tidy;
|
$page_meta_description = $tidy;
|
||||||
|
|
||||||
return view('legacy.content-type', compact('contentType','rootCategories','artworks','page_title','page_meta_description','subcategories','id'));
|
return view('legacy::content-type', compact('contentType','rootCategories','artworks','page_title','page_meta_description','subcategories','id'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ class MonthlyCommentatorsController extends Controller
|
|||||||
->whereRaw('t1.created_at >= DATE_SUB(NOW(), INTERVAL 30 DAY)')
|
->whereRaw('t1.created_at >= DATE_SUB(NOW(), INTERVAL 30 DAY)')
|
||||||
->select(
|
->select(
|
||||||
't2.id as user_id',
|
't2.id as user_id',
|
||||||
|
't2.username as user_username',
|
||||||
DB::raw('COALESCE(t2.username, t2.name, "User") as uname'),
|
DB::raw('COALESCE(t2.username, t2.name, "User") as uname'),
|
||||||
DB::raw('COUNT(*) as num_comments')
|
DB::raw('COUNT(*) as num_comments')
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ class MyBuddiesController extends Controller
|
|||||||
->leftJoin('users as t2', 't1.friend_id', '=', 't2.id')
|
->leftJoin('users as t2', 't1.friend_id', '=', 't2.id')
|
||||||
->leftJoin('user_profiles as p', 'p.user_id', '=', 't2.id')
|
->leftJoin('user_profiles as p', 'p.user_id', '=', 't2.id')
|
||||||
->where('t1.user_id', $user->id)
|
->where('t1.user_id', $user->id)
|
||||||
->select('t1.id', 't1.friend_id', 't1.user_id', 't2.name as uname', 'p.avatar_hash as icon', 't1.date_added')
|
->select('t1.id', 't1.friend_id', 't1.user_id', 't2.name as uname', 't2.username as user_username', 'p.avatar_hash as icon', 't1.date_added')
|
||||||
->orderByDesc('t1.date_added');
|
->orderByDesc('t1.date_added');
|
||||||
|
|
||||||
$buddies = $query->paginate($perPage)->withQueryString();
|
$buddies = $query->paginate($perPage)->withQueryString();
|
||||||
|
|||||||
@@ -383,7 +383,7 @@ class ProfileController extends Controller
|
|||||||
// ── Favourites ───────────────────────────────────────────────────────
|
// ── Favourites ───────────────────────────────────────────────────────
|
||||||
$favourites = collect();
|
$favourites = collect();
|
||||||
if (Schema::hasTable('user_favorites')) {
|
if (Schema::hasTable('user_favorites')) {
|
||||||
$favourites = DB::table('user_favorites as uf')
|
$favIds = DB::table('user_favorites as uf')
|
||||||
->join('artworks as a', 'a.id', '=', 'uf.artwork_id')
|
->join('artworks as a', 'a.id', '=', 'uf.artwork_id')
|
||||||
->where('uf.user_id', $user->id)
|
->where('uf.user_id', $user->id)
|
||||||
->whereNull('a.deleted_at')
|
->whereNull('a.deleted_at')
|
||||||
@@ -391,20 +391,18 @@ class ProfileController extends Controller
|
|||||||
->where('a.is_approved', true)
|
->where('a.is_approved', true)
|
||||||
->orderByDesc('uf.created_at')
|
->orderByDesc('uf.created_at')
|
||||||
->limit(12)
|
->limit(12)
|
||||||
->select(['a.id', 'a.title as name', 'a.hash', 'a.thumb_ext', 'a.width', 'a.height', 'a.user_id'])
|
->pluck('a.id');
|
||||||
->get()
|
|
||||||
->map(function ($row) {
|
if ($favIds->isNotEmpty()) {
|
||||||
$thumbUrl = ($row->hash && $row->thumb_ext)
|
$indexed = Artwork::with('user:id,name,username')
|
||||||
? ThumbnailService::fromHash($row->hash, $row->thumb_ext, 'sm')
|
->whereIn('id', $favIds)
|
||||||
: '/images/placeholder.jpg';
|
->get()
|
||||||
return (object) [
|
->keyBy('id');
|
||||||
'id' => $row->id,
|
// Preserve the ordering from the favourites table
|
||||||
'name' => $row->name,
|
$favourites = $favIds
|
||||||
'thumb' => $thumbUrl,
|
->filter(fn ($id) => $indexed->has($id))
|
||||||
'width' => $row->width,
|
->map(fn ($id) => $indexed[$id]);
|
||||||
'height'=> $row->height,
|
}
|
||||||
];
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Statistics ───────────────────────────────────────────────────────
|
// ── Statistics ───────────────────────────────────────────────────────
|
||||||
@@ -499,6 +497,16 @@ class ProfileController extends Controller
|
|||||||
$countryName = $countryName ?? strtoupper((string) $profile->country_code);
|
$countryName = $countryName ?? strtoupper((string) $profile->country_code);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Hero background artwork ─────────────────────────────────────────
|
||||||
|
$heroBgUrl = Artwork::public()
|
||||||
|
->published()
|
||||||
|
->where('user_id', $user->id)
|
||||||
|
->whereNotNull('hash')
|
||||||
|
->whereNotNull('thumb_ext')
|
||||||
|
->inRandomOrder()
|
||||||
|
->limit(1)
|
||||||
|
->first()?->thumbUrl('lg');
|
||||||
|
|
||||||
// ── Increment profile views (async-safe, ignore errors) ──────────────
|
// ── Increment profile views (async-safe, ignore errors) ──────────────
|
||||||
if (! $isOwner) {
|
if (! $isOwner) {
|
||||||
try {
|
try {
|
||||||
@@ -510,7 +518,7 @@ class ProfileController extends Controller
|
|||||||
} catch (\Throwable) {}
|
} catch (\Throwable) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
return response()->view('legacy.profile', [
|
return response()->view('legacy::profile', [
|
||||||
'user' => $user,
|
'user' => $user,
|
||||||
'profile' => $profile,
|
'profile' => $profile,
|
||||||
'artworks' => $artworks,
|
'artworks' => $artworks,
|
||||||
@@ -521,6 +529,7 @@ class ProfileController extends Controller
|
|||||||
'followerCount' => $followerCount,
|
'followerCount' => $followerCount,
|
||||||
'recentFollowers' => $recentFollowers,
|
'recentFollowers' => $recentFollowers,
|
||||||
'viewerIsFollowing' => $viewerIsFollowing,
|
'viewerIsFollowing' => $viewerIsFollowing,
|
||||||
|
'heroBgUrl' => $heroBgUrl,
|
||||||
'profileComments' => $profileComments,
|
'profileComments' => $profileComments,
|
||||||
'countryName' => $countryName,
|
'countryName' => $countryName,
|
||||||
'isOwner' => $isOwner,
|
'isOwner' => $isOwner,
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ class TodayInHistoryController extends Controller
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return view('legacy.today-in-history', [
|
return view('legacy::today-in-history', [
|
||||||
'artworks' => $artworks,
|
'artworks' => $artworks,
|
||||||
'page_title' => 'Popular on this day in history',
|
'page_title' => 'Popular on this day in history',
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -51,6 +51,6 @@ class TopFavouritesController extends Controller
|
|||||||
|
|
||||||
$page_title = 'Top Favourites';
|
$page_title = 'Top Favourites';
|
||||||
|
|
||||||
return view('legacy.top-favourites', ['page_title' => $page_title, 'artworks' => $paginator]);
|
return view('legacy::top-favourites', ['page_title' => $page_title, 'artworks' => $paginator]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace App\Http\Controllers\User;
|
namespace App\Http\Controllers\User;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\Artwork;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
class UserController extends Controller
|
class UserController extends Controller
|
||||||
@@ -20,8 +21,28 @@ class UserController extends Controller
|
|||||||
$profile = null;
|
$profile = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return view('legacy.user', [
|
// Hero background: prefer featured wallpapers or photography
|
||||||
'profile' => $profile,
|
$heroBgUrl = Artwork::public()
|
||||||
|
->published()
|
||||||
|
->whereNotNull('hash')
|
||||||
|
->whereNotNull('thumb_ext')
|
||||||
|
->whereHas('features', function ($q) {
|
||||||
|
$q->where('is_active', true)
|
||||||
|
->where(function ($q2) {
|
||||||
|
$q2->whereNull('expires_at')->orWhere('expires_at', '>', now());
|
||||||
|
});
|
||||||
|
})
|
||||||
|
->whereHas('categories', function ($q) {
|
||||||
|
// content_type_id 2 = Wallpapers, 3 = Photography
|
||||||
|
$q->whereIn('content_type_id', [2, 3]);
|
||||||
|
})
|
||||||
|
->inRandomOrder()
|
||||||
|
->limit(1)
|
||||||
|
->first()?->thumbUrl('lg');
|
||||||
|
|
||||||
|
return view('legacy::user', [
|
||||||
|
'profile' => $profile,
|
||||||
|
'heroBgUrl' => $heroBgUrl,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,119 +1,34 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Http\Controllers\Web;
|
namespace App\Http\Controllers\Web;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Services\HomepageService;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use App\Services\ArtworkService;
|
|
||||||
use Illuminate\Support\Collection;
|
|
||||||
use Illuminate\Support\Facades\DB;
|
|
||||||
use Illuminate\Database\QueryException;
|
|
||||||
use Illuminate\Support\Facades\Log;
|
|
||||||
|
|
||||||
class HomeController extends Controller
|
final class HomeController extends Controller
|
||||||
{
|
{
|
||||||
protected ArtworkService $artworks;
|
public function __construct(private readonly HomepageService $homepage) {}
|
||||||
|
|
||||||
public function __construct(ArtworkService $artworks)
|
public function index(Request $request): \Illuminate\View\View
|
||||||
{
|
{
|
||||||
$this->artworks = $artworks;
|
$sections = $this->homepage->all();
|
||||||
}
|
|
||||||
|
|
||||||
public function index(Request $request)
|
$hero = $sections['hero'];
|
||||||
{
|
|
||||||
$page_title = 'Skinbase - Photography, Skins & Wallpapers';
|
|
||||||
$page_meta_description = 'Skinbase legacy home, rendered via Laravel.';
|
|
||||||
$page_meta_keywords = 'wallpapers, skins, photography, community';
|
|
||||||
|
|
||||||
$featuredResult = $this->artworks->getFeaturedArtworks(null, 39);
|
$meta = [
|
||||||
if ($featuredResult instanceof \Illuminate\Pagination\LengthAwarePaginator) {
|
'title' => 'Skinbase – Digital Art & Wallpapers',
|
||||||
$featuredCollection = $featuredResult->getCollection();
|
'description' => 'Discover stunning digital art, wallpapers, and skins from a global community of creators. Browse trending works, fresh uploads, and beloved classics.',
|
||||||
$featured = $featuredCollection->get(0);
|
'keywords' => 'wallpapers, digital art, skins, photography, community, wallpaper downloads',
|
||||||
$memberFeatured = $featuredCollection->get(1);
|
'og_image' => $hero['thumb_lg'] ?? $hero['thumb'] ?? null,
|
||||||
} elseif (is_array($featuredResult)) {
|
'canonical' => url('/'),
|
||||||
$featured = $featuredResult[0] ?? null;
|
];
|
||||||
$memberFeatured = $featuredResult[1] ?? null;
|
|
||||||
} elseif ($featuredResult instanceof Collection) {
|
|
||||||
$featured = $featuredResult->get(0);
|
|
||||||
$memberFeatured = $featuredResult->get(1);
|
|
||||||
} else {
|
|
||||||
$featured = $featuredResult;
|
|
||||||
$memberFeatured = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$latestUploads = $this->artworks->getLatestArtworks(20);
|
return view('web.home', [
|
||||||
|
'meta' => $meta,
|
||||||
// Forum news (prefer migrated legacy news category id 2876, fallback to slug)
|
'props' => $sections,
|
||||||
try {
|
]);
|
||||||
$forumNews = DB::table('forum_threads as t1')
|
|
||||||
->leftJoin('users as u', 't1.user_id', '=', 'u.id')
|
|
||||||
->leftJoin('forum_categories as c', 't1.category_id', '=', 'c.id')
|
|
||||||
->selectRaw('t1.id as topic_id, t1.title as topic, COALESCE(u.name, ?) as uname, t1.created_at as post_date, t1.content as preview', ['Unknown'])
|
|
||||||
->whereNull('t1.deleted_at')
|
|
||||||
->where(function ($query) {
|
|
||||||
$query->where('t1.category_id', 2876)
|
|
||||||
->orWhereIn('c.slug', ['news', 'forum-news']);
|
|
||||||
})
|
|
||||||
->orderByDesc('t1.created_at')
|
|
||||||
->limit(8)
|
|
||||||
->get();
|
|
||||||
} catch (QueryException $e) {
|
|
||||||
Log::warning('Forum threads table missing or DB error when loading forum news', ['exception' => $e->getMessage()]);
|
|
||||||
$forumNews = collect();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Our news (latest site news)
|
|
||||||
try {
|
|
||||||
$ourNews = DB::table('news as t1')
|
|
||||||
->join('news_categories as c', 't1.category_id', '=', 'c.category_id')
|
|
||||||
->join('users as u', 't1.user_id', '=', 'u.user_id')
|
|
||||||
->selectRaw('t1.news_id, t1.headline, t1.user_id, t1.picture, t1.preview, u.uname, t1.create_date, t1.views, c.category_name, (SELECT COUNT(*) FROM news_comments WHERE news_id = t1.news_id) AS num_comments')
|
|
||||||
->orderBy('t1.create_date', 'desc')
|
|
||||||
->limit(5)
|
|
||||||
->get();
|
|
||||||
} catch (QueryException $e) {
|
|
||||||
Log::warning('News table missing or DB error when loading our news', ['exception' => $e->getMessage()]);
|
|
||||||
$ourNews = collect();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Latest forum activity (exclude forum news category)
|
|
||||||
try {
|
|
||||||
$latestForumActivity = DB::table('forum_threads as t1')
|
|
||||||
->leftJoin('forum_categories as c', 't1.category_id', '=', 'c.id')
|
|
||||||
->leftJoin('forum_posts as p', function ($join) {
|
|
||||||
$join->on('p.thread_id', '=', 't1.id')
|
|
||||||
->whereNull('p.deleted_at');
|
|
||||||
})
|
|
||||||
->selectRaw('t1.id as topic_id, t1.title as topic, COUNT(p.id) as numPosts')
|
|
||||||
->whereNull('t1.deleted_at')
|
|
||||||
->where(function ($query) {
|
|
||||||
$query->where('t1.category_id', '<>', 2876)
|
|
||||||
->orWhereNull('t1.category_id');
|
|
||||||
})
|
|
||||||
->where(function ($query) {
|
|
||||||
$query->whereNull('c.slug')
|
|
||||||
->orWhereNotIn('c.slug', ['news', 'forum-news']);
|
|
||||||
})
|
|
||||||
->groupBy('t1.id', 't1.title')
|
|
||||||
->orderByDesc('t1.last_post_at')
|
|
||||||
->orderByDesc('t1.created_at')
|
|
||||||
->limit(10)
|
|
||||||
->get();
|
|
||||||
} catch (QueryException $e) {
|
|
||||||
Log::warning('Forum threads table missing or DB error when loading latest forum activity', ['exception' => $e->getMessage()]);
|
|
||||||
$latestForumActivity = collect();
|
|
||||||
}
|
|
||||||
|
|
||||||
return view('web.home', compact(
|
|
||||||
'page_title',
|
|
||||||
'page_meta_description',
|
|
||||||
'page_meta_keywords',
|
|
||||||
'featured',
|
|
||||||
'memberFeatured',
|
|
||||||
'latestUploads',
|
|
||||||
'forumNews',
|
|
||||||
'ourNews',
|
|
||||||
'latestForumActivity'
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,11 @@ class AppServiceProvider extends ServiceProvider
|
|||||||
*/
|
*/
|
||||||
public function boot(): void
|
public function boot(): void
|
||||||
{
|
{
|
||||||
|
// Map the 'legacy' view namespace to resources/views/_legacy so all
|
||||||
|
// view('legacy::foo') and @include('legacy::foo') calls resolve correctly
|
||||||
|
// after the folder was renamed from legacy/ to _legacy/.
|
||||||
|
View::addNamespace('legacy', resource_path('views/_legacy'));
|
||||||
|
|
||||||
$this->configureAuthRateLimiters();
|
$this->configureAuthRateLimiters();
|
||||||
$this->configureUploadRateLimiters();
|
$this->configureUploadRateLimiters();
|
||||||
$this->configureMailFailureLogging();
|
$this->configureMailFailureLogging();
|
||||||
|
|||||||
304
app/Services/HomepageService.php
Normal file
304
app/Services/HomepageService.php
Normal 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(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
72
resources/js/Pages/Home/HomeCreators.jsx
Normal file
72
resources/js/Pages/Home/HomeCreators.jsx
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
const AVATAR_FALLBACK = 'https://files.skinbase.org/avatars/default.webp'
|
||||||
|
|
||||||
|
function CreatorCard({ creator }) {
|
||||||
|
return (
|
||||||
|
<article className="group relative flex flex-col items-center gap-3 overflow-hidden rounded-xl bg-panel p-5 shadow-sm text-center transition hover:ring-1 hover:ring-nova-500">
|
||||||
|
{/* Background artwork thumbnail */}
|
||||||
|
{creator.bg_thumb && (
|
||||||
|
<>
|
||||||
|
<img
|
||||||
|
src={creator.bg_thumb}
|
||||||
|
alt=""
|
||||||
|
aria-hidden="true"
|
||||||
|
className="pointer-events-none absolute inset-0 h-full w-full object-cover opacity-50 transition duration-500 group-hover:opacity-20 group-hover:scale-105"
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
|
/>
|
||||||
|
<div className="pointer-events-none absolute inset-0 bg-gradient-to-t from-panel via-panel/80 to-panel/60" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<a href={creator.url} className="relative block">
|
||||||
|
<img
|
||||||
|
src={creator.avatar}
|
||||||
|
alt={creator.name}
|
||||||
|
className="mx-auto h-16 w-16 rounded-full object-cover ring-4 bg-nova-800/80 ring-nova-800"
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
|
onError={(e) => { e.currentTarget.src = AVATAR_FALLBACK }}
|
||||||
|
/>
|
||||||
|
<h3 className="mt-2 text-sm font-semibold text-white">{creator.name}</h3>
|
||||||
|
</a>
|
||||||
|
<div className="relative flex flex-wrap justify-center gap-3 text-xs text-soft">
|
||||||
|
<span title="Total uploads">📁 {creator.uploads}</span>
|
||||||
|
{creator.weekly_uploads > 0 && (
|
||||||
|
<span title="Uploads this week" className="text-accent font-semibold">↑{creator.weekly_uploads} this week</span>
|
||||||
|
)}
|
||||||
|
<span title="Views">👁 {creator.views.toLocaleString()}</span>
|
||||||
|
{creator.awards > 0 && <span title="Awards">🏆 {creator.awards}</span>}
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
href={creator.url}
|
||||||
|
className="relative mt-1 rounded-lg bg-nova-700 px-4 py-1.5 text-xs font-semibold text-white transition hover:bg-nova-600"
|
||||||
|
>
|
||||||
|
View Profile
|
||||||
|
</a>
|
||||||
|
</article>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function HomeCreators({ creators }) {
|
||||||
|
if (!Array.isArray(creators) || creators.length === 0) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="mt-14 px-4 sm:px-6 lg:px-8">
|
||||||
|
<div className="mb-5 flex items-center justify-between">
|
||||||
|
<h2 className="text-xl font-bold text-white">👤 Creator Spotlight</h2>
|
||||||
|
<a href="/members" className="text-sm text-nova-300 hover:text-white transition">
|
||||||
|
All creators →
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-6">
|
||||||
|
{creators.map((c) => (
|
||||||
|
<CreatorCard key={c.id} creator={c} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
73
resources/js/Pages/Home/HomeFresh.jsx
Normal file
73
resources/js/Pages/Home/HomeFresh.jsx
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
const FALLBACK = 'https://files.skinbase.org/default/missing_md.webp'
|
||||||
|
const AVATAR_FALLBACK = 'https://files.skinbase.org/avatars/default.webp'
|
||||||
|
|
||||||
|
function FreshCard({ item }) {
|
||||||
|
const username = item.author_username ? `@${item.author_username}` : null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article>
|
||||||
|
<a
|
||||||
|
href={item.url}
|
||||||
|
className="group relative block overflow-hidden rounded-2xl ring-1 ring-white/5 bg-black/20 shadow-lg shadow-black/40 transition-all duration-200 ease-out hover:-translate-y-0.5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-300/70"
|
||||||
|
>
|
||||||
|
<div className="relative aspect-video overflow-hidden bg-neutral-900">
|
||||||
|
{/* Gloss sheen */}
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-br from-white/10 via-white/5 to-transparent pointer-events-none z-10" />
|
||||||
|
|
||||||
|
<img
|
||||||
|
src={item.thumb || FALLBACK}
|
||||||
|
alt={item.title}
|
||||||
|
className="h-full w-full object-cover transition-[transform,filter] duration-300 ease-out group-hover:scale-[1.04]"
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
|
onError={(e) => { e.currentTarget.src = FALLBACK }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Top-right View badge */}
|
||||||
|
<div className="absolute right-3 top-3 z-30 flex items-center gap-2 opacity-0 transition-opacity duration-200 group-hover:opacity-100">
|
||||||
|
<span className="inline-flex items-center rounded-md bg-black/60 px-2 py-1 text-[11px] font-medium text-white ring-1 ring-white/10">View</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bottom info overlay — always visible on mobile, hover-only on md+ */}
|
||||||
|
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-20 bg-gradient-to-t from-black/80 via-black/40 to-transparent p-3 backdrop-blur-[2px] opacity-100 transition-opacity duration-200 md:opacity-0 md:group-hover:opacity-100 md:group-focus-visible:opacity-100">
|
||||||
|
<div className="truncate text-sm font-semibold text-white">{item.title}</div>
|
||||||
|
<div className="mt-1 flex items-center gap-2 text-xs text-white/80">
|
||||||
|
<img
|
||||||
|
src={item.author_avatar || AVATAR_FALLBACK}
|
||||||
|
alt={item.author}
|
||||||
|
className="w-6 h-6 rounded-full object-cover shrink-0"
|
||||||
|
onError={(e) => { e.currentTarget.src = AVATAR_FALLBACK }}
|
||||||
|
/>
|
||||||
|
<span className="truncate">{item.author}</span>
|
||||||
|
{username && <span className="text-white/50 shrink-0">{username}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="sr-only">{item.title} by {item.author}</span>
|
||||||
|
</a>
|
||||||
|
</article>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function HomeFresh({ items }) {
|
||||||
|
if (!Array.isArray(items) || items.length === 0) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="mt-14 px-4 sm:px-6 lg:px-8">
|
||||||
|
<div className="mb-5 flex items-center justify-between">
|
||||||
|
<h2 className="text-xl font-bold text-white">🆕 Fresh Uploads</h2>
|
||||||
|
<a href="/browse" className="text-sm text-nova-300 hover:text-white transition">
|
||||||
|
See all →
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 xl:grid-cols-6">
|
||||||
|
{items.map((item) => (
|
||||||
|
<FreshCard key={item.id} item={item} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
77
resources/js/Pages/Home/HomeHero.jsx
Normal file
77
resources/js/Pages/Home/HomeHero.jsx
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
const FALLBACK = 'https://files.skinbase.org/default/missing_lg.webp'
|
||||||
|
|
||||||
|
export default function HomeHero({ artwork }) {
|
||||||
|
if (!artwork) {
|
||||||
|
return (
|
||||||
|
<section className="relative flex min-h-[27vw] max-h-[300px] w-full items-end overflow-hidden bg-nova-900">
|
||||||
|
<div className="pointer-events-none absolute inset-0 bg-gradient-to-t from-nova-900 via-nova-900/60 to-transparent" />
|
||||||
|
<div className="relative z-10 w-full px-6 pb-7 sm:px-10 lg:px-16">
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight text-white sm:text-4xl">
|
||||||
|
Discover Digital Art
|
||||||
|
</h1>
|
||||||
|
<p className="mt-2 max-w-xl text-sm text-soft">
|
||||||
|
Wallpapers, skins & digital creations from a global community.
|
||||||
|
</p>
|
||||||
|
<div className="mt-4 flex flex-wrap gap-3">
|
||||||
|
<a href="/browse" className="rounded-xl bg-accent px-5 py-2 text-sm font-semibold text-white shadow-lg transition hover:brightness-110">Explore</a>
|
||||||
|
<a href="/upload" className="rounded-xl bg-nova-700 px-5 py-2 text-sm font-semibold text-white shadow-lg transition hover:bg-nova-600">Upload</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const src = artwork.thumb_lg || artwork.thumb || FALLBACK
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="group relative flex min-h-[27vw] max-h-[300px] w-full items-end overflow-hidden bg-nova-900">
|
||||||
|
{/* Background image */}
|
||||||
|
<img
|
||||||
|
src={src}
|
||||||
|
alt={artwork.title}
|
||||||
|
className="absolute inset-0 h-full w-full object-cover transition-transform duration-700 group-hover:scale-[1.02]"
|
||||||
|
fetchpriority="high"
|
||||||
|
decoding="async"
|
||||||
|
onError={(e) => { e.currentTarget.src = FALLBACK }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Gradient overlay */}
|
||||||
|
<div className="pointer-events-none absolute inset-0 bg-gradient-to-t from-nova-900 via-nova-900/55 to-transparent" />
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="relative z-10 w-full px-6 pb-7 sm:px-10 lg:px-16">
|
||||||
|
<p className="mb-1.5 text-xs font-semibold uppercase tracking-widest text-accent">
|
||||||
|
Featured Artwork
|
||||||
|
</p>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight text-white drop-shadow sm:text-4xl lg:text-5xl">
|
||||||
|
{artwork.title}
|
||||||
|
</h1>
|
||||||
|
<p className="mt-1.5 text-sm text-soft">
|
||||||
|
by <a href={artwork.url} className="text-nova-200 hover:text-white transition">{artwork.author}</a>
|
||||||
|
</p>
|
||||||
|
<div className="mt-4 flex flex-wrap gap-3">
|
||||||
|
<a
|
||||||
|
href="/browse"
|
||||||
|
className="rounded-xl bg-accent px-5 py-2 text-sm font-semibold text-white shadow-lg transition hover:brightness-110"
|
||||||
|
>
|
||||||
|
Explore
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="/upload"
|
||||||
|
className="rounded-xl bg-nova-700 px-5 py-2 text-sm font-semibold text-white shadow-lg transition hover:bg-nova-600"
|
||||||
|
>
|
||||||
|
Upload
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href={artwork.url}
|
||||||
|
className="rounded-xl border border-nova-600 px-5 py-2 text-sm font-semibold text-nova-200 shadow transition hover:border-nova-400 hover:text-white"
|
||||||
|
>
|
||||||
|
View Artwork
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
42
resources/js/Pages/Home/HomeNews.jsx
Normal file
42
resources/js/Pages/Home/HomeNews.jsx
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
function formatDate(dateStr) {
|
||||||
|
if (!dateStr) return ''
|
||||||
|
try {
|
||||||
|
return new Date(dateStr).toLocaleDateString('en-US', {
|
||||||
|
year: 'numeric', month: 'short', day: 'numeric',
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function HomeNews({ items }) {
|
||||||
|
if (!Array.isArray(items) || items.length === 0) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="mt-14 px-4 sm:px-6 lg:px-8">
|
||||||
|
<div className="mb-5 flex items-center justify-between">
|
||||||
|
<h2 className="text-xl font-bold text-white">📰 News & Updates</h2>
|
||||||
|
<a href="/forum/news" className="text-sm text-nova-300 hover:text-white transition">
|
||||||
|
All news →
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="divide-y divide-nova-800 rounded-xl bg-panel overflow-hidden">
|
||||||
|
{items.map((item) => (
|
||||||
|
<a
|
||||||
|
key={item.id}
|
||||||
|
href={item.url}
|
||||||
|
className="flex items-start justify-between gap-4 px-5 py-4 transition hover:bg-nova-800"
|
||||||
|
>
|
||||||
|
<span className="text-sm font-medium text-white line-clamp-2">{item.title}</span>
|
||||||
|
{item.date && (
|
||||||
|
<span className="flex-shrink-0 text-xs text-soft">{formatDate(item.date)}</span>
|
||||||
|
)}
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
63
resources/js/Pages/Home/HomePage.jsx
Normal file
63
resources/js/Pages/Home/HomePage.jsx
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import React, { lazy, Suspense } from 'react'
|
||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
|
||||||
|
// Sub-section components — lazy-loaded so only the hero blocks the initial bundle
|
||||||
|
import HomeHero from './HomeHero'
|
||||||
|
|
||||||
|
const HomeTrending = lazy(() => import('./HomeTrending'))
|
||||||
|
const HomeFresh = lazy(() => import('./HomeFresh'))
|
||||||
|
const HomeTags = lazy(() => import('./HomeTags'))
|
||||||
|
const HomeCreators = lazy(() => import('./HomeCreators'))
|
||||||
|
const HomeNews = lazy(() => import('./HomeNews'))
|
||||||
|
|
||||||
|
function SectionFallback() {
|
||||||
|
return (
|
||||||
|
<div className="mt-14 h-48 animate-pulse rounded-xl bg-nova-800 mx-4 sm:mx-6 lg:mx-8" />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function HomePage({ hero, trending, fresh, tags, creators, news }) {
|
||||||
|
return (
|
||||||
|
<div className="pb-24">
|
||||||
|
{/* Hero — above-fold, eager */}
|
||||||
|
<HomeHero artwork={hero} />
|
||||||
|
|
||||||
|
{/* Below-fold sections — lazy */}
|
||||||
|
<Suspense fallback={<SectionFallback />}>
|
||||||
|
<HomeTrending items={trending} />
|
||||||
|
</Suspense>
|
||||||
|
|
||||||
|
<Suspense fallback={<SectionFallback />}>
|
||||||
|
<HomeFresh items={fresh} />
|
||||||
|
</Suspense>
|
||||||
|
|
||||||
|
<Suspense fallback={<SectionFallback />}>
|
||||||
|
<HomeTags tags={tags} />
|
||||||
|
</Suspense>
|
||||||
|
|
||||||
|
<Suspense fallback={<SectionFallback />}>
|
||||||
|
<HomeCreators creators={creators} />
|
||||||
|
</Suspense>
|
||||||
|
|
||||||
|
<Suspense fallback={<SectionFallback />}>
|
||||||
|
<HomeNews items={news} />
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-mount when the Blade view provides #homepage-root
|
||||||
|
const mountEl = document.getElementById('homepage-root')
|
||||||
|
if (mountEl) {
|
||||||
|
let props = {}
|
||||||
|
try {
|
||||||
|
const propsEl = document.getElementById('homepage-props')
|
||||||
|
props = propsEl ? JSON.parse(propsEl.textContent || '{}') : {}
|
||||||
|
} catch {
|
||||||
|
props = {}
|
||||||
|
}
|
||||||
|
|
||||||
|
createRoot(mountEl).render(<HomePage {...props} />)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default HomePage
|
||||||
26
resources/js/Pages/Home/HomeTags.jsx
Normal file
26
resources/js/Pages/Home/HomeTags.jsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
export default function HomeTags({ tags }) {
|
||||||
|
if (!Array.isArray(tags) || tags.length === 0) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="mt-14 px-4 sm:px-6 lg:px-8">
|
||||||
|
<h2 className="mb-5 text-xl font-bold text-white">🏷️ Popular Tags</h2>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{tags.map((tag) => (
|
||||||
|
<a
|
||||||
|
key={tag.id}
|
||||||
|
href={`/tag/${tag.slug}`}
|
||||||
|
className="rounded-full bg-nova-800 px-4 py-1.5 text-sm font-medium text-nova-200 transition hover:bg-nova-700 hover:text-white"
|
||||||
|
>
|
||||||
|
{tag.name}
|
||||||
|
{tag.count > 0 && (
|
||||||
|
<span className="ml-1.5 text-xs text-soft">{tag.count.toLocaleString()}</span>
|
||||||
|
)}
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
75
resources/js/Pages/Home/HomeTrending.jsx
Normal file
75
resources/js/Pages/Home/HomeTrending.jsx
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
const FALLBACK = 'https://files.skinbase.org/default/missing_md.webp'
|
||||||
|
const AVATAR_FALLBACK = 'https://files.skinbase.org/avatars/default.webp'
|
||||||
|
|
||||||
|
function ArtCard({ item }) {
|
||||||
|
const username = item.author_username ? `@${item.author_username}` : null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article className="min-w-[72%] snap-start sm:min-w-[44%] lg:min-w-0">
|
||||||
|
<a
|
||||||
|
href={item.url}
|
||||||
|
className="group relative block overflow-hidden rounded-2xl ring-1 ring-white/5 bg-black/20 shadow-lg shadow-black/40 transition-all duration-200 ease-out hover:-translate-y-0.5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-300/70"
|
||||||
|
>
|
||||||
|
<div className="relative aspect-video overflow-hidden bg-neutral-900">
|
||||||
|
{/* Gloss sheen */}
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-br from-white/10 via-white/5 to-transparent pointer-events-none z-10" />
|
||||||
|
|
||||||
|
<img
|
||||||
|
src={item.thumb || FALLBACK}
|
||||||
|
alt={item.title}
|
||||||
|
className="h-full w-full object-cover transition-[transform,filter] duration-300 ease-out group-hover:scale-[1.04]"
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
|
onError={(e) => { e.currentTarget.src = FALLBACK }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Top-right View badge */}
|
||||||
|
<div className="absolute right-3 top-3 z-30 flex items-center gap-2 opacity-0 transition-opacity duration-200 group-hover:opacity-100">
|
||||||
|
<span className="inline-flex items-center rounded-md bg-black/60 px-2 py-1 text-[11px] font-medium text-white ring-1 ring-white/10">View</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bottom info overlay — always visible on mobile, hover-only on md+ */}
|
||||||
|
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-20 bg-gradient-to-t from-black/80 via-black/40 to-transparent p-3 backdrop-blur-[2px] opacity-100 transition-opacity duration-200 md:opacity-0 md:group-hover:opacity-100 md:group-focus-visible:opacity-100">
|
||||||
|
<div className="truncate text-sm font-semibold text-white">{item.title}</div>
|
||||||
|
<div className="mt-1 flex items-center gap-2 text-xs text-white/80">
|
||||||
|
<img
|
||||||
|
src={item.author_avatar || AVATAR_FALLBACK}
|
||||||
|
alt={item.author}
|
||||||
|
className="w-6 h-6 rounded-full object-cover shrink-0"
|
||||||
|
onError={(e) => { e.currentTarget.src = AVATAR_FALLBACK }}
|
||||||
|
/>
|
||||||
|
<span className="truncate">{item.author}</span>
|
||||||
|
{username && <span className="text-white/50 shrink-0">{username}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="sr-only">{item.title} by {item.author}</span>
|
||||||
|
</a>
|
||||||
|
</article>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function HomeTrending({ items }) {
|
||||||
|
if (!Array.isArray(items) || items.length === 0) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="mt-14 px-4 sm:px-6 lg:px-8">
|
||||||
|
<div className="mb-5 flex items-center justify-between">
|
||||||
|
<h2 className="text-xl font-bold text-white">
|
||||||
|
🔥 Trending This Week
|
||||||
|
</h2>
|
||||||
|
<a href="/browse" className="text-sm text-nova-300 hover:text-white transition">
|
||||||
|
See all →
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex snap-x snap-mandatory gap-4 overflow-x-auto pb-3 lg:grid lg:grid-cols-4 xl:grid-cols-6 lg:overflow-visible">
|
||||||
|
{items.map((item) => (
|
||||||
|
<ArtCard key={item.id} item={item} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,7 +1,11 @@
|
|||||||
import React from 'react'
|
import React, { useState } from 'react'
|
||||||
import SearchBar from '../Search/SearchBar'
|
import SearchBar from '../Search/SearchBar'
|
||||||
|
|
||||||
export default function Topbar() {
|
const DEFAULT_AVATAR = 'https://files.skinbase.org/avatars/default.webp'
|
||||||
|
|
||||||
|
export default function Topbar({ user = null }) {
|
||||||
|
const [menuOpen, setMenuOpen] = useState(false)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="fixed top-0 left-0 right-0 h-16 bg-neutral-900 border-b border-neutral-800 z-50">
|
<header className="fixed top-0 left-0 right-0 h-16 bg-neutral-900 border-b border-neutral-800 z-50">
|
||||||
<div className="h-full px-5 flex items-center justify-between gap-4">
|
<div className="h-full px-5 flex items-center justify-between gap-4">
|
||||||
@@ -16,11 +20,53 @@ export default function Topbar() {
|
|||||||
<SearchBar />
|
<SearchBar />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-4 sm:gap-5">
|
<div className="flex items-center gap-3 sm:gap-4">
|
||||||
<a href="/forum" className="hidden sm:inline text-sm hover:text-sky-400">Forum</a>
|
<a href="/forum" className="hidden sm:inline text-sm text-neutral-300 hover:text-sky-400 transition-colors">Forum</a>
|
||||||
<button aria-label="User menu" className="text-neutral-200 hover:text-sky-400">
|
|
||||||
<i className="fas fa-user" aria-hidden="true"></i>
|
{user ? (
|
||||||
</button>
|
<div className="relative">
|
||||||
|
<button
|
||||||
|
onClick={() => setMenuOpen(o => !o)}
|
||||||
|
className="flex items-center gap-2 rounded-lg px-2 py-1 hover:bg-white/5 transition-colors"
|
||||||
|
aria-label="User menu"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={user.avatarUrl || DEFAULT_AVATAR}
|
||||||
|
alt={user.displayName}
|
||||||
|
className="w-7 h-7 rounded-full object-cover ring-1 ring-white/10"
|
||||||
|
onError={(e) => { e.currentTarget.src = DEFAULT_AVATAR }}
|
||||||
|
/>
|
||||||
|
<span className="hidden sm:inline text-sm text-white/90">{user.displayName}</span>
|
||||||
|
<i className="fas fa-chevron-down text-xs text-white/50" aria-hidden="true"></i>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{menuOpen && (
|
||||||
|
<div className="absolute right-0 mt-2 w-48 rounded-lg bg-neutral-800 border border-neutral-700 shadow-xl overflow-hidden z-50">
|
||||||
|
<a href={`/@${user.username}`} className="flex items-center gap-2 px-4 py-2 text-sm hover:bg-white/5">
|
||||||
|
<img
|
||||||
|
src={user.avatarUrl || DEFAULT_AVATAR}
|
||||||
|
alt={user.displayName}
|
||||||
|
className="w-6 h-6 rounded-full object-cover"
|
||||||
|
onError={(e) => { e.currentTarget.src = DEFAULT_AVATAR }}
|
||||||
|
/>
|
||||||
|
<span className="truncate">{user.displayName}</span>
|
||||||
|
</a>
|
||||||
|
<div className="border-t border-neutral-700" />
|
||||||
|
<a href={user.uploadUrl} className="block px-4 py-2 text-sm hover:bg-white/5">Upload</a>
|
||||||
|
<a href="/dashboard" className="block px-4 py-2 text-sm hover:bg-white/5">Dashboard</a>
|
||||||
|
<div className="border-t border-neutral-700" />
|
||||||
|
<a href="/logout" className="block px-4 py-2 text-sm text-red-400 hover:bg-white/5"
|
||||||
|
onClick={(e) => { e.preventDefault(); document.getElementById('logout-form')?.submit() }}>
|
||||||
|
Sign out
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<a href="/login" className="text-sm text-neutral-300 hover:text-sky-400 transition-colors">
|
||||||
|
<i className="fas fa-user" aria-hidden="true"></i>
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -6,8 +6,18 @@ function mount() {
|
|||||||
const container = document.getElementById('topbar-root')
|
const container = document.getElementById('topbar-root')
|
||||||
if (!container) return
|
if (!container) return
|
||||||
|
|
||||||
|
const user = container.dataset.userId
|
||||||
|
? {
|
||||||
|
id: container.dataset.userId,
|
||||||
|
displayName: container.dataset.displayName || 'Account',
|
||||||
|
username: container.dataset.username || '',
|
||||||
|
avatarUrl: container.dataset.avatarUrl || null,
|
||||||
|
uploadUrl: container.dataset.uploadUrl || '/upload',
|
||||||
|
}
|
||||||
|
: null
|
||||||
|
|
||||||
const root = createRoot(container)
|
const root = createRoot(container)
|
||||||
root.render(<Topbar />)
|
root.render(<Topbar user={user} />)
|
||||||
|
|
||||||
// hide legacy header if present
|
// hide legacy header if present
|
||||||
const legacy = document.getElementById('legacy-topbar')
|
const legacy = document.getElementById('legacy-topbar')
|
||||||
|
|||||||
@@ -8,10 +8,10 @@
|
|||||||
|
|
||||||
@section('content')
|
@section('content')
|
||||||
<div class="container-fluid legacy-page">
|
<div class="container-fluid legacy-page">
|
||||||
@include('legacy.home.featured')
|
@include('legacy::home.featured')
|
||||||
|
|
||||||
@include('legacy.home.uploads')
|
@include('legacy::home.uploads')
|
||||||
|
|
||||||
@include('legacy.home.news')
|
@include('legacy::home.news')
|
||||||
</div>
|
</div>
|
||||||
@endsection
|
@endsection
|
||||||
@@ -19,15 +19,16 @@
|
|||||||
$friendId = $b->friend_id ?? $b->friendId ?? null;
|
$friendId = $b->friend_id ?? $b->friendId ?? null;
|
||||||
@endphp
|
@endphp
|
||||||
|
|
||||||
|
@php $buddyUrl = ($b->user_username ?? null) ? '/@' . $b->user_username : '/profile/' . $friendId; @endphp
|
||||||
<div class="icon-flex">
|
<div class="icon-flex">
|
||||||
<div>
|
<div>
|
||||||
<a href="/profile/{{ $friendId }}/{{ Str::slug($uname) }}">
|
<a href="{{ $buddyUrl }}">
|
||||||
<h4>{{ $uname }}</h4>
|
<h4>{{ $uname }}</h4>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<a href="/profile/{{ $friendId }}/{{ Str::slug($uname) }}">
|
<a href="{{ $buddyUrl }}">
|
||||||
<img src="{{ \App\Support\AvatarUrl::forUser((int) $friendId, null, 50) }}" alt="{{ $uname }}">
|
<img src="{{ \App\Support\AvatarUrl::forUser((int) $friendId, null, 50) }}" alt="{{ $uname }}">
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -65,13 +65,10 @@
|
|||||||
<meta property="og:url" content="{{ $page_canonical ?? url()->current() }}">
|
<meta property="og:url" content="{{ $page_canonical ?? url()->current() }}">
|
||||||
<style>
|
<style>
|
||||||
.profile-hero-bg {
|
.profile-hero-bg {
|
||||||
background: linear-gradient(135deg,
|
|
||||||
rgba(15,23,36,0.98) 0%,
|
|
||||||
rgba(21,30,46,0.95) 50%,
|
|
||||||
rgba(9,16,26,0.98) 100%);
|
|
||||||
position: relative;
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
.profile-hero-bg::before {
|
.profile-hero-bg::after {
|
||||||
content: '';
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
@@ -79,6 +76,7 @@
|
|||||||
radial-gradient(ellipse at 20% 50%, rgba(77,163,255,.12), transparent 60%),
|
radial-gradient(ellipse at 20% 50%, rgba(77,163,255,.12), transparent 60%),
|
||||||
radial-gradient(ellipse at 80% 20%, rgba(224,122,33,.08), transparent 50%);
|
radial-gradient(ellipse at 80% 20%, rgba(224,122,33,.08), transparent 50%);
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
|
z-index: 1;
|
||||||
}
|
}
|
||||||
.nova-panel {
|
.nova-panel {
|
||||||
background: var(--panel-dark);
|
background: var(--panel-dark);
|
||||||
@@ -162,7 +160,17 @@
|
|||||||
{{-- ═══════════════════════════════════════════════════════════
|
{{-- ═══════════════════════════════════════════════════════════
|
||||||
PROFILE HERO
|
PROFILE HERO
|
||||||
═══════════════════════════════════════════════════════════ --}}
|
═══════════════════════════════════════════════════════════ --}}
|
||||||
<div class="profile-hero-bg border-b border-[--sb-line]">
|
<div class="profile-hero-bg border-b border-[--sb-line]"
|
||||||
|
@if(!empty($heroBgUrl))
|
||||||
|
style="background: url('{{ $heroBgUrl }}') center/cover no-repeat;"
|
||||||
|
@else
|
||||||
|
style="background: linear-gradient(135deg, rgba(15,23,36,1) 0%, rgba(21,30,46,1) 50%, rgba(9,16,26,1) 100%);"
|
||||||
|
@endif
|
||||||
|
>
|
||||||
|
{{-- Dark overlay so the content stays readable --}}
|
||||||
|
@if(!empty($heroBgUrl))
|
||||||
|
<div class="absolute inset-0 bg-gradient-to-r from-[#0f1724]/95 via-[#0f1724]/80 to-[#0f1724]/60"></div>
|
||||||
|
@endif
|
||||||
<div class="relative z-10 max-w-screen-xl mx-auto px-4 py-8">
|
<div class="relative z-10 max-w-screen-xl mx-auto px-4 py-8">
|
||||||
<div class="flex flex-col sm:flex-row items-center sm:items-end gap-5">
|
<div class="flex flex-col sm:flex-row items-center sm:items-end gap-5">
|
||||||
|
|
||||||
@@ -350,7 +358,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="nova-panel-body">
|
<div class="nova-panel-body">
|
||||||
@if(isset($artworks) && !$artworks->isEmpty())
|
@if(isset($artworks) && !$artworks->isEmpty())
|
||||||
<div class="gallery-grid"
|
<div class="gallery-grid grid grid-cols-2 gap-4"
|
||||||
data-nova-gallery
|
data-nova-gallery
|
||||||
data-gallery-type="profile"
|
data-gallery-type="profile"
|
||||||
data-gallery-grid
|
data-gallery-grid
|
||||||
@@ -380,12 +388,9 @@
|
|||||||
Favourites
|
Favourites
|
||||||
</div>
|
</div>
|
||||||
<div class="nova-panel-body">
|
<div class="nova-panel-body">
|
||||||
<div class="fav-grid">
|
<div class="grid grid-cols-2 gap-4">
|
||||||
@foreach($favourites as $fav)
|
@foreach($favourites as $fav)
|
||||||
<a href="/art/{{ $fav->id }}/{{ \Illuminate\Support\Str::slug($fav->name) }}"
|
<x-artwork-card :art="$fav" />
|
||||||
title="{{ e($fav->name) }}">
|
|
||||||
<img src="{{ $fav->thumb }}" alt="{{ e($fav->name) }}" loading="lazy">
|
|
||||||
</a>
|
|
||||||
@endforeach
|
@endforeach
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
<div class="container_photo gallery_box">
|
<div class="container_photo gallery_box">
|
||||||
<div class="grid-sizer"></div>
|
<div class="grid-sizer"></div>
|
||||||
@foreach ($artworks as $art)
|
@foreach ($artworks as $art)
|
||||||
@include('legacy._artwork_card', ['art' => $art])
|
@include('legacy::_artwork_card', ['art' => $art])
|
||||||
@endforeach
|
@endforeach
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -15,16 +15,39 @@
|
|||||||
: \App\Support\AvatarUrl::default();
|
: \App\Support\AvatarUrl::default();
|
||||||
@endphp
|
@endphp
|
||||||
|
|
||||||
<div class="min-h-screen bg-deep text-white py-12">
|
@push('styles')
|
||||||
|
<style>
|
||||||
|
.edit-profile-hero { position: relative; overflow: hidden; }
|
||||||
|
</style>
|
||||||
|
@endpush
|
||||||
|
|
||||||
|
{{-- ── Hero background ──────────────────────────────────────────────── --}}
|
||||||
|
<div class="edit-profile-hero border-b border-[--sb-line]"
|
||||||
|
@if(!empty($heroBgUrl))
|
||||||
|
style="background: url('{{ $heroBgUrl }}') center/cover no-repeat;"
|
||||||
|
@else
|
||||||
|
style="background: linear-gradient(135deg, rgba(15,23,36,1) 0%, rgba(21,30,46,1) 50%, rgba(9,16,26,1) 100%);"
|
||||||
|
@endif
|
||||||
|
>
|
||||||
|
@if(!empty($heroBgUrl))
|
||||||
|
<div class="absolute inset-0 bg-gradient-to-r from-[#0f1724]/95 via-[#0f1724]/80 to-[#0f1724]/50"></div>
|
||||||
|
@endif
|
||||||
|
<div class="relative z-10 max-w-5xl mx-auto px-4 py-10 flex items-end gap-5">
|
||||||
|
<img src="{{ $currentAvatarUrl }}"
|
||||||
|
alt="Your avatar"
|
||||||
|
class="w-20 h-20 rounded-full object-cover border-4 border-[--sb-line] shadow-lg shrink-0">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold text-white">Edit Profile</h1>
|
||||||
|
<p class="text-sm text-[--sb-muted] mt-1">Manage your account settings</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="min-h-screen text-white py-12">
|
||||||
|
|
||||||
<!-- Container -->
|
<!-- Container -->
|
||||||
<div class="max-w-5xl mx-auto px-4">
|
<div class="max-w-5xl mx-auto px-4">
|
||||||
|
|
||||||
<!-- Page Title -->
|
|
||||||
<h1 class="text-3xl font-semibold mb-8">
|
|
||||||
Edit Profile
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
@if ($errors->any())
|
@if ($errors->any())
|
||||||
<div class="mb-4 rounded-lg bg-red-700/10 border border-red-700/20 p-3 text-sm text-red-300">
|
<div class="mb-4 rounded-lg bg-red-700/10 border border-red-700/20 p-3 text-sm text-red-300">
|
||||||
<div class="font-semibold mb-2">Please fix the following errors:</div>
|
<div class="font-semibold mb-2">Please fix the following errors:</div>
|
||||||
@@ -38,7 +61,7 @@
|
|||||||
|
|
||||||
|
|
||||||
<!-- ================= Profile Card ================= -->
|
<!-- ================= Profile Card ================= -->
|
||||||
<div class="bg-panel rounded-xl shadow-lg p-8 mb-10">
|
<div class="bg-nova-800 rounded-xl shadow-lg p-8 mb-10">
|
||||||
|
|
||||||
<form method="POST" action="{{ route('profile.update') }}" enctype="multipart/form-data">
|
<form method="POST" action="{{ route('profile.update') }}" enctype="multipart/form-data">
|
||||||
@csrf
|
@csrf
|
||||||
@@ -237,7 +260,7 @@
|
|||||||
|
|
||||||
|
|
||||||
<!-- ================= PASSWORD CARD ================= -->
|
<!-- ================= PASSWORD CARD ================= -->
|
||||||
<div class="bg-panel rounded-xl shadow-lg p-8">
|
<div class="bg-nova-800 rounded-xl shadow-lg p-8">
|
||||||
|
|
||||||
<h2 class="text-xl font-semibold mb-6">
|
<h2 class="text-xl font-semibold mb-6">
|
||||||
Change Password
|
Change Password
|
||||||
@@ -59,9 +59,17 @@
|
|||||||
<body class="bg-nova-900 text-white min-h-screen flex flex-col" @if($selectedAuthBg) style="background: url('{{ $selectedAuthBg }}') center/cover no-repeat; background-attachment: fixed;" @endif>
|
<body class="bg-nova-900 text-white min-h-screen flex flex-col" @if($selectedAuthBg) style="background: url('{{ $selectedAuthBg }}') center/cover no-repeat; background-attachment: fixed;" @endif>
|
||||||
|
|
||||||
<!-- React Topbar mount point -->
|
<!-- React Topbar mount point -->
|
||||||
<div id="topbar-root"></div>
|
<div id="topbar-root"
|
||||||
|
@auth
|
||||||
|
data-user-id="{{ Auth::id() }}"
|
||||||
|
data-display-name="{{ Auth::user()->name ?? '' }}"
|
||||||
|
data-username="{{ Auth::user()->username ?? '' }}"
|
||||||
|
data-avatar-url="{{ \App\Support\AvatarUrl::forUser((int) Auth::id(), optional(Auth::user()->profile)->avatar_hash, 64) }}"
|
||||||
|
data-upload-url="{{ Route::has('upload') ? route('upload') : '/upload' }}"
|
||||||
|
@endauth
|
||||||
|
></div>
|
||||||
@include('layouts.nova.toolbar')
|
@include('layouts.nova.toolbar')
|
||||||
<main class="flex-1 pt-16">
|
<main class="flex-1 @yield('main-class', 'pt-16')">
|
||||||
@yield('content')
|
@yield('content')
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
[#6223] Red Cloud XP
|
|
||||||
→ windows-logo, retro-computing, red-dominant-colour, dark-mood, pixelated-graphics, digital-art, grunge-texture, office-shortcuts-menu, 90s-aesthetic, chromatic-aberration, high-contrast, textured-background
|
|
||||||
[#6225] Helping Hand zoomers (part 1)
|
|
||||||
→ desktop screenshot, computer icons, windows interface, digital-art, blue-grey tones, flat design, minimalist-style, iconography, organized layout, technical illustration, screen capture, system icons
|
|
||||||
[#6226] Helping Hand zoomers (part 2)
|
|
||||||
PS D:\Sites\Skinbase26>
|
|
||||||
@@ -18,7 +18,7 @@
|
|||||||
@foreach ($comments as $comment)
|
@foreach ($comments as $comment)
|
||||||
@php
|
@php
|
||||||
$artUrl = '/art/' . (int)($comment->id ?? 0) . '/' . ($comment->artwork_slug ?? 'artwork');
|
$artUrl = '/art/' . (int)($comment->id ?? 0) . '/' . ($comment->artwork_slug ?? 'artwork');
|
||||||
$userUrl = '/profile/' . (int)($comment->commenter_id ?? 0) . '/' . rawurlencode($comment->uname ?? 'user');
|
$userUrl = ($comment->commenter_username ?? null) ? '/@' . $comment->commenter_username : '/profile/' . (int)($comment->commenter_id ?? 0);
|
||||||
$avatarUrl = \App\Support\AvatarUrl::forUser((int)($comment->commenter_id ?? 0), $comment->icon ?? null, 40);
|
$avatarUrl = \App\Support\AvatarUrl::forUser((int)($comment->commenter_id ?? 0), $comment->icon ?? null, 40);
|
||||||
$ago = \Carbon\Carbon::parse($comment->datetime ?? now())->diffForHumans();
|
$ago = \Carbon\Carbon::parse($comment->datetime ?? now())->diffForHumans();
|
||||||
$snippet = \Illuminate\Support\Str::limit(strip_tags($comment->comment_description ?? ''), 160);
|
$snippet = \Illuminate\Support\Str::limit(strip_tags($comment->comment_description ?? ''), 160);
|
||||||
|
|||||||
@@ -38,7 +38,7 @@
|
|||||||
@foreach ($rows as $i => $row)
|
@foreach ($rows as $i => $row)
|
||||||
@php
|
@php
|
||||||
$rank = $offset + $i + 1;
|
$rank = $offset + $i + 1;
|
||||||
$profileUrl = '/profile/' . (int)($row->user_id ?? 0) . '/' . rawurlencode($row->uname ?? 'user');
|
$profileUrl = ($row->user_username ?? null) ? '/@' . $row->user_username : '/profile/' . (int)($row->user_id ?? 0);
|
||||||
$avatarUrl = \App\Support\AvatarUrl::forUser((int)($row->user_id ?? 0), null, 40);
|
$avatarUrl = \App\Support\AvatarUrl::forUser((int)($row->user_id ?? 0), null, 40);
|
||||||
@endphp
|
@endphp
|
||||||
<div class="grid grid-cols-[3rem_1fr_auto] items-center gap-4 px-5 py-4
|
<div class="grid grid-cols-[3rem_1fr_auto] items-center gap-4 px-5 py-4
|
||||||
|
|||||||
@@ -1,17 +1,70 @@
|
|||||||
@extends('layouts.nova')
|
@extends('layouts.nova')
|
||||||
|
|
||||||
@php
|
@push('head')
|
||||||
use Illuminate\Support\Str;
|
<title>{{ $meta['title'] }}</title>
|
||||||
use Carbon\Carbon;
|
<meta name="description" content="{{ $meta['description'] }}">
|
||||||
use App\Services\LegacyService;
|
<meta name="keywords" content="{{ $meta['keywords'] }}">
|
||||||
@endphp
|
<link rel="canonical" href="{{ $meta['canonical'] }}">
|
||||||
|
|
||||||
|
{{-- Open Graph --}}
|
||||||
|
<meta property="og:type" content="website">
|
||||||
|
<meta property="og:site_name" content="Skinbase">
|
||||||
|
<meta property="og:title" content="{{ $meta['title'] }}">
|
||||||
|
<meta property="og:description" content="{{ $meta['description'] }}">
|
||||||
|
<meta property="og:url" content="{{ $meta['canonical'] }}">
|
||||||
|
@if(!empty($meta['og_image']))
|
||||||
|
<meta property="og:image" content="{{ $meta['og_image'] }}">
|
||||||
|
<meta property="og:image:type" content="image/webp">
|
||||||
|
@endif
|
||||||
|
|
||||||
|
{{-- Twitter --}}
|
||||||
|
<meta name="twitter:card" content="summary_large_image">
|
||||||
|
<meta name="twitter:title" content="{{ $meta['title'] }}">
|
||||||
|
<meta name="twitter:description" content="{{ $meta['description'] }}">
|
||||||
|
@if(!empty($meta['og_image']))
|
||||||
|
<meta name="twitter:image" content="{{ $meta['og_image'] }}">
|
||||||
|
@endif
|
||||||
|
|
||||||
|
{{-- JSON-LD WebSite schema --}}
|
||||||
|
@php
|
||||||
|
$websiteSchema = [
|
||||||
|
'@context' => 'https://schema.org',
|
||||||
|
'@type' => 'WebSite',
|
||||||
|
'name' => 'Skinbase',
|
||||||
|
'url' => url('/'),
|
||||||
|
'description' => $meta['description'],
|
||||||
|
'potentialAction' => [
|
||||||
|
'@type' => 'SearchAction',
|
||||||
|
'target' => url('/search') . '?q={search_term_string}',
|
||||||
|
'query-input' => 'required name=search_term_string',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
@endphp
|
||||||
|
<script type="application/ld+json">{!! json_encode($websiteSchema, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_HEX_TAG) !!}</script>
|
||||||
|
|
||||||
|
{{-- Preload hero image for faster LCP --}}
|
||||||
|
@if(!empty($props['hero']['thumb_lg']))
|
||||||
|
<link rel="preload" as="image" href="{{ $props['hero']['thumb_lg'] }}">
|
||||||
|
@elseif(!empty($props['hero']['thumb']))
|
||||||
|
<link rel="preload" as="image" href="{{ $props['hero']['thumb'] }}">
|
||||||
|
@endif
|
||||||
|
@endpush
|
||||||
|
|
||||||
|
@section('main-class', '')
|
||||||
|
|
||||||
@section('content')
|
@section('content')
|
||||||
<div class="min-h-screen">
|
{{-- Inline props for the React component (avoids data-attribute length limits) --}}
|
||||||
@include('web.home.featured')
|
<script id="homepage-props" type="application/json">
|
||||||
|
{!! json_encode($props, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP) !!}
|
||||||
|
</script>
|
||||||
|
|
||||||
@include('web.home.uploads')
|
<div id="homepage-root" class="min-h-screen">
|
||||||
|
{{-- Loading skeleton (replaced by React on hydration) --}}
|
||||||
@include('web.home.news')
|
<div class="flex min-h-[60vh] items-center justify-center">
|
||||||
|
<div class="h-8 w-8 animate-spin rounded-full border-4 border-nova-500 border-t-transparent"></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@vite(['resources/js/Pages/Home/HomePage.jsx'])
|
||||||
@endsection
|
@endsection
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ export default defineConfig({
|
|||||||
'resources/js/entry-topbar.jsx',
|
'resources/js/entry-topbar.jsx',
|
||||||
'resources/js/entry-search.jsx',
|
'resources/js/entry-search.jsx',
|
||||||
'resources/js/upload.jsx',
|
'resources/js/upload.jsx',
|
||||||
'resources/js/Pages/ArtworkPage.jsx'
|
'resources/js/Pages/ArtworkPage.jsx',
|
||||||
|
'resources/js/Pages/Home/HomePage.jsx'
|
||||||
],
|
],
|
||||||
refresh: true,
|
refresh: true,
|
||||||
}),
|
}),
|
||||||
|
|||||||
Reference in New Issue
Block a user