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 ( + <> +
+

Settings

+
+ + + +
+ + + Creator Studio + +
+ + ) +} + +export default function SettingsLayout({ children, title }) { + const { url } = usePage() + const [mobileOpen, setMobileOpen] = useState(false) + + const isActive = (href) => url.startsWith(href) + + return ( +
+ {/* Mobile top bar */} +
+

Settings

+ +
+ + {/* Mobile nav overlay */} + {mobileOpen && ( +
setMobileOpen(false)}> + +
+ )} + +
+ {/* Desktop sidebar */} + + + {/* Main content */} +
+ {title && ( +

{title}

+ )} + {children} +
+
+
+ ) +} diff --git a/resources/js/Pages/Home/HomeHero.jsx b/resources/js/Pages/Home/HomeHero.jsx index 498b79e7..f216a0b0 100644 --- a/resources/js/Pages/Home/HomeHero.jsx +++ b/resources/js/Pages/Home/HomeHero.jsx @@ -34,7 +34,7 @@ export default function HomeHero({ artwork, isLoggedIn }) { src={src} alt={artwork.title} className="absolute inset-0 h-full w-full object-cover transition-transform duration-700 group-hover:scale-[1.02]" - fetchpriority="high" + fetchPriority="high" decoding="async" onError={(e) => { e.currentTarget.src = FALLBACK }} /> diff --git a/resources/js/Pages/Settings/ProfileEdit.jsx b/resources/js/Pages/Settings/ProfileEdit.jsx new file mode 100644 index 00000000..c6c6a266 --- /dev/null +++ b/resources/js/Pages/Settings/ProfileEdit.jsx @@ -0,0 +1,727 @@ +import React, { useState, useRef, useCallback } from 'react' +import { usePage } from '@inertiajs/react' +import SettingsLayout from '../../Layouts/SettingsLayout' +import TextInput from '../../components/ui/TextInput' +import Textarea from '../../components/ui/Textarea' +import Button from '../../components/ui/Button' +import Toggle from '../../components/ui/Toggle' +import Select from '../../components/ui/Select' +import Modal from '../../components/ui/Modal' +import { RadioGroup } from '../../components/ui/Radio' + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +function getCsrfToken() { + return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '' +} + +const MONTHS = [ + { value: '1', label: 'January' }, { value: '2', label: 'February' }, + { value: '3', label: 'March' }, { value: '4', label: 'April' }, + { value: '5', label: 'May' }, { value: '6', label: 'June' }, + { value: '7', label: 'July' }, { value: '8', label: 'August' }, + { value: '9', label: 'September' }, { value: '10', label: 'October' }, + { value: '11', label: 'November' }, { value: '12', label: 'December' }, +] + +const GENDER_OPTIONS = [ + { value: 'm', label: 'Male' }, + { value: 'f', label: 'Female' }, + { value: 'x', label: 'Prefer not to say' }, +] + +function buildDayOptions() { + return Array.from({ length: 31 }, (_, i) => ({ value: String(i + 1), label: String(i + 1) })) +} + +function buildYearOptions() { + const currentYear = new Date().getFullYear() + const years = [] + for (let y = currentYear; y >= currentYear - 100; y--) { + years.push({ value: String(y), label: String(y) }) + } + return years +} + +// ─── Sub-components ────────────────────────────────────────────────────────── + +function Section({ children, className = '' }) { + return ( +
+ {children} +
+ ) +} + +function SectionTitle({ icon, children, description }) { + return ( +
+

+ {icon && } + {children} +

+ {description &&

{description}

} +
+ ) +} + +// ─── Main Component ────────────────────────────────────────────────────────── + +export default function ProfileEdit() { + const { props } = usePage() + const { + user, + avatarUrl: initialAvatarUrl, + birthDay: initDay, + birthMonth: initMonth, + birthYear: initYear, + countries = [], + flash = {}, + } = props + + // ── Profile State ────────────────────────────────────────────────────────── + const [name, setName] = useState(user?.name || '') + const [email, setEmail] = useState(user?.email || '') + const [username, setUsername] = useState(user?.username || '') + const [homepage, setHomepage] = useState(user?.homepage || user?.website || '') + const [about, setAbout] = useState(user?.about_me || user?.about || '') + const [signature, setSignature] = useState(user?.signature || '') + const [description, setDescription] = useState(user?.description || '') + const [day, setDay] = useState(initDay ? String(parseInt(initDay, 10)) : '') + const [month, setMonth] = useState(initMonth ? String(parseInt(initMonth, 10)) : '') + const [year, setYear] = useState(initYear ? String(initYear) : '') + const [gender, setGender] = useState(() => { + const g = (user?.gender || '').toLowerCase() + if (g === 'm') return 'm' + if (g === 'f') return 'f' + if (g === 'x' || g === 'n') return 'x' + return '' + }) + const [country, setCountry] = useState(user?.country_code || user?.country || '') + const [mailing, setMailing] = useState(!!user?.mlist) + const [notify, setNotify] = useState(!!user?.friend_upload_notice) + const [autoPost, setAutoPost] = useState(!!user?.auto_post_upload) + + // Avatar + const [avatarUrl, setAvatarUrl] = useState(initialAvatarUrl || '') + const [avatarFile, setAvatarFile] = useState(null) + const [avatarUploading, setAvatarUploading] = useState(false) + const avatarInputRef = useRef(null) + const [dragActive, setDragActive] = useState(false) + + // Save state + const [saving, setSaving] = useState(false) + const [profileErrors, setProfileErrors] = useState({}) + const [profileSaved, setProfileSaved] = useState(!!flash?.status) + + // Password state + const [currentPassword, setCurrentPassword] = useState('') + const [newPassword, setNewPassword] = useState('') + const [confirmPassword, setConfirmPassword] = useState('') + const [passwordSaving, setPasswordSaving] = useState(false) + const [passwordErrors, setPasswordErrors] = useState({}) + const [passwordSaved, setPasswordSaved] = useState(false) + + // Delete account + const [showDelete, setShowDelete] = useState(false) + const [deletePassword, setDeletePassword] = useState('') + const [deleting, setDeleting] = useState(false) + const [deleteError, setDeleteError] = useState('') + + // ── Country Options ───────────────────────────────────────────────────────── + const countryOptions = (countries || []).map((c) => ({ + value: c.country_code || c.code || c.id || '', + label: c.country_name || c.name || '', + })) + + // ── Avatar Handlers ──────────────────────────────────────────────────────── + const handleAvatarSelect = (file) => { + if (!file || !file.type.startsWith('image/')) return + setAvatarFile(file) + setAvatarUrl(URL.createObjectURL(file)) + } + + const handleAvatarUpload = useCallback(async () => { + if (!avatarFile) return + setAvatarUploading(true) + try { + const fd = new FormData() + fd.append('avatar', avatarFile) + const res = await fetch('/avatar/upload', { + method: 'POST', + headers: { Accept: 'application/json', 'X-CSRF-TOKEN': getCsrfToken() }, + credentials: 'same-origin', + body: fd, + }) + const data = await res.json() + if (res.ok && data.url) { + setAvatarUrl(data.url) + setAvatarFile(null) + } + } catch (err) { + console.error('Avatar upload failed:', err) + } finally { + setAvatarUploading(false) + } + }, [avatarFile]) + + const handleDrag = (e) => { + e.preventDefault() + e.stopPropagation() + } + + const handleDragIn = (e) => { + e.preventDefault() + e.stopPropagation() + setDragActive(true) + } + + const handleDragOut = (e) => { + e.preventDefault() + e.stopPropagation() + setDragActive(false) + } + + const handleDrop = (e) => { + e.preventDefault() + e.stopPropagation() + setDragActive(false) + const file = e.dataTransfer?.files?.[0] + if (file) handleAvatarSelect(file) + } + + // ── Profile Save ─────────────────────────────────────────────────────────── + const handleProfileSave = useCallback(async () => { + setSaving(true) + setProfileSaved(false) + setProfileErrors({}) + try { + const fd = new FormData() + fd.append('_method', 'PUT') + fd.append('email', email) + fd.append('username', username) + fd.append('name', name) + if (homepage) fd.append('web', homepage) + if (about) fd.append('about', about) + if (signature) fd.append('signature', signature) + if (description) fd.append('description', description) + if (day) fd.append('day', day) + if (month) fd.append('month', month) + if (year) fd.append('year', year) + if (gender) fd.append('gender', gender) + if (country) fd.append('country', country) + fd.append('mailing', mailing ? '1' : '0') + fd.append('notify', notify ? '1' : '0') + fd.append('auto_post_upload', autoPost ? '1' : '0') + if (avatarFile) fd.append('avatar', avatarFile) + + const res = await fetch('/profile', { + method: 'POST', + headers: { Accept: 'application/json', 'X-CSRF-TOKEN': getCsrfToken() }, + credentials: 'same-origin', + body: fd, + }) + + if (res.ok || res.status === 302) { + setProfileSaved(true) + setAvatarFile(null) + setTimeout(() => setProfileSaved(false), 4000) + } else { + const data = await res.json().catch(() => ({})) + if (data.errors) setProfileErrors(data.errors) + else if (data.message) setProfileErrors({ _general: [data.message] }) + } + } catch (err) { + console.error('Profile save failed:', err) + } finally { + setSaving(false) + } + }, [email, username, name, homepage, about, signature, description, day, month, year, gender, country, mailing, notify, autoPost, avatarFile]) + + // ── Password Change ──────────────────────────────────────────────────────── + const handlePasswordChange = useCallback(async () => { + setPasswordSaving(true) + setPasswordSaved(false) + setPasswordErrors({}) + try { + const res = await fetch('/profile/password', { + method: 'PUT', + headers: { 'Content-Type': 'application/json', Accept: 'application/json', 'X-CSRF-TOKEN': getCsrfToken() }, + credentials: 'same-origin', + body: JSON.stringify({ + current_password: currentPassword, + password: newPassword, + password_confirmation: confirmPassword, + }), + }) + + if (res.ok || res.status === 302) { + setPasswordSaved(true) + setCurrentPassword('') + setNewPassword('') + setConfirmPassword('') + setTimeout(() => setPasswordSaved(false), 4000) + } else { + const data = await res.json().catch(() => ({})) + if (data.errors) setPasswordErrors(data.errors) + else if (data.message) setPasswordErrors({ _general: [data.message] }) + } + } catch (err) { + console.error('Password change failed:', err) + } finally { + setPasswordSaving(false) + } + }, [currentPassword, newPassword, confirmPassword]) + + // ── Delete Account ───────────────────────────────────────────────────────── + const handleDeleteAccount = async () => { + setDeleting(true) + setDeleteError('') + try { + const res = await fetch('/profile', { + method: 'DELETE', + headers: { 'Content-Type': 'application/json', Accept: 'application/json', 'X-CSRF-TOKEN': getCsrfToken() }, + credentials: 'same-origin', + body: JSON.stringify({ password: deletePassword }), + }) + if (res.ok || res.status === 302) { + window.location.href = '/' + } else { + const data = await res.json().catch(() => ({})) + setDeleteError(data.errors?.password?.[0] || data.message || 'Deletion failed.') + } + } catch (err) { + setDeleteError('Request failed.') + } finally { + setDeleting(false) + } + } + + // ── Render ───────────────────────────────────────────────────────────────── + return ( + +
+ + {/* ── General Errors ── */} + {profileErrors._general && ( +
+ {profileErrors._general[0]} +
+ )} + + {/* ════════════════════════════════════════════════════════════════════ + AVATAR SECTION + ════════════════════════════════════════════════════════════════════ */} +
+ + Avatar + + +
+ {/* Preview */} +
+ {username + {avatarUploading && ( +
+
+
+ )} +
+ + {/* Dropzone */} +
+ + handleAvatarSelect(e.target.files?.[0])} + /> + {avatarFile && ( +
+ + +
+ )} +
+
+
+ + {/* ════════════════════════════════════════════════════════════════════ + ACCOUNT INFO + ════════════════════════════════════════════════════════════════════ */} +
+ + Account + + +
+ setUsername(e.target.value)} + error={profileErrors.username?.[0]} + hint={user?.username_changed_at ? `Last changed: ${new Date(user.username_changed_at).toLocaleDateString()}` : undefined} + /> + setEmail(e.target.value)} + error={profileErrors.email?.[0]} + required + /> + setName(e.target.value)} + placeholder="Your real name (optional)" + error={profileErrors.name?.[0]} + /> + setHomepage(e.target.value)} + placeholder="https://" + error={profileErrors.web?.[0] || profileErrors.homepage?.[0]} + /> +
+
+ + {/* ════════════════════════════════════════════════════════════════════ + ABOUT & BIO + ════════════════════════════════════════════════════════════════════ */} +
+ + About & Bio + + +
+ + @error('message')

