feat: Inertia profile settings page, Studio edit redesign, EGS, Nova UI components\n\n- Redesign /dashboard/profile as Inertia React page (Settings/ProfileEdit)\n with SettingsLayout sidebar, Nova UI components (TextInput, Textarea,\n Toggle, Select, RadioGroup, Modal, Button), avatar drag-and-drop,\n password change, and account deletion sections\n- Redesign Studio artwork edit page with two-column layout, Nova components,\n integrated TagPicker, and version history modal\n- Add shared MarkdownEditor component\n- Add Early-Stage Growth System (EGS): SpotlightEngine, FeedBlender,\n GridFiller, AdaptiveTimeWindow, ActivityLayer, admin panel\n- Fix upload category/tag persistence (V1+V2 paths)\n- Fix tag source enum, category tree display, binding resolution\n- Add settings.jsx Vite entry, settings.blade.php wrapper\n- Update ProfileController with JSON response support for API calls\n- Various route fixes (profile.edit, toolbar settings link)"

This commit is contained in:
2026-03-03 20:57:43 +01:00
parent dc51d65440
commit b9c2d8597d
114 changed files with 8760 additions and 693 deletions

View File

@@ -0,0 +1,119 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Services\EarlyGrowth\ActivityLayer;
use App\Services\EarlyGrowth\AdaptiveTimeWindow;
use App\Services\EarlyGrowth\EarlyGrowth;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\View\View;
/**
* EarlyGrowthAdminController (§14)
*
* Admin panel for the Early-Stage Growth System.
* All toggles are ENV-driven; updating .env requires a deploy.
* This panel provides a read-only status view plus a cache-flush action.
*
* Future v2: wire to a `settings` DB table so admins can toggle without
* a deploy. The EarlyGrowth::enabled() contract already supports this.
*/
final class EarlyGrowthAdminController extends Controller
{
public function __construct(
private readonly AdaptiveTimeWindow $timeWindow,
private readonly ActivityLayer $activityLayer,
) {}
/**
* GET /admin/early-growth
* Status dashboard: shows current config, live stats, toggle instructions.
*/
public function index(): View
{
$uploadsPerDay = $this->timeWindow->getUploadsPerDay();
return view('admin.early-growth.index', [
'status' => EarlyGrowth::status(),
'mode' => EarlyGrowth::mode(),
'uploads_per_day' => $uploadsPerDay,
'window_days' => $this->timeWindow->getTrendingWindowDays(30),
'activity' => $this->activityLayer->getSignals(),
'cache_keys' => [
'egs.uploads_per_day',
'egs.auto_disable_check',
'egs.spotlight.*',
'egs.curated.*',
'egs.grid_filler.*',
'egs.activity_signals',
'homepage.fresh.*',
'discover.trending.*',
'discover.rising.*',
],
'env_toggles' => [
['key' => 'NOVA_EARLY_GROWTH_ENABLED', 'current' => env('NOVA_EARLY_GROWTH_ENABLED', 'false')],
['key' => 'NOVA_EARLY_GROWTH_MODE', 'current' => env('NOVA_EARLY_GROWTH_MODE', 'off')],
['key' => 'NOVA_EGS_ADAPTIVE_WINDOW', 'current' => env('NOVA_EGS_ADAPTIVE_WINDOW', 'true')],
['key' => 'NOVA_EGS_GRID_FILLER', 'current' => env('NOVA_EGS_GRID_FILLER', 'true')],
['key' => 'NOVA_EGS_SPOTLIGHT', 'current' => env('NOVA_EGS_SPOTLIGHT', 'true')],
['key' => 'NOVA_EGS_ACTIVITY_LAYER', 'current' => env('NOVA_EGS_ACTIVITY_LAYER', 'false')],
],
]);
}
/**
* DELETE /admin/early-growth/cache
* Flush all EGS-related cache keys so new config changes take effect immediately.
*/
public function flushCache(Request $request): RedirectResponse
{
$keys = [
'egs.uploads_per_day',
'egs.auto_disable_check',
'egs.activity_signals',
];
// Flush the EGS daily spotlight caches for today
$today = now()->format('Y-m-d');
foreach ([6, 12, 18, 24] as $n) {
Cache::forget("egs.spotlight.{$today}.{$n}");
Cache::forget("egs.curated.{$today}.{$n}.7");
}
// Flush fresh/trending homepage sections
foreach ([6, 8, 10, 12] as $limit) {
foreach (['off', 'light', 'aggressive'] as $mode) {
Cache::forget("homepage.fresh.{$limit}.egs-{$mode}");
Cache::forget("homepage.fresh.{$limit}.std");
}
Cache::forget("homepage.trending.{$limit}");
Cache::forget("homepage.rising.{$limit}");
}
// Flush key keys
foreach ($keys as $key) {
Cache::forget($key);
}
return redirect()->route('admin.early-growth.index')
->with('success', 'Early Growth System cache flushed. Changes will take effect on next page load.');
}
/**
* GET /admin/early-growth/status (JSON for monitoring/healthcheck)
*/
public function status(): JsonResponse
{
return response()->json([
'egs' => EarlyGrowth::status(),
'uploads_per_day' => $this->timeWindow->getUploadsPerDay(),
'window_days' => $this->timeWindow->getTrendingWindowDays(30),
]);
}
}

View File

