diff --git a/.env.example b/.env.example index 15bd0ae7..33ec412c 100644 --- a/.env.example +++ b/.env.example @@ -232,3 +232,36 @@ AWS_BUCKET= AWS_USE_PATH_STYLE_ENDPOINT=false VITE_APP_NAME="${APP_NAME}" + +# ─── Early-Stage Growth System ─────────────────────────────────────────────── +# Set NOVA_EARLY_GROWTH_ENABLED=false to instantly revert to normal behaviour. +# NOVA_EARLY_GROWTH_MODE: off | light | aggressive +NOVA_EARLY_GROWTH_ENABLED=false +NOVA_EARLY_GROWTH_MODE=off + +# Module toggles (only active when NOVA_EARLY_GROWTH_ENABLED=true) +NOVA_EGS_ADAPTIVE_WINDOW=true +NOVA_EGS_GRID_FILLER=true +NOVA_EGS_SPOTLIGHT=true +NOVA_EGS_ACTIVITY_LAYER=false + +# AdaptiveTimeWindow thresholds +NOVA_EGS_UPLOADS_PER_DAY_NARROW=10 +NOVA_EGS_UPLOADS_PER_DAY_WIDE=3 +NOVA_EGS_WINDOW_NARROW_DAYS=7 +NOVA_EGS_WINDOW_MEDIUM_DAYS=30 +NOVA_EGS_WINDOW_WIDE_DAYS=90 + +# GridFiller minimum items per page +NOVA_EGS_GRID_MIN_RESULTS=12 + +# Auto-disable when site reaches organic scale +NOVA_EGS_AUTO_DISABLE=false +NOVA_EGS_AUTO_DISABLE_UPLOADS=50 +NOVA_EGS_AUTO_DISABLE_USERS=500 + +# Cache TTLs (seconds) +NOVA_EGS_SPOTLIGHT_TTL=3600 +NOVA_EGS_BLEND_TTL=300 +NOVA_EGS_WINDOW_TTL=600 +NOVA_EGS_ACTIVITY_TTL=1800 diff --git a/README.md b/README.md index 2fd1100d..1b5bff38 100644 --- a/README.md +++ b/README.md @@ -421,6 +421,21 @@ curl -X POST "$CLIP_BASE_URL$CLIP_ANALYZE_ENDPOINT" \ - AI tags appear on the artwork when services are healthy. - Failures are logged, but publish is unaffected. +## Queue workers + +The contact form mails are queued. To process them you need a worker. Locally you can run a foreground worker: + +``` +php artisan queue:work --sleep=3 --tries=3 +``` + +For production we provide example configs under `deploy/`: + +- `deploy/supervisor/skinbase-queue.conf` — Supervisor config +- `deploy/systemd/skinbase-queue.service` — systemd unit file + +See `docs/QUEUE.md` for full setup steps and commands. + ## License The Laravel framework is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT). diff --git a/app/Http/Controllers/Admin/EarlyGrowthAdminController.php b/app/Http/Controllers/Admin/EarlyGrowthAdminController.php new file mode 100644 index 00000000..b5bb868c --- /dev/null +++ b/app/Http/Controllers/Admin/EarlyGrowthAdminController.php @@ -0,0 +1,119 @@ +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), + ]); + } +} diff --git a/app/Http/Controllers/Api/ArtworkController.php b/app/Http/Controllers/Api/ArtworkController.php index 5da22066..9aa0cc5f 100644 --- a/app/Http/Controllers/Api/ArtworkController.php +++ b/app/Http/Controllers/Api/ArtworkController.php @@ -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([ diff --git a/app/Http/Controllers/Api/UploadController.php b/app/Http/Controllers/Api/UploadController.php index 0782ca6e..265ba69b 100644 --- a/app/Http/Controllers/Api/UploadController.php +++ b/app/Http/Controllers/Api/UploadController.php @@ -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; diff --git a/app/Http/Controllers/User/ProfileController.php b/app/Http/Controllers/User/ProfileController.php index e87beb2c..4e069d66 100644 --- a/app/Http/Controllers/User/ProfileController.php +++ b/app/Http/Controllers/User/ProfileController.php @@ -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'); } diff --git a/app/Http/Controllers/Web/ApplicationController.php b/app/Http/Controllers/Web/ApplicationController.php new file mode 100644 index 00000000..710894ff --- /dev/null +++ b/app/Http/Controllers/Web/ApplicationController.php @@ -0,0 +1,93 @@ +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.'); + } +} diff --git a/app/Http/Controllers/Web/ArtworkPageController.php b/app/Http/Controllers/Web/ArtworkPageController.php index 5e53e44c..9ad2752e 100644 --- a/app/Http/Controllers/Web/ArtworkPageController.php +++ b/app/Http/Controllers/Web/ArtworkPageController.php @@ -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(); + } + } } diff --git a/app/Http/Controllers/Web/BlogController.php b/app/Http/Controllers/Web/BlogController.php new file mode 100644 index 00000000..5ae6119c --- /dev/null +++ b/app/Http/Controllers/Web/BlogController.php @@ -0,0 +1,52 @@ +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], + ]), + ]); + } +} diff --git a/app/Http/Controllers/Web/BugReportController.php b/app/Http/Controllers/Web/BugReportController.php new file mode 100644 index 00000000..545f9a49 --- /dev/null +++ b/app/Http/Controllers/Web/BugReportController.php @@ -0,0 +1,57 @@ + '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); + } +} diff --git a/app/Http/Controllers/Web/DiscoverController.php b/app/Http/Controllers/Web/DiscoverController.php index 12755507..ecc2ce21 100644 --- a/app/Http/Controllers/Web/DiscoverController.php +++ b/app/Http/Controllers/Web/DiscoverController.php @@ -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, ]; }); diff --git a/app/Http/Controllers/Web/ErrorController.php b/app/Http/Controllers/Web/ErrorController.php new file mode 100644 index 00000000..8968c763 --- /dev/null +++ b/app/Http/Controllers/Web/ErrorController.php @@ -0,0 +1,112 @@ +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(); + } + } +} diff --git a/app/Http/Controllers/Web/ExploreController.php b/app/Http/Controllers/Web/ExploreController.php new file mode 100644 index 00000000..f7cbf2b7 --- /dev/null +++ b/app/Http/Controllers/Web/ExploreController.php @@ -0,0 +1,252 @@ + ['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'); + } +} diff --git a/app/Http/Controllers/Web/FooterController.php b/app/Http/Controllers/Web/FooterController.php new file mode 100644 index 00000000..c2e3edba --- /dev/null +++ b/app/Http/Controllers/Web/FooterController.php @@ -0,0 +1,87 @@ + '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', + ]); + } +} diff --git a/app/Http/Controllers/Web/PageController.php b/app/Http/Controllers/Web/PageController.php new file mode 100644 index 00000000..3df31042 --- /dev/null +++ b/app/Http/Controllers/Web/PageController.php @@ -0,0 +1,75 @@ +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], + ]), + ]); + } +} diff --git a/app/Http/Controllers/Web/RssFeedController.php b/app/Http/Controllers/Web/RssFeedController.php new file mode 100644 index 00000000..7f3b6073 --- /dev/null +++ b/app/Http/Controllers/Web/RssFeedController.php @@ -0,0 +1,114 @@ + ['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', + ]); + } +} diff --git a/app/Http/Controllers/Web/StaffApplicationAdminController.php b/app/Http/Controllers/Web/StaffApplicationAdminController.php new file mode 100644 index 00000000..de64c620 --- /dev/null +++ b/app/Http/Controllers/Web/StaffApplicationAdminController.php @@ -0,0 +1,21 @@ +paginate(25); + return view('admin.staff_applications.index', ['items' => $items]); + } + + public function show(StaffApplication $staffApplication) + { + return view('admin.staff_applications.show', ['item' => $staffApplication]); + } +} diff --git a/app/Http/Controllers/Web/StaffController.php b/app/Http/Controllers/Web/StaffController.php new file mode 100644 index 00000000..a148554c --- /dev/null +++ b/app/Http/Controllers/Web/StaffController.php @@ -0,0 +1,52 @@ +> $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', + ]); + } +} diff --git a/app/Http/Controllers/Web/TagController.php b/app/Http/Controllers/Web/TagController.php index 00f0846f..37280eaf 100644 --- a/app/Http/Controllers/Web/TagController.php +++ b/app/Http/Controllers/Web/TagController.php @@ -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', ]); } } diff --git a/app/Http/Requests/Artworks/ArtworkCreateRequest.php b/app/Http/Requests/Artworks/ArtworkCreateRequest.php index 4606e64c..7dfc5c98 100644 --- a/app/Http/Requests/Artworks/ArtworkCreateRequest.php +++ b/app/Http/Requests/Artworks/ArtworkCreateRequest.php @@ -24,7 +24,7 @@ final class ArtworkCreateRequest extends FormRequest return [ 'title' => 'required|string|max:150', 'description' => 'nullable|string', - 'category' => 'nullable|string|max:120', + 'category' => 'nullable|integer|exists:categories,id', 'tags' => 'nullable|string|max:200', 'license' => 'nullable|boolean', ]; diff --git a/app/Mail/StaffApplicationReceived.php b/app/Mail/StaffApplicationReceived.php new file mode 100644 index 00000000..1cfa7652 --- /dev/null +++ b/app/Mail/StaffApplicationReceived.php @@ -0,0 +1,43 @@ +application = $application; + } + + /** + * Build the message. + */ + public function build() + { + $topicLabel = match ($this->application->topic ?? 'apply') { + 'apply' => 'Application', + 'bug' => 'Bug Report', + 'contact' => 'Contact', + default => 'Message', + }; + + return $this->subject("New {$topicLabel}: " . ($this->application->name ?? 'Unnamed')) + ->from(config('mail.from.address'), config('mail.from.name')) + ->view('emails.staff_application_received') + ->text('emails.staff_application_received_plain') + ->with(['application' => $this->application, 'topicLabel' => $topicLabel]); + } +} diff --git a/app/Models/BlogPost.php b/app/Models/BlogPost.php new file mode 100644 index 00000000..2e3630c6 --- /dev/null +++ b/app/Models/BlogPost.php @@ -0,0 +1,71 @@ + 'boolean', + 'published_at' => 'datetime', + ]; + + // ── Relations ──────────────────────────────────────────────────────── + + public function author() + { + return $this->belongsTo(User::class, 'author_id'); + } + + // ── Scopes ─────────────────────────────────────────────────────────── + + public function scopePublished($query) + { + return $query->where('is_published', true) + ->where(fn ($q) => $q->whereNull('published_at')->orWhere('published_at', '<=', now())); + } + + // ── Accessors ──────────────────────────────────────────────────────── + + public function getUrlAttribute(): string + { + return url('/blog/' . $this->slug); + } +} diff --git a/app/Models/BugReport.php b/app/Models/BugReport.php new file mode 100644 index 00000000..7285e4fd --- /dev/null +++ b/app/Models/BugReport.php @@ -0,0 +1,28 @@ +belongsTo(User::class); + } +} diff --git a/app/Models/Page.php b/app/Models/Page.php new file mode 100644 index 00000000..a9e292d5 --- /dev/null +++ b/app/Models/Page.php @@ -0,0 +1,63 @@ + 'boolean', + 'published_at' => 'datetime', + ]; + + // ── Scopes ─────────────────────────────────────────────────────────── + + public function scopePublished($query) + { + return $query->where('is_published', true) + ->where(fn ($q) => $q->whereNull('published_at')->orWhere('published_at', '<=', now())); + } + + // ── Accessors ──────────────────────────────────────────────────────── + + public function getUrlAttribute(): string + { + return url('/pages/' . $this->slug); + } + + public function getCanonicalUrlAttribute(): string + { + return $this->url; + } +} diff --git a/app/Models/StaffApplication.php b/app/Models/StaffApplication.php new file mode 100644 index 00000000..badc1a43 --- /dev/null +++ b/app/Models/StaffApplication.php @@ -0,0 +1,32 @@ + 'array', + ]; + + protected static function booted() + { + static::creating(function ($model) { + if (empty($model->id)) { + $model->id = (string) Str::uuid(); + } + }); + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 37b20bb3..4d371b06 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -42,6 +42,12 @@ class AppServiceProvider extends ServiceProvider \App\Services\Recommendations\VectorSimilarity\VectorAdapterInterface::class, fn () => \App\Services\Recommendations\VectorSimilarity\VectorAdapterFactory::make(), ); + + // EGS: bind SpotlightEngineInterface to the concrete SpotlightEngine + $this->app->singleton( + \App\Services\EarlyGrowth\SpotlightEngineInterface::class, + \App\Services\EarlyGrowth\SpotlightEngine::class, + ); } /** diff --git a/app/Services/ArtworkSearchService.php b/app/Services/ArtworkSearchService.php index bc224802..0b995502 100644 --- a/app/Services/ArtworkSearchService.php +++ b/app/Services/ArtworkSearchService.php @@ -6,6 +6,7 @@ namespace App\Services; use App\Models\Artwork; use App\Models\Tag; +use App\Services\EarlyGrowth\AdaptiveTimeWindow; use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Support\Facades\Cache; @@ -19,6 +20,10 @@ final class ArtworkSearchService private const BASE_FILTER = 'is_public = true AND is_approved = true'; private const CACHE_TTL = 300; // 5 minutes + public function __construct( + private readonly AdaptiveTimeWindow $timeWindow, + ) {} + /** * Full-text search with optional filters. * @@ -256,10 +261,13 @@ final class ArtworkSearchService */ public function discoverTrending(int $perPage = 24): LengthAwarePaginator { - $page = (int) request()->get('page', 1); - $cutoff = now()->subDays(30)->toDateString(); + $page = (int) request()->get('page', 1); + $windowDays = $this->timeWindow->getTrendingWindowDays(30); + $cutoff = now()->subDays($windowDays)->toDateString(); + // Include window in cache key so adaptive expansions surface immediately + $cacheKey = "discover.trending.{$windowDays}d.{$page}"; - return Cache::remember("discover.trending.{$page}", self::CACHE_TTL, function () use ($perPage, $cutoff) { + return Cache::remember($cacheKey, self::CACHE_TTL, function () use ($perPage, $cutoff) { return Artwork::search('') ->options([ 'filter' => self::BASE_FILTER . ' AND created_at >= "' . $cutoff . '"', @@ -277,10 +285,12 @@ final class ArtworkSearchService */ public function discoverRising(int $perPage = 24): LengthAwarePaginator { - $page = (int) request()->get('page', 1); - $cutoff = now()->subDays(30)->toDateString(); + $page = (int) request()->get('page', 1); + $windowDays = $this->timeWindow->getTrendingWindowDays(30); + $cutoff = now()->subDays($windowDays)->toDateString(); + $cacheKey = "discover.rising.{$windowDays}d.{$page}"; - return Cache::remember("discover.rising.{$page}", 120, function () use ($perPage, $cutoff) { + return Cache::remember($cacheKey, 120, function () use ($perPage, $cutoff) { return Artwork::search('') ->options([ 'filter' => self::BASE_FILTER . ' AND created_at >= "' . $cutoff . '"', diff --git a/app/Services/Artworks/ArtworkDraftService.php b/app/Services/Artworks/ArtworkDraftService.php index 7c18c374..d91aaff7 100644 --- a/app/Services/Artworks/ArtworkDraftService.php +++ b/app/Services/Artworks/ArtworkDraftService.php @@ -11,9 +11,9 @@ use Illuminate\Support\Str; final class ArtworkDraftService { - public function createDraft(int $userId, string $title, ?string $description): ArtworkDraftResult + public function createDraft(int $userId, string $title, ?string $description, ?int $categoryId = null): ArtworkDraftResult { - return DB::transaction(function () use ($userId, $title, $description) { + return DB::transaction(function () use ($userId, $title, $description, $categoryId) { $slug = $this->uniqueSlug($title); $artwork = Artwork::create([ @@ -32,6 +32,11 @@ final class ArtworkDraftService 'published_at' => null, ]); + // Attach the selected category to the artwork pivot table + if ($categoryId !== null && \App\Models\Category::where('id', $categoryId)->exists()) { + $artwork->categories()->sync([$categoryId]); + } + return new ArtworkDraftResult((int) $artwork->id, 'draft'); }); } diff --git a/app/Services/EarlyGrowth/ActivityLayer.php b/app/Services/EarlyGrowth/ActivityLayer.php new file mode 100644 index 00000000..243c5105 --- /dev/null +++ b/app/Services/EarlyGrowth/ActivityLayer.php @@ -0,0 +1,149 @@ + + */ + public function getSignals(): array + { + if (! EarlyGrowth::activityLayerEnabled()) { + return []; + } + + $ttl = (int) config('early_growth.cache_ttl.activity', 1800); + + return Cache::remember('egs.activity_signals', $ttl, fn (): array => $this->buildSignals()); + } + + // ─── Signal builders ───────────────────────────────────────────────────── + + private function buildSignals(): array + { + $signals = []; + + // §8: "X artworks published recently" + $recentCount = $this->recentArtworkCount(7); + if ($recentCount > 0) { + $signals[] = [ + 'icon' => '🎨', + 'text' => "{$recentCount} artwork" . ($recentCount !== 1 ? 's' : '') . ' published this week', + 'type' => 'uploads', + ]; + } + + // §8: "X new creators joined this month" + $newCreators = $this->newCreatorsThisMonth(); + if ($newCreators > 0) { + $signals[] = [ + 'icon' => '🌟', + 'text' => "{$newCreators} new creator" . ($newCreators !== 1 ? 's' : '') . ' joined this month', + 'type' => 'creators', + ]; + } + + // §8: "Trending this week" + $trendingCount = $this->recentArtworkCount(7); + if ($trendingCount > 0) { + $signals[] = [ + 'icon' => '🔥', + 'text' => 'Trending this week', + 'type' => 'trending', + ]; + } + + // §8: "Rising in Wallpapers" (first content type with recent uploads) + $risingType = $this->getRisingContentType(); + if ($risingType !== null) { + $signals[] = [ + 'icon' => '📈', + 'text' => "Rising in {$risingType}", + 'type' => 'rising', + ]; + } + + return array_values($signals); + } + + /** + * Count approved public artworks published in the last N days. + */ + private function recentArtworkCount(int $days): int + { + try { + return Artwork::query() + ->where('is_public', true) + ->where('is_approved', true) + ->whereNull('deleted_at') + ->where('published_at', '>=', now()->subDays($days)) + ->count(); + } catch (\Throwable) { + return 0; + } + } + + /** + * Count users who registered (email_verified_at set) this calendar month. + */ + private function newCreatorsThisMonth(): int + { + try { + return User::query() + ->whereNotNull('email_verified_at') + ->where('email_verified_at', '>=', now()->startOfMonth()) + ->count(); + } catch (\Throwable) { + return 0; + } + } + + /** + * Returns the name of the content type with the most uploads in the last 30 days, + * or null if the content_types table isn't available. + */ + private function getRisingContentType(): ?string + { + try { + $row = DB::table('artworks') + ->join('content_types', 'content_types.id', '=', 'artworks.content_type_id') + ->where('artworks.is_public', true) + ->where('artworks.is_approved', true) + ->whereNull('artworks.deleted_at') + ->where('artworks.published_at', '>=', now()->subDays(30)) + ->selectRaw('content_types.name, COUNT(*) as cnt') + ->groupBy('content_types.id', 'content_types.name') + ->orderByDesc('cnt') + ->first(); + + return $row ? (string) $row->name : null; + } catch (\Throwable) { + return null; + } + } +} diff --git a/app/Services/EarlyGrowth/AdaptiveTimeWindow.php b/app/Services/EarlyGrowth/AdaptiveTimeWindow.php new file mode 100644 index 00000000..829c02fb --- /dev/null +++ b/app/Services/EarlyGrowth/AdaptiveTimeWindow.php @@ -0,0 +1,78 @@ +getUploadsPerDay(); + $narrowThreshold = (int) config('early_growth.thresholds.uploads_per_day_narrow', 10); + $wideThreshold = (int) config('early_growth.thresholds.uploads_per_day_wide', 3); + + $narrowDays = (int) config('early_growth.thresholds.window_narrow_days', 7); + $mediumDays = (int) config('early_growth.thresholds.window_medium_days', 30); + $wideDays = (int) config('early_growth.thresholds.window_wide_days', 90); + + if ($uploadsPerDay >= $narrowThreshold) { + return $narrowDays; // Healthy activity → normal 7-day window + } + + if ($uploadsPerDay >= $wideThreshold) { + return $mediumDays; // Moderate activity → expand to 30 days + } + + return $wideDays; // Low activity → expand to 90 days + } + + /** + * Rolling 7-day average of approved public uploads per day. + * Cached for `early_growth.cache_ttl.time_window` seconds. + */ + public function getUploadsPerDay(): float + { + $ttl = (int) config('early_growth.cache_ttl.time_window', 600); + + return Cache::remember('egs.uploads_per_day', $ttl, function (): float { + $count = Artwork::query() + ->where('is_public', true) + ->where('is_approved', true) + ->whereNull('deleted_at') + ->whereNotNull('published_at') + ->where('published_at', '>=', now()->subDays(7)) + ->count(); + + return round($count / 7, 2); + }); + } +} diff --git a/app/Services/EarlyGrowth/EarlyGrowth.php b/app/Services/EarlyGrowth/EarlyGrowth.php new file mode 100644 index 00000000..ff5f0f7c --- /dev/null +++ b/app/Services/EarlyGrowth/EarlyGrowth.php @@ -0,0 +1,149 @@ + 1.0, + 'curated' => 0.0, + 'spotlight' => 0.0, + ]); + } + + // ─── Auto-disable logic ─────────────────────────────────────────────────── + + /** + * Check whether upload volume or active-user count has crossed the + * configured threshold for organic scale, and the system should self-disable. + * Result is cached for 10 minutes to avoid constant DB polling. + */ + private static function shouldAutoDisable(): bool + { + return (bool) Cache::remember('egs.auto_disable_check', 600, function (): bool { + $uploadsThreshold = (int) config('early_growth.auto_disable.uploads_per_day', 50); + $usersThreshold = (int) config('early_growth.auto_disable.active_users', 500); + + // Average daily uploads over the last 7 days + $recentUploads = Artwork::query() + ->where('is_public', true) + ->where('is_approved', true) + ->whereNull('deleted_at') + ->where('published_at', '>=', now()->subDays(7)) + ->count(); + + $uploadsPerDay = $recentUploads / 7; + + if ($uploadsPerDay >= $uploadsThreshold) { + return true; + } + + // Active users: verified accounts who uploaded in last 30 days + $activeCreators = Artwork::query() + ->where('is_public', true) + ->where('is_approved', true) + ->where('published_at', '>=', now()->subDays(30)) + ->distinct('user_id') + ->count('user_id'); + + return $activeCreators >= $usersThreshold; + }); + } + + // ─── Status summary ────────────────────────────────────────────────────── + + /** + * Return a summary array suitable for admin panels / logging. + */ + public static function status(): array + { + return [ + 'enabled' => self::enabled(), + 'mode' => self::mode(), + 'adaptive_window' => self::adaptiveWindowEnabled(), + 'grid_filler' => self::gridFillerEnabled(), + 'spotlight' => self::spotlightEnabled(), + 'activity_layer' => self::activityLayerEnabled(), + ]; + } +} diff --git a/app/Services/EarlyGrowth/FeedBlender.php b/app/Services/EarlyGrowth/FeedBlender.php new file mode 100644 index 00000000..38e222cf --- /dev/null +++ b/app/Services/EarlyGrowth/FeedBlender.php @@ -0,0 +1,124 @@ + 1) + */ + public function blend( + LengthAwarePaginator $freshResults, + int $perPage = 24, + int $page = 1, + ): LengthAwarePaginator { + // Only blend on page 1; real pagination takes over for deeper pages + if (! EarlyGrowth::enabled() || $page > 1) { + return $freshResults; + } + + $ratios = EarlyGrowth::blendRatios(); + + if (($ratios['curated'] + $ratios['spotlight']) < 0.001) { + // Mode is effectively "fresh only" — nothing to blend + return $freshResults; + } + + $fresh = $freshResults->getCollection(); + $freshIds = $fresh->pluck('id')->toArray(); + + // Calculate absolute item counts from ratios + [$freshCount, $curatedCount, $spotlightCount] = $this->allocateCounts($ratios, $perPage); + + // Fetch sources — over-fetch to account for deduplication losses + $curated = $this->spotlight + ->getCurated($curatedCount + 6) + ->filter(fn ($a) => ! in_array($a->id, $freshIds, true)) + ->take($curatedCount) + ->values(); + + $curatedIds = $curated->pluck('id')->toArray(); + + $spotlightItems = $this->spotlight + ->getSpotlight($spotlightCount + 6) + ->filter(fn ($a) => ! in_array($a->id, $freshIds, true)) + ->filter(fn ($a) => ! in_array($a->id, $curatedIds, true)) + ->take($spotlightCount) + ->values(); + + // Compose blended page + $blended = $fresh->take($freshCount) + ->concat($curated) + ->concat($spotlightItems) + ->unique('id') + ->values(); + + // Pad back to $perPage with leftover fresh items if any source ran short + if ($blended->count() < $perPage) { + $usedIds = $blended->pluck('id')->toArray(); + $pad = $fresh + ->filter(fn ($a) => ! in_array($a->id, $usedIds, true)) + ->take($perPage - $blended->count()); + $blended = $blended->concat($pad)->unique('id')->values(); + } + + // Rebuild paginator preserving the real total so pagination links remain stable + return new LengthAwarePaginator( + $blended->take($perPage)->all(), + $freshResults->total(), // ← real total, not blended count + $perPage, + $page, + [ + 'path' => $freshResults->path(), + 'pageName' => $freshResults->getPageName(), + ] + ); + } + + // ─── Private helpers ───────────────────────────────────────────────────── + + /** + * Distribute $perPage slots across fresh / curated / spotlight. + * Returns [freshCount, curatedCount, spotlightCount]. + */ + private function allocateCounts(array $ratios, int $perPage): array + { + $total = max(0.001, ($ratios['fresh'] ?? 0) + ($ratios['curated'] ?? 0) + ($ratios['spotlight'] ?? 0)); + $freshN = (int) round($perPage * ($ratios['fresh'] ?? 1.0) / $total); + $curatedN = (int) round($perPage * ($ratios['curated'] ?? 0.0) / $total); + $spotN = $perPage - $freshN - $curatedN; + + return [max(0, $freshN), max(0, $curatedN), max(0, $spotN)]; + } +} diff --git a/app/Services/EarlyGrowth/GridFiller.php b/app/Services/EarlyGrowth/GridFiller.php new file mode 100644 index 00000000..2954fea3 --- /dev/null +++ b/app/Services/EarlyGrowth/GridFiller.php @@ -0,0 +1,129 @@ + 1 + * - Real result count already meets the minimum + */ + public function fill( + LengthAwarePaginator $results, + int $minimum = 0, + int $page = 1, + ): LengthAwarePaginator { + if (! EarlyGrowth::gridFillerEnabled() || $page > 1) { + return $results; + } + + $minimum = $minimum > 0 + ? $minimum + : (int) config('early_growth.grid_min_results', 12); + + $items = $results->getCollection(); + $count = $items->count(); + + if ($count >= $minimum) { + return $results; + } + + $needed = $minimum - $count; + $exclude = $items->pluck('id')->all(); + $filler = $this->fetchTrendingFiller($needed + 6, $exclude)->take($needed); + + $merged = $items + ->concat($filler) + ->unique('id') + ->values(); + + return new LengthAwarePaginator( + $merged->all(), + max((int) $results->total(), $merged->count()), // never shrink reported total + $results->perPage(), + $page, + [ + 'path' => $results->path(), + 'pageName' => $results->getPageName(), + ] + ); + } + + /** + * Fill a plain Collection (for non-paginated grids like homepage sections). + */ + public function fillCollection(Collection $items, int $minimum = 0): Collection + { + if (! EarlyGrowth::gridFillerEnabled()) { + return $items; + } + + $minimum = $minimum > 0 + ? $minimum + : (int) config('early_growth.grid_min_results', 12); + + if ($items->count() >= $minimum) { + return $items; + } + + $needed = $minimum - $items->count(); + $exclude = $items->pluck('id')->all(); + $filler = $this->fetchTrendingFiller($needed + 6, $exclude)->take($needed); + + return $items->concat($filler)->unique('id')->values(); + } + + // ─── Private ───────────────────────────────────────────────────────────── + + /** + * Pull high-ranking artworks as grid filler. + * Cache key includes an exclude-hash so different grids get distinct content. + */ + private function fetchTrendingFiller(int $limit, array $excludeIds): Collection + { + $ttl = (int) config('early_growth.cache_ttl.feed_blend', 300); + $excludeHash = md5(implode(',', array_slice(array_unique($excludeIds), 0, 50))); + $cacheKey = "egs.grid_filler.{$excludeHash}.{$limit}"; + + return Cache::remember($cacheKey, $ttl, function () use ($limit, $excludeIds): Collection { + return Artwork::query() + ->public() + ->published() + ->with([ + 'user:id,name,username', + 'user.profile:user_id,avatar_hash', + 'categories:id,name,slug,content_type_id,parent_id,sort_order', + ]) + ->leftJoin('artwork_stats as _gf_stats', '_gf_stats.artwork_id', '=', 'artworks.id') + ->select('artworks.*') + ->when(! empty($excludeIds), fn ($q) => $q->whereNotIn('artworks.id', $excludeIds)) + ->orderByDesc('_gf_stats.ranking_score') + ->limit($limit) + ->get() + ->values(); + }); + } +} diff --git a/app/Services/EarlyGrowth/SpotlightEngine.php b/app/Services/EarlyGrowth/SpotlightEngine.php new file mode 100644 index 00000000..5950081a --- /dev/null +++ b/app/Services/EarlyGrowth/SpotlightEngine.php @@ -0,0 +1,116 @@ +format('Y-m-d') . ".{$limit}"; + + return Cache::remember($cacheKey, $ttl, fn (): Collection => $this->selectSpotlight($limit)); + } + + /** + * Return high-quality older artworks for feed blending ("curated" pool). + * Excludes artworks newer than $olderThanDays to keep them out of the + * "fresh" section yet available for blending. + * + * Cached per (limit, olderThanDays) tuple and rotated daily. + */ + public function getCurated(int $limit = 12, int $olderThanDays = 7): Collection + { + if (! EarlyGrowth::enabled()) { + return collect(); + } + + $ttl = (int) config('early_growth.cache_ttl.spotlight', 3600); + $cacheKey = 'egs.curated.' . now()->format('Y-m-d') . ".{$limit}.{$olderThanDays}"; + + return Cache::remember($cacheKey, $ttl, fn (): Collection => $this->selectCurated($limit, $olderThanDays)); + } + + // ─── Private selection logic ────────────────────────────────────────────── + + /** + * Select spotlight artworks. + * Uses a date-based seed for deterministic daily rotation. + * Fetches 3× the needed count and selects the top-ranked subset. + */ + private function selectSpotlight(int $limit): Collection + { + $seed = (int) now()->format('Ymd'); + + // Artworks published > 7 days ago with meaningful ranking score + return Artwork::query() + ->public() + ->published() + ->with([ + 'user:id,name,username', + 'user.profile:user_id,avatar_hash', + 'categories:id,name,slug,content_type_id,parent_id,sort_order', + ]) + ->leftJoin('artwork_stats as _ast', '_ast.artwork_id', '=', 'artworks.id') + ->select('artworks.*') + ->where('artworks.published_at', '<=', now()->subDays(7)) + // Blend ranking quality with daily-seeded randomness so spotlight varies + ->orderByRaw("COALESCE(_ast.ranking_score, 0) * 0.6 + RAND({$seed}) * 0.4 DESC") + ->limit($limit * 3) + ->get() + ->sortByDesc(fn ($a) => optional($a->artworkStats)->ranking_score ?? 0) + ->take($limit) + ->values(); + } + + /** + * Select curated older artworks for feed blending. + */ + private function selectCurated(int $limit, int $olderThanDays): Collection + { + $seed = (int) now()->format('Ymd'); + + return Artwork::query() + ->public() + ->published() + ->with([ + 'user:id,name,username', + 'user.profile:user_id,avatar_hash', + 'categories:id,name,slug,content_type_id,parent_id,sort_order', + ]) + ->leftJoin('artwork_stats as _ast2', '_ast2.artwork_id', '=', 'artworks.id') + ->select('artworks.*') + ->where('artworks.published_at', '<=', now()->subDays($olderThanDays)) + ->orderByRaw("COALESCE(_ast2.ranking_score, 0) * 0.7 + RAND({$seed}) * 0.3 DESC") + ->limit($limit) + ->get() + ->values(); + } +} diff --git a/app/Services/EarlyGrowth/SpotlightEngineInterface.php b/app/Services/EarlyGrowth/SpotlightEngineInterface.php new file mode 100644 index 00000000..24dbe1a6 --- /dev/null +++ b/app/Services/EarlyGrowth/SpotlightEngineInterface.php @@ -0,0 +1,18 @@ +with(['user', 'stats']) + ->public() + ->published() + ->orderByDesc('trending_score_7d') + ->limit($limit) + ->get() + ->map(fn (Artwork $a) => $this->artworkCard($a)); + }); + } + + // ── Similar tags by slug prefix / Levenshtein approximation (max 10) ───── + + public function similarTags(string $slug, int $limit = 10): Collection + { + $limit = min($limit, 10); + $prefix = substr($slug, 0, 3); + + return Cache::remember("error_suggestions.similar_tags.{$slug}.{$limit}", self::CACHE_TTL, function () use ($slug, $limit, $prefix) { + return Tag::query() + ->withCount('artworks') + ->where('slug', '!=', $slug) + ->where(function ($q) use ($prefix, $slug) { + $q->where('slug', 'like', $prefix . '%') + ->orWhere('slug', 'like', '%' . substr($slug, -3) . '%'); + }) + ->orderByDesc('artworks_count') + ->limit($limit) + ->get(['id', 'name', 'slug', 'artworks_count']); + }); + } + + // ── Trending tags (max 10) ──────────────────────────────────────────────── + + public function trendingTags(int $limit = 10): Collection + { + $limit = min($limit, 10); + + return Cache::remember("error_suggestions.tags.{$limit}", self::CACHE_TTL, function () use ($limit) { + return Tag::query() + ->withCount('artworks') + ->orderByDesc('artworks_count') + ->limit($limit) + ->get(['id', 'name', 'slug', 'artworks_count']); + }); + } + + // ── Trending creators (max 6) ───────────────────────────────────────────── + + public function trendingCreators(int $limit = 6): Collection + { + $limit = min($limit, 6); + + return Cache::remember("error_suggestions.creators.{$limit}", self::CACHE_TTL, function () use ($limit) { + return User::query() + ->with('profile') + ->withCount(['artworks' => fn ($q) => $q->public()->published()]) + ->having('artworks_count', '>', 0) + ->orderByDesc('artworks_count') + ->limit($limit) + ->get(['users.id', 'users.name', 'users.username']) + ->map(fn (User $u) => $this->creatorCard($u, $u->artworks_count)); + }); + } + + // ── Recently joined creators (max 6) ───────────────────────────────────── + + public function recentlyJoinedCreators(int $limit = 6): Collection + { + $limit = min($limit, 6); + + return Cache::remember("error_suggestions.creators.recent.{$limit}", self::CACHE_TTL, function () use ($limit) { + return User::query() + ->with('profile') + ->withCount(['artworks' => fn ($q) => $q->public()->published()]) + ->having('artworks_count', '>', 0) + ->orderByDesc('users.id') + ->limit($limit) + ->get(['users.id', 'users.name', 'users.username']) + ->map(fn (User $u) => $this->creatorCard($u, $u->artworks_count)); + }); + } + + // ── Latest blog posts (max 6) ───────────────────────────────────────────── + + public function latestBlogPosts(int $limit = 6): Collection + { + $limit = min($limit, 6); + + return Cache::remember("error_suggestions.blog.{$limit}", self::CACHE_TTL, function () use ($limit) { + return BlogPost::published() + ->orderByDesc('published_at') + ->limit($limit) + ->get(['id', 'title', 'slug', 'excerpt', 'published_at']) + ->map(fn ($p) => [ + 'id' => $p->id, + 'title' => $p->title, + 'excerpt' => Str::limit($p->excerpt ?? '', 100), + 'url' => '/blog/' . $p->slug, + 'published_at' => $p->published_at?->diffForHumans(), + ]); + }); + } + + // ── Private helpers ─────────────────────────────────────────────────────── + + private function artworkCard(Artwork $a): array + { + $slug = Str::slug((string) ($a->slug ?: $a->title)) ?: (string) $a->id; + $md = 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, + ]; + } + + private function creatorCard(User $u, int $artworksCount = 0): array + { + return [ + 'id' => $u->id, + 'name' => $u->name ?: $u->username, + 'username' => $u->username, + 'url' => '/@' . $u->username, + 'avatar_url' => \App\Support\AvatarUrl::forUser( + (int) $u->id, + optional($u->profile)->avatar_hash, + 64 + ), + 'artworks_count' => $artworksCount, + ]; + } +} diff --git a/app/Services/HomepageService.php b/app/Services/HomepageService.php index db827bc5..edee7004 100644 --- a/app/Services/HomepageService.php +++ b/app/Services/HomepageService.php @@ -7,6 +7,8 @@ namespace App\Services; use App\Models\Artwork; use App\Models\Tag; use App\Services\ArtworkSearchService; +use App\Services\EarlyGrowth\EarlyGrowth; +use App\Services\EarlyGrowth\GridFiller; use App\Services\Recommendation\RecommendationService; use App\Services\UserPreferenceService; use App\Support\AvatarUrl; @@ -30,7 +32,8 @@ final class HomepageService private readonly ArtworkService $artworks, private readonly ArtworkSearchService $search, private readonly UserPreferenceService $prefs, - private readonly RecommendationService $reco, + private readonly RecommendationService $reco, + private readonly GridFiller $gridFiller, ) {} // ───────────────────────────────────────────────────────────────────────── @@ -255,11 +258,16 @@ final class HomepageService } /** - * Fresh uploads: latest 12 approved public artworks. + * Fresh uploads: latest 10 approved public artworks. + * EGS: GridFiller ensures the section is never empty even on low-traffic days. */ public function getFreshUploads(int $limit = 10): array { - return Cache::remember("homepage.fresh.{$limit}", self::CACHE_TTL, function () use ($limit): array { + // Include EGS mode in cache key so toggling EGS updates the section within TTL + $egsKey = EarlyGrowth::gridFillerEnabled() ? 'egs-' . EarlyGrowth::mode() : 'std'; + $cacheKey = "homepage.fresh.{$limit}.{$egsKey}"; + + return Cache::remember($cacheKey, self::CACHE_TTL, function () use ($limit): array { $artworks = Artwork::public() ->published() ->with(['user:id,name,username', 'user.profile:user_id,avatar_hash']) @@ -267,6 +275,9 @@ final class HomepageService ->limit($limit) ->get(); + // EGS: fill up to $limit when fresh uploads are sparse + $artworks = $this->gridFiller->fillCollection($artworks, $limit); + return $artworks->map(fn ($a) => $this->serializeArtwork($a))->values()->all(); }); } diff --git a/app/Services/NotFoundLogger.php b/app/Services/NotFoundLogger.php new file mode 100644 index 00000000..c703cf48 --- /dev/null +++ b/app/Services/NotFoundLogger.php @@ -0,0 +1,59 @@ +info('404 Not Found', [ + 'url' => $request->fullUrl(), + 'method' => $request->method(), + 'referrer' => $request->header('Referer') ?? '(direct)', + 'user_agent' => $request->userAgent(), + 'user_id' => $request->user()?->id, + 'ip' => $request->ip(), + ]); + } + + /** + * Log a 500 server error with a generated correlation ID. + * Returns the correlation ID so it can be shown on the error page. + */ + public function log500(\Throwable $e, Request $request): string + { + $correlationId = strtoupper(Str::random(8)); + + Log::error('500 Server Error [' . $correlationId . ']', [ + 'correlation_id' => $correlationId, + 'url' => $request->fullUrl(), + 'method' => $request->method(), + 'exception' => get_class($e), + 'message' => $e->getMessage(), + 'file' => $e->getFile(), + 'line' => $e->getLine(), + 'user_id' => $request->user()?->id, + 'ip' => $request->ip(), + ]); + + return $correlationId; + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index 676ef0f6..5c1b29bf 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -23,5 +23,72 @@ return Application::configure(basePath: dirname(__DIR__)) ]); }) ->withExceptions(function (Exceptions $exceptions): void { - // + + // ── 404 / 410 / 403 — web HTML responses only ───────────────────────── + $exceptions->render(function ( + \Symfony\Component\HttpKernel\Exception\HttpException $e, + \Illuminate\Http\Request $request + ) { + if ($request->expectsJson()) { + return null; // Let Laravel produce the default JSON response. + } + + $status = $e->getStatusCode(); + + // 403 and 401 use their generic Blade views — no extra data needed. + if ($status === 403) { + return response(view('errors.403', ['message' => $e->getMessage() ?: null]), 403); + } + if ($status === 401) { + return response(view('errors.401'), 401); + } + if ($status === 410) { + return response(view('errors.410'), 410); + } + + // Generic 404 — smart URL-pattern routing to contextual views. + if ($status === 404) { + return app(\App\Http\Controllers\Web\ErrorController::class) + ->handleNotFound($request); + } + + return null; // Fallback to Laravel's default. + }); + + // ── ModelNotFoundException → contextual 404 on web ─────────────────── + $exceptions->render(function ( + \Illuminate\Database\Eloquent\ModelNotFoundException $e, + \Illuminate\Http\Request $request + ) { + if ($request->expectsJson()) { + return null; + } + + return app(\App\Http\Controllers\Web\ErrorController::class) + ->handleNotFound($request); + }); + + // ── 500 server errors — log with correlation ID ─────────────────────── + $exceptions->render(function ( + \Throwable $e, + \Illuminate\Http\Request $request + ) { + if ($request->expectsJson()) { + return null; + } + + // Only handle truly unexpected server errors (not HTTP exceptions already handled above). + if ($e instanceof \Symfony\Component\HttpKernel\Exception\HttpException) { + return null; + } + + try { + $correlationId = app(\App\Services\NotFoundLogger::class)->log500($e, $request); + } catch (\Throwable) { + $correlationId = 'UNKNOWN'; + } + + return response(view('errors.500', ['correlationId' => $correlationId]), 500); + }); + })->create(); diff --git a/config/early_growth.php b/config/early_growth.php new file mode 100644 index 00000000..89059e7d --- /dev/null +++ b/config/early_growth.php @@ -0,0 +1,78 @@ + (bool) env('NOVA_EARLY_GROWTH_ENABLED', false), + + // operating mode: off | light | aggressive + 'mode' => env('NOVA_EARLY_GROWTH_MODE', 'off'), + + // ─── Module toggles (each respects master 'enabled') ───────────────────── + 'adaptive_time_window' => (bool) env('NOVA_EGS_ADAPTIVE_WINDOW', true), + 'grid_filler' => (bool) env('NOVA_EGS_GRID_FILLER', true), + 'spotlight' => (bool) env('NOVA_EGS_SPOTLIGHT', true), + 'activity_layer' => (bool) env('NOVA_EGS_ACTIVITY_LAYER', false), + + // ─── AdaptiveTimeWindow thresholds ─────────────────────────────────────── + // uploads_per_day is the 7-day rolling average of published artworks/day. + 'thresholds' => [ + 'uploads_per_day_narrow' => (int) env('NOVA_EGS_UPLOADS_PER_DAY_NARROW', 10), + 'uploads_per_day_wide' => (int) env('NOVA_EGS_UPLOADS_PER_DAY_WIDE', 3), + // Days to look back for trending / rising queries + 'window_narrow_days' => (int) env('NOVA_EGS_WINDOW_NARROW_DAYS', 7), + 'window_medium_days' => (int) env('NOVA_EGS_WINDOW_MEDIUM_DAYS', 30), + 'window_wide_days' => (int) env('NOVA_EGS_WINDOW_WIDE_DAYS', 90), + ], + + // ─── FeedBlender ratios per mode ───────────────────────────────────────── + // Values are proportions; they are normalised internally (sum need not equal 1). + 'blend_ratios' => [ + 'light' => [ + 'fresh' => 0.60, + 'curated' => 0.25, + 'spotlight' => 0.15, + ], + 'aggressive' => [ + 'fresh' => 0.30, + 'curated' => 0.50, + 'spotlight' => 0.20, + ], + ], + + // ─── GridFiller ────────────────────────────────────────────────────────── + // Minimum number of items to display per grid page. + 'grid_min_results' => (int) env('NOVA_EGS_GRID_MIN_RESULTS', 12), + + // ─── Auto-disable when site reaches organic scale ──────────────────────── + 'auto_disable' => [ + 'enabled' => (bool) env('NOVA_EGS_AUTO_DISABLE', false), + 'uploads_per_day' => (int) env('NOVA_EGS_AUTO_DISABLE_UPLOADS', 50), + 'active_users' => (int) env('NOVA_EGS_AUTO_DISABLE_USERS', 500), + ], + + // ─── Cache TTLs (seconds) ───────────────────────────────────────────────── + 'cache_ttl' => [ + 'spotlight' => (int) env('NOVA_EGS_SPOTLIGHT_TTL', 3600), // 1 h – rotated daily + 'feed_blend' => (int) env('NOVA_EGS_BLEND_TTL', 300), // 5 min + 'time_window' => (int) env('NOVA_EGS_WINDOW_TTL', 600), // 10 min + 'activity' => (int) env('NOVA_EGS_ACTIVITY_TTL', 1800), // 30 min + ], + +]; diff --git a/config/mail.php b/config/mail.php index 522b284b..38a3d0fc 100644 --- a/config/mail.php +++ b/config/mail.php @@ -111,8 +111,8 @@ return [ */ 'from' => [ - 'address' => env('MAIL_FROM_ADDRESS', 'hello@example.com'), - 'name' => env('MAIL_FROM_NAME', 'Example'), + 'address' => env('MAIL_FROM_ADDRESS', 'gregor@klevze.com'), + 'name' => env('MAIL_FROM_NAME', 'Skinbase'), ], ]; diff --git a/config/services.php b/config/services.php index 09d3b8e6..fc1cde53 100644 --- a/config/services.php +++ b/config/services.php @@ -54,4 +54,13 @@ return [ 'timeout' => (int) env('TURNSTILE_TIMEOUT', 5), ], + /* + * Google AdSense + * Set GOOGLE_ADSENSE_PUBLISHER_ID to your ca-pub-XXXXXXXXXXXXXXXX value. + * Ads are only loaded after the user accepts cookies via the consent banner. + */ + 'google_adsense' => [ + 'publisher_id' => env('GOOGLE_ADSENSE_PUBLISHER_ID'), + ], + ]; diff --git a/database/factories/BlogPostFactory.php b/database/factories/BlogPostFactory.php new file mode 100644 index 00000000..56006a7d --- /dev/null +++ b/database/factories/BlogPostFactory.php @@ -0,0 +1,40 @@ + + */ +final class BlogPostFactory extends Factory +{ + protected $model = BlogPost::class; + + public function definition(): array + { + $title = $this->faker->sentence(5); + + return [ + 'slug' => Str::slug($title) . '-' . $this->faker->unique()->numberBetween(1, 99999), + 'title' => $title, + 'body' => '
' . implode('
', $this->faker->paragraphs(3)) . '
', + 'excerpt' => $this->faker->sentence(15), + 'author_id' => null, + 'featured_image' => null, + 'meta_title' => null, + 'meta_description' => null, + 'is_published' => true, + 'published_at' => $this->faker->dateTimeBetween('-1 year', 'now'), + ]; + } + + public function draft(): static + { + return $this->state(['is_published' => false, 'published_at' => null]); + } +} diff --git a/database/factories/PageFactory.php b/database/factories/PageFactory.php new file mode 100644 index 00000000..adc715a2 --- /dev/null +++ b/database/factories/PageFactory.php @@ -0,0 +1,38 @@ + + */ +final class PageFactory extends Factory +{ + protected $model = Page::class; + + public function definition(): array + { + $title = $this->faker->sentence(4); + + return [ + 'slug' => Str::slug($title) . '-' . $this->faker->unique()->numberBetween(1, 99999), + 'title' => $title, + 'body' => '' . implode('
', $this->faker->paragraphs(2)) . '
', + 'layout' => 'default', + 'meta_title' => null, + 'meta_description' => null, + 'is_published' => true, + 'published_at' => $this->faker->dateTimeBetween('-1 year', 'now'), + ]; + } + + public function draft(): static + { + return $this->state(['is_published' => false, 'published_at' => null]); + } +} diff --git a/database/migrations/2026_03_03_000000_create_staff_applications_table.php b/database/migrations/2026_03_03_000000_create_staff_applications_table.php new file mode 100644 index 00000000..dc449db5 --- /dev/null +++ b/database/migrations/2026_03_03_000000_create_staff_applications_table.php @@ -0,0 +1,29 @@ +uuid('id')->primary(); + $table->string('topic')->index(); + $table->string('name'); + $table->string('email'); + $table->string('role')->nullable(); + $table->string('portfolio')->nullable(); + $table->text('message')->nullable(); + $table->json('payload')->nullable(); + $table->string('ip', 45)->nullable(); + $table->text('user_agent')->nullable(); + $table->timestamps(); + }); + } + + public function down() + { + Schema::dropIfExists('staff_applications'); + } +}; diff --git a/database/migrations/2026_03_03_000001_create_pages_table.php b/database/migrations/2026_03_03_000001_create_pages_table.php new file mode 100644 index 00000000..f361e4e1 --- /dev/null +++ b/database/migrations/2026_03_03_000001_create_pages_table.php @@ -0,0 +1,32 @@ +id(); + $table->string('slug', 191)->unique(); + $table->string('title', 255); + $table->mediumText('body'); + $table->string('layout', 50)->default('default'); // default | legal | help + $table->string('meta_title', 255)->nullable(); + $table->text('meta_description')->nullable(); + $table->boolean('is_published')->default(false); + $table->timestamp('published_at')->nullable(); + $table->timestamps(); + $table->softDeletes(); + + $table->index(['is_published', 'published_at']); + }); + } + + public function down(): void + { + Schema::dropIfExists('pages'); + } +}; diff --git a/database/migrations/2026_03_03_000002_create_blog_posts_table.php b/database/migrations/2026_03_03_000002_create_blog_posts_table.php new file mode 100644 index 00000000..f0680d1f --- /dev/null +++ b/database/migrations/2026_03_03_000002_create_blog_posts_table.php @@ -0,0 +1,35 @@ +id(); + $table->string('slug', 191)->unique(); + $table->string('title', 255); + $table->mediumText('body'); + $table->text('excerpt')->nullable(); + $table->foreignId('author_id')->nullable() + ->constrained('users')->nullOnDelete(); + $table->string('featured_image', 500)->nullable(); + $table->string('meta_title', 255)->nullable(); + $table->text('meta_description')->nullable(); + $table->boolean('is_published')->default(false); + $table->timestamp('published_at')->nullable(); + $table->timestamps(); + $table->softDeletes(); + + $table->index(['is_published', 'published_at']); + }); + } + + public function down(): void + { + Schema::dropIfExists('blog_posts'); + } +}; diff --git a/database/migrations/2026_03_03_000003_create_bug_reports_table.php b/database/migrations/2026_03_03_000003_create_bug_reports_table.php new file mode 100644 index 00000000..bde895ee --- /dev/null +++ b/database/migrations/2026_03_03_000003_create_bug_reports_table.php @@ -0,0 +1,27 @@ +id(); + $table->foreignId('user_id')->nullable()->constrained('users')->nullOnDelete(); + $table->string('subject'); + $table->text('description'); + $table->ipAddress('ip_address')->nullable(); + $table->string('user_agent', 512)->nullable(); + $table->enum('status', ['open', 'in_progress', 'resolved', 'closed'])->default('open'); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('bug_reports'); + } +}; diff --git a/deploy/supervisor/skinbase-queue.conf b/deploy/supervisor/skinbase-queue.conf new file mode 100644 index 00000000..b9f9e868 --- /dev/null +++ b/deploy/supervisor/skinbase-queue.conf @@ -0,0 +1,10 @@ +[program:skinbase-queue] +command=/usr/bin/php /var/www/skinbase/artisan queue:work --sleep=3 --tries=3 --timeout=90 --queue=default +process_name=%(program_name)s_%(process_num)02d +numprocs=1 +autostart=true +autorestart=true +user=www-data +redirect_stderr=true +stdout_logfile=/var/log/skinbase/queue.log +stopwaitsecs=3600 diff --git a/deploy/systemd/skinbase-queue.service b/deploy/systemd/skinbase-queue.service new file mode 100644 index 00000000..3dadbbd8 --- /dev/null +++ b/deploy/systemd/skinbase-queue.service @@ -0,0 +1,17 @@ +[Unit] +Description=Skinbase Laravel Queue Worker +After=network.target + +[Service] +User=www-data +Group=www-data +Restart=always +RestartSec=3 +WorkingDirectory=/var/www/skinbase +ExecStart=/usr/bin/php /var/www/skinbase/artisan queue:work --sleep=3 --tries=3 --timeout=90 --queue=default +StandardOutput=syslog +StandardError=syslog +SyslogIdentifier=skinbase-queue + +[Install] +WantedBy=multi-user.target diff --git a/docs/QUEUE.md b/docs/QUEUE.md new file mode 100644 index 00000000..26598cad --- /dev/null +++ b/docs/QUEUE.md @@ -0,0 +1,104 @@ +Queue worker setup +================== + +This document explains how to run Laravel queue workers for Skinbase and suggested Supervisor / systemd configs included in `deploy/`. + +1) Choose a queue driver +------------------------ + +Pick a driver in your `.env`, for example using the database driver (simple to run locally): + +``` +QUEUE_CONNECTION=database +``` + +Or use Redis for production: + +``` +QUEUE_CONNECTION=redis +``` + +2) Database queue (if using database driver) +------------------------------------------- + +Create the jobs table and run migrations: + +```bash +php artisan queue:table +php artisan migrate +``` + +3) Supervisor (recommended for many setups) +------------------------------------------- + +We provide an example Supervisor config at `deploy/supervisor/skinbase-queue.conf`. + +To use it on a Debian/Ubuntu server: + +```bash +# copy the file to supervisor's config directory +sudo cp deploy/supervisor/skinbase-queue.conf /etc/supervisor/conf.d/skinbase-queue.conf +# make sure the logs dir exists +sudo mkdir -p /var/log/skinbase +sudo chown www-data:www-data /var/log/skinbase +# tell supervisor to reload configs and start +sudo supervisorctl reread +sudo supervisorctl update +sudo supervisorctl start skinbase-queue +# check status +sudo supervisorctl status skinbase-queue +``` + +Adjust the `command` and `user` in the conf to match your deployment (path to PHP, project root and user). + +4) systemd alternative +---------------------- + +If you prefer systemd, an example unit is at `deploy/systemd/skinbase-queue.service`. + +```bash +sudo cp deploy/systemd/skinbase-queue.service /etc/systemd/system/skinbase-queue.service +sudo systemctl daemon-reload +sudo systemctl enable --now skinbase-queue.service +sudo systemctl status skinbase-queue.service +``` + +Adjust `WorkingDirectory` and `User` in the unit to match your deployment. + +5) Helpful artisan commands +--------------------------- + +- Start a one-off worker (foreground): + +```bash +php artisan queue:work --sleep=3 --tries=3 +``` + +- Restart all workers gracefully (useful after deployments): + +```bash +php artisan queue:restart +``` + +- Inspect failed jobs: + +```bash +php artisan queue:failed +php artisan queue:retry {id} +php artisan queue:flush +``` + +6) Logging & monitoring +----------------------- + +- Supervisor example logs to `/var/log/skinbase/queue.log` (see `deploy/supervisor/skinbase-queue.conf`). +- Use `journalctl -u skinbase-queue` for systemd logs. + +7) Notes and troubleshooting +--------------------------- + +- Ensure `QUEUE_CONNECTION` in `.env` matches the driver you've configured. +- If using `database` driver, the `jobs` and `failed_jobs` tables must exist. +- The mailable used for contact submissions is queued; if the queue worker is not running mails will accumulate in the queue table (or Redis). + +Questions or prefer a different process manager? Tell me your target host and I can produce exact commands tailored to it. diff --git a/resources/js/Layouts/SettingsLayout.jsx b/resources/js/Layouts/SettingsLayout.jsx new file mode 100644 index 00000000..362e966b --- /dev/null +++ b/resources/js/Layouts/SettingsLayout.jsx @@ -0,0 +1,102 @@ +import React, { useState } from 'react' +import { Link, usePage } from '@inertiajs/react' + +const navItems = [ + { label: 'Profile', href: '/dashboard/profile', icon: 'fa-solid fa-user' }, + // Future: { label: 'Notifications', href: '/dashboard/notifications', icon: 'fa-solid fa-bell' }, + // Future: { label: 'Privacy', href: '/dashboard/privacy', icon: 'fa-solid fa-shield-halved' }, +] + +function NavLink({ item, active, onClick }) { + return ( + + + {item.label} + + ) +} + +function SidebarContent({ isActive, onNavigate }) { + return ( + <> +{description}
} +Mailing List
+Receive occasional emails about Skinbase news
+Upload Notifications
+Get notified when people you follow upload new work
+Auto-post Uploads
+Automatically post to your feed when you publish artwork
+Delete Account
+Remove your account and all associated data permanently.
++ This action is irreversible. All your artworks, + comments, and profile data will be permanently deleted. +
++ Editing ·{' '} + + {isPublic ? 'Published' : 'Draft'} - {versionCount > 1 && ( +
+{fileMeta.name}
++ + Requires re-approval after replace +
)}{fileMeta.name}
-{formatBytes(fileMeta.size)}
- {fileMeta.width > 0 && ( -{fileMeta.width} × {fileMeta.height} px
- )} + + {/* Replace File */} +
- {ct.name}
- {active && (
-
-
+ {/* Quick Links */}
+
{ e.target.style.display = 'none' }}
+ />
+
+ {ct.name}
- )}
- {errors.category_id[0]}
} +{errors.title[0]}
} -{errors.description[0]}
} -+ {isPublic + ? 'Your artwork is visible to everyone' + : 'Your artwork is only visible to you'} +
- {tagQuery ? 'No tags found' : 'Type to search tags'} -
- )} - - {!tagLoading && - tagResults.map((tag) => { - const isSelected = tags.some((t) => t.id === tag.id) - return ( -{tags.length}/15 tags selected
- {errors.tags &&{errors.tags[0]}
} - - - {/* ── Visibility ── */} -+ Restoring creates a new version — nothing is deleted. +
+ } + > + {historyLoading && ( +- {v.created_at ? new Date(v.created_at).toLocaleString() : ''} -
- {v.width && ( -{v.width} × {v.height} px · {formatBytes(v.file_size)}
- )} - {v.change_note && ( -“{v.change_note}”
++ {v.created_at ? new Date(v.created_at).toLocaleString() : ''} +
+ {v.width && ( ++ {v.width} × {v.height} px · {formatBytes(v.file_size)} +
+ )} + {v.change_note && ( +“{v.change_note}”
)}No version history yet.
- )} -- Older versions are preserved. Restoring creates a new version—nothing is deleted. -
-No version history yet.
+ )}+ Markdown supported · Ctrl+B bold · Ctrl+I italic · Ctrl+K link +
+ > + )} + + {tab === 'preview' && ( +Nothing to preview
+ )} +- Markdown supported · Ctrl+B bold · Ctrl+I italic · Ctrl+K link -
- > - )} - - {tab === 'preview' && ( -Nothing to preview
- )} -
+ A non-deceptive layer that keeps Nova feeling alive when uploads are sparse.
+ Toggle via .env — no deployment required for mode changes.
+
System
+ {!! $pill($status['enabled']) !!} +Mode
+ + {{ strtoupper($mode) }} + +Adaptive Window
+ {!! $pill($status['adaptive_window']) !!} +Grid Filler
+ {!! $pill($status['grid_filler']) !!} +Spotlight
+ {!! $pill($status['spotlight']) !!} +Activity Layer
+ {!! $pill($status['activity_layer']) !!} +Uploads / day (7-day avg)
+{{ number_format($uploads_per_day, 1) }}
+Active trending window
+{{ $window_days }}d
+Edit .env to change these values. Run php artisan config:clear after changes.
| Variable | +Current Value | +Effect | +
|---|---|---|
| {{ $t['key'] }} | +{{ $t['current'] }} | +
+ @switch($t['key'])
+ @case('NOVA_EARLY_GROWTH_ENABLED') Master switch. Set to false to disable entire system. @break
+ @case('NOVA_EARLY_GROWTH_MODE') off / light / aggressive @break
+ @case('NOVA_EGS_ADAPTIVE_WINDOW') Widen trending window when uploads low. @break
+ @case('NOVA_EGS_GRID_FILLER') Backfill page-1 grids to 12 items. @break
+ @case('NOVA_EGS_SPOTLIGHT') Daily-rotating curated picks. @break
+ @case('NOVA_EGS_ACTIVITY_LAYER') Real activity summary badges. @break
+ @endswitch
+ |
+
To enable (light mode):
+NOVA_EARLY_GROWTH_ENABLED=true +NOVA_EARLY_GROWTH_MODE=light+
To disable instantly:
+NOVA_EARLY_GROWTH_ENABLED=false+
Use the "Flush EGS Cache" button above to clear these in one action.
+| When | +Topic | +Name | +Actions | +|
|---|---|---|---|---|
| {{ $i->created_at->toDayDateTimeString() }} | +{{ ucfirst($i->topic) }} | +{{ $i->name }} | +{{ $i->email }} | +View | +
| No submissions yet. | ||||
{{ $subtitle }}
+ @endif + + @if($slot->isNotEmpty()) +
+ {{ $application->payload['data']['steps'] }}
+ {{ $application->message }}{{ $artwork['title'] }}
+by {{ $artwork['author'] }}
++ {{ $message }} +
+ + {{-- Primary CTA --}} + @yield('primary-cta') + + {{-- Secondary CTAs --}} + @hasSection('secondary-ctas') +{{ $post['title'] }}
+ @if(!empty($post['excerpt'])) +{{ $post['excerpt'] }}
+ @endif + @if(!empty($post['published_at'])) +{{ $post['published_at'] }}
+ @endif + + @endforeach +{{ $creator['name'] }}
+{{ $creator['artworks_count'] }} uploads
+{{ $creator['name'] }}
+{{ $creator['artworks_count'] }} uploads
++ We use essential cookies to keep you logged in and protect your session. + With your permission we also load advertising cookies from third-party networks. + Learn more ↗ +
+