{{ $message }}

@enderror +
+ + {{-- Bug-specific fields --}} +
+ + + @error('affected_url')

{{ $message }}

@enderror + + + + @error('steps')

{{ $message }}

@enderror +
+ + {{-- Honeypot field (hidden from real users) --}} + + +
+ +
+ + +

By submitting this form you consent to Skinbase storing your application details for review.

+
+@endsection diff --git a/resources/views/web/blog/index.blade.php b/resources/views/web/blog/index.blade.php new file mode 100644 index 00000000..597a5b20 --- /dev/null +++ b/resources/views/web/blog/index.blade.php @@ -0,0 +1,54 @@ +{{-- + Blog index — uses ContentLayout. +--}} +@extends('layouts.nova.content-layout') + +@php + $hero_title = 'Blog'; + $hero_description = 'News, tutorials and community stories from the Skinbase team.'; +@endphp + +@section('page-content') + +@if($posts->isNotEmpty()) + + +
+ {{ $posts->withQueryString()->links() }} +
+@else +
+ +

No blog posts published yet. Check back soon!

+
+@endif + +@endsection diff --git a/resources/views/web/blog/show.blade.php b/resources/views/web/blog/show.blade.php new file mode 100644 index 00000000..20302028 --- /dev/null +++ b/resources/views/web/blog/show.blade.php @@ -0,0 +1,60 @@ +{{-- + Blog post — uses ContentLayout. +--}} +@extends('layouts.nova.content-layout') + +@php + $hero_title = $post->title; +@endphp + +@push('head') + {{-- Article structured data --}} + +@endpush + +@section('page-content') + +
+ @if($post->featured_image) +
+ {{ $post->title }} +
+ @endif + + @if($post->published_at) + + @endif + +
+ {!! $post->body !!} +
+ + +
+ +@endsection diff --git a/resources/views/web/bug-report.blade.php b/resources/views/web/bug-report.blade.php new file mode 100644 index 00000000..9bd315e2 --- /dev/null +++ b/resources/views/web/bug-report.blade.php @@ -0,0 +1,75 @@ +@extends('layouts.nova.content-layout') + +@section('page-content') + +@if ($success) +
+ Your report was submitted successfully. Thank you — we'll look into it as soon as possible. +
+@endif + +
+ @guest +
+ + + +

You need to be signed in to submit a bug report.

+ + Sign In + +
+ @else +

+ Found a bug or have a suggestion? Fill out the form below and our team will review it. + For security issues, please contact us directly via email. +

+ +
+ @csrf + +
+ + + @error('subject') +

{{ $message }}

+ @enderror +
+ +
+ + + @error('description') +

{{ $message }}

+ @enderror +
+ +
+ +
+
+ @endguest +
+ +@endsection diff --git a/resources/views/web/creators/rising.blade.php b/resources/views/web/creators/rising.blade.php index 268ecdb4..6208a309 100644 --- a/resources/views/web/creators/rising.blade.php +++ b/resources/views/web/creators/rising.blade.php @@ -38,7 +38,7 @@ $profileUrl = ($creator->username ?? null) ? '/@' . $creator->username : '/profile/' . (int) $creator->user_id; - $avatarUrl = \App\Support\AvatarUrl::forUser((int) $creator->user_id, null, 40); + $avatarUrl = \App\Support\AvatarUrl::forUser((int) $creator->user_id, $creator->avatar_hash ?? null, 40); @endphp
@@ -62,7 +62,7 @@

{{ $creator->uname }}

@if($creator->username ?? null) -

@{{ $creator->username }}

+

{{ '@' . $creator->username }}

@endif
diff --git a/resources/views/web/explore/index.blade.php b/resources/views/web/explore/index.blade.php new file mode 100644 index 00000000..9eb2af8a --- /dev/null +++ b/resources/views/web/explore/index.blade.php @@ -0,0 +1,73 @@ +{{-- + Explore index — uses ExploreLayout. + Displays an artwork grid with hero header, mode tabs, pagination. +--}} +@extends('layouts.nova.explore-layout') + +@section('explore-grid') + +{{-- ══ EGS §11: FEATURED SPOTLIGHT ROW ══ --}} +@if(!empty($spotlight) && $spotlight->isNotEmpty()) + +@endif + +@php + $galleryArtworks = collect($artworks->items())->map(fn ($art) => [ + 'id' => $art->id, + 'name' => $art->name ?? null, + 'thumb' => $art->thumb_url ?? $art->thumb ?? null, + 'thumb_srcset' => $art->thumb_srcset ?? null, + 'uname' => $art->uname ?? '', + 'username' => $art->username ?? $art->uname ?? '', + 'avatar_url' => $art->avatar_url ?? null, + 'category_name' => $art->category_name ?? '', + 'category_slug' => $art->category_slug ?? '', + 'slug' => $art->slug ?? '', + 'width' => $art->width ?? null, + 'height' => $art->height ?? null, + ])->values(); +@endphp + +
+ +@endsection diff --git a/resources/views/web/faq.blade.php b/resources/views/web/faq.blade.php new file mode 100644 index 00000000..2e25fd0b --- /dev/null +++ b/resources/views/web/faq.blade.php @@ -0,0 +1,332 @@ +@extends('layouts.nova.content-layout') + +@section('page-content') + +
+ + {{-- Intro --}} +
+

Last updated: March 1, 2026

+

+ Answers to the questions we hear most often. If something isn't covered here, feel free to reach out to + any staff member — we're happy to help. +

+
+ + {{-- Table of Contents --}} + + + {{-- 01 About Skinbase --}} +
+

+ 01 + About Skinbase +

+
+
+
What is Skinbase?
+
+ Skinbase is a community gallery for desktop customisation — skins, themes, wallpapers, icons, and + more. Members upload their own creations, collect favourites, leave feedback, and discuss all things + design in the forums. We've been online since 2001 and still going strong. +
+
+
+
Is Skinbase free to use?
+
+ Yes, completely. Browsing and downloading are free without an account. Registering (also free) + unlocks uploading, commenting, favourites, collections, and messaging. +
+
+
+
Who runs Skinbase?
+
+ Skinbase is maintained by a small volunteer staff team. + Staff moderate uploads, help members, and keep the lights on. There is no corporate ownership — + this is a community project. +
+
+
+
How can I support Skinbase?
+
+ The best support is participation — upload your work, leave constructive comments, report problems, + and invite other creators. You can also help by flagging rule-breaking content so staff can review it quickly. +
+
+
+
+ + {{-- 02 Uploading & Submissions --}} +
+

+ 02 + Uploading & Submissions +

+
+
+
What file types are accepted?
+
+ Skins and resources are generally uploaded as .zip archives. + Preview images are accepted as JPG, PNG, or WebP. Wallpapers may be uploaded directly as image files. + Check the upload form for the exact size and type limits per category. +
+
+
+
Is there a file size limit?
+
+ Yes. The current limit is displayed on the upload page. If your file exceeds the limit, try removing + any unnecessary assets from the archive before re-uploading. +
+
+
+
Why was my upload removed?
+
+ Uploads are removed when they break the Rules & Guidelines — + most commonly for containing photographs you don't own (photoskins), missing preview images, or + violating copyright. You will usually receive a message explaining the reason. + If you believe a removal was in error, contact a staff member. +
+
+
+
Can I upload work-in-progress skins?
+
+ You may share works-in-progress in the forums for feedback. The main gallery is intended for + finished, download-ready submissions only. +
+
+
+
+ + {{-- 03 Copyright & Photoskins --}} + + + {{-- 04 Skinning Help --}} +
+

+ 04 + Skinning Help +

+
+
+
How do I make a skin?
+
+ Every application is different, but skins generally consist of a folder of images and small + text/config files. A good starting point is to unpack an existing skin (many Winamp skins are + simply renamed .zip files), study the structure, then replace the + images with your own artwork. Check the application's official documentation for its exact format. +
+
+
+
How do I apply a Windows theme?
+
+ To change the visual style of Windows you typically need a third-party tool such as + WindowBlinds, SecureUxTheme, + or a patched uxtheme.dll. Install your chosen tool, download a + compatible theme from Skinbase, then follow the tool's instructions to apply it. +
+
+
+
Where can I get help with a specific application?
+
+ The forums are the best place — there are dedicated sections for popular skinnable applications. + You can also check the comments on popular skins for tips from other members. +
+
+
+
What image editing software do skinners use?
+
+ The community uses a wide range of tools. Popular choices include + Adobe Photoshop, GIMP (free), + Affinity Designer, Figma, + and Krita (free). The best tool is the one you're comfortable with. +
+
+
+
+ + {{-- 05 Account & Profile --}} +
+

+ 05 + Account & Profile +