@@ -29,10 +29,15 @@ class ArtworkController extends Controller
$user = $request->user();
$data = $request->validated();
$categoryId = isset($data['category']) && ctype_digit((string) $data['category'])
? (int) $data['category']
: null;
$result = $drafts->createDraft(
(int) $user->id,
(string) $data['title'],
isset($data['description']) ? (string) $data['description'] : null
isset($data['description']) ? (string) $data['description'] : null,
$categoryId
);
return response()->json([

View File

@@ -487,6 +487,9 @@ final class UploadController extends Controller
$validated = $request->validate([
'title' => ['nullable', 'string', 'max:150'],
'description' => ['nullable', 'string'],
'category' => ['nullable', 'integer', 'exists:categories,id'],
'tags' => ['nullable', 'array', 'max:15'],
'tags.*' => ['string', 'max:64'],
// Scheduled-publishing fields
'mode' => ['nullable', 'string', 'in:now,schedule'],
'publish_at' => ['nullable', 'string', 'date'],
@@ -548,6 +551,25 @@ final class UploadController extends Controller
$artwork->slug = $slug;
$artwork->artwork_timezone = $validated['timezone'] ?? null;
// Sync category if provided
$categoryId = isset($validated['category']) ? (int) $validated['category'] : null;
if ($categoryId && \App\Models\Category::where('id', $categoryId)->exists()) {
$artwork->categories()->sync([$categoryId]);
}
// Sync tags if provided
if (!empty($validated['tags']) && is_array($validated['tags'])) {
$tagIds = [];
foreach ($validated['tags'] as $tagSlug) {
$tag = \App\Models\Tag::firstOrCreate(
['slug' => Str::slug($tagSlug)],
['name' => $tagSlug, 'is_active' => true, 'usage_count' => 0]
);
$tagIds[$tag->id] = ['source' => 'user', 'confidence' => 1.0];
}
$artwork->tags()->sync($tagIds);
}
if ($mode === 'schedule' && $publishAt) {
// Scheduled: store publish_at but don't make public yet
$artwork->is_public = false;

View File

@@ -121,7 +121,94 @@ class ProfileController extends Controller
]);
}
public function update(ProfileUpdateRequest $request, \App\Services\AvatarService $avatarService): RedirectResponse
/**
* Inertia-powered profile edit page (Settings/ProfileEdit).
*/
public function editSettings(Request $request)
{
$user = $request->user();
// Parse birth date parts
$birthDay = null;
$birthMonth = null;
$birthYear = null;
// Merge modern user_profiles data
$profileData = [];
try {
if (Schema::hasTable('user_profiles')) {
$profile = DB::table('user_profiles')->where('user_id', $user->id)->first();
if ($profile) {
$profileData = (array) $profile;
if (isset($profile->website)) $user->homepage = $profile->website;
if (isset($profile->about)) $user->about_me = $profile->about;
if (isset($profile->birthdate)) $user->birth = $profile->birthdate;
if (isset($profile->gender)) $user->gender = $profile->gender;
if (isset($profile->country_code)) $user->country_code = $profile->country_code;
if (isset($profile->signature)) $user->signature = $profile->signature;
if (isset($profile->description)) $user->description = $profile->description;
if (isset($profile->mlist)) $user->mlist = $profile->mlist;
if (isset($profile->friend_upload_notice)) $user->friend_upload_notice = $profile->friend_upload_notice;
if (isset($profile->auto_post_upload)) $user->auto_post_upload = $profile->auto_post_upload;
}
}
} catch (\Throwable $e) {}
if (!empty($user->birth)) {
try {
$dt = \Carbon\Carbon::parse($user->birth);
$birthDay = $dt->format('d');
$birthMonth = $dt->format('m');
$birthYear = $dt->format('Y');
} catch (\Throwable $e) {}
}
// Country list
$countries = collect();
try {
if (Schema::hasTable('country_list')) {
$countries = DB::table('country_list')->orderBy('country_name')->get();
} elseif (Schema::hasTable('countries')) {
$countries = DB::table('countries')->orderBy('name')->get();
}
} catch (\Throwable $e) {}
// Avatar URL
$avatarHash = $profileData['avatar_hash'] ?? $user->icon ?? null;
$avatarUrl = !empty($avatarHash)
? AvatarUrl::forUser((int) $user->id, $avatarHash, 128)
: AvatarUrl::default();
return Inertia::render('Settings/ProfileEdit', [
'user' => [
'id' => $user->id,
'username' => $user->username,
'email' => $user->email,
'name' => $user->name,
'homepage' => $user->homepage ?? $user->website ?? null,
'about_me' => $user->about_me ?? $user->about ?? null,
'signature' => $user->signature ?? null,
'description' => $user->description ?? null,
'gender' => $user->gender ?? null,
'country_code' => $user->country_code ?? null,
'mlist' => $user->mlist ?? false,
'friend_upload_notice' => $user->friend_upload_notice ?? false,
'auto_post_upload' => $user->auto_post_upload ?? false,
'username_changed_at' => $user->username_changed_at,
],
'avatarUrl' => $avatarUrl,
'birthDay' => $birthDay,
'birthMonth' => $birthMonth,
'birthYear' => $birthYear,
'countries' => $countries->values(),
'flash' => [
'status' => session('status'),
'error' => session('error'),
],
])->rootView('settings');
}
public function update(ProfileUpdateRequest $request, \App\Services\AvatarService $avatarService): RedirectResponse|JsonResponse
{
$user = $request->user();
@@ -144,18 +231,22 @@ class ProfileController extends Controller
'current_username' => $currentUsername,
]);
return Redirect::back()->withErrors([
'username' => 'This username is too similar to a reserved name and requires manual approval.',
]);
$error = ['username' => ['This username is too similar to a reserved name and requires manual approval.']];
if ($request->expectsJson()) {
return response()->json(['errors' => $error], 422);
}
return Redirect::back()->withErrors($error);
}
$cooldownDays = (int) config('usernames.rename_cooldown_days', 90);
$isAdmin = method_exists($user, 'isAdmin') ? $user->isAdmin() : false;
if (! $isAdmin && $user->username_changed_at !== null && $user->username_changed_at->gt(now()->subDays($cooldownDays))) {
return Redirect::back()->withErrors([
'username' => "Username can only be changed once every {$cooldownDays} days.",
]);
$error = ['username' => ["Username can only be changed once every {$cooldownDays} days."]];
if ($request->expectsJson()) {
return response()->json(['errors' => $error], 422);
}
return Redirect::back()->withErrors($error);
}
$user->username = $incomingUsername;
@@ -234,6 +325,9 @@ class ProfileController extends Controller
try {
$avatarService->storeFromUploadedFile($user->id, $request->file('avatar'));
} catch (\Exception $e) {
if ($request->expectsJson()) {
return response()->json(['errors' => ['avatar' => ['Avatar processing failed: ' . $e->getMessage()]]], 422);
}
return Redirect::back()->with('error', 'Avatar processing failed: ' . $e->getMessage());
}
}
@@ -274,12 +368,17 @@ class ProfileController extends Controller
logger()->error('Profile update error: '.$e->getMessage());
}
if ($request->expectsJson()) {
return response()->json(['success' => true]);
}
return Redirect::route('dashboard.profile')->with('status', 'profile-updated');
}
public function destroy(Request $request): RedirectResponse
public function destroy(Request $request): RedirectResponse|JsonResponse
{
$request->validateWithBag('userDeletion', [
$bag = $request->expectsJson() ? 'default' : 'userDeletion';
$request->validateWithBag($bag, [
'password' => ['required', 'current_password'],
]);
@@ -292,10 +391,14 @@ class ProfileController extends Controller
$request->session()->invalidate();
$request->session()->regenerateToken();
if ($request->expectsJson()) {
return response()->json(['success' => true]);
}
return Redirect::to('/');
}
public function password(Request $request): RedirectResponse
public function password(Request $request): RedirectResponse|JsonResponse
{
$request->validate([
'current_password' => ['required', 'current_password'],
@@ -306,6 +409,10 @@ class ProfileController extends Controller
$user->password = Hash::make($request->input('password'));
$user->save();
if ($request->expectsJson()) {
return response()->json(['success' => true]);
}
return Redirect::route('dashboard.profile')->with('status', 'password-updated');
}

View File

@@ -0,0 +1,93 @@
<?php
namespace App\Http\Controllers\Web;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Str;
use App\Models\StaffApplication;
class ApplicationController extends Controller
{
public function show()
{
return view('web.apply');
}
public function submit(Request $request)
{
$data = $request->validate([
'topic' => 'required|string|in:apply,bug,contact,other',
'name' => 'required|string|max:100',
'email' => 'required|email|max:150',
'role' => 'nullable|string|max:100',
'portfolio' => 'nullable|url|max:255',
'affected_url' => 'nullable|url|max:255',
'steps' => 'nullable|string|max:2000',
'message' => 'nullable|string|max:2000',
]);
$payload = [
'id' => (string) Str::uuid(),
'submitted_at' => now()->toISOString(),
'ip' => $request->ip(),
'user_agent' => $request->userAgent(),
'data' => $data,
];
// Honeypot: silently drop submissions that fill the hidden field
if ($request->filled('website')) {
return redirect()->route('contact.show')->with('success', 'Your submission was received.');
}
try {
Storage::append('staff_applications.jsonl', json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
} catch (\Throwable $e) {
// best-effort store; don't fail the user if write fails
}
// store in DB as well
try {
StaffApplication::create([
'id' => $payload['id'],
'topic' => $data['topic'] ?? 'apply',
'name' => $data['name'] ?? null,
'email' => $data['email'] ?? null,
'role' => $data['role'] ?? null,
'portfolio' => $data['portfolio'] ?? null,
'message' => $data['message'] ?? null,
'payload' => $payload,
'ip' => $payload['ip'],
'user_agent' => $payload['user_agent'],
]);
} catch (\Throwable $e) {
// ignore DB errors
}
$to = config('mail.from.address');
if ($to) {
try {
// prefer the DB model when available
$appModel = isset($appModel) ? $appModel : StaffApplication::find($payload['id']) ?? null;
if (! $appModel) {
// construct a lightweight model-like object for the mailable
$appModel = new StaffApplication($payload['data'] ?? []);
$appModel->id = $payload['id'];
$appModel->payload = $payload;
$appModel->ip = $payload['ip'];
$appModel->user_agent = $payload['user_agent'];
$appModel->created_at = now();
}
Mail::to($to)->queue(new \App\Mail\StaffApplicationReceived($appModel));
} catch (\Throwable $e) {
// ignore mail errors but don't fail user
}
}
return redirect()->route('contact.show')->with('success', 'Your submission was received. Thank you — we will review it soon.');
}
}

View File

@@ -9,15 +9,77 @@ use App\Http\Resources\ArtworkResource;
use App\Models\Artwork;
use App\Models\ArtworkComment;
use App\Services\ThumbnailPresenter;
use App\Services\ErrorSuggestionService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Str;
use Illuminate\View\View;
final class ArtworkPageController extends Controller
{
public function show(Request $request, int $id, ?string $slug = null): View|RedirectResponse
public function show(Request $request, int $id, ?string $slug = null): View|RedirectResponse|Response
{
// ── Step 1: check existence including soft-deleted ─────────────────
$raw = Artwork::withTrashed()->where('id', $id)->first();
if (! $raw) {
// Artwork never existed → contextual 404
$suggestions = app(ErrorSuggestionService::class);
return response(view('errors.contextual.artwork-not-found', [
'trendingArtworks' => $this->safeSuggestions(fn () => $suggestions->trendingArtworks()),
]), 404);
}
if ($raw->trashed()) {
// Artwork permanently deleted → 410 Gone
return response(view('errors.410'), 410);
}
if (! $raw->is_public || ! $raw->is_approved) {
// Artwork exists but is private/unapproved → 403 Forbidden.
// Show other public artworks by the same creator as recovery suggestions.
$suggestions = app(ErrorSuggestionService::class);
$creatorArtworks = collect();
$creatorUsername = null;
if ($raw->user_id) {
$raw->loadMissing('user');
$creatorUsername = $raw->user?->username;
$creatorArtworks = $this->safeSuggestions(function () use ($raw) {
return Artwork::query()
->with('user')
->where('user_id', $raw->user_id)
->where('id', '!=', $raw->id)
->public()
->published()
->limit(6)
->get()
->map(function (Artwork $a) {
$slug = \Illuminate\Support\Str::slug((string) ($a->slug ?: $a->title)) ?: (string) $a->id;
$md = \App\Services\ThumbnailPresenter::present($a, 'md');
return [
'id' => $a->id,
'title' => html_entity_decode((string) $a->title, ENT_QUOTES | ENT_HTML5, 'UTF-8'),
'author' => html_entity_decode((string) ($a->user?->name ?: $a->user?->username ?: 'Artist'), ENT_QUOTES | ENT_HTML5, 'UTF-8'),
'url' => route('art.show', ['id' => $a->id, 'slug' => $slug]),
'thumb' => $md['url'] ?? null,
];
});
});
}
return response(view('errors.contextual.artwork-not-found', [
'message' => 'This artwork is not publicly available.',
'isForbidden' => true,
'creatorArtworks' => $creatorArtworks,
'creatorUsername' => $creatorUsername,
'trendingArtworks' => $this->safeSuggestions(fn () => $suggestions->trendingArtworks()),
]), 403);
}
// ── Step 2: full load with all relations ───────────────────────────
$artwork = Artwork::with(['user.profile', 'categories.contentType', 'categories.parent.contentType', 'tags', 'stats'])
->where('id', $id)
->public()
@@ -150,4 +212,14 @@ final class ArtworkPageController extends Controller
'comments' => $comments,
]);
}
/** Silently catch suggestion query failures so error page never crashes. */
private function safeSuggestions(callable $fn): mixed
{
try {
return $fn();
} catch (\Throwable) {
return collect();
}
}
}

View File

@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Web;
use App\Http\Controllers\Controller;
use App\Models\BlogPost;
use Illuminate\Http\Request;
use Illuminate\View\View;
/**
* BlogController /blog index + single post.
*/
final class BlogController extends Controller
{
public function index(Request $request): View
{
$posts = BlogPost::published()
->orderByDesc('published_at')
->paginate(12)
->withQueryString();
return view('web.blog.index', [
'posts' => $posts,
'page_title' => 'Blog — Skinbase',
'page_meta_description' => 'News, tutorials and community stories from the Skinbase team.',
'page_canonical' => url('/blog'),
'page_robots' => 'index,follow',
'breadcrumbs' => collect([
(object) ['name' => 'Blog', 'url' => '/blog'],
]),
]);
}
public function show(string $slug): View
{
$post = BlogPost::published()->where('slug', $slug)->firstOrFail();
return view('web.blog.show', [
'post' => $post,
'page_title' => ($post->meta_title ?: $post->title) . ' — Skinbase Blog',
'page_meta_description' => $post->meta_description ?: $post->excerpt ?: '',
'page_canonical' => $post->url,
'page_robots' => 'index,follow',
'breadcrumbs' => collect([
(object) ['name' => 'Blog', 'url' => '/blog'],
(object) ['name' => $post->title, 'url' => $post->url],
]),
]);
}
}

View File

@@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Web;
use App\Http\Controllers\Controller;
use App\Models\BugReport;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
/**
* BugReportController /bug-report
*
* GET /bug-report show form (guests see a login prompt)
* POST /bug-report authenticated users submit a report
*/
final class BugReportController extends Controller
{
public function show(Request $request): View
{
return view('web.bug-report', [
'page_title' => 'Bug Report — Skinbase',
'page_meta_description' => 'Submit a bug report or suggestion to the Skinbase team.',
'page_canonical' => url('/bug-report'),
'hero_title' => 'Bug Report',
'hero_description' => 'Found something broken? Submit a report and our team will look into it.',
'breadcrumbs' => collect([
(object) ['name' => 'Home', 'url' => '/'],
(object) ['name' => 'Bug Report', 'url' => '/bug-report'],
]),
'success' => session('bug_report_success', false),
'center_content' => true,
'center_max' => '3xl',
]);
}
public function submit(Request $request): RedirectResponse
{
$validated = $request->validate([
'subject' => ['required', 'string', 'max:255'],
'description' => ['required', 'string', 'max:5000'],
]);
BugReport::create([
'user_id' => $request->user()->id,
'subject' => $validated['subject'],
'description' => $validated['description'],
'ip_address' => $request->ip(),
'user_agent' => substr($request->userAgent() ?? '', 0, 512),
'status' => 'open',
]);
return redirect()->route('bug-report')->with('bug_report_success', true);
}
}

View File

@@ -6,6 +6,8 @@ use App\Http\Controllers\Controller;
use App\Models\Artwork;
use App\Services\ArtworkSearchService;
use App\Services\ArtworkService;
use App\Services\EarlyGrowth\FeedBlender;
use App\Services\EarlyGrowth\GridFiller;
use App\Services\Recommendation\RecommendationService;
use App\Services\ThumbnailPresenter;
use Illuminate\Http\Request;
@@ -27,17 +29,21 @@ use Illuminate\Support\Facades\Schema;
final class DiscoverController extends Controller
{
public function __construct(
private readonly ArtworkService $artworkService,
private readonly ArtworkSearchService $searchService,
private readonly ArtworkService $artworkService,
private readonly ArtworkSearchService $searchService,
private readonly RecommendationService $recoService,
private readonly FeedBlender $feedBlender,
private readonly GridFiller $gridFiller,
) {}
// ─── /discover/trending ──────────────────────────────────────────────────
public function trending(Request $request)
{
$perPage = 24;
$results = $this->searchService->discoverTrending($perPage);
$perPage = 24;
$page = max(1, (int) $request->query('page', 1));
$results = $this->searchService->discoverTrending($perPage);
$results = $this->gridFiller->fill($results, 0, $page);
$this->hydrateDiscoverSearchResults($results);
return view('web.discover.index', [
@@ -53,8 +59,10 @@ final class DiscoverController extends Controller
public function rising(Request $request)
{
$perPage = 24;
$results = $this->searchService->discoverRising($perPage);
$perPage = 24;
$page = max(1, (int) $request->query('page', 1));
$results = $this->searchService->discoverRising($perPage);
$results = $this->gridFiller->fill($results, 0, $page);
$this->hydrateDiscoverSearchResults($results);
return view('web.discover.index', [
@@ -70,8 +78,12 @@ final class DiscoverController extends Controller
public function fresh(Request $request)
{
$perPage = 24;
$results = $this->searchService->discoverFresh($perPage);
$perPage = 24;
$page = max(1, (int) $request->query('page', 1));
$results = $this->searchService->discoverFresh($perPage);
// EGS: blend fresh feed with curated + spotlight on page 1
$results = $this->feedBlender->blend($results, $perPage, $page);
$results = $this->gridFiller->fill($results, 0, $page);
$this->hydrateDiscoverSearchResults($results);
return view('web.discover.index', [
@@ -87,8 +99,10 @@ final class DiscoverController extends Controller
public function topRated(Request $request)
{
$perPage = 24;
$results = $this->searchService->discoverTopRated($perPage);
$perPage = 24;
$page = max(1, (int) $request->query('page', 1));
$results = $this->searchService->discoverTopRated($perPage);
$results = $this->gridFiller->fill($results, 0, $page);
$this->hydrateDiscoverSearchResults($results);
return view('web.discover.index', [
@@ -104,8 +118,10 @@ final class DiscoverController extends Controller
public function mostDownloaded(Request $request)
{
$perPage = 24;
$results = $this->searchService->discoverMostDownloaded($perPage);
$perPage = 24;
$page = max(1, (int) $request->query('page', 1));
$results = $this->searchService->discoverMostDownloaded($perPage);
$results = $this->gridFiller->fill($results, 0, $page);
$this->hydrateDiscoverSearchResults($results);
return view('web.discover.index', [
@@ -180,7 +196,8 @@ final class DiscoverController extends Controller
$creators = DB::table(DB::raw('(' . $sub->toSql() . ') as t'))
->mergeBindings($sub->getQuery())
->join('users as u', 'u.id', '=', 't.user_id')
->select('u.id as user_id', 'u.name as uname', 'u.username', 't.recent_views', 't.latest_published')
->leftJoin('user_profiles as up', 'up.user_id', '=', 't.user_id')
->select('u.id as user_id', 'u.name as uname', 'u.username', 't.recent_views', 't.latest_published', 'up.avatar_hash')
->orderByDesc('t.recent_views')
->orderByDesc('t.latest_published')
->paginate($perPage)
@@ -188,11 +205,12 @@ final class DiscoverController extends Controller
$creators->getCollection()->transform(function ($row) {
return (object) [
'user_id' => $row->user_id,
'uname' => $row->uname,
'username' => $row->username,
'total' => (int) $row->recent_views,
'metric' => 'views',
'user_id' => $row->user_id,
'uname' => $row->uname,
'username' => $row->username,
'total' => (int) $row->recent_views,
'metric' => 'views',
'avatar_hash' => $row->avatar_hash ?? null,
];
});

View File

@@ -0,0 +1,112 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Web;
use App\Http\Controllers\Controller;
use App\Services\ErrorSuggestionService;
use App\Services\NotFoundLogger;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
/**
* ErrorController
*
* Handles contextual 404 rendering.
* Invoked from bootstrap/app.php exception handler for web 404s.
*
* Pattern detection:
* /blog/* blog-not-found (latest posts)
* /tag/* tag-not-found (similar + trending tags)
* /@username creator-not-found (trending creators)
* /pages/* page-not-found
* /about|/help etc. page-not-found
* everything else generic 404 (trending artworks + tags)
*/
final class ErrorController extends Controller
{
public function __construct(
private readonly ErrorSuggestionService $suggestions,
private readonly NotFoundLogger $logger,
) {}
public function handleNotFound(Request $request): Response|JsonResponse
{
// For JSON / Inertia API requests return a minimal JSON 404.
if ($request->expectsJson() || $request->header('X-Inertia')) {
return response()->json(['message' => 'Not Found'], 404);
}
// Log every 404 hit for later analysis.
try {
$this->logger->log404($request);
} catch (\Throwable) {
// Never let the logger itself break the error page.
}
$path = ltrim($request->path(), '/');
// ── /blog/* ──────────────────────────────────────────────────────────
if (str_starts_with($path, 'blog/')) {
return response(view('errors.contextual.blog-not-found', [
'latestPosts' => $this->safeFetch(fn () => $this->suggestions->latestBlogPosts()),
]), 404);
}
// ── /tag/* ───────────────────────────────────────────────────────────
if (str_starts_with($path, 'tag/')) {
$slug = ltrim(substr($path, 4), '/');
return response(view('errors.contextual.tag-not-found', [
'requestedSlug' => $slug,
'similarTags' => $this->safeFetch(fn () => $this->suggestions->similarTags($slug)),
'trendingTags' => $this->safeFetch(fn () => $this->suggestions->trendingTags()),
]), 404);
}
// ── /@username or /creator/* ───────────────────────────────────────
if (str_starts_with($path, '@') || str_starts_with($path, 'creator/')) {
$username = str_starts_with($path, '@') ? substr($path, 1) : null;
return response(view('errors.contextual.creator-not-found', [
'requestedUsername' => $username,
'trendingCreators' => $this->safeFetch(fn () => $this->suggestions->trendingCreators()),
'recentCreators' => $this->safeFetch(fn () => $this->suggestions->recentlyJoinedCreators()),
]), 404);
}
// ── /{contentType}/{category}/{artwork-slug} — artwork not found ──────
if (preg_match('#^(wallpapers|skins|photography|other)/#', $path)) {
return response(view('errors.contextual.artwork-not-found', [
'trendingArtworks' => $this->safeFetch(fn () => $this->suggestions->trendingArtworks()),
]), 404);
}
// ── /pages/* or /about | /help | /contact | /legal/* ───────────────
if (
str_starts_with($path, 'pages/')
|| in_array($path, ['about', 'help', 'contact', 'faq', 'staff', 'privacy-policy', 'terms-of-service', 'rules-and-guidelines'])
|| str_starts_with($path, 'legal/')
) {
return response(view('errors.contextual.page-not-found'), 404);
}
// ── Generic 404 ───────────────────────────────────────────────────────
return response(view('errors.404', [
'trendingArtworks' => $this->safeFetch(fn () => $this->suggestions->trendingArtworks()),
'trendingTags' => $this->safeFetch(fn () => $this->suggestions->trendingTags()),
]), 404);
}
/**
* Silently catch any DB/cache error so the error page itself never crashes.
*/
private function safeFetch(callable $fn): mixed
{
try {
return $fn();
} catch (\Throwable) {
return collect();
}
}
}

View File

@@ -0,0 +1,252 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Web;
use App\Http\Controllers\Controller;
use App\Models\Artwork;
use App\Models\ContentType;
use App\Services\ArtworkSearchService;
use App\Services\EarlyGrowth\EarlyGrowth;
use App\Services\EarlyGrowth\GridFiller;
use App\Services\EarlyGrowth\SpotlightEngineInterface;
use App\Services\ThumbnailPresenter;
use Illuminate\Http\Request;
use Illuminate\Pagination\AbstractCursorPaginator;
use Illuminate\Pagination\AbstractPaginator;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
/**
* ExploreController
*
* Powers the /explore/* structured catalog pages (§3.2 of routing spec).
* Delegates to the same Meilisearch pipeline as BrowseGalleryController but
* uses canonical /explore/* URLs with the ExploreLayout blade template.
*/
final class ExploreController extends Controller
{
private const CONTENT_TYPE_SLUGS = ['artworks', 'wallpapers', 'skins', 'photography', 'other'];
/** Meilisearch sort-field arrays per sort alias. */
private const SORT_MAP = [
'trending' => ['trending_score_24h:desc', 'trending_score_7d:desc', 'favorites_count:desc', 'created_at:desc'],
'new-hot' => ['trending_score_7d:desc', 'favorites_count:desc', 'created_at:desc'],
'best' => ['awards_received_count:desc', 'favorites_count:desc'],
'latest' => ['created_at:desc'],
];
private const SORT_TTL = [
'trending' => 300,
'new-hot' => 120,
'best' => 600,
'latest' => 120,
];
private const SORT_OPTIONS = [
['value' => 'trending', 'label' => '🔥 Trending'],
['value' => 'new-hot', 'label' => '🚀 New & Hot'],
['value' => 'best', 'label' => '⭐ Best'],
['value' => 'latest', 'label' => '🕐 Latest'],
];
public function __construct(
private readonly ArtworkSearchService $search,
private readonly GridFiller $gridFiller,
private readonly SpotlightEngineInterface $spotlight,
) {}
// ── /explore (hub) ──────────────────────────────────────────────────
public function index(Request $request)
{
$sort = $this->resolveSort($request);
$perPage = $this->resolvePerPage($request);
$page = max(1, (int) $request->query('page', 1));
$ttl = self::SORT_TTL[$sort] ?? 300;
$artworks = Cache::remember("explore.all.{$sort}.{$page}", $ttl, fn () =>
Artwork::search('')->options([
'filter' => 'is_public = true AND is_approved = true',
'sort' => self::SORT_MAP[$sort] ?? ['created_at:desc'],
])->paginate($perPage)
);
// EGS: fill grid to minimum when uploads are sparse
$artworks = $this->gridFiller->fill($artworks, 0, $page);
$artworks->getCollection()->transform(fn ($a) => $this->presentArtwork($a));
// EGS §11: featured spotlight row on page 1 only
$spotlightItems = ($page === 1 && EarlyGrowth::spotlightEnabled())
? $this->spotlight->getSpotlight(6)->map(fn ($a) => $this->presentArtwork($a))
: collect();
$contentTypes = $this->contentTypeLinks();
$seo = $this->paginationSeo($request, url('/explore'), $artworks);
return view('web.explore.index', [
'artworks' => $artworks,
'spotlight' => $spotlightItems,
'contentTypes' => $contentTypes,
'activeType' => null,
'current_sort' => $sort,
'sort_options' => self::SORT_OPTIONS,
'hero_title' => 'Explore',
'hero_description' => 'Browse the full Skinbase catalog — wallpapers, skins, photography and more.',
'breadcrumbs' => collect([
(object) ['name' => 'Explore', 'url' => '/explore'],
]),
'page_title' => 'Explore Artworks — Skinbase',
'page_meta_description' => 'Explore the full catalog of wallpapers, skins, photography and other artworks on Skinbase.',
'page_canonical' => $seo['canonical'],
'page_rel_prev' => $seo['prev'],
'page_rel_next' => $seo['next'],
'page_robots' => 'index,follow',
]);
}
// ── /explore/:type ──────────────────────────────────────────────────
public function byType(Request $request, string $type)
{
$type = strtolower($type);
if (!in_array($type, self::CONTENT_TYPE_SLUGS, true)) {
abort(404);
}
// "artworks" is the umbrella — search all types
$isAll = $type === 'artworks';
$sort = $this->resolveSort($request);
$perPage = $this->resolvePerPage($request);
$page = max(1, (int) $request->query('page', 1));
$ttl = self::SORT_TTL[$sort] ?? 300;
$filter = 'is_public = true AND is_approved = true';
if (!$isAll) {
$filter .= ' AND content_type = "' . $type . '"';
}
$artworks = Cache::remember("explore.{$type}.{$sort}.{$page}", $ttl, fn () =>
Artwork::search('')->options([
'filter' => $filter,
'sort' => self::SORT_MAP[$sort] ?? ['created_at:desc'],
])->paginate($perPage)
);
// EGS: fill grid to minimum when uploads are sparse
$artworks = $this->gridFiller->fill($artworks, 0, $page);
$artworks->getCollection()->transform(fn ($a) => $this->presentArtwork($a));
// EGS §11: featured spotlight row on page 1 only
$spotlightItems = ($page === 1 && EarlyGrowth::spotlightEnabled())
? $this->spotlight->getSpotlight(6)->map(fn ($a) => $this->presentArtwork($a))
: collect();
$contentTypes = $this->contentTypeLinks();
$baseUrl = url('/explore/' . $type);
$seo = $this->paginationSeo($request, $baseUrl, $artworks);
$humanType = ucfirst($type);
return view('web.explore.index', [
'artworks' => $artworks,
'spotlight' => $spotlightItems,
'contentTypes' => $contentTypes,
'activeType' => $type,
'current_sort' => $sort,
'sort_options' => self::SORT_OPTIONS,
'hero_title' => $humanType,
'hero_description' => "Browse {$humanType} on Skinbase.",
'breadcrumbs' => collect([
(object) ['name' => 'Explore', 'url' => '/explore'],
(object) ['name' => $humanType, 'url' => "/explore/{$type}"],
]),
'page_title' => "{$humanType} — Explore — Skinbase",
'page_meta_description' => "Discover the best {$humanType} artworks on Skinbase. Browse trending, new and top-rated.",
'page_canonical' => $seo['canonical'],
'page_rel_prev' => $seo['prev'],
'page_rel_next' => $seo['next'],
'page_robots' => 'index,follow',
]);
}
// ── /explore/:type/:mode ────────────────────────────────────────────
public function byTypeMode(Request $request, string $type, string $mode)
{
// Rewrite the sort via the URL segment and delegate
$request->query->set('sort', $mode);
return $this->byType($request, $type);
}
// ── Helpers ──────────────────────────────────────────────────────────
private function contentTypeLinks(): Collection
{
return collect([
(object) ['name' => 'All Artworks', 'slug' => 'artworks', 'url' => '/explore/artworks'],
...ContentType::orderBy('id')->get(['name', 'slug'])->map(fn ($ct) => (object) [
'name' => $ct->name,
'slug' => $ct->slug,
'url' => '/explore/' . strtolower($ct->slug),
]),
]);
}
private function resolveSort(Request $request): string
{
$s = (string) $request->query('sort', 'trending');
return array_key_exists($s, self::SORT_MAP) ? $s : 'trending';
}
private function resolvePerPage(Request $request): int
{
$v = (int) ($request->query('per_page') ?: $request->query('limit') ?: 24);
return max(12, min($v, 80));
}
private function presentArtwork(Artwork $artwork): object
{
$primary = $artwork->categories->sortBy('sort_order')->first();
$present = ThumbnailPresenter::present($artwork, 'md');
$avatarUrl = \App\Support\AvatarUrl::forUser(
(int) ($artwork->user_id ?? 0),
$artwork->user?->profile?->avatar_hash ?? null,
64
);
return (object) [
'id' => $artwork->id,
'name' => $artwork->title,
'category_name' => $primary->name ?? '',
'category_slug' => $primary->slug ?? '',
'thumb_url' => $present['url'],
'thumb_srcset' => $present['srcset'] ?? $present['url'],
'uname' => $artwork->user?->name ?? 'Skinbase',
'username' => $artwork->user?->username ?? '',
'avatar_url' => $avatarUrl,
'published_at' => $artwork->published_at,
'slug' => $artwork->slug ?? '',
'width' => $artwork->width ?? null,
'height' => $artwork->height ?? null,
];
}
private function paginationSeo(Request $request, string $base, mixed $paginator): array
{
$q = $request->query();
unset($q['grid']);
if (($q['page'] ?? null) !== null && (int) $q['page'] <= 1) {
unset($q['page']);
}
$canonical = $base . ($q ? '?' . http_build_query($q) : '');
$prev = null;
$next = null;
if ($paginator instanceof AbstractPaginator || $paginator instanceof AbstractCursorPaginator) {
$prev = $paginator->previousPageUrl();
$next = $paginator->nextPageUrl();
}
return compact('canonical', 'prev', 'next');
}
}

View File

@@ -0,0 +1,87 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Web;
use App\Http\Controllers\Controller;
use Illuminate\View\View;
/**
* FooterController serves static footer pages.
*
* /faq faq()
* /rules-and-guidelines rules()
* /privacy-policy privacyPolicy()
* /terms-of-service termsOfService()
*/
final class FooterController extends Controller
{
public function faq(): View
{
return view('web.faq', [
'page_title' => 'FAQ — Skinbase',
'page_meta_description' => 'Frequently Asked Questions about Skinbase — the community for skins, wallpapers, and photography.',
'page_canonical' => url('/faq'),
'hero_title' => 'Frequently Asked Questions',
'hero_description' => 'Answers to the most common questions from our members. Last updated March 1, 2026.',
'breadcrumbs' => collect([
(object) ['name' => 'Home', 'url' => '/'],
(object) ['name' => 'FAQ', 'url' => '/faq'],
]),
'center_content' => true,
'center_max' => '3xl',
]);
}
public function rules(): View
{
return view('web.rules', [
'page_title' => 'Rules & Guidelines — Skinbase',
'page_meta_description' => 'Read the Skinbase community rules and content guidelines before submitting your work.',
'page_canonical' => url('/rules-and-guidelines'),
'hero_title' => 'Rules & Guidelines',
'hero_description' => 'Please review these guidelines before uploading or participating. Last updated March 1, 2026.',
'breadcrumbs' => collect([
(object) ['name' => 'Home', 'url' => '/'],
(object) ['name' => 'Rules & Guidelines', 'url' => '/rules-and-guidelines'],
]),
'center_content' => true,
'center_max' => '3xl',
]);
}
public function termsOfService(): View
{
return view('web.terms-of-service', [
'page_title' => 'Terms of Service — Skinbase',
'page_meta_description' => 'Read the Skinbase Terms of Service — the agreement that governs your use of the platform.',
'page_canonical' => url('/terms-of-service'),
'hero_title' => 'Terms of Service',
'hero_description' => 'The agreement between you and Skinbase that governs your use of the platform. Last updated March 1, 2026.',
'breadcrumbs' => collect([
(object) ['name' => 'Home', 'url' => '/'],
(object) ['name' => 'Terms of Service', 'url' => '/terms-of-service'],
]),
'center_content' => true,
'center_max' => '3xl',
]);
}
public function privacyPolicy(): View
{
return view('web.privacy-policy', [
'page_title' => 'Privacy Policy — Skinbase',
'page_meta_description' => 'Read the Skinbase privacy policy to understand how we collect and use your data.',
'page_canonical' => url('/privacy-policy'),
'hero_title' => 'Privacy Policy',
'hero_description' => 'How Skinbase collects, uses, and protects your information. Last updated March 1, 2026.',
'breadcrumbs' => collect([
(object) ['name' => 'Home', 'url' => '/'],
(object) ['name' => 'Privacy Policy', 'url' => '/privacy-policy'],
]),
'center_content' => true,
'center_max' => '3xl',
]);
}
}

View File

@@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Web;
use App\Http\Controllers\Controller;
use App\Models\Page;
use Illuminate\View\View;
/**
* PageController DB-driven static pages (/pages/:slug).
*
* Also handles root-level marketing pages (/about, /help, /contact)
* and legal pages (/legal/terms, /legal/privacy, /legal/cookies).
*/
final class PageController extends Controller
{
public function show(string $slug): View
{
$page = Page::published()->where('slug', $slug)->firstOrFail();
return view('web.pages.show', [
'page' => $page,
'page_title' => ($page->meta_title ?: $page->title) . ' — Skinbase',
'page_meta_description' => $page->meta_description ?: '',
'page_canonical' => $page->canonical_url,
'page_robots' => 'index,follow',
'breadcrumbs' => collect([
(object) ['name' => $page->title, 'url' => $page->url],
]),
]);
}
/**
* Serve root-level marketing slugs (/about, /help, /contact).
* Falls back to 404 if no matching page exists.
*/
public function marketing(string $slug): View
{
$page = Page::published()->where('slug', $slug)->firstOrFail();
return view('web.pages.show', [
'page' => $page,
'page_title' => ($page->meta_title ?: $page->title) . ' — Skinbase',
'page_meta_description' => $page->meta_description ?: '',
'page_canonical' => url('/' . $slug),
'page_robots' => 'index,follow',
'breadcrumbs' => collect([
(object) ['name' => $page->title, 'url' => '/' . $slug],
]),
]);
}
/**
* Legal pages (/legal/terms, /legal/privacy, /legal/cookies).
* Looks for page with slug "legal-{section}".
*/
public function legal(string $section): View
{
$page = Page::published()->where('slug', 'legal-' . $section)->firstOrFail();
return view('web.pages.show', [
'page' => $page,
'page_title' => ($page->meta_title ?: $page->title) . ' — Skinbase',
'page_meta_description' => $page->meta_description ?: '',
'page_canonical' => url('/legal/' . $section),
'page_robots' => 'index,follow',
'breadcrumbs' => collect([
(object) ['name' => 'Legal', 'url' => '#'],
(object) ['name' => $page->title, 'url' => '/legal/' . $section],
]),
]);
}
}

View File

@@ -0,0 +1,114 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Web;
use App\Http\Controllers\Controller;
use App\Models\Artwork;
use App\Models\ContentType;
use Illuminate\Http\Response;
use Illuminate\View\View;
/**
* RssFeedController
*
* GET /rss-feeds info page listing available feeds
* GET /rss/latest-uploads.xml all published artworks
* GET /rss/latest-skins.xml skins only
* GET /rss/latest-wallpapers.xml wallpapers only
* GET /rss/latest-photos.xml photography only
*/
final class RssFeedController extends Controller
{
/** Number of items per feed. */
private const FEED_LIMIT = 25;
/** Feed definitions shown on the info page. */
public const FEEDS = [
'uploads' => ['title' => 'Latest Uploads', 'url' => '/rss/latest-uploads.xml'],
'skins' => ['title' => 'Latest Skins', 'url' => '/rss/latest-skins.xml'],
'wallpapers' => ['title' => 'Latest Wallpapers', 'url' => '/rss/latest-wallpapers.xml'],
'photos' => ['title' => 'Latest Photos', 'url' => '/rss/latest-photos.xml'],
];
/** Info page at /rss-feeds */
public function index(): View
{
return view('web.rss-feeds', [
'page_title' => 'RSS Feeds — Skinbase',
'page_meta_description' => 'Subscribe to Skinbase RSS feeds to stay up to date with the latest uploads, skins, wallpapers, and photos.',
'page_canonical' => url('/rss-feeds'),
'hero_title' => 'RSS Feeds',
'hero_description' => 'Subscribe to stay up to date with the latest content on Skinbase.',
'breadcrumbs' => collect([
(object) ['name' => 'Home', 'url' => '/'],
(object) ['name' => 'RSS Feeds', 'url' => '/rss-feeds'],
]),
'feeds' => self::FEEDS,
'center_content' => true,
'center_max' => '3xl',
]);
}
/** /rss/latest-uploads.xml — all content types */
public function latestUploads(): Response
{
$artworks = Artwork::published()
->with(['user'])
->latest('published_at')
->limit(self::FEED_LIMIT)
->get();
return $this->buildFeed('Latest Uploads', url('/rss/latest-uploads.xml'), $artworks);
}
/** /rss/latest-skins.xml */
public function latestSkins(): Response
{
return $this->feedByContentType('skins', 'Latest Skins', '/rss/latest-skins.xml');
}
/** /rss/latest-wallpapers.xml */
public function latestWallpapers(): Response
{
return $this->feedByContentType('wallpapers', 'Latest Wallpapers', '/rss/latest-wallpapers.xml');
}
/** /rss/latest-photos.xml */
public function latestPhotos(): Response
{
return $this->feedByContentType('photography', 'Latest Photos', '/rss/latest-photos.xml');
}
// -------------------------------------------------------------------------
private function feedByContentType(string $slug, string $title, string $feedPath): Response
{
$contentType = ContentType::where('slug', $slug)->first();
$query = Artwork::published()->with(['user'])->latest('published_at')->limit(self::FEED_LIMIT);
if ($contentType) {
$query->whereHas('categories', fn ($q) => $q->where('content_type_id', $contentType->id));
}
return $this->buildFeed($title, url($feedPath), $query->get());
}
private function buildFeed(string $channelTitle, string $feedUrl, $artworks): Response
{
$content = view('rss.feed', [
'channelTitle' => $channelTitle . ' — Skinbase',
'channelDescription' => 'The latest ' . strtolower($channelTitle) . ' from Skinbase.org',
'channelLink' => url('/'),
'feedUrl' => $feedUrl,
'artworks' => $artworks,
'buildDate' => now()->toRfc2822String(),
])->render();
return response($content, 200, [
'Content-Type' => 'application/rss+xml; charset=utf-8',
]);
}
}

View File

@@ -0,0 +1,21 @@
<?php
namespace App\Http\Controllers\Web;
use App\Http\Controllers\Controller;
use App\Models\StaffApplication;
use Illuminate\Http\Request;
class StaffApplicationAdminController extends Controller
{
public function index(Request $request)
{
$items = StaffApplication::orderBy('created_at', 'desc')->paginate(25);
return view('admin.staff_applications.index', ['items' => $items]);
}
public function show(StaffApplication $staffApplication)
{
return view('admin.staff_applications.show', ['item' => $staffApplication]);
}
}

View File

@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Web;
use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Support\Collection;
use Illuminate\View\View;
/**
* StaffController /staff
*
* Displays all users with an elevated role (admin, moderator) grouped by role.
*/
final class StaffController extends Controller
{
/** Roles that appear on the staff page, in display order. */
private const STAFF_ROLES = ['admin', 'moderator'];
public function index(): View
{
/** @var Collection<string, \Illuminate\Support\Collection<int, User>> $staffByRole */
$staffByRole = User::with('profile')
->whereIn('role', self::STAFF_ROLES)
->where('is_active', true)
->orderByRaw("CASE role WHEN 'admin' THEN 0 WHEN 'moderator' THEN 1 ELSE 2 END")
->orderBy('username')
->get()
->groupBy('role');
return view('web.staff', [
'page_title' => 'Staff — Skinbase',
'page_meta_description' => 'Meet the Skinbase team — admins and moderators who keep the community running.',
'page_canonical' => url('/staff'),
'hero_title' => 'Meet the Staff',
'hero_description' => 'The people behind Skinbase who keep the community running smoothly.',
'breadcrumbs' => collect([
(object) ['name' => 'Home', 'url' => '/'],
(object) ['name' => 'Staff', 'url' => '/staff'],
]),
'staffByRole' => $staffByRole,
'roleLabels' => [
'admin' => 'Administrators',
'moderator' => 'Moderators',
],
'center_content' => true,
'center_max' => '3xl',
]);
}
}

View File

@@ -8,12 +8,16 @@ use App\Http\Controllers\Controller;
use App\Models\ContentType;
use App\Models\Tag;
use App\Services\ArtworkSearchService;
use App\Services\EarlyGrowth\GridFiller;
use Illuminate\Http\Request;
use Illuminate\View\View;
final class TagController extends Controller
{
public function __construct(private readonly ArtworkSearchService $search) {}
public function __construct(
private readonly ArtworkSearchService $search,
private readonly GridFiller $gridFiller,
) {}
public function index(Request $request): View
{
@@ -52,6 +56,10 @@ final class TagController extends Controller
->paginate($perPage)
->appends(['sort' => $sort]);
// EGS: ensure tag pages never show a half-empty grid on page 1
$page = max(1, (int) $request->query('page', 1));
$artworks = $this->gridFiller->fill($artworks, 0, $page);
// Eager-load relations needed by the artwork-card component.
// Scout returns bare Eloquent models; without this, each card triggers N+1 queries.
$artworks->getCollection()->loadMissing(['user.profile']);
@@ -65,20 +73,15 @@ final class TagController extends Controller
'url' => '/' . strtolower($type->slug),
]);
return view('gallery.index', [
'gallery_type' => 'tag',
'mainCategories' => $mainCategories,
'subcategories' => collect(),
'contentType' => null,
'category' => null,
'artworks' => $artworks,
'hero_title' => '#' . $tag->name,
'hero_description'=> 'All artworks tagged "' . e($tag->name) . '".',
'breadcrumbs' => collect(),
'page_title' => 'Artworks tagged "' . $tag->name . '" — Skinbase',
return view('tags.show', [
'tag' => $tag,
'artworks' => $artworks,
'sort' => $sort,
'ogImage' => null,
'page_title' => 'Artworks tagged "' . $tag->name . '" — Skinbase',
'page_meta_description' => 'Browse all Skinbase artworks tagged "' . $tag->name . '". Discover photography, wallpapers and skins.',
'page_canonical' => route('tags.show', $tag->slug),
'page_robots' => 'index,follow',
'page_canonical' => route('tags.show', $tag->slug),
'page_robots' => 'index,follow',
]);
}
}