+
+
+
How do I set a profile picture?
+
+ Go to Settings → Avatar, choose an image from your device, and + save. Your avatar appears on your profile page and next to all your comments. +
+
+
+
Can I change my username?
+
+ Username changes are handled by staff. Send a message to a + staff member with your requested new name. + We reserve the right to decline requests that are inappropriate or conflict with an existing account. +
+
+
+
How do I delete my account?
+
+ Account deletion requests must be sent to staff. Please be aware that your publicly submitted + artwork may remain in the gallery under your username unless you also request removal of specific + uploads. See our Privacy Policy + for details on data retention. +
+
+
+
I forgot my password. How do I reset it?
+
+ Use the Forgot password? link on the login page. An email with + a reset link will be sent to the address on your account. If you no longer have access to that + email, contact a staff member for assistance. +
+
+
+
+ + {{-- 06 Community & Forums --}} +
+

+ 06 + Community & Forums +

+
+
+
Do I need an account to use the forums?
+
+ Guests can read most forum threads without an account. Posting, replying, and creating new topics + require a registered account. +
+
+
+
Can I promote my own work in the forums?
+
+ Yes — there are dedicated showcase and feedback sections. Limit self-promotion to those areas and + avoid spamming multiple threads with the same content. +
+
+
+
How do I report a bad comment or forum post?
+
+ Every comment and post has a Report link. Use it to flag content that breaks the rules; staff will + review it promptly. For urgent issues, message a + staff member directly. +
+
+
+
What are the messaging rules?
+
+ Private messaging is for genuine one-to-one communication. Do not use it to harass, solicit, or + send unsolicited promotional material. Violations can result in messaging being disabled on your account. +
+
+
+
+ + {{-- 07 Policies & Conduct --}} +
+

+ 07 + Policies & Conduct +

+
+
+
Are there many rules to follow?
+
+ We keep the rules straightforward: respect everyone, only upload work you own or have permission to + share, and keep it drama-free. The full list is in our + Rules & Guidelines. +
+
+
+
What happens if I break the rules?
+
+ Depending on severity, staff may issue a warning, remove the offending content, temporarily + restrict your account, or permanently ban you. Serious offences (harassment, illegal content) + result in an immediate permanent ban with no prior warning. +
+
+
+
How do I appeal a ban or removed upload?
+
+ Contact a senior staff member and explain the situation calmly. Provide any supporting evidence. + Staff decisions can be reversed when new information comes to light, but appeals submitted + aggressively or repeatedly will not be reconsidered. +
+
+
+
I still can't find what I need. What now?
+
+ Send a private message to any staff member + or post in the Help section of the forums. Someone from the community will usually respond + quickly. +
+
+
+
+ + {{-- Footer note --}} +
+ This FAQ is reviewed periodically. For legal matters such as copyright, data, or account deletion, + please refer to our Privacy Policy + and Rules & Guidelines, + or contact the staff team directly. +
+ +
+ +@endsection diff --git a/resources/views/web/pages/show.blade.php b/resources/views/web/pages/show.blade.php new file mode 100644 index 00000000..70e47f32 --- /dev/null +++ b/resources/views/web/pages/show.blade.php @@ -0,0 +1,18 @@ +{{-- + Static page — uses ContentLayout. +--}} +@extends('layouts.nova.content-layout') + +@php + $hero_title = $page->title; +@endphp + +@section('page-content') + +
+
+ {!! $page->body !!} +
+
+ +@endsection diff --git a/resources/views/web/privacy-policy.blade.php b/resources/views/web/privacy-policy.blade.php new file mode 100644 index 00000000..fb517d39 --- /dev/null +++ b/resources/views/web/privacy-policy.blade.php @@ -0,0 +1,392 @@ +@extends('layouts.nova.content-layout') + +@section('page-content') + +{{-- Table of contents --}} +
+

Last updated:

+

+ This Privacy Policy explains how Skinbase ("we", "us", "our") collects, uses, stores, and protects + information about you when you use our website at skinbase.org. + By using Skinbase you agree to the practices described in this policy. +

+ + {{-- TOC --}} + + + {{-- Sections --}} +
+ + {{-- 1 --}} +
+

+ 01 + Information We Collect +

+

+ We collect information in two ways: information you give us directly, and information + collected automatically as you use the site. +

+

Information you provide

+
    +
  • Account registration — username, email address, and password (stored as a secure hash).
  • +
  • Profile information — display name, avatar, bio, website URL, and location if you choose to provide them.
  • +
  • Uploaded content — artworks, wallpapers, skins, and photographs, along with their titles, descriptions, and tags.
  • +
  • Communications — messages sent through features such as private messaging, forum posts, comments, and bug reports.
  • +
+

Information collected automatically

+
    +
  • Log data — IP address, browser type and version, operating system, referring URL, pages visited, and timestamps.
  • +
  • Usage data — download counts, favourite actions, search queries, and interaction events used to improve recommendations.
  • +
  • Cookies & local storage — see Section 3 for full details.
  • +
+
+ + {{-- 2 --}} +
+

+ 02 + How We Use Your Information +

+

We use the information we collect to:

+
    +
  • Provide, operate, and maintain the Skinbase service.
  • +
  • Authenticate your identity and keep your account secure.
  • +
  • Personalise your experience, including content recommendations.
  • +
  • Send transactional emails (password resets, email verification, notifications you subscribe to).
  • +
  • Moderate content and enforce our Rules & Guidelines.
  • +
  • Analyse usage patterns to improve site performance and features.
  • +
  • Detect, prevent, and investigate fraud, abuse, or security incidents.
  • +
  • Comply with legal obligations.
  • +
+

+ We will never sell your personal data or use it for purposes materially different from those + stated above without first obtaining your explicit consent. +

+ + {{-- Lawful basis table (GDPR Art. 13(1)(c)) --}} +

Lawful basis for processing (GDPR Art. 6)

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Processing activityLawful basis
Account registration & authenticationArt. 6(1)(b) — Performance of contract
Delivering and operating the ServiceArt. 6(1)(b) — Performance of contract
Transactional emails (password reset, verification)Art. 6(1)(b) — Performance of contract
Security, fraud prevention, abuse detectionArt. 6(1)(f) — Legitimate interests
Analytics & site-performance monitoringArt. 6(1)(f) — Legitimate interests
Essential cookies (session, CSRF, remember-me)Art. 6(1)(f) — Legitimate interests
Third-party advertising cookiesArt. 6(1)(a) — Consent (via cookie banner)
Compliance with legal obligationsArt. 6(1)(c) — Legal obligation
+
+
+ + {{-- 3 --}} +
+

+ 03 + Cookies & Tracking +

+

+ Skinbase uses cookies — small text files stored in your browser — to deliver a reliable, + personalised experience. No cookies are linked to sensitive personal data. +

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
CookiePurposeDuration
skinbase_sessionAuthentication session identifierBrowser session
XSRF-TOKENCross-site request forgery protectionBrowser session
remember_web_*"Remember me" persistent login30 days
__gads, ar_debug,
DSID, IDE, NID
Google AdSense — interest-based ad targeting & frequency capping. Only loaded after you accept cookies. (See Section 9)Up to 13 months
+
+

+ You can disable cookies in your browser settings. Doing so may prevent some features + (such as staying logged in) from working correctly. +

+
+ + {{-- 4 --}} +
+

+ 04 + Sharing of Information +

+

+ We do not sell or rent your personal data. We may share information only in the following + limited circumstances: +

+
    +
  • Legal requirements — if required by law, court order, or governmental authority.
  • +
  • Protection of rights — to enforce our policies, prevent fraud, or protect the safety of our users or the public.
  • +
  • Service providers — trusted third-party vendors (e.g. hosting, email delivery, analytics) who are contractually bound to handle data only as instructed by us.
  • +
  • Business transfers — in the event of a merger, acquisition, or sale of assets, you will be notified via email and/or a prominent notice on the site.
  • +
+
+ + {{-- 5 --}} +
+

+ 05 + User-Generated Content +

+

+ Artworks, comments, forum posts, and other content you upload or publish on Skinbase are + publicly visible. Do not include personal information (phone numbers, home addresses, etc.) + in public content. You retain ownership of your original work; by uploading you grant + Skinbase a non-exclusive licence to display and distribute it as part of the service. + You may delete your own content at any time from your dashboard. +

+

+ Content found to infringe copyright or violate our rules will be removed. + To report a submission, please contact a staff member. +

+
+ + {{-- 6 --}} +
+

+ 06 + Data Retention +

+

+ We retain your account data for as long as your account is active. If you delete your + account, we will remove or anonymise your personal data within 30 days, + except where we are required to retain it for legal or fraud-prevention purposes. + Anonymised aggregate statistics (e.g. download counts) may be retained indefinitely. + Server log files containing IP addresses are rotated and deleted after 90 days. +

+
+ + {{-- 7 --}} +
+

+ 07 + Security +

+

+ We implement industry-standard measures to protect your information, including: +

+
    +
  • HTTPS (TLS) encryption for all data in transit.
  • +
  • Bcrypt hashing for all stored passwords — we never store passwords in plain text.
  • +
  • CSRF protection on all state-changing requests.
  • +
  • Rate limiting and account lockouts to resist brute-force attacks.
  • +
+

+ No method of transmission over the Internet is 100% secure. If you believe your account + has been compromised, please contact us immediately. +

+
+ + {{-- 8 --}} +
+

+ 08 + Your Rights +

+

+ Depending on where you live, you may have certain rights over your personal data: +

+
+ @foreach ([ + ['Access', 'Request a copy of the personal data we hold about you.'], + ['Rectification', 'Correct inaccurate or incomplete data via your account settings.'], + ['Erasure', 'Request deletion of your account and associated personal data.'], + ['Portability', 'Receive your data in a structured, machine-readable format.'], + ['Restriction', 'Ask us to limit how we process your data in certain circumstances.'], + ['Objection', 'Object to processing based on legitimate interests or for direct marketing.'], + ] as [$right, $desc]) +
+

{{ $right }}

+

{{ $desc }}

+
+ @endforeach +
+

+ To exercise any of these rights, please contact us. + We will respond within 30 days. You also have the right to lodge a complaint with your + local data protection authority. +

+
+ + {{-- 9 --}} +
+

+ 09 + Advertising +

+

+ Skinbase uses Google AdSense (operated by Google LLC, + 1600 Amphitheatre Parkway, Mountain View, CA 94043, USA) to display advertisements. Google AdSense + may use cookies and web beacons to collect information about your browsing activity in order to + serve interest-based (personalised) ads. +

+

+ Consent required. Google AdSense cookies are only loaded + after you click Accept all in the cookie consent banner. If you choose + Essential only, no advertising cookies will be placed. + You can withdraw consent at any time by clicking Cookie Preferences + in the footer. +

+

+ Data collected by Google AdSense (such as browser type, pages visited, and ad interactions) is + processed by Google under + Google's Privacy Policy. + Skinbase does not share any personally identifiable information with Google AdSense beyond what is + automatically collected through the ad script. +

+

+ Google's use of advertising cookies can be managed at + google.com/settings/ads, + or you may opt out of personalised advertising through the + Digital Advertising Alliance opt-out. +

+

+ Registered members may see reduced advertising frequency depending on their account status. +

+
+ + {{-- 10 --}} + + + {{-- 11 --}} +
+

+ 11 + Children's Privacy +

+

+ Skinbase is a general-audience website. In compliance with the Children's Online Privacy + Protection Act (COPPA) we do not knowingly collect personal information from children + under the age of 13. If we become aware that a + child under 13 has registered, we will promptly delete their account and data. + If you believe a child has provided us with personal information, please + contact us. +

+
+ + {{-- 12 --}} +
+

+ 12 + Changes to This Policy +

+

+ We may update this Privacy Policy from time to time. When we do, we will revise the + "Last updated" date at the top of this page. For material changes we will notify + registered members by email and/or by a prominent notice on the site. We encourage you + to review this policy periodically. Continued use of Skinbase after changes are posted + constitutes your acceptance of the revised policy. +

+
+ + {{-- 13 --}} +
+

+ 13 + Contact Us +

+

+ If you have any questions, concerns, or requests regarding this Privacy Policy or our + data practices, please reach out via our + contact form or by + sending a private message to any staff member. + We aim to respond to all privacy-related enquiries within 10 business days. +

+ +
+

Data Controller

+

+ Skinbase.org — operated by the Skinbase team.
+ Contact: via contact form +

+
+
+ +
+
+ +@endsection diff --git a/resources/views/web/rss-feeds.blade.php b/resources/views/web/rss-feeds.blade.php new file mode 100644 index 00000000..c1d24652 --- /dev/null +++ b/resources/views/web/rss-feeds.blade.php @@ -0,0 +1,63 @@ +@extends('layouts.nova.content-layout') + +@section('page-content') + +
+ + {{-- Feed list --}} +
+

Available Feeds

+
    + @foreach ($feeds as $key => $feed) +
  • + + + +
    +

    {{ $feed['title'] }}

    +

    {{ url($feed['url']) }}

    +
    + + Subscribe + +
  • + @endforeach +
+
+ + {{-- About RSS --}} +
+

About RSS

+

+ RSS is a family of web feed formats used to publish frequently updated digital content, + such as blogs, news feeds, or upload streams. By subscribing to an RSS feed you can + follow Skinbase updates in your favourite feed reader without needing to visit the site. +

+

How to subscribe

+

+ Copy one of the feed URLs above and paste it into your feed reader (e.g. Feedly, Inoreader, + or any app that supports RSS 2.0). The reader will automatically check for new content and + notify you of updates. +

+

Feed formats

+
    +
  • Really Simple Syndication (RSS 2.0)
  • +
  • Rich Site Summary (RSS 0.91, RSS 1.0)
  • +
  • RDF Site Summary (RSS 0.9 and 1.0)
  • +
+

+ RSS delivers its information as an XML file. Our feeds include title, description, + author, publication date, and a media thumbnail for each item. +

+
+ +
+ +@push('head') + @foreach ($feeds as $key => $feed) + + @endforeach +@endpush + +@endsection diff --git a/resources/views/web/rules.blade.php b/resources/views/web/rules.blade.php new file mode 100644 index 00000000..f3e9fa3c --- /dev/null +++ b/resources/views/web/rules.blade.php @@ -0,0 +1,251 @@ +@extends('layouts.nova.content-layout') + +@section('page-content') + +
+ +

Last updated:

+

+ Skinbase is a creative community built on respect and trust. These rules apply to all members + and all content. By registering or uploading you agree to follow them. They are intentionally + kept minimal so that the most important ones are easy to remember — be respectful, + upload only what you own, and have fun. +

+ + {{-- TOC --}} + + +
+ + {{-- 1 --}} +
+

+ 01 + Community Conduct +

+

+ Skinbase is a friendly, general-audience community. Treat every member and guest as you + would wish to be treated yourself. +

+
    +
  • Be respectful in comments, messages, forum posts, and all other interactions.
  • +
  • Constructive criticism is welcome; personal attacks, harassment, or bullying are not.
  • +
  • No hate speech, discrimination, or content targeting individuals based on race, ethnicity, religion, gender, sexual orientation, disability, or nationality.
  • +
  • No spam — this includes repetitive comments, self-promotion outside designated areas, and unsolicited advertising in private messages.
  • +
  • Keep drama off the site. Disputes should be resolved respectfully or escalated to a staff member.
  • +
+
+ + {{-- 2 --}} +
+

+ 02 + Ownership & Copyright +

+

+ You retain ownership of everything you create and upload. By submitting, you confirm that: +

+
    +
  • The work is entirely your own creation, or you have explicit written permission from the original author for any third-party assets used.
  • +
  • If third-party assets are included, proof of permission must be included in the zip file.
  • +
  • The submission does not violate any trademark, copyright, or other intellectual property right.
  • +
+

+ Uploads found to infringe copyright will be removed. Repeat infringers will have their + accounts terminated. To report a suspected infringement, use our + contact form. +

+
+ + {{-- 3 --}} +
+

+ 03 + Licence to Skinbase +

+

+ By uploading your work you grant Skinbase a non-exclusive, royalty-free licence + to display, distribute, and promote your content as part of the service — for example, + displaying it in galleries, featuring it on the homepage, or including it in promotional + material for Skinbase. This licence exists only to allow the site to function and does + not transfer ownership. +

+

+ The site is free — you don't pay to store your work, and we don't charge others to + download it. You may delete your uploads at any time from your dashboard. +

+
+ + {{-- 4 --}} +
+

+ 04 + Submission Quality +

+

+ Every submission represents the Skinbase community. Please put care into what you publish: +

+
    +
  • Test before uploading. Incomplete or broken zip files will be removed.
  • +
  • Full-size screenshots only. Our server auto-generates thumbnails — do not pre-scale your preview image.
  • +
  • Accurate categorisation. Choose the correct content type and category to help others find your work.
  • +
  • Meaningful title & description. Titles like "skin1" or "untitled" are discouraged; a short description helps your work get discovered.
  • +
  • Appropriate tags. Add relevant tags, but do not keyword-stuff.
  • +
+
+ + {{-- 5 --}} +
+

+ 05 + Prohibited Content +

+

+ The following will be removed immediately and may result in account suspension or permanent termination: +

+
+ @foreach ([ + ['Pornography & explicit nudity', 'Frontal nudity is not accepted. Exceptional artistic work with incidental nudity may be considered on a case-by-case basis.'], + ['Hate & discriminatory content', 'Content that demeans or attacks people based on protected characteristics.'], + ['Violence & gore', 'Graphic depictions of real-world violence or gratuitous gore.'], + ['Malware & harmful files', 'Any executable or zip that contains malware, spyware, or harmful scripts.'], + ['Personal information', 'Posting another person\'s private data (doxxing) without consent.'], + ['Illegal content', 'Anything that violates applicable law, including DMCA violations.'], + ] as [$title, $desc]) +
+

{!! $title !!}

+

{{ $desc }}

+
+ @endforeach +
+
+ + {{-- 6 --}} +
+

+ 06 + Ripping (Copyright Theft) +

+

+ "Ripping" means uploading another artist's work — in whole or in part — without their + explicit permission. This includes extracting assets from commercial software, games, or + other skins and re-releasing them as your own. Ripped submissions will be removed and the + uploader's account will be reviewed. Repeat offenders will be permanently banned. +

+
+

Photo-based skins

+

+ Using photographs found on the internet without the photographer's consent constitutes + copyright infringement, even if the skin itself is original artwork. + Only use photos you took yourself, or images with a licence that explicitly permits + use in derivative works (e.g. CC0 or a compatible Creative Commons licence). +

+
+
+ + {{-- 7 --}} +
+

+ 07 + Accounts & Identity +

+
    +
  • One account per person. Duplicate accounts created to evade a suspension or ban will be terminated.
  • +
  • Do not impersonate staff, other members, or real individuals.
  • +
  • Keep your contact email address up to date — it is used for important account notifications.
  • +
  • You are responsible for all activity that occurs under your account. Keep your password secure.
  • +
  • Accounts that have been inactive for more than 3 years and contain no uploads may be reclaimed for the username pool.
  • +
+
+ + {{-- 8 --}} +
+

+ 08 + Moderation & Enforcement +

+

+ Skinbase staff may take any of + the following actions in response to rule violations: +

+
+ @foreach ([ + ['Warning', 'A private message from staff explaining the violation.'], + ['Content removal', 'Removal of the offending upload, comment, or post.'], + ['Temporary suspension', 'Account access restricted for a defined period.'], + ['Permanent ban', 'Account terminated for severe or repeated violations.'], + ['IP block', 'Used in cases of persistent abuse or ban evasion.'], + ['Legal referral', 'For serious illegal activity, authorities may be notified.'], + ] as [$action, $desc]) +
+

{{ $action }}

+

{{ $desc }}

+
+ @endforeach +
+

+ Skinbase reserves the right to remove any content or terminate any account at any time, + with or without prior notice, at staff discretion. +

+
+ + {{-- 9 --}} +
+

+ 09 + Appeals +

+

+ If you believe a moderation action was made in error, you may appeal by contacting a + senior staff member via the contact form + or by sending a private message to an admin. + Please include your username, the content or account involved, and a clear explanation + of why you believe the decision was incorrect. We aim to review all appeals within + 5 business days. +

+
+ + {{-- 10 --}} +
+

+ 10 + Liability +

+

+ Skinbase is provided "as is". We make no warranties regarding uptime, data integrity, + or fitness for a particular purpose. We are not responsible for user-generated content — + when you upload something, the legal and moral responsibility lies with you. We will + act promptly on valid takedown requests and reports of illegal content, but we cannot + pre-screen every submission. +

+
+ +
+ + {{-- Footer note --}} +
+

+ Questions about these rules? Send a message to any + staff member + or use our contact form. + We're here to help — not to catch you out. +

+
+ +
+ +@endsection diff --git a/resources/views/web/staff.blade.php b/resources/views/web/staff.blade.php new file mode 100644 index 00000000..78c5a5dd --- /dev/null +++ b/resources/views/web/staff.blade.php @@ -0,0 +1,83 @@ +@extends('layouts.nova.content-layout') + +@section('page-content') + +
+

Last updated:

+

+ Our volunteer staff help keep Skinbase running — from moderation and technical maintenance to community support. + If you need assistance, reach out to any team member listed below or use the contact form. +

+
+ +@if ($staffByRole->isEmpty()) +
+ + + +

We're building our team. Check back soon!

+
+@else +
+ @foreach ($roleLabels as $roleSlug => $roleLabel) + @if ($staffByRole->has($roleSlug)) +
+

+ {{ $roleLabel }} +

+ +
+ @foreach ($staffByRole[$roleSlug] as $member) + @php + $avatarUrl = $member->profile?->avatar_url; + $profileUrl = '/@' . $member->username; + @endphp +
+ {{-- Avatar --}} + + @if ($avatarUrl) + {{ $member->username }} + @else +
+ + {{ substr($member->username, 0, 1) }} + +
+ @endif +
+ + {{-- Info --}} +
+ + {{ $member->username }} + + @if ($member->name && $member->name !== $member->username) +

{{ $member->name }}

+ @endif + + {{ ucfirst($roleSlug) }} + + @if ($member->profile?->bio) +

{{ $member->profile->bio }}

+ @endif +
+
+ @endforeach +
+
+ @endif + @endforeach +
+@endif + + {{-- Footer note: contact staff --}} +
+ Need help? Start with the Contact / Apply form or send a private message to any staff member. +
+ +@endsection diff --git a/resources/views/web/tags/index.blade.php b/resources/views/web/tags/index.blade.php index aa066bfd..169fc7a9 100644 --- a/resources/views/web/tags/index.blade.php +++ b/resources/views/web/tags/index.blade.php @@ -1,19 +1,15 @@ -@extends('layouts.nova') +@extends('layouts.nova.content-layout') -@section('content') +@php + $hero_title = 'Tags'; + $hero_description = 'Browse all artwork tags on Skinbase.'; + $breadcrumbs = $breadcrumbs ?? collect([ + (object) ['name' => 'Explore', 'url' => '/explore'], + (object) ['name' => 'Tags', 'url' => '/tags'], + ]); +@endphp -
-
-

Browse

-

- - Tags -

-

Browse all artwork tags on Skinbase.

-
-
- -
+@section('page-content') @if($tags->isNotEmpty())
@foreach($tags as $tag) @@ -35,6 +31,4 @@

No tags found.

@endif -
- @endsection diff --git a/resources/views/web/terms-of-service.blade.php b/resources/views/web/terms-of-service.blade.php new file mode 100644 index 00000000..fface1b2 --- /dev/null +++ b/resources/views/web/terms-of-service.blade.php @@ -0,0 +1,380 @@ +@extends('layouts.nova.content-layout') + +@section('page-content') + +
+ + {{-- Intro --}} +
+

Last updated: March 1, 2026

+

+ These Terms of Service ("Terms") govern your access to and use of Skinbase ("we", "us", "our", + "the Service") at skinbase.org. By creating an account or + using the Service in any way, you agree to be bound by these Terms. If you do not agree, do not + use Skinbase. +

+
+ + {{-- Table of Contents --}} + + + {{-- 01 --}} +
+

+ 01 + Acceptance of Terms +

+

+ By accessing or using Skinbase — whether by browsing the site, registering an account, uploading + content, or any other interaction — you confirm that you have read, understood, and agree to + these Terms and our Privacy Policy, + which is incorporated into these Terms by reference. If you are using Skinbase on behalf of an + organisation, you represent that you have authority to bind that organisation to these Terms. +

+
+ + {{-- 02 --}} +
+

+ 02 + The Service +

+

+ Skinbase is a community platform for sharing and discovering desktop customisation artwork — + including skins, themes, wallpapers, icons, and related resources. The Service includes the + website, galleries, forums, messaging, comments, and any other features we provide. +

+

+ We reserve the right to modify, suspend, or discontinue any part of the Service at any time + with or without notice. We will not be liable to you or any third party for any modification, + suspension, or discontinuation of the Service. +

+
+ + {{-- 03 --}} +
+

+ 03 + Accounts & Eligibility +

+
+

+ Age. You must be at least 13 years old + to create a Skinbase account. If you are under 18, you represent that you have your parent's or + guardian's permission to use the Service. +

+

+ Accurate information. You agree to provide accurate, current, and + complete information when registering and to keep your account information up to date. +

+

+ Account security. You are responsible for maintaining the confidentiality + of your password and for all activity that occurs under your account. Notify us immediately at + skinbase.org/bug-report if you believe + your account has been compromised. +

+

+ One account per person. You may not create multiple accounts to + circumvent bans or restrictions, or to misrepresent your identity to other users. +

+

+ Account transfer. Accounts are personal and non-transferable. + You may not sell, trade, or give away your account. +

+
+
+ + {{-- 04 --}} +
+

+ 04 + Your Content & Licence Grant +

+
+

+ Ownership. You retain ownership of any original artwork, + skins, themes, or other creative works ("Your Content") that you upload to Skinbase. These Terms + do not transfer any intellectual property rights to us. +

+

+ Licence to Skinbase. By uploading or publishing Your Content + on Skinbase, you grant us a worldwide, non-exclusive, royalty-free, sublicensable licence to + host, store, reproduce, display, distribute, and make Your Content available as part of the + Service, including in thumbnails, feeds, promotional materials, and search results. This licence + exists only for as long as Your Content remains on the Service. +

+

+ Licence to other users. Unless you specify otherwise in your + upload description, Your Content may be downloaded and used by other users for personal, + non-commercial use. You are responsible for clearly communicating any additional licence terms + or restrictions within your upload. +

+

+ Representations. By submitting Your Content you represent and + warrant that: (a) you own or have all necessary rights to the content; (b) the content does not + infringe any third-party intellectual property, privacy, or publicity rights; and (c) the content + complies with these Terms and our + Rules & Guidelines. +

+

+ Removal. You may delete Your Content from your account at any + time via your dashboard. Upon deletion, the content will be removed from public view within a + reasonable time, though cached copies may persist briefly. +

+
+
+ + {{-- 05 --}} +
+

+ 05 + Prohibited Conduct +

+

+ You agree not to use the Service to: +

+
    +
  • Upload content that infringes any copyright, trademark, patent, trade secret, or other proprietary right.
  • +
  • Upload photographs or photoskins using images you do not own or have permission to use (see our Rules & Guidelines).
  • +
  • Harass, threaten, bully, stalk, or intimidate any person.
  • +
  • Post content that is defamatory, obscene, pornographic, hateful, or promotes violence or illegal activity.
  • +
  • Impersonate any person or entity, or falsely claim affiliation with any person, entity, or Skinbase staff.
  • +
  • Distribute spam, chain letters, unsolicited commercial messages, or phishing content.
  • +
  • Attempt to gain unauthorised access to any part of the Service, other accounts, or our systems.
  • +
  • Use automated tools (bots, scrapers, crawlers) to access the Service without prior written permission.
  • +
  • Interfere with or disrupt the integrity or performance of the Service or the data contained therein.
  • +
  • Collect or harvest personal information about other users without their consent.
  • +
  • Use the Service for any unlawful purpose or in violation of applicable laws or regulations.
  • +
+

+ Violations may result in content removal, account suspension, or a permanent ban. Serious violations + may be reported to relevant authorities. +

+
+ + {{-- 06 --}} + + + {{-- 07 --}} +
+

+ 07 + Skinbase Intellectual Property +

+

+ The Skinbase name, logo, website design, software, and all Skinbase-produced content are owned by + Skinbase and are protected by copyright, trademark, and other intellectual property laws. Nothing in + these Terms grants you any right to use the Skinbase name, logo, or branding without our prior written + consent. You may not copy, modify, distribute, sell, or lease any part of our Service or included + software, nor may you reverse-engineer or attempt to extract the source code of the Service. +

+
+ + {{-- 08 --}} +
+

+ 08 + Disclaimers +

+
+

+ THE SERVICE IS PROVIDED ON AN "AS IS" AND "AS AVAILABLE" BASIS WITHOUT WARRANTIES OF ANY KIND, + EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO WARRANTIES OF MERCHANTABILITY, FITNESS + FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND UNINTERRUPTED OR ERROR-FREE OPERATION. +

+
+
+

+ We do not warrant that the Service will be uninterrupted, secure, or free of errors, viruses, + or other harmful components. We do not endorse any user-submitted content and are not responsible + for its accuracy, legality, or appropriateness. +

+

+ Downloaded files are provided by third-party users. You download and install any content at your own + risk. Always scan downloaded files with up-to-date antivirus software. +

+
+
+ + {{-- 09 --}} +
+

+ 09 + Limitation of Liability +

+
+

+ TO THE MAXIMUM EXTENT PERMITTED BY APPLICABLE LAW, SKINBASE AND ITS OPERATORS, STAFF, AND + CONTRIBUTORS SHALL NOT BE LIABLE FOR ANY INDIRECT, INCIDENTAL, SPECIAL, CONSEQUENTIAL, OR + PUNITIVE DAMAGES — INCLUDING BUT NOT LIMITED TO LOSS OF DATA, LOSS OF PROFITS, OR LOSS OF + GOODWILL — ARISING OUT OF OR IN CONNECTION WITH THESE TERMS OR YOUR USE OF THE SERVICE, + EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. +

+
+

+ Our total liability to you for any claim arising out of or relating to these Terms or the Service + shall not exceed the greater of (a) the amount you paid us in the twelve months prior to the claim, + or (b) USD $50. Some jurisdictions do not allow limitations on implied warranties or exclusion of + incidental/consequential damages, so the above limitations may not apply to you. +

+
+ + {{-- 10 --}} +
+

+ 10 + Indemnification +

+

+ You agree to indemnify, defend, and hold harmless Skinbase, its operators, staff, and contributors + from and against any claims, liabilities, damages, losses, and expenses (including reasonable legal + fees) arising out of or in any way connected with: (a) your access to or use of the Service; + (b) Your Content; (c) your violation of these Terms; or (d) your violation of any rights of another + person or entity. +

+
+ + {{-- 11 --}} +
+

+ 11 + Termination +

+
+

+ By you. You may close your account at any time by contacting + a staff member. Account deletion requests + are processed within 30 days. +

+

+ By us. We may suspend or terminate your account immediately + and without notice if we determine, in our sole discretion, that you have violated these Terms, + the Rules & Guidelines, + or applicable law. We may also terminate accounts that have been inactive for an extended period, + with prior notice where practicable. +

+

+ Effect of termination. Upon termination, your right to access + the Service ceases immediately. Publicly uploaded content may remain on the Service unless you + separately request its removal. Sections of these Terms that by their nature should survive + termination (including Sections 4, 6, 7, 8, 9, 10, and 12) shall survive. +

+
+
+ + {{-- 12 --}} +
+

+ 12 + Governing Law +

+

+ These Terms are governed by and construed in accordance with applicable law. Any dispute arising + under or in connection with these Terms that cannot be resolved informally shall be submitted to + the exclusive jurisdiction of the competent courts in the applicable jurisdiction. Nothing in this + section limits your rights under mandatory consumer-protection or data-protection laws of your + country of residence. +

+
+ + {{-- 13 --}} +
+

+ 13 + Changes to These Terms +

+

+ We may update these Terms from time to time. When we make material changes, we will revise the + "Last updated" date at the top of this page and, where the changes are significant, notify + registered members by email and/or a prominent notice on the site. Your continued use of the + Service after any changes take effect constitutes your acceptance of the revised Terms. If you do + not agree to the revised Terms, you must stop using the Service. +

+
+ + {{-- 14 --}} +
+

+ 14 + Contact +

+

+ If you have questions about these Terms, please contact us via our + contact form or by sending a + private message to any staff member. + We aim to respond to all legal enquiries within 10 business days. +

+ +
+

Skinbase.org

+

+ Operated by the Skinbase team.
+ Contact: via contact form  |  + Staff page +

+
+
+ + {{-- Footer note --}} +
+ These Terms of Service should be read alongside our + Privacy Policy and + Rules & Guidelines, + which together form the complete agreement between you and Skinbase. +
+ +
+ +@endsection diff --git a/routes/web.php b/routes/web.php index 333ed2e8..36a47652 100644 --- a/routes/web.php +++ b/routes/web.php @@ -33,10 +33,21 @@ use App\Http\Controllers\Web\BrowseCategoriesController; use App\Http\Controllers\Web\GalleryController; use App\Http\Controllers\Web\BrowseGalleryController; use App\Http\Controllers\Web\DiscoverController; +use App\Http\Controllers\Web\ExploreController; +use App\Http\Controllers\Web\BlogController; +use App\Http\Controllers\Web\PageController; +use App\Http\Controllers\Web\FooterController; +use App\Http\Controllers\Web\StaffController; +use App\Http\Controllers\Web\RssFeedController; +use App\Http\Controllers\Web\ApplicationController; +use App\Http\Controllers\Web\StaffApplicationAdminController; use Inertia\Inertia; -// ── DISCOVER routes (/discover/*) ───────────────────────────────────────────── +// ── DISCOVER routes (/discover/*) — DiscoverLayout ──────────────────────────── Route::prefix('discover')->name('discover.')->group(function () { + // /discover → redirect to /discover/trending (§6.2 canonical) + Route::get('/', fn () => redirect('/discover/trending', 301)); + Route::get('/trending', [DiscoverController::class, 'trending'])->name('trending'); Route::get('/rising', [DiscoverController::class, 'rising'])->name('rising'); Route::get('/fresh', [DiscoverController::class, 'fresh'])->name('fresh'); @@ -51,6 +62,73 @@ Route::prefix('discover')->name('discover.')->group(function () { Route::middleware('auth')->get('/for-you', [DiscoverController::class, 'forYou'])->name('for-you'); }); +// ── EXPLORE routes (/explore/*) — ExploreLayout ─────────────────────────────── +Route::prefix('explore')->name('explore.')->group(function () { + Route::get('/', [ExploreController::class, 'index'])->name('index'); + Route::get('/{type}', [ExploreController::class, 'byType']) + ->where('type', 'artworks|wallpapers|skins|photography|other') + ->name('type'); + Route::get('/{type}/{mode}', [ExploreController::class, 'byTypeMode']) + ->where('type', 'artworks|wallpapers|skins|photography|other') + ->where('mode', 'trending|new-hot|best|latest') + ->name('type.mode'); +}); + +// ── BLOG routes (/blog/*) — ContentLayout ───────────────────────────────────── +Route::prefix('blog')->name('blog.')->group(function () { + Route::get('/', [BlogController::class, 'index'])->name('index'); + Route::get('/{slug}', [BlogController::class, 'show'])->where('slug', '[a-z0-9\-]+') + ->name('show'); +}); + +// ── PAGES (DB-driven static pages) — ContentLayout ──────────────────────────── +Route::get('/pages/{slug}', [PageController::class, 'show'])->where('slug', '[a-z0-9\-]+') + ->name('pages.show'); + +// Root-level marketing pages (About, Help, Contact) +Route::get('/about', [PageController::class, 'marketing'])->defaults('slug', 'about')->name('about'); +Route::get('/help', [PageController::class, 'marketing'])->defaults('slug', 'help')->name('help'); +Route::get('/contact', [PageController::class, 'marketing'])->defaults('slug', 'contact')->name('contact'); + +// Legal pages +Route::get('/legal/{section}', [PageController::class, 'legal']) + ->where('section', 'terms|privacy|cookies') + ->name('legal'); + +// ── FOOTER pages ───────────────────────────────────────────────────────────── +// Legacy /bug-report now redirects to the universal contact form +Route::match(['get','post'], '/bug-report', fn() => redirect('/contact', 301))->name('bug-report.redirect'); +Route::get('/rss-feeds', [RssFeedController::class, 'index'])->name('rss-feeds'); +Route::get('/faq', [FooterController::class, 'faq'])->name('faq'); +Route::get('/rules-and-guidelines', [FooterController::class, 'rules'])->name('rules'); +Route::get('/privacy-policy', [FooterController::class, 'privacyPolicy'])->name('privacy-policy'); +Route::get('/terms-of-service', [FooterController::class, 'termsOfService'])->name('terms-of-service'); +Route::get('/staff', [StaffController::class, 'index'])->name('staff'); +// Contact form (formerly /apply) +Route::get('/contact', [ApplicationController::class, 'show'])->name('contact.show'); +Route::post('/contact', [ApplicationController::class, 'submit'])->middleware('throttle:6,1')->name('contact.submit'); + +// Backwards-compatibility: redirect old /apply to /contact +Route::get('/apply', fn() => redirect('/contact', 301))->name('legacy.apply.redirect'); + +// Admin: staff application submissions +Route::middleware(['auth'])->prefix('admin')->name('admin.')->group(function () { + Route::get('/applications', [StaffApplicationAdminController::class, 'index'])->name('applications.index'); + Route::get('/applications/{staffApplication}', [StaffApplicationAdminController::class, 'show'])->name('applications.show'); +}); + +// RSS XML feeds +Route::get('/rss/latest-uploads.xml', [RssFeedController::class, 'latestUploads'])->name('rss.uploads'); +Route::get('/rss/latest-skins.xml', [RssFeedController::class, 'latestSkins'])->name('rss.skins'); +Route::get('/rss/latest-wallpapers.xml', [RssFeedController::class, 'latestWallpapers'])->name('rss.wallpapers'); +Route::get('/rss/latest-photos.xml', [RssFeedController::class, 'latestPhotos'])->name('rss.photos'); + +// ── 301 REDIRECTS: Legacy → Canonical URLs ──────────────────────────────────── +// §6.1 — /browse → /explore +Route::get('/browse-redirect', fn () => redirect('/explore', 301))->name('legacy.browse.redirect'); +// §6.1 — /wallpapers as standalone → /explore/wallpapers +Route::get('/wallpapers-redirect', fn () => redirect('/explore/wallpapers', 301))->name('legacy.wallpapers.redirect'); + // ── CREATORS routes (/creators/*) ───────────────────────────────────────────── Route::prefix('creators')->name('creators.')->group(function () { // Top Creators → reuse existing top-authors controller @@ -134,7 +212,8 @@ Route::get('/downloads/today', [TodayDownloadsController::class, 'index'])->nam Route::get('/category/{group}/{slug?}/{id?}', [BrowseGalleryController::class, 'legacyCategory'])->name('legacy.category'); -Route::get('/browse', [BrowseGalleryController::class, 'browse'])->name('legacy.browse'); +// §6.1 — /browse → 301 to /explore (canonical) +Route::get('/browse', fn () => redirect('/explore', 301))->name('legacy.browse'); Route::get('/featured', [FeaturedArtworksController::class, 'index'])->name('legacy.featured'); Route::get('/featured-artworks', [FeaturedArtworksController::class, 'index'])->name('legacy.featured_artworks'); Route::get('/daily-uploads', [DailyUploadsController::class, 'index'])->name('legacy.daily_uploads'); @@ -187,10 +266,8 @@ Route::middleware('ensure.onboarding.complete')->get('/gallery/{id}/{username?}' Route::middleware('auth')->get('/recieved-comments', [ReceivedCommentsController::class, 'index'])->name('legacy.received_comments'); -// Canonical dashboard profile route: serve legacy Nova-themed UI here so the -// visual remains identical to the old `/user` page while the canonical path -// follows the routing standard `/dashboard/profile`. -Route::middleware(['auth'])->match(['get','post'], '/dashboard/profile', [\App\Http\Controllers\Legacy\UserController::class, 'index'])->name('dashboard.profile'); +// Canonical dashboard profile route: Inertia-powered Settings page. +Route::middleware(['auth'])->get('/dashboard/profile', [ProfileController::class, 'editSettings'])->name('dashboard.profile'); // Keep legacy `/user` as a permanent redirect to the canonical dashboard path. Route::middleware(['auth'])->match(['get','post'], '/user', function () { @@ -256,6 +333,8 @@ Route::middleware(['auth', 'normalize.username', 'ensure.onboarding.complete'])- })->name('legacy.profile.redirect'); // Backwards-compatible settings path used by some layouts/links Route::get('/settings', [ProfileController::class, 'edit'])->name('settings'); + // Backwards-compatible route name expected by some packages/views + Route::get('/profile/edit', [ProfileController::class, 'edit'])->name('profile.edit'); Route::match(['post','put','patch'], '/profile', [ProfileController::class, 'update'])->name('profile.update'); Route::delete('/profile', [ProfileController::class, 'destroy'])->name('profile.destroy'); // Password change endpoint (accepts POST or PUT from legacy and new forms) @@ -340,7 +419,9 @@ Route::view('/blank', 'blank')->name('blank'); // Bind the artwork route parameter to a model if it exists, otherwise return null use App\Models\Artwork; Route::bind('artwork', function ($value) { - return Artwork::where('slug', $value)->first(); + // firstOrFail triggers ModelNotFoundException → caught by exception handler + // which renders contextual artwork-not-found or generic 404 view. + return Artwork::where('slug', $value)->firstOrFail(); }); // Universal content router: handles content-type roots, nested categories and artwork slugs. @@ -382,6 +463,13 @@ Route::middleware(['auth'])->prefix('admin')->name('admin.')->group(function () Route::get('reports', function () { return view('admin.reports.queue'); })->middleware('admin.moderation')->name('reports.queue'); + + // ── Early Growth System admin panel (§14) ───────────────────────────── + Route::middleware('admin.moderation')->prefix('early-growth')->name('early-growth.')->group(function () { + Route::get('/', [\App\Http\Controllers\Admin\EarlyGrowthAdminController::class, 'index'])->name('index'); + Route::delete('/cache', [\App\Http\Controllers\Admin\EarlyGrowthAdminController::class, 'flushCache'])->name('cache.flush'); + Route::get('/status', [\App\Http\Controllers\Admin\EarlyGrowthAdminController::class, 'status'])->name('status'); + }); }); Route::middleware(['auth', 'ensure.onboarding.complete']) @@ -422,3 +510,9 @@ Route::middleware(['auth']) // ── Feed 2.0: Post Search ───────────────────────────────────────────────────── Route::get('/feed/search', [\App\Http\Controllers\Web\Posts\SearchFeedController::class, 'index']) ->name('feed.search'); + +// ── Fallback: Generic 404 ───────────────────────────────────────────────────── +// Must be last. Catches any URL not matched by a registered route. +Route::fallback(function (\Illuminate\Http\Request $request) { + return app(\App\Http\Controllers\Web\ErrorController::class)->handleNotFound($request); +})->name('404.fallback'); diff --git a/tests/Feature/FooterPagesTest.php b/tests/Feature/FooterPagesTest.php new file mode 100644 index 00000000..66cc8417 --- /dev/null +++ b/tests/Feature/FooterPagesTest.php @@ -0,0 +1,116 @@ +get('/faq') + ->assertOk() + ->assertSee('Frequently Asked Questions'); + }); + + it('shows the Rules & Guidelines page', function () { + $this->get('/rules-and-guidelines') + ->assertOk() + ->assertSee('Rules & Guidelines'); + }); + + it('shows the Privacy Policy page', function () { + $this->get('/privacy-policy') + ->assertOk() + ->assertSee('Privacy Policy'); + }); + + it('shows the Terms of Service page', function () { + $this->get('/terms-of-service') + ->assertOk() + ->assertSee('Terms of Service'); + }); + + it('shows the bug report page to guests with login prompt', function () { + $this->get('/bug-report') + ->assertOk() + ->assertSee('Bug Report'); + }); + + it('shows the bug report form to authenticated users', function () { + $user = \App\Models\User::factory()->create(); + + $this->actingAs($user) + ->get('/bug-report') + ->assertOk() + ->assertSee('Subject'); + }); + + it('submits a bug report as authenticated user', function () { + $user = \App\Models\User::factory()->create(); + + $this->actingAs($user) + ->post('/bug-report', [ + 'subject' => 'Test subject', + 'description' => 'This is a test bug report description.', + ]) + ->assertRedirect('/bug-report'); + + $this->assertDatabaseHas('bug_reports', [ + 'user_id' => $user->id, + 'subject' => 'Test subject', + ]); + }); + + it('rejects bug report submission from guests', function () { + $this->post('/bug-report', [ + 'subject' => 'Test', + 'description' => 'Test description', + ])->assertRedirect('/login'); + }); + + it('shows the staff page', function () { + $this->get('/staff') + ->assertOk() + ->assertSee('Meet the Staff'); + }); + + it('shows the RSS feeds info page', function () { + $this->get('/rss-feeds') + ->assertOk() + ->assertSee('RSS') + ->assertSee('Latest Uploads'); + }); + + it('returns XML for latest uploads feed', function () { + $this->get('/rss/latest-uploads.xml') + ->assertOk() + ->assertHeader('Content-Type', 'application/rss+xml; charset=utf-8'); + }); + + it('returns XML for latest skins feed', function () { + $this->get('/rss/latest-skins.xml') + ->assertOk() + ->assertHeader('Content-Type', 'application/rss+xml; charset=utf-8'); + }); + + it('returns XML for latest wallpapers feed', function () { + $this->get('/rss/latest-wallpapers.xml') + ->assertOk() + ->assertHeader('Content-Type', 'application/rss+xml; charset=utf-8'); + }); + + it('returns XML for latest photos feed', function () { + $this->get('/rss/latest-photos.xml') + ->assertOk() + ->assertHeader('Content-Type', 'application/rss+xml; charset=utf-8'); + }); + + it('RSS feed contains valid XML', function () { + $response = $this->get('/rss/latest-uploads.xml'); + $response->assertOk(); + $xml = simplexml_load_string($response->getContent()); + expect($xml)->not->toBeFalse(); + expect((string) $xml->channel->title)->toContain('Skinbase'); + }); + +}); diff --git a/tests/Feature/RoutingUnificationTest.php b/tests/Feature/RoutingUnificationTest.php new file mode 100644 index 00000000..645b5aeb --- /dev/null +++ b/tests/Feature/RoutingUnificationTest.php @@ -0,0 +1,194 @@ +get('/explore')->assertOk(); +}); + +it('GET /explore contains "Explore" heading', function () { + $this->get('/explore')->assertOk()->assertSee('Explore', false); +}); + +it('GET /explore/wallpapers returns 200', function () { + $this->get('/explore/wallpapers')->assertOk(); +}); + +it('GET /explore/skins returns 200', function () { + $this->get('/explore/skins')->assertOk(); +}); + +it('GET /explore/photography returns 200', function () { + $this->get('/explore/photography')->assertOk(); +}); + +it('GET /explore/artworks returns 200', function () { + $this->get('/explore/artworks')->assertOk(); +}); + +it('GET /explore/other returns 200', function () { + $this->get('/explore/other')->assertOk(); +}); + +it('GET /explore/wallpapers/trending returns 200', function () { + $this->get('/explore/wallpapers/trending')->assertOk(); +}); + +it('GET /explore/wallpapers/latest returns 200', function () { + $this->get('/explore/wallpapers/latest')->assertOk(); +}); + +it('GET /explore/wallpapers/best returns 200', function () { + $this->get('/explore/wallpapers/best')->assertOk(); +}); + +it('GET /explore/wallpapers/new-hot returns 200', function () { + $this->get('/explore/wallpapers/new-hot')->assertOk(); +}); + +it('/explore pages include canonical link tag', function () { + $html = $this->get('/explore')->assertOk()->getContent(); + expect($html)->toContain('rel="canonical"'); +}); + +it('/explore pages set robots index,follow', function () { + $html = $this->get('/explore')->assertOk()->getContent(); + expect($html)->toContain('index,follow'); +}); + +it('/explore pages include breadcrumb JSON-LD', function () { + $html = $this->get('/explore/wallpapers')->assertOk()->getContent(); + expect($html)->toContain('BreadcrumbList'); +}); + +// ── 301 redirects from legacy routes ───────────────────────────────────────── + +it('GET /browse redirects to /explore with 301', function () { + $this->get('/browse')->assertRedirect('/explore')->assertStatus(301); +}); + +it('GET /discover redirects to /discover/trending with 301', function () { + $this->get('/discover')->assertRedirect('/discover/trending')->assertStatus(301); +}); + +// ── /blog routes ───────────────────────────────────────────────────────────── + +it('GET /blog returns 200', function () { + $this->get('/blog')->assertOk(); +}); + +it('/blog page contains Blog heading', function () { + $html = $this->get('/blog')->assertOk()->getContent(); + expect($html)->toContain('Blog'); +}); + +it('/blog page includes canonical link', function () { + $html = $this->get('/blog')->assertOk()->getContent(); + expect($html)->toContain('rel="canonical"'); +}); + +it('/blog/:slug returns 404 for non-existent post', function () { + $this->get('/blog/this-post-does-not-exist-xyz')->assertNotFound(); +}); + +it('published blog post is accessible at /blog/:slug', function () { + $post = \App\Models\BlogPost::factory()->create([ + 'slug' => 'hello-world', + 'title' => 'Hello World Post', + 'body' => '

Test content.

', + 'is_published' => true, + 'published_at' => now()->subDay(), + ]); + + $html = $this->get('/blog/hello-world')->assertOk()->getContent(); + expect($html)->toContain('Hello World Post'); +}); + +it('unpublished blog post returns 404', function () { + \App\Models\BlogPost::factory()->create([ + 'slug' => 'draft-post', + 'title' => 'Draft Post', + 'body' => '

Draft.

', + 'is_published' => false, + ]); + + $this->get('/blog/draft-post')->assertNotFound(); +}); + +it('/blog post includes Article JSON-LD', function () { + \App\Models\BlogPost::factory()->create([ + 'slug' => 'schema-post', + 'title' => 'Schema Post', + 'body' => '

Content.

', + 'is_published' => true, + 'published_at' => now()->subHour(), + ]); + + $html = $this->get('/blog/schema-post')->assertOk()->getContent(); + expect($html)->toContain('"Article"'); +}); + +// ── /pages routes ───────────────────────────────────────────────────────────── + +it('GET /pages/:slug returns 404 for non-existent page', function () { + $this->get('/pages/does-not-exist-xyz')->assertNotFound(); +}); + +it('published page is accessible at /pages/:slug', function () { + \App\Models\Page::factory()->create([ + 'slug' => 'test-page', + 'title' => 'Test Page Title', + 'body' => '

Page content.

', + 'is_published' => true, + 'published_at' => now()->subDay(), + ]); + + $html = $this->get('/pages/test-page')->assertOk()->getContent(); + expect($html)->toContain('Test Page Title'); +}); + +it('/about returns 200 when about page exists', function () { + \App\Models\Page::factory()->create([ + 'slug' => 'about', + 'title' => 'About Skinbase', + 'body' => '

About us.

', + 'is_published' => true, + 'published_at' => now()->subDay(), + ]); + + $this->get('/about')->assertOk(); +}); + +it('/legal/terms returns 200 when legal-terms page exists', function () { + \App\Models\Page::factory()->create([ + 'slug' => 'legal-terms', + 'title' => 'Terms of Service', + 'body' => '

Terms.

', + 'is_published' => true, + 'published_at' => now()->subDay(), + ]); + + $this->get('/legal/terms')->assertOk(); +}); + +// ── tags index layout ───────────────────────────────────────────────────────── + +it('/tags page renders with ContentLayout breadcrumbs', function () { + $html = $this->get('/tags')->assertOk()->getContent(); + // ContentLayout should inject breadcrumb nav + expect($html)->toContain('Tags'); +}); + +it('/tags page includes canonical and robots', function () { + $html = $this->get('/tags')->assertOk()->getContent(); + expect($html) + ->toContain('rel="canonical"') + ->toContain('index,follow'); +}); diff --git a/tests/Unit/EarlyGrowth/EarlyGrowthTest.php b/tests/Unit/EarlyGrowth/EarlyGrowthTest.php new file mode 100644 index 00000000..5395154a --- /dev/null +++ b/tests/Unit/EarlyGrowth/EarlyGrowthTest.php @@ -0,0 +1,298 @@ +set('early_growth.enabled', false); + config()->set('early_growth.mode', 'light'); + + expect(EarlyGrowth::enabled())->toBeFalse(); +}); + +it('EarlyGrowth::enabled returns false when mode is off', function () { + config()->set('early_growth.enabled', true); + config()->set('early_growth.mode', 'off'); + + expect(EarlyGrowth::enabled())->toBeFalse(); +}); + +it('EarlyGrowth::enabled returns true when enabled and mode is light', function () { + config()->set('early_growth.enabled', true); + config()->set('early_growth.mode', 'light'); + config()->set('early_growth.auto_disable.enabled', false); + + expect(EarlyGrowth::enabled())->toBeTrue(); +}); + +it('EarlyGrowth::enabled returns true when mode is aggressive', function () { + config()->set('early_growth.enabled', true); + config()->set('early_growth.mode', 'aggressive'); + config()->set('early_growth.auto_disable.enabled', false); + + expect(EarlyGrowth::enabled())->toBeTrue(); +}); + +it('EarlyGrowth::mode rejects unknown values and returns off', function () { + config()->set('early_growth.mode', 'extreme_turbo'); + + expect(EarlyGrowth::mode())->toBe('off'); +}); + +it('EarlyGrowth::status returns all keys', function () { + config()->set('early_growth.enabled', false); + config()->set('early_growth.mode', 'off'); + + $status = EarlyGrowth::status(); + + expect($status)->toHaveKeys(['enabled', 'mode', 'adaptive_window', 'grid_filler', 'spotlight', 'activity_layer']); +}); + +it('module toggles are false when EGS is disabled', function () { + config()->set('early_growth.enabled', false); + + expect(EarlyGrowth::adaptiveWindowEnabled())->toBeFalse(); + expect(EarlyGrowth::gridFillerEnabled())->toBeFalse(); + expect(EarlyGrowth::spotlightEnabled())->toBeFalse(); + expect(EarlyGrowth::activityLayerEnabled())->toBeFalse(); +}); + +it('module toggles respect individual flags when EGS is on', function () { + config()->set('early_growth.enabled', true); + config()->set('early_growth.mode', 'light'); + config()->set('early_growth.auto_disable.enabled', false); + config()->set('early_growth.adaptive_time_window', false); + config()->set('early_growth.grid_filler', true); + config()->set('early_growth.spotlight', false); + config()->set('early_growth.activity_layer', true); + + expect(EarlyGrowth::adaptiveWindowEnabled())->toBeFalse(); + expect(EarlyGrowth::gridFillerEnabled())->toBeTrue(); + expect(EarlyGrowth::spotlightEnabled())->toBeFalse(); + expect(EarlyGrowth::activityLayerEnabled())->toBeTrue(); +}); + +it('EarlyGrowth returns correct blend ratios per mode', function () { + config()->set('early_growth.enabled', true); + config()->set('early_growth.mode', 'light'); + config()->set('early_growth.auto_disable.enabled', false); + config()->set('early_growth.blend_ratios.light', ['fresh' => 0.60, 'curated' => 0.25, 'spotlight' => 0.15]); + + $ratios = EarlyGrowth::blendRatios(); + + expect($ratios['fresh'])->toBe(0.60); + expect($ratios['curated'])->toBe(0.25); + expect($ratios['spotlight'])->toBe(0.15); +}); + +// ─── AdaptiveTimeWindow ─────────────────────────────────────────────────────── + +it('AdaptiveTimeWindow returns default when EGS disabled', function () { + config()->set('early_growth.enabled', false); + + $tw = new AdaptiveTimeWindow(); + + expect($tw->getTrendingWindowDays(30))->toBe(30); + expect($tw->getTrendingWindowDays(7))->toBe(7); +}); + +it('AdaptiveTimeWindow expands to medium window when uploads below narrow threshold', function () { + config()->set('early_growth.enabled', true); + config()->set('early_growth.mode', 'light'); + config()->set('early_growth.auto_disable.enabled', false); + config()->set('early_growth.adaptive_time_window', true); + config()->set('early_growth.thresholds.uploads_per_day_narrow', 10); + config()->set('early_growth.thresholds.uploads_per_day_wide', 3); + config()->set('early_growth.thresholds.window_narrow_days', 7); + config()->set('early_growth.thresholds.window_medium_days', 30); + config()->set('early_growth.thresholds.window_wide_days', 90); + + // Mock: 5 uploads/day (between narrow=10 and wide=3) → 30 days + Cache::put('egs.uploads_per_day', 5.0, 60); + + $tw = new AdaptiveTimeWindow(); + expect($tw->getTrendingWindowDays(7))->toBe(30); +}); + +it('AdaptiveTimeWindow expands to wide window when uploads below wide threshold', function () { + config()->set('early_growth.enabled', true); + config()->set('early_growth.mode', 'light'); + config()->set('early_growth.auto_disable.enabled', false); + config()->set('early_growth.adaptive_time_window', true); + config()->set('early_growth.thresholds.uploads_per_day_narrow', 10); + config()->set('early_growth.thresholds.uploads_per_day_wide', 3); + config()->set('early_growth.thresholds.window_narrow_days', 7); + config()->set('early_growth.thresholds.window_medium_days', 30); + config()->set('early_growth.thresholds.window_wide_days', 90); + + // Mock: 1 upload/day (below wide=3) → 90 days + Cache::put('egs.uploads_per_day', 1.0, 60); + + $tw = new AdaptiveTimeWindow(); + expect($tw->getTrendingWindowDays(7))->toBe(90); +}); + +it('AdaptiveTimeWindow keeps narrow window when uploads above narrow threshold', function () { + config()->set('early_growth.enabled', true); + config()->set('early_growth.mode', 'light'); + config()->set('early_growth.auto_disable.enabled', false); + config()->set('early_growth.adaptive_time_window', true); + config()->set('early_growth.thresholds.uploads_per_day_narrow', 10); + config()->set('early_growth.thresholds.uploads_per_day_wide', 3); + config()->set('early_growth.thresholds.window_narrow_days', 7); + config()->set('early_growth.thresholds.window_medium_days', 30); + config()->set('early_growth.thresholds.window_wide_days', 90); + + // Mock: 15 uploads/day (above narrow=10) → normal 7-day window + Cache::put('egs.uploads_per_day', 15.0, 60); + + $tw = new AdaptiveTimeWindow(); + expect($tw->getTrendingWindowDays(7))->toBe(7); +}); + +// ─── GridFiller ─────────────────────────────────────────────────────────────── + +it('GridFiller does nothing when EGS disabled', function () { + config()->set('early_growth.enabled', false); + + $original = make_paginator(3); + $gf = new GridFiller(); + + $result = $gf->fill($original, 12, 1); + + expect($result->getCollection()->count())->toBe(3); +}); + +it('GridFiller does not fill pages beyond page 1', function () { + config()->set('early_growth.enabled', true); + config()->set('early_growth.mode', 'light'); + config()->set('early_growth.auto_disable.enabled', false); + config()->set('early_growth.grid_filler', true); + + $original = make_paginator(3, perPage: 12, page: 2); + $gf = new GridFiller(); + + $result = $gf->fill($original, 12, 2); + + // Page > 1 → leave untouched + expect($result->getCollection()->count())->toBe(3); +}); + +it('GridFiller fillCollection does nothing when EGS disabled', function () { + config()->set('early_growth.enabled', false); + + $items = collect(range(1, 3))->map(fn ($i) => (object) ['id' => $i]); + $gf = new GridFiller(); + + expect($gf->fillCollection($items, 12)->count())->toBe(3); +}); + +// ─── FeedBlender ───────────────────────────────────────────────────────────── + +it('FeedBlender returns original paginator when EGS disabled', function () { + config()->set('early_growth.enabled', false); + + $spotlight = Mockery::mock(SpotlightEngineInterface::class); + $blender = new FeedBlender($spotlight); + + $original = make_paginator(8); + $result = $blender->blend($original, 24, 1); + + expect($result->getCollection()->count())->toBe(8); +}); + +it('FeedBlender returns original paginator on page > 1', function () { + config()->set('early_growth.enabled', true); + config()->set('early_growth.mode', 'light'); + config()->set('early_growth.auto_disable.enabled', false); + + $spotlight = Mockery::mock(SpotlightEngineInterface::class); + $blender = new FeedBlender($spotlight); + + $original = make_paginator(8, page: 2); + $result = $blender->blend($original, 24, 2); + + expect($result->getCollection()->count())->toBe(8); +}); + +it('FeedBlender preserves original total in blended paginator', function () { + config()->set('early_growth.enabled', true); + config()->set('early_growth.mode', 'light'); + config()->set('early_growth.auto_disable.enabled', false); + config()->set('early_growth.blend_ratios.light', ['fresh' => 0.60, 'curated' => 0.25, 'spotlight' => 0.15]); + + $spotlight = Mockery::mock(SpotlightEngineInterface::class); + $spotlight->allows('getCurated')->andReturn(collect()); + $spotlight->allows('getSpotlight')->andReturn(collect()); + + $blender = new FeedBlender($spotlight); + $original = make_paginator(6, total: 100); + + $result = $blender->blend($original, 24, 1); + + // Original total must be preserved so pagination links are stable + expect($result->total())->toBe(100); +}); + +it('FeedBlender removes duplicate IDs across sources', function () { + config()->set('early_growth.enabled', true); + config()->set('early_growth.mode', 'aggressive'); + config()->set('early_growth.auto_disable.enabled', false); + config()->set('early_growth.blend_ratios.aggressive', ['fresh' => 0.30, 'curated' => 0.50, 'spotlight' => 0.20]); + + // Curated returns some IDs that overlap with fresh + $freshItems = collect(range(1, 10))->map(fn ($i) => make_artwork_stub($i)); + $curatedItems = collect(range(5, 15))->map(fn ($i) => make_artwork_stub($i)); // IDs 5-10 overlap + $spotlightItems = collect(range(20, 25))->map(fn ($i) => make_artwork_stub($i)); + + $spotlight = Mockery::mock(SpotlightEngineInterface::class); + $spotlight->allows('getCurated')->andReturn($curatedItems); + $spotlight->allows('getSpotlight')->andReturn($spotlightItems); + + $blender = new FeedBlender($spotlight); + $original = make_paginator(10, total: 100, items: $freshItems); + + $result = $blender->blend($original, 24, 1); + $ids = $result->getCollection()->pluck('id')->toArray(); + $uniqueIds = array_unique($ids); + + expect(count($ids))->toBe(count($uniqueIds)); +}); + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +function make_paginator( + int $count = 12, + int $total = 0, + int $perPage = 24, + int $page = 1, + ?\Illuminate\Support\Collection $items = null, +): LengthAwarePaginator { + $collection = $items ?? collect(range(1, $count))->map(fn ($i) => make_artwork_stub($i)); + $total = $total > 0 ? $total : $count; + + return new LengthAwarePaginator($collection->all(), $total, $perPage, $page, [ + 'path' => '/discover/fresh', + ]); +} + +function make_artwork_stub(int $id): object +{ + return (object) [ + 'id' => $id, + 'title' => "Artwork {$id}", + 'published_at' => now()->subDays($id), + ]; +} diff --git a/vite.config.js b/vite.config.js index c1add4f6..01c4d0ec 100644 --- a/vite.config.js +++ b/vite.config.js @@ -22,6 +22,7 @@ export default defineConfig({ 'resources/js/Pages/Messages/Index.jsx', 'resources/js/profile.jsx', 'resources/js/feed.jsx', + 'resources/js/settings.jsx', 'resources/js/entry-forum.jsx', ], // Only watch Blade templates & routes for full-reload triggers