feat: Inertia profile settings page, Studio edit redesign, EGS, Nova UI components\n\n- Redesign /dashboard/profile as Inertia React page (Settings/ProfileEdit)\n with SettingsLayout sidebar, Nova UI components (TextInput, Textarea,\n Toggle, Select, RadioGroup, Modal, Button), avatar drag-and-drop,\n password change, and account deletion sections\n- Redesign Studio artwork edit page with two-column layout, Nova components,\n integrated TagPicker, and version history modal\n- Add shared MarkdownEditor component\n- Add Early-Stage Growth System (EGS): SpotlightEngine, FeedBlender,\n GridFiller, AdaptiveTimeWindow, ActivityLayer, admin panel\n- Fix upload category/tag persistence (V1+V2 paths)\n- Fix tag source enum, category tree display, binding resolution\n- Add settings.jsx Vite entry, settings.blade.php wrapper\n- Update ProfileController with JSON response support for API calls\n- Various route fixes (profile.edit, toolbar settings link)"
This commit is contained in:
119
app/Http/Controllers/Admin/EarlyGrowthAdminController.php
Normal file
119
app/Http/Controllers/Admin/EarlyGrowthAdminController.php
Normal file
@@ -0,0 +1,119 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\EarlyGrowth\ActivityLayer;
|
||||
use App\Services\EarlyGrowth\AdaptiveTimeWindow;
|
||||
use App\Services\EarlyGrowth\EarlyGrowth;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\View\View;
|
||||
|
||||
/**
|
||||
* EarlyGrowthAdminController (§14)
|
||||
*
|
||||
* Admin panel for the Early-Stage Growth System.
|
||||
* All toggles are ENV-driven; updating .env requires a deploy.
|
||||
* This panel provides a read-only status view plus a cache-flush action.
|
||||
*
|
||||
* Future v2: wire to a `settings` DB table so admins can toggle without
|
||||
* a deploy. The EarlyGrowth::enabled() contract already supports this.
|
||||
*/
|
||||
final class EarlyGrowthAdminController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly AdaptiveTimeWindow $timeWindow,
|
||||
private readonly ActivityLayer $activityLayer,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* GET /admin/early-growth
|
||||
* Status dashboard: shows current config, live stats, toggle instructions.
|
||||
*/
|
||||
public function index(): View
|
||||
{
|
||||
$uploadsPerDay = $this->timeWindow->getUploadsPerDay();
|
||||
|
||||
return view('admin.early-growth.index', [
|
||||
'status' => EarlyGrowth::status(),
|
||||
'mode' => EarlyGrowth::mode(),
|
||||
'uploads_per_day' => $uploadsPerDay,
|
||||
'window_days' => $this->timeWindow->getTrendingWindowDays(30),
|
||||
'activity' => $this->activityLayer->getSignals(),
|
||||
'cache_keys' => [
|
||||
'egs.uploads_per_day',
|
||||
'egs.auto_disable_check',
|
||||
'egs.spotlight.*',
|
||||
'egs.curated.*',
|
||||
'egs.grid_filler.*',
|
||||
'egs.activity_signals',
|
||||
'homepage.fresh.*',
|
||||
'discover.trending.*',
|
||||
'discover.rising.*',
|
||||
],
|
||||
'env_toggles' => [
|
||||
['key' => 'NOVA_EARLY_GROWTH_ENABLED', 'current' => env('NOVA_EARLY_GROWTH_ENABLED', 'false')],
|
||||
['key' => 'NOVA_EARLY_GROWTH_MODE', 'current' => env('NOVA_EARLY_GROWTH_MODE', 'off')],
|
||||
['key' => 'NOVA_EGS_ADAPTIVE_WINDOW', 'current' => env('NOVA_EGS_ADAPTIVE_WINDOW', 'true')],
|
||||
['key' => 'NOVA_EGS_GRID_FILLER', 'current' => env('NOVA_EGS_GRID_FILLER', 'true')],
|
||||
['key' => 'NOVA_EGS_SPOTLIGHT', 'current' => env('NOVA_EGS_SPOTLIGHT', 'true')],
|
||||
['key' => 'NOVA_EGS_ACTIVITY_LAYER', 'current' => env('NOVA_EGS_ACTIVITY_LAYER', 'false')],
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /admin/early-growth/cache
|
||||
* Flush all EGS-related cache keys so new config changes take effect immediately.
|
||||
*/
|
||||
public function flushCache(Request $request): RedirectResponse
|
||||
{
|
||||
$keys = [
|
||||
'egs.uploads_per_day',
|
||||
'egs.auto_disable_check',
|
||||
'egs.activity_signals',
|
||||
];
|
||||
|
||||
// Flush the EGS daily spotlight caches for today
|
||||
$today = now()->format('Y-m-d');
|
||||
foreach ([6, 12, 18, 24] as $n) {
|
||||
Cache::forget("egs.spotlight.{$today}.{$n}");
|
||||
Cache::forget("egs.curated.{$today}.{$n}.7");
|
||||
}
|
||||
|
||||
// Flush fresh/trending homepage sections
|
||||
foreach ([6, 8, 10, 12] as $limit) {
|
||||
foreach (['off', 'light', 'aggressive'] as $mode) {
|
||||
Cache::forget("homepage.fresh.{$limit}.egs-{$mode}");
|
||||
Cache::forget("homepage.fresh.{$limit}.std");
|
||||
}
|
||||
Cache::forget("homepage.trending.{$limit}");
|
||||
Cache::forget("homepage.rising.{$limit}");
|
||||
}
|
||||
|
||||
// Flush key keys
|
||||
foreach ($keys as $key) {
|
||||
Cache::forget($key);
|
||||
}
|
||||
|
||||
return redirect()->route('admin.early-growth.index')
|
||||
->with('success', 'Early Growth System cache flushed. Changes will take effect on next page load.');
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /admin/early-growth/status (JSON — for monitoring/healthcheck)
|
||||
*/
|
||||
public function status(): JsonResponse
|
||||
{
|
||||
return response()->json([
|
||||
'egs' => EarlyGrowth::status(),
|
||||
'uploads_per_day' => $this->timeWindow->getUploadsPerDay(),
|
||||
'window_days' => $this->timeWindow->getTrendingWindowDays(30),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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([
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
|
||||
93
app/Http/Controllers/Web/ApplicationController.php
Normal file
93
app/Http/Controllers/Web/ApplicationController.php
Normal file
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Web;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Str;
|
||||
use App\Models\StaffApplication;
|
||||
|
||||
class ApplicationController extends Controller
|
||||
{
|
||||
public function show()
|
||||
{
|
||||
return view('web.apply');
|
||||
}
|
||||
|
||||
public function submit(Request $request)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'topic' => 'required|string|in:apply,bug,contact,other',
|
||||
'name' => 'required|string|max:100',
|
||||
'email' => 'required|email|max:150',
|
||||
'role' => 'nullable|string|max:100',
|
||||
'portfolio' => 'nullable|url|max:255',
|
||||
'affected_url' => 'nullable|url|max:255',
|
||||
'steps' => 'nullable|string|max:2000',
|
||||
'message' => 'nullable|string|max:2000',
|
||||
]);
|
||||
|
||||
$payload = [
|
||||
'id' => (string) Str::uuid(),
|
||||
'submitted_at' => now()->toISOString(),
|
||||
'ip' => $request->ip(),
|
||||
'user_agent' => $request->userAgent(),
|
||||
'data' => $data,
|
||||
];
|
||||
|
||||
// Honeypot: silently drop submissions that fill the hidden field
|
||||
if ($request->filled('website')) {
|
||||
return redirect()->route('contact.show')->with('success', 'Your submission was received.');
|
||||
}
|
||||
|
||||
try {
|
||||
Storage::append('staff_applications.jsonl', json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
|
||||
} catch (\Throwable $e) {
|
||||
// best-effort store; don't fail the user if write fails
|
||||
}
|
||||
|
||||
// store in DB as well
|
||||
try {
|
||||
StaffApplication::create([
|
||||
'id' => $payload['id'],
|
||||
'topic' => $data['topic'] ?? 'apply',
|
||||
'name' => $data['name'] ?? null,
|
||||
'email' => $data['email'] ?? null,
|
||||
'role' => $data['role'] ?? null,
|
||||
'portfolio' => $data['portfolio'] ?? null,
|
||||
'message' => $data['message'] ?? null,
|
||||
'payload' => $payload,
|
||||
'ip' => $payload['ip'],
|
||||
'user_agent' => $payload['user_agent'],
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
// ignore DB errors
|
||||
}
|
||||
|
||||
$to = config('mail.from.address');
|
||||
|
||||
if ($to) {
|
||||
try {
|
||||
// prefer the DB model when available
|
||||
$appModel = isset($appModel) ? $appModel : StaffApplication::find($payload['id']) ?? null;
|
||||
if (! $appModel) {
|
||||
// construct a lightweight model-like object for the mailable
|
||||
$appModel = new StaffApplication($payload['data'] ?? []);
|
||||
$appModel->id = $payload['id'];
|
||||
$appModel->payload = $payload;
|
||||
$appModel->ip = $payload['ip'];
|
||||
$appModel->user_agent = $payload['user_agent'];
|
||||
$appModel->created_at = now();
|
||||
}
|
||||
|
||||
Mail::to($to)->queue(new \App\Mail\StaffApplicationReceived($appModel));
|
||||
} catch (\Throwable $e) {
|
||||
// ignore mail errors but don't fail user
|
||||
}
|
||||
}
|
||||
|
||||
return redirect()->route('contact.show')->with('success', 'Your submission was received. Thank you — we will review it soon.');
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
52
app/Http/Controllers/Web/BlogController.php
Normal file
52
app/Http/Controllers/Web/BlogController.php
Normal file
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Web;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\BlogPost;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
/**
|
||||
* BlogController — /blog index + single post.
|
||||
*/
|
||||
final class BlogController extends Controller
|
||||
{
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$posts = BlogPost::published()
|
||||
->orderByDesc('published_at')
|
||||
->paginate(12)
|
||||
->withQueryString();
|
||||
|
||||
return view('web.blog.index', [
|
||||
'posts' => $posts,
|
||||
'page_title' => 'Blog — Skinbase',
|
||||
'page_meta_description' => 'News, tutorials and community stories from the Skinbase team.',
|
||||
'page_canonical' => url('/blog'),
|
||||
'page_robots' => 'index,follow',
|
||||
'breadcrumbs' => collect([
|
||||
(object) ['name' => 'Blog', 'url' => '/blog'],
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
public function show(string $slug): View
|
||||
{
|
||||
$post = BlogPost::published()->where('slug', $slug)->firstOrFail();
|
||||
|
||||
return view('web.blog.show', [
|
||||
'post' => $post,
|
||||
'page_title' => ($post->meta_title ?: $post->title) . ' — Skinbase Blog',
|
||||
'page_meta_description' => $post->meta_description ?: $post->excerpt ?: '',
|
||||
'page_canonical' => $post->url,
|
||||
'page_robots' => 'index,follow',
|
||||
'breadcrumbs' => collect([
|
||||
(object) ['name' => 'Blog', 'url' => '/blog'],
|
||||
(object) ['name' => $post->title, 'url' => $post->url],
|
||||
]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
57
app/Http/Controllers/Web/BugReportController.php
Normal file
57
app/Http/Controllers/Web/BugReportController.php
Normal file
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Web;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\BugReport;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
/**
|
||||
* BugReportController — /bug-report
|
||||
*
|
||||
* GET /bug-report → show form (guests see a login prompt)
|
||||
* POST /bug-report → authenticated users submit a report
|
||||
*/
|
||||
final class BugReportController extends Controller
|
||||
{
|
||||
public function show(Request $request): View
|
||||
{
|
||||
return view('web.bug-report', [
|
||||
'page_title' => 'Bug Report — Skinbase',
|
||||
'page_meta_description' => 'Submit a bug report or suggestion to the Skinbase team.',
|
||||
'page_canonical' => url('/bug-report'),
|
||||
'hero_title' => 'Bug Report',
|
||||
'hero_description' => 'Found something broken? Submit a report and our team will look into it.',
|
||||
'breadcrumbs' => collect([
|
||||
(object) ['name' => 'Home', 'url' => '/'],
|
||||
(object) ['name' => 'Bug Report', 'url' => '/bug-report'],
|
||||
]),
|
||||
'success' => session('bug_report_success', false),
|
||||
'center_content' => true,
|
||||
'center_max' => '3xl',
|
||||
]);
|
||||
}
|
||||
|
||||
public function submit(Request $request): RedirectResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'subject' => ['required', 'string', 'max:255'],
|
||||
'description' => ['required', 'string', 'max:5000'],
|
||||
]);
|
||||
|
||||
BugReport::create([
|
||||
'user_id' => $request->user()->id,
|
||||
'subject' => $validated['subject'],
|
||||
'description' => $validated['description'],
|
||||
'ip_address' => $request->ip(),
|
||||
'user_agent' => substr($request->userAgent() ?? '', 0, 512),
|
||||
'status' => 'open',
|
||||
]);
|
||||
|
||||
return redirect()->route('bug-report')->with('bug_report_success', true);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
});
|
||||
|
||||
|
||||
112
app/Http/Controllers/Web/ErrorController.php
Normal file
112
app/Http/Controllers/Web/ErrorController.php
Normal file
@@ -0,0 +1,112 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Web;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\ErrorSuggestionService;
|
||||
use App\Services\NotFoundLogger;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
|
||||
/**
|
||||
* ErrorController
|
||||
*
|
||||
* Handles contextual 404 rendering.
|
||||
* Invoked from bootstrap/app.php exception handler for web 404s.
|
||||
*
|
||||
* Pattern detection:
|
||||
* /blog/* → blog-not-found (latest posts)
|
||||
* /tag/* → tag-not-found (similar + trending tags)
|
||||
* /@username → creator-not-found (trending creators)
|
||||
* /pages/* → page-not-found
|
||||
* /about|/help etc. → page-not-found
|
||||
* everything else → generic 404 (trending artworks + tags)
|
||||
*/
|
||||
final class ErrorController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ErrorSuggestionService $suggestions,
|
||||
private readonly NotFoundLogger $logger,
|
||||
) {}
|
||||
|
||||
public function handleNotFound(Request $request): Response|JsonResponse
|
||||
{
|
||||
// For JSON / Inertia API requests return a minimal JSON 404.
|
||||
if ($request->expectsJson() || $request->header('X-Inertia')) {
|
||||
return response()->json(['message' => 'Not Found'], 404);
|
||||
}
|
||||
|
||||
// Log every 404 hit for later analysis.
|
||||
try {
|
||||
$this->logger->log404($request);
|
||||
} catch (\Throwable) {
|
||||
// Never let the logger itself break the error page.
|
||||
}
|
||||
|
||||
$path = ltrim($request->path(), '/');
|
||||
|
||||
// ── /blog/* ──────────────────────────────────────────────────────────
|
||||
if (str_starts_with($path, 'blog/')) {
|
||||
return response(view('errors.contextual.blog-not-found', [
|
||||
'latestPosts' => $this->safeFetch(fn () => $this->suggestions->latestBlogPosts()),
|
||||
]), 404);
|
||||
}
|
||||
|
||||
// ── /tag/* ───────────────────────────────────────────────────────────
|
||||
if (str_starts_with($path, 'tag/')) {
|
||||
$slug = ltrim(substr($path, 4), '/');
|
||||
return response(view('errors.contextual.tag-not-found', [
|
||||
'requestedSlug' => $slug,
|
||||
'similarTags' => $this->safeFetch(fn () => $this->suggestions->similarTags($slug)),
|
||||
'trendingTags' => $this->safeFetch(fn () => $this->suggestions->trendingTags()),
|
||||
]), 404);
|
||||
}
|
||||
|
||||
// ── /@username or /creator/* ───────────────────────────────────────
|
||||
if (str_starts_with($path, '@') || str_starts_with($path, 'creator/')) {
|
||||
$username = str_starts_with($path, '@') ? substr($path, 1) : null;
|
||||
return response(view('errors.contextual.creator-not-found', [
|
||||
'requestedUsername' => $username,
|
||||
'trendingCreators' => $this->safeFetch(fn () => $this->suggestions->trendingCreators()),
|
||||
'recentCreators' => $this->safeFetch(fn () => $this->suggestions->recentlyJoinedCreators()),
|
||||
]), 404);
|
||||
}
|
||||
|
||||
// ── /{contentType}/{category}/{artwork-slug} — artwork not found ──────
|
||||
if (preg_match('#^(wallpapers|skins|photography|other)/#', $path)) {
|
||||
return response(view('errors.contextual.artwork-not-found', [
|
||||
'trendingArtworks' => $this->safeFetch(fn () => $this->suggestions->trendingArtworks()),
|
||||
]), 404);
|
||||
}
|
||||
|
||||
// ── /pages/* or /about | /help | /contact | /legal/* ───────────────
|
||||
if (
|
||||
str_starts_with($path, 'pages/')
|
||||
|| in_array($path, ['about', 'help', 'contact', 'faq', 'staff', 'privacy-policy', 'terms-of-service', 'rules-and-guidelines'])
|
||||
|| str_starts_with($path, 'legal/')
|
||||
) {
|
||||
return response(view('errors.contextual.page-not-found'), 404);
|
||||
}
|
||||
|
||||
// ── Generic 404 ───────────────────────────────────────────────────────
|
||||
return response(view('errors.404', [
|
||||
'trendingArtworks' => $this->safeFetch(fn () => $this->suggestions->trendingArtworks()),
|
||||
'trendingTags' => $this->safeFetch(fn () => $this->suggestions->trendingTags()),
|
||||
]), 404);
|
||||
}
|
||||
|
||||
/**
|
||||
* Silently catch any DB/cache error so the error page itself never crashes.
|
||||
*/
|
||||
private function safeFetch(callable $fn): mixed
|
||||
{
|
||||
try {
|
||||
return $fn();
|
||||
} catch (\Throwable) {
|
||||
return collect();
|
||||
}
|
||||
}
|
||||
}
|
||||
252
app/Http/Controllers/Web/ExploreController.php
Normal file
252
app/Http/Controllers/Web/ExploreController.php
Normal file
@@ -0,0 +1,252 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Web;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Artwork;
|
||||
use App\Models\ContentType;
|
||||
use App\Services\ArtworkSearchService;
|
||||
use App\Services\EarlyGrowth\EarlyGrowth;
|
||||
use App\Services\EarlyGrowth\GridFiller;
|
||||
use App\Services\EarlyGrowth\SpotlightEngineInterface;
|
||||
use App\Services\ThumbnailPresenter;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Pagination\AbstractCursorPaginator;
|
||||
use Illuminate\Pagination\AbstractPaginator;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
/**
|
||||
* ExploreController
|
||||
*
|
||||
* Powers the /explore/* structured catalog pages (§3.2 of routing spec).
|
||||
* Delegates to the same Meilisearch pipeline as BrowseGalleryController but
|
||||
* uses canonical /explore/* URLs with the ExploreLayout blade template.
|
||||
*/
|
||||
final class ExploreController extends Controller
|
||||
{
|
||||
private const CONTENT_TYPE_SLUGS = ['artworks', 'wallpapers', 'skins', 'photography', 'other'];
|
||||
|
||||
/** Meilisearch sort-field arrays per sort alias. */
|
||||
private const SORT_MAP = [
|
||||
'trending' => ['trending_score_24h:desc', 'trending_score_7d:desc', 'favorites_count:desc', 'created_at:desc'],
|
||||
'new-hot' => ['trending_score_7d:desc', 'favorites_count:desc', 'created_at:desc'],
|
||||
'best' => ['awards_received_count:desc', 'favorites_count:desc'],
|
||||
'latest' => ['created_at:desc'],
|
||||
];
|
||||
|
||||
private const SORT_TTL = [
|
||||
'trending' => 300,
|
||||
'new-hot' => 120,
|
||||
'best' => 600,
|
||||
'latest' => 120,
|
||||
];
|
||||
|
||||
private const SORT_OPTIONS = [
|
||||
['value' => 'trending', 'label' => '🔥 Trending'],
|
||||
['value' => 'new-hot', 'label' => '🚀 New & Hot'],
|
||||
['value' => 'best', 'label' => '⭐ Best'],
|
||||
['value' => 'latest', 'label' => '🕐 Latest'],
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
private readonly ArtworkSearchService $search,
|
||||
private readonly GridFiller $gridFiller,
|
||||
private readonly SpotlightEngineInterface $spotlight,
|
||||
) {}
|
||||
|
||||
// ── /explore (hub) ──────────────────────────────────────────────────
|
||||
|
||||
public function index(Request $request)
|
||||
{
|
||||
$sort = $this->resolveSort($request);
|
||||
$perPage = $this->resolvePerPage($request);
|
||||
$page = max(1, (int) $request->query('page', 1));
|
||||
$ttl = self::SORT_TTL[$sort] ?? 300;
|
||||
|
||||
$artworks = Cache::remember("explore.all.{$sort}.{$page}", $ttl, fn () =>
|
||||
Artwork::search('')->options([
|
||||
'filter' => 'is_public = true AND is_approved = true',
|
||||
'sort' => self::SORT_MAP[$sort] ?? ['created_at:desc'],
|
||||
])->paginate($perPage)
|
||||
);
|
||||
// EGS: fill grid to minimum when uploads are sparse
|
||||
$artworks = $this->gridFiller->fill($artworks, 0, $page);
|
||||
$artworks->getCollection()->transform(fn ($a) => $this->presentArtwork($a));
|
||||
|
||||
// EGS §11: featured spotlight row on page 1 only
|
||||
$spotlightItems = ($page === 1 && EarlyGrowth::spotlightEnabled())
|
||||
? $this->spotlight->getSpotlight(6)->map(fn ($a) => $this->presentArtwork($a))
|
||||
: collect();
|
||||
|
||||
$contentTypes = $this->contentTypeLinks();
|
||||
$seo = $this->paginationSeo($request, url('/explore'), $artworks);
|
||||
|
||||
return view('web.explore.index', [
|
||||
'artworks' => $artworks,
|
||||
'spotlight' => $spotlightItems,
|
||||
'contentTypes' => $contentTypes,
|
||||
'activeType' => null,
|
||||
'current_sort' => $sort,
|
||||
'sort_options' => self::SORT_OPTIONS,
|
||||
'hero_title' => 'Explore',
|
||||
'hero_description' => 'Browse the full Skinbase catalog — wallpapers, skins, photography and more.',
|
||||
'breadcrumbs' => collect([
|
||||
(object) ['name' => 'Explore', 'url' => '/explore'],
|
||||
]),
|
||||
'page_title' => 'Explore Artworks — Skinbase',
|
||||
'page_meta_description' => 'Explore the full catalog of wallpapers, skins, photography and other artworks on Skinbase.',
|
||||
'page_canonical' => $seo['canonical'],
|
||||
'page_rel_prev' => $seo['prev'],
|
||||
'page_rel_next' => $seo['next'],
|
||||
'page_robots' => 'index,follow',
|
||||
]);
|
||||
}
|
||||
|
||||
// ── /explore/:type ──────────────────────────────────────────────────
|
||||
|
||||
public function byType(Request $request, string $type)
|
||||
{
|
||||
$type = strtolower($type);
|
||||
if (!in_array($type, self::CONTENT_TYPE_SLUGS, true)) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
// "artworks" is the umbrella — search all types
|
||||
$isAll = $type === 'artworks';
|
||||
|
||||
$sort = $this->resolveSort($request);
|
||||
$perPage = $this->resolvePerPage($request);
|
||||
$page = max(1, (int) $request->query('page', 1));
|
||||
$ttl = self::SORT_TTL[$sort] ?? 300;
|
||||
|
||||
$filter = 'is_public = true AND is_approved = true';
|
||||
if (!$isAll) {
|
||||
$filter .= ' AND content_type = "' . $type . '"';
|
||||
}
|
||||
|
||||
$artworks = Cache::remember("explore.{$type}.{$sort}.{$page}", $ttl, fn () =>
|
||||
Artwork::search('')->options([
|
||||
'filter' => $filter,
|
||||
'sort' => self::SORT_MAP[$sort] ?? ['created_at:desc'],
|
||||
])->paginate($perPage)
|
||||
);
|
||||
// EGS: fill grid to minimum when uploads are sparse
|
||||
$artworks = $this->gridFiller->fill($artworks, 0, $page);
|
||||
$artworks->getCollection()->transform(fn ($a) => $this->presentArtwork($a));
|
||||
|
||||
// EGS §11: featured spotlight row on page 1 only
|
||||
$spotlightItems = ($page === 1 && EarlyGrowth::spotlightEnabled())
|
||||
? $this->spotlight->getSpotlight(6)->map(fn ($a) => $this->presentArtwork($a))
|
||||
: collect();
|
||||
|
||||
$contentTypes = $this->contentTypeLinks();
|
||||
$baseUrl = url('/explore/' . $type);
|
||||
$seo = $this->paginationSeo($request, $baseUrl, $artworks);
|
||||
$humanType = ucfirst($type);
|
||||
|
||||
return view('web.explore.index', [
|
||||
'artworks' => $artworks,
|
||||
'spotlight' => $spotlightItems,
|
||||
'contentTypes' => $contentTypes,
|
||||
'activeType' => $type,
|
||||
'current_sort' => $sort,
|
||||
'sort_options' => self::SORT_OPTIONS,
|
||||
'hero_title' => $humanType,
|
||||
'hero_description' => "Browse {$humanType} on Skinbase.",
|
||||
'breadcrumbs' => collect([
|
||||
(object) ['name' => 'Explore', 'url' => '/explore'],
|
||||
(object) ['name' => $humanType, 'url' => "/explore/{$type}"],
|
||||
]),
|
||||
'page_title' => "{$humanType} — Explore — Skinbase",
|
||||
'page_meta_description' => "Discover the best {$humanType} artworks on Skinbase. Browse trending, new and top-rated.",
|
||||
'page_canonical' => $seo['canonical'],
|
||||
'page_rel_prev' => $seo['prev'],
|
||||
'page_rel_next' => $seo['next'],
|
||||
'page_robots' => 'index,follow',
|
||||
]);
|
||||
}
|
||||
|
||||
// ── /explore/:type/:mode ────────────────────────────────────────────
|
||||
|
||||
public function byTypeMode(Request $request, string $type, string $mode)
|
||||
{
|
||||
// Rewrite the sort via the URL segment and delegate
|
||||
$request->query->set('sort', $mode);
|
||||
return $this->byType($request, $type);
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
private function contentTypeLinks(): Collection
|
||||
{
|
||||
return collect([
|
||||
(object) ['name' => 'All Artworks', 'slug' => 'artworks', 'url' => '/explore/artworks'],
|
||||
...ContentType::orderBy('id')->get(['name', 'slug'])->map(fn ($ct) => (object) [
|
||||
'name' => $ct->name,
|
||||
'slug' => $ct->slug,
|
||||
'url' => '/explore/' . strtolower($ct->slug),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
private function resolveSort(Request $request): string
|
||||
{
|
||||
$s = (string) $request->query('sort', 'trending');
|
||||
return array_key_exists($s, self::SORT_MAP) ? $s : 'trending';
|
||||
}
|
||||
|
||||
private function resolvePerPage(Request $request): int
|
||||
{
|
||||
$v = (int) ($request->query('per_page') ?: $request->query('limit') ?: 24);
|
||||
return max(12, min($v, 80));
|
||||
}
|
||||
|
||||
private function presentArtwork(Artwork $artwork): object
|
||||
{
|
||||
$primary = $artwork->categories->sortBy('sort_order')->first();
|
||||
$present = ThumbnailPresenter::present($artwork, 'md');
|
||||
$avatarUrl = \App\Support\AvatarUrl::forUser(
|
||||
(int) ($artwork->user_id ?? 0),
|
||||
$artwork->user?->profile?->avatar_hash ?? null,
|
||||
64
|
||||
);
|
||||
|
||||
return (object) [
|
||||
'id' => $artwork->id,
|
||||
'name' => $artwork->title,
|
||||
'category_name' => $primary->name ?? '',
|
||||
'category_slug' => $primary->slug ?? '',
|
||||
'thumb_url' => $present['url'],
|
||||
'thumb_srcset' => $present['srcset'] ?? $present['url'],
|
||||
'uname' => $artwork->user?->name ?? 'Skinbase',
|
||||
'username' => $artwork->user?->username ?? '',
|
||||
'avatar_url' => $avatarUrl,
|
||||
'published_at' => $artwork->published_at,
|
||||
'slug' => $artwork->slug ?? '',
|
||||
'width' => $artwork->width ?? null,
|
||||
'height' => $artwork->height ?? null,
|
||||
];
|
||||
}
|
||||
|
||||
private function paginationSeo(Request $request, string $base, mixed $paginator): array
|
||||
{
|
||||
$q = $request->query();
|
||||
unset($q['grid']);
|
||||
if (($q['page'] ?? null) !== null && (int) $q['page'] <= 1) {
|
||||
unset($q['page']);
|
||||
}
|
||||
$canonical = $base . ($q ? '?' . http_build_query($q) : '');
|
||||
|
||||
$prev = null;
|
||||
$next = null;
|
||||
if ($paginator instanceof AbstractPaginator || $paginator instanceof AbstractCursorPaginator) {
|
||||
$prev = $paginator->previousPageUrl();
|
||||
$next = $paginator->nextPageUrl();
|
||||
}
|
||||
|
||||
return compact('canonical', 'prev', 'next');
|
||||
}
|
||||
}
|
||||
87
app/Http/Controllers/Web/FooterController.php
Normal file
87
app/Http/Controllers/Web/FooterController.php
Normal file
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Web;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\View\View;
|
||||
|
||||
/**
|
||||
* FooterController — serves static footer pages.
|
||||
*
|
||||
* /faq → faq()
|
||||
* /rules-and-guidelines → rules()
|
||||
* /privacy-policy → privacyPolicy()
|
||||
* /terms-of-service → termsOfService()
|
||||
*/
|
||||
final class FooterController extends Controller
|
||||
{
|
||||
public function faq(): View
|
||||
{
|
||||
return view('web.faq', [
|
||||
'page_title' => 'FAQ — Skinbase',
|
||||
'page_meta_description' => 'Frequently Asked Questions about Skinbase — the community for skins, wallpapers, and photography.',
|
||||
'page_canonical' => url('/faq'),
|
||||
'hero_title' => 'Frequently Asked Questions',
|
||||
'hero_description' => 'Answers to the most common questions from our members. Last updated March 1, 2026.',
|
||||
'breadcrumbs' => collect([
|
||||
(object) ['name' => 'Home', 'url' => '/'],
|
||||
(object) ['name' => 'FAQ', 'url' => '/faq'],
|
||||
]),
|
||||
'center_content' => true,
|
||||
'center_max' => '3xl',
|
||||
]);
|
||||
}
|
||||
|
||||
public function rules(): View
|
||||
{
|
||||
return view('web.rules', [
|
||||
'page_title' => 'Rules & Guidelines — Skinbase',
|
||||
'page_meta_description' => 'Read the Skinbase community rules and content guidelines before submitting your work.',
|
||||
'page_canonical' => url('/rules-and-guidelines'),
|
||||
'hero_title' => 'Rules & Guidelines',
|
||||
'hero_description' => 'Please review these guidelines before uploading or participating. Last updated March 1, 2026.',
|
||||
'breadcrumbs' => collect([
|
||||
(object) ['name' => 'Home', 'url' => '/'],
|
||||
(object) ['name' => 'Rules & Guidelines', 'url' => '/rules-and-guidelines'],
|
||||
]),
|
||||
'center_content' => true,
|
||||
'center_max' => '3xl',
|
||||
]);
|
||||
}
|
||||
|
||||
public function termsOfService(): View
|
||||
{
|
||||
return view('web.terms-of-service', [
|
||||
'page_title' => 'Terms of Service — Skinbase',
|
||||
'page_meta_description' => 'Read the Skinbase Terms of Service — the agreement that governs your use of the platform.',
|
||||
'page_canonical' => url('/terms-of-service'),
|
||||
'hero_title' => 'Terms of Service',
|
||||
'hero_description' => 'The agreement between you and Skinbase that governs your use of the platform. Last updated March 1, 2026.',
|
||||
'breadcrumbs' => collect([
|
||||
(object) ['name' => 'Home', 'url' => '/'],
|
||||
(object) ['name' => 'Terms of Service', 'url' => '/terms-of-service'],
|
||||
]),
|
||||
'center_content' => true,
|
||||
'center_max' => '3xl',
|
||||
]);
|
||||
}
|
||||
|
||||
public function privacyPolicy(): View
|
||||
{
|
||||
return view('web.privacy-policy', [
|
||||
'page_title' => 'Privacy Policy — Skinbase',
|
||||
'page_meta_description' => 'Read the Skinbase privacy policy to understand how we collect and use your data.',
|
||||
'page_canonical' => url('/privacy-policy'),
|
||||
'hero_title' => 'Privacy Policy',
|
||||
'hero_description' => 'How Skinbase collects, uses, and protects your information. Last updated March 1, 2026.',
|
||||
'breadcrumbs' => collect([
|
||||
(object) ['name' => 'Home', 'url' => '/'],
|
||||
(object) ['name' => 'Privacy Policy', 'url' => '/privacy-policy'],
|
||||
]),
|
||||
'center_content' => true,
|
||||
'center_max' => '3xl',
|
||||
]);
|
||||
}
|
||||
}
|
||||
75
app/Http/Controllers/Web/PageController.php
Normal file
75
app/Http/Controllers/Web/PageController.php
Normal file
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Web;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Page;
|
||||
use Illuminate\View\View;
|
||||
|
||||
/**
|
||||
* PageController — DB-driven static pages (/pages/:slug).
|
||||
*
|
||||
* Also handles root-level marketing pages (/about, /help, /contact)
|
||||
* and legal pages (/legal/terms, /legal/privacy, /legal/cookies).
|
||||
*/
|
||||
final class PageController extends Controller
|
||||
{
|
||||
public function show(string $slug): View
|
||||
{
|
||||
$page = Page::published()->where('slug', $slug)->firstOrFail();
|
||||
|
||||
return view('web.pages.show', [
|
||||
'page' => $page,
|
||||
'page_title' => ($page->meta_title ?: $page->title) . ' — Skinbase',
|
||||
'page_meta_description' => $page->meta_description ?: '',
|
||||
'page_canonical' => $page->canonical_url,
|
||||
'page_robots' => 'index,follow',
|
||||
'breadcrumbs' => collect([
|
||||
(object) ['name' => $page->title, 'url' => $page->url],
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Serve root-level marketing slugs (/about, /help, /contact).
|
||||
* Falls back to 404 if no matching page exists.
|
||||
*/
|
||||
public function marketing(string $slug): View
|
||||
{
|
||||
$page = Page::published()->where('slug', $slug)->firstOrFail();
|
||||
|
||||
return view('web.pages.show', [
|
||||
'page' => $page,
|
||||
'page_title' => ($page->meta_title ?: $page->title) . ' — Skinbase',
|
||||
'page_meta_description' => $page->meta_description ?: '',
|
||||
'page_canonical' => url('/' . $slug),
|
||||
'page_robots' => 'index,follow',
|
||||
'breadcrumbs' => collect([
|
||||
(object) ['name' => $page->title, 'url' => '/' . $slug],
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Legal pages (/legal/terms, /legal/privacy, /legal/cookies).
|
||||
* Looks for page with slug "legal-{section}".
|
||||
*/
|
||||
public function legal(string $section): View
|
||||
{
|
||||
$page = Page::published()->where('slug', 'legal-' . $section)->firstOrFail();
|
||||
|
||||
return view('web.pages.show', [
|
||||
'page' => $page,
|
||||
'page_title' => ($page->meta_title ?: $page->title) . ' — Skinbase',
|
||||
'page_meta_description' => $page->meta_description ?: '',
|
||||
'page_canonical' => url('/legal/' . $section),
|
||||
'page_robots' => 'index,follow',
|
||||
'breadcrumbs' => collect([
|
||||
(object) ['name' => 'Legal', 'url' => '#'],
|
||||
(object) ['name' => $page->title, 'url' => '/legal/' . $section],
|
||||
]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
114
app/Http/Controllers/Web/RssFeedController.php
Normal file
114
app/Http/Controllers/Web/RssFeedController.php
Normal file
@@ -0,0 +1,114 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Web;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Artwork;
|
||||
use App\Models\ContentType;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\View\View;
|
||||
|
||||
/**
|
||||
* RssFeedController
|
||||
*
|
||||
* GET /rss-feeds → info page listing available feeds
|
||||
* GET /rss/latest-uploads.xml → all published artworks
|
||||
* GET /rss/latest-skins.xml → skins only
|
||||
* GET /rss/latest-wallpapers.xml → wallpapers only
|
||||
* GET /rss/latest-photos.xml → photography only
|
||||
*/
|
||||
final class RssFeedController extends Controller
|
||||
{
|
||||
/** Number of items per feed. */
|
||||
private const FEED_LIMIT = 25;
|
||||
|
||||
/** Feed definitions shown on the info page. */
|
||||
public const FEEDS = [
|
||||
'uploads' => ['title' => 'Latest Uploads', 'url' => '/rss/latest-uploads.xml'],
|
||||
'skins' => ['title' => 'Latest Skins', 'url' => '/rss/latest-skins.xml'],
|
||||
'wallpapers' => ['title' => 'Latest Wallpapers', 'url' => '/rss/latest-wallpapers.xml'],
|
||||
'photos' => ['title' => 'Latest Photos', 'url' => '/rss/latest-photos.xml'],
|
||||
];
|
||||
|
||||
/** Info page at /rss-feeds */
|
||||
public function index(): View
|
||||
{
|
||||
return view('web.rss-feeds', [
|
||||
'page_title' => 'RSS Feeds — Skinbase',
|
||||
'page_meta_description' => 'Subscribe to Skinbase RSS feeds to stay up to date with the latest uploads, skins, wallpapers, and photos.',
|
||||
'page_canonical' => url('/rss-feeds'),
|
||||
'hero_title' => 'RSS Feeds',
|
||||
'hero_description' => 'Subscribe to stay up to date with the latest content on Skinbase.',
|
||||
'breadcrumbs' => collect([
|
||||
(object) ['name' => 'Home', 'url' => '/'],
|
||||
(object) ['name' => 'RSS Feeds', 'url' => '/rss-feeds'],
|
||||
]),
|
||||
'feeds' => self::FEEDS,
|
||||
'center_content' => true,
|
||||
'center_max' => '3xl',
|
||||
]);
|
||||
}
|
||||
|
||||
/** /rss/latest-uploads.xml — all content types */
|
||||
public function latestUploads(): Response
|
||||
{
|
||||
$artworks = Artwork::published()
|
||||
->with(['user'])
|
||||
->latest('published_at')
|
||||
->limit(self::FEED_LIMIT)
|
||||
->get();
|
||||
|
||||
return $this->buildFeed('Latest Uploads', url('/rss/latest-uploads.xml'), $artworks);
|
||||
}
|
||||
|
||||
/** /rss/latest-skins.xml */
|
||||
public function latestSkins(): Response
|
||||
{
|
||||
return $this->feedByContentType('skins', 'Latest Skins', '/rss/latest-skins.xml');
|
||||
}
|
||||
|
||||
/** /rss/latest-wallpapers.xml */
|
||||
public function latestWallpapers(): Response
|
||||
{
|
||||
return $this->feedByContentType('wallpapers', 'Latest Wallpapers', '/rss/latest-wallpapers.xml');
|
||||
}
|
||||
|
||||
/** /rss/latest-photos.xml */
|
||||
public function latestPhotos(): Response
|
||||
{
|
||||
return $this->feedByContentType('photography', 'Latest Photos', '/rss/latest-photos.xml');
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private function feedByContentType(string $slug, string $title, string $feedPath): Response
|
||||
{
|
||||
$contentType = ContentType::where('slug', $slug)->first();
|
||||
|
||||
$query = Artwork::published()->with(['user'])->latest('published_at')->limit(self::FEED_LIMIT);
|
||||
|
||||
if ($contentType) {
|
||||
$query->whereHas('categories', fn ($q) => $q->where('content_type_id', $contentType->id));
|
||||
}
|
||||
|
||||
return $this->buildFeed($title, url($feedPath), $query->get());
|
||||
}
|
||||
|
||||
private function buildFeed(string $channelTitle, string $feedUrl, $artworks): Response
|
||||
{
|
||||
$content = view('rss.feed', [
|
||||
'channelTitle' => $channelTitle . ' — Skinbase',
|
||||
'channelDescription' => 'The latest ' . strtolower($channelTitle) . ' from Skinbase.org',
|
||||
'channelLink' => url('/'),
|
||||
'feedUrl' => $feedUrl,
|
||||
'artworks' => $artworks,
|
||||
'buildDate' => now()->toRfc2822String(),
|
||||
])->render();
|
||||
|
||||
return response($content, 200, [
|
||||
'Content-Type' => 'application/rss+xml; charset=utf-8',
|
||||
]);
|
||||
}
|
||||
}
|
||||
21
app/Http/Controllers/Web/StaffApplicationAdminController.php
Normal file
21
app/Http/Controllers/Web/StaffApplicationAdminController.php
Normal file
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Web;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\StaffApplication;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class StaffApplicationAdminController extends Controller
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
$items = StaffApplication::orderBy('created_at', 'desc')->paginate(25);
|
||||
return view('admin.staff_applications.index', ['items' => $items]);
|
||||
}
|
||||
|
||||
public function show(StaffApplication $staffApplication)
|
||||
{
|
||||
return view('admin.staff_applications.show', ['item' => $staffApplication]);
|
||||
}
|
||||
}
|
||||
52
app/Http/Controllers/Web/StaffController.php
Normal file
52
app/Http/Controllers/Web/StaffController.php
Normal file
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Web;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\View\View;
|
||||
|
||||
/**
|
||||
* StaffController — /staff
|
||||
*
|
||||
* Displays all users with an elevated role (admin, moderator) grouped by role.
|
||||
*/
|
||||
final class StaffController extends Controller
|
||||
{
|
||||
/** Roles that appear on the staff page, in display order. */
|
||||
private const STAFF_ROLES = ['admin', 'moderator'];
|
||||
|
||||
public function index(): View
|
||||
{
|
||||
/** @var Collection<string, \Illuminate\Support\Collection<int, User>> $staffByRole */
|
||||
$staffByRole = User::with('profile')
|
||||
->whereIn('role', self::STAFF_ROLES)
|
||||
->where('is_active', true)
|
||||
->orderByRaw("CASE role WHEN 'admin' THEN 0 WHEN 'moderator' THEN 1 ELSE 2 END")
|
||||
->orderBy('username')
|
||||
->get()
|
||||
->groupBy('role');
|
||||
|
||||
return view('web.staff', [
|
||||
'page_title' => 'Staff — Skinbase',
|
||||
'page_meta_description' => 'Meet the Skinbase team — admins and moderators who keep the community running.',
|
||||
'page_canonical' => url('/staff'),
|
||||
'hero_title' => 'Meet the Staff',
|
||||
'hero_description' => 'The people behind Skinbase who keep the community running smoothly.',
|
||||
'breadcrumbs' => collect([
|
||||
(object) ['name' => 'Home', 'url' => '/'],
|
||||
(object) ['name' => 'Staff', 'url' => '/staff'],
|
||||
]),
|
||||
'staffByRole' => $staffByRole,
|
||||
'roleLabels' => [
|
||||
'admin' => 'Administrators',
|
||||
'moderator' => 'Moderators',
|
||||
],
|
||||
'center_content' => true,
|
||||
'center_max' => '3xl',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
];
|
||||
|
||||
43
app/Mail/StaffApplicationReceived.php
Normal file
43
app/Mail/StaffApplicationReceived.php
Normal file
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\Mail;
|
||||
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use App\Models\StaffApplication;
|
||||
|
||||
class StaffApplicationReceived extends Mailable implements ShouldQueue
|
||||
{
|
||||
use Queueable, SerializesModels;
|
||||
|
||||
public StaffApplication $application;
|
||||
|
||||
/**
|
||||
* Create a new message instance.
|
||||
*/
|
||||
public function __construct(StaffApplication $application)
|
||||
{
|
||||
$this->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]);
|
||||
}
|
||||
}
|
||||
71
app/Models/BlogPost.php
Normal file
71
app/Models/BlogPost.php
Normal file
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
/**
|
||||
* Blog post model for the /blog section.
|
||||
*
|
||||
* @property int $id
|
||||
* @property string $slug
|
||||
* @property string $title
|
||||
* @property string $body HTML or Markdown content
|
||||
* @property string|null $excerpt
|
||||
* @property int|null $author_id
|
||||
* @property string|null $featured_image
|
||||
* @property string|null $meta_title
|
||||
* @property string|null $meta_description
|
||||
* @property bool $is_published
|
||||
* @property \Carbon\Carbon|null $published_at
|
||||
*/
|
||||
class BlogPost extends Model
|
||||
{
|
||||
use HasFactory, SoftDeletes;
|
||||
|
||||
protected $table = 'blog_posts';
|
||||
|
||||
protected $fillable = [
|
||||
'slug',
|
||||
'title',
|
||||
'body',
|
||||
'excerpt',
|
||||
'author_id',
|
||||
'featured_image',
|
||||
'meta_title',
|
||||
'meta_description',
|
||||
'is_published',
|
||||
'published_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'is_published' => '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);
|
||||
}
|
||||
}
|
||||
28
app/Models/BugReport.php
Normal file
28
app/Models/BugReport.php
Normal file
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* App\Models\BugReport
|
||||
*/
|
||||
final class BugReport extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'subject',
|
||||
'description',
|
||||
'ip_address',
|
||||
'user_agent',
|
||||
'status',
|
||||
];
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
}
|
||||
63
app/Models/Page.php
Normal file
63
app/Models/Page.php
Normal file
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
/**
|
||||
* DB-driven static/content page (About, Help, Legal, etc.)
|
||||
*
|
||||
* @property int $id
|
||||
* @property string $slug
|
||||
* @property string $title
|
||||
* @property string $body HTML or Markdown content
|
||||
* @property string $layout 'default' | 'legal' | 'help'
|
||||
* @property string|null $meta_title
|
||||
* @property string|null $meta_description
|
||||
* @property bool $is_published
|
||||
* @property \Carbon\Carbon|null $published_at
|
||||
*/
|
||||
class Page extends Model
|
||||
{
|
||||
use HasFactory, SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
'slug',
|
||||
'title',
|
||||
'body',
|
||||
'layout',
|
||||
'meta_title',
|
||||
'meta_description',
|
||||
'is_published',
|
||||
'published_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'is_published' => '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;
|
||||
}
|
||||
}
|
||||
32
app/Models/StaffApplication.php
Normal file
32
app/Models/StaffApplication.php
Normal file
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class StaffApplication extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
public $incrementing = false;
|
||||
protected $keyType = 'string';
|
||||
|
||||
protected $fillable = [
|
||||
'id','topic','name','email','role','portfolio','message','payload','ip','user_agent'
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'payload' => 'array',
|
||||
];
|
||||
|
||||
protected static function booted()
|
||||
{
|
||||
static::creating(function ($model) {
|
||||
if (empty($model->id)) {
|
||||
$model->id = (string) Str::uuid();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 . '"',
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
|
||||
149
app/Services/EarlyGrowth/ActivityLayer.php
Normal file
149
app/Services/EarlyGrowth/ActivityLayer.php
Normal file
@@ -0,0 +1,149 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\EarlyGrowth;
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* ActivityLayer (§8 — Optional)
|
||||
*
|
||||
* Surfaces real site-activity signals as human-readable summaries.
|
||||
* All data is genuine — no fabrication, no fake counts.
|
||||
*
|
||||
* Examples:
|
||||
* "🔥 Trending this week: 24 artworks"
|
||||
* "📈 Rising in Wallpapers"
|
||||
* "🌟 5 new creators joined this month"
|
||||
* "🎨 38 artworks published recently"
|
||||
*
|
||||
* Only active when EarlyGrowth::activityLayerEnabled() returns true.
|
||||
*/
|
||||
final class ActivityLayer
|
||||
{
|
||||
/**
|
||||
* Return an array of activity signal strings for use in UI badges/widgets.
|
||||
* Empty array when ActivityLayer is disabled.
|
||||
*
|
||||
* @return array<int, array{icon: string, text: string, type: string}>
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
78
app/Services/EarlyGrowth/AdaptiveTimeWindow.php
Normal file
78
app/Services/EarlyGrowth/AdaptiveTimeWindow.php
Normal file
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\EarlyGrowth;
|
||||
|
||||
use App\Models\Artwork;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
/**
|
||||
* AdaptiveTimeWindow
|
||||
*
|
||||
* Dynamically widens the look-back window used by trending / rising feeds
|
||||
* when recent upload volume is below configured thresholds.
|
||||
*
|
||||
* This only affects RANKING QUERIES — it never modifies artwork timestamps,
|
||||
* canonical URLs, or any stored data.
|
||||
*
|
||||
* Behaviour:
|
||||
* uploads/day ≥ narrow_threshold → normal window (7 d)
|
||||
* uploads/day ≥ wide_threshold → medium window (30 d)
|
||||
* uploads/day < wide_threshold → wide window (90 d)
|
||||
*
|
||||
* All thresholds and window sizes are configurable in config/early_growth.php.
|
||||
*/
|
||||
final class AdaptiveTimeWindow
|
||||
{
|
||||
/**
|
||||
* Return the number of look-back days to use for trending / rising queries.
|
||||
*
|
||||
* @param int $defaultDays Returned as-is when EGS is disabled.
|
||||
*/
|
||||
public function getTrendingWindowDays(int $defaultDays = 30): int
|
||||
{
|
||||
if (! EarlyGrowth::adaptiveWindowEnabled()) {
|
||||
return $defaultDays;
|
||||
}
|
||||
|
||||
$uploadsPerDay = $this->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);
|
||||
});
|
||||
}
|
||||
}
|
||||
149
app/Services/EarlyGrowth/EarlyGrowth.php
Normal file
149
app/Services/EarlyGrowth/EarlyGrowth.php
Normal file
@@ -0,0 +1,149 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\EarlyGrowth;
|
||||
|
||||
use App\Models\Artwork;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
/**
|
||||
* EarlyGrowth
|
||||
*
|
||||
* Central service for the Early-Stage Growth System.
|
||||
* All other EGS modules consult this class for feature-flag status.
|
||||
*
|
||||
* Toggle via .env:
|
||||
* NOVA_EARLY_GROWTH_ENABLED=true
|
||||
* NOVA_EARLY_GROWTH_MODE=light # off | light | aggressive
|
||||
*
|
||||
* Set NOVA_EARLY_GROWTH_ENABLED=false to instantly revert to
|
||||
* normal production behaviour across every integration point.
|
||||
*/
|
||||
final class EarlyGrowth
|
||||
{
|
||||
// ─── Feature-flag helpers ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Is the entire Early Growth System active?
|
||||
* Checks master enabled flag AND that mode is not 'off'.
|
||||
*/
|
||||
public static function enabled(): bool
|
||||
{
|
||||
if (! (bool) config('early_growth.enabled', false)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Auto-disable check (optional)
|
||||
if ((bool) config('early_growth.auto_disable.enabled', false) && self::shouldAutoDisable()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return self::mode() !== 'off';
|
||||
}
|
||||
|
||||
/**
|
||||
* Current operating mode: off | light | aggressive
|
||||
*/
|
||||
public static function mode(): string
|
||||
{
|
||||
$mode = (string) config('early_growth.mode', 'off');
|
||||
|
||||
return in_array($mode, ['off', 'light', 'aggressive'], true) ? $mode : 'off';
|
||||
}
|
||||
|
||||
/** Is the AdaptiveTimeWindow module active? */
|
||||
public static function adaptiveWindowEnabled(): bool
|
||||
{
|
||||
return self::enabled() && (bool) config('early_growth.adaptive_time_window', true);
|
||||
}
|
||||
|
||||
/** Is the GridFiller module active? */
|
||||
public static function gridFillerEnabled(): bool
|
||||
{
|
||||
return self::enabled() && (bool) config('early_growth.grid_filler', true);
|
||||
}
|
||||
|
||||
/** Is the SpotlightEngine module active? */
|
||||
public static function spotlightEnabled(): bool
|
||||
{
|
||||
return self::enabled() && (bool) config('early_growth.spotlight', true);
|
||||
}
|
||||
|
||||
/** Is the optional ActivityLayer module active? */
|
||||
public static function activityLayerEnabled(): bool
|
||||
{
|
||||
return self::enabled() && (bool) config('early_growth.activity_layer', false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Blend ratios for the current mode.
|
||||
* Returns proportions for fresh / curated / spotlight slices.
|
||||
*/
|
||||
public static function blendRatios(): array
|
||||
{
|
||||
$mode = self::mode();
|
||||
|
||||
return config("early_growth.blend_ratios.{$mode}", [
|
||||
'fresh' => 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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
124
app/Services/EarlyGrowth/FeedBlender.php
Normal file
124
app/Services/EarlyGrowth/FeedBlender.php
Normal file
@@ -0,0 +1,124 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\EarlyGrowth;
|
||||
|
||||
use App\Services\EarlyGrowth\SpotlightEngineInterface;
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
|
||||
/**
|
||||
* FeedBlender
|
||||
*
|
||||
* Blends real fresh uploads with curated older content and spotlight picks
|
||||
* to make early-stage feeds feel alive and diverse without faking engagement.
|
||||
*
|
||||
* Rules:
|
||||
* - ONLY applied to page 1 — deeper pages use the real feed untouched.
|
||||
* - No fake artworks, timestamps, or metrics.
|
||||
* - Duplicates removed before merging.
|
||||
* - The original paginator's total / path / page-name are preserved so
|
||||
* pagination links and SEO canonical/prev/next remain correct.
|
||||
*
|
||||
* Mode blend ratios are defined in config/early_growth.php:
|
||||
* light → 60% fresh / 25% curated / 15% spotlight
|
||||
* aggressive → 30% fresh / 50% curated / 20% spotlight
|
||||
*/
|
||||
final class FeedBlender
|
||||
{
|
||||
public function __construct(
|
||||
private readonly SpotlightEngineInterface $spotlight,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Blend a LengthAwarePaginator of fresh artworks with curated and spotlight content.
|
||||
*
|
||||
* @param LengthAwarePaginator $freshResults Original fresh-upload paginator
|
||||
* @param int $perPage Items per page
|
||||
* @param int $page Current page number
|
||||
* @return LengthAwarePaginator Blended paginator (page 1) or original (page > 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)];
|
||||
}
|
||||
}
|
||||
129
app/Services/EarlyGrowth/GridFiller.php
Normal file
129
app/Services/EarlyGrowth/GridFiller.php
Normal file
@@ -0,0 +1,129 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\EarlyGrowth;
|
||||
|
||||
use App\Models\Artwork;
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
/**
|
||||
* GridFiller
|
||||
*
|
||||
* Ensures that browse / discover grids never appear half-empty.
|
||||
* When real results fall below the configured minimum, it backfills
|
||||
* with real trending artworks from the general pool.
|
||||
*
|
||||
* Rules (per spec):
|
||||
* - Fill only the visible first page — never mix page-number scopes.
|
||||
* - Filler is always real content (no fake items).
|
||||
* - The original total is not reduced (pagination links stay stable).
|
||||
* - Content is not labelled as "filler" in the UI — it is just valid content.
|
||||
*/
|
||||
final class GridFiller
|
||||
{
|
||||
/**
|
||||
* Ensure a LengthAwarePaginator contains at least $minimum items on page 1.
|
||||
* Returns the original paginator unchanged when:
|
||||
* - EGS is disabled
|
||||
* - Page is > 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();
|
||||
});
|
||||
}
|
||||
}
|
||||
116
app/Services/EarlyGrowth/SpotlightEngine.php
Normal file
116
app/Services/EarlyGrowth/SpotlightEngine.php
Normal file
@@ -0,0 +1,116 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\EarlyGrowth;
|
||||
|
||||
use App\Models\Artwork;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
/**
|
||||
* SpotlightEngine
|
||||
*
|
||||
* Selects and rotates curated spotlight artworks for use in feed blending,
|
||||
* grid filling, and dedicated spotlight sections.
|
||||
*
|
||||
* Selection is date-seeded so the spotlight rotates daily without DB writes.
|
||||
* No artwork timestamps or engagement metrics are modified — this is purely
|
||||
* a read-and-present layer.
|
||||
*/
|
||||
final class SpotlightEngine implements SpotlightEngineInterface
|
||||
{
|
||||
/**
|
||||
* Return spotlight artworks for the current day.
|
||||
* Cached for `early_growth.cache_ttl.spotlight` seconds (default 1 hour).
|
||||
* Rotates daily via a date-seeded RAND() expression.
|
||||
*
|
||||
* Returns empty collection when SpotlightEngine is disabled.
|
||||
*/
|
||||
public function getSpotlight(int $limit = 6): Collection
|
||||
{
|
||||
if (! EarlyGrowth::spotlightEnabled()) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
$ttl = (int) config('early_growth.cache_ttl.spotlight', 3600);
|
||||
$cacheKey = 'egs.spotlight.' . now()->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();
|
||||
}
|
||||
}
|
||||
18
app/Services/EarlyGrowth/SpotlightEngineInterface.php
Normal file
18
app/Services/EarlyGrowth/SpotlightEngineInterface.php
Normal file
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\EarlyGrowth;
|
||||
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
/**
|
||||
* Contract for spotlight / curated content selection.
|
||||
* Allows test doubles and alternative implementations.
|
||||
*/
|
||||
interface SpotlightEngineInterface
|
||||
{
|
||||
public function getSpotlight(int $limit = 6): Collection;
|
||||
|
||||
public function getCurated(int $limit = 12, int $olderThanDays = 7): Collection;
|
||||
}
|
||||
168
app/Services/ErrorSuggestionService.php
Normal file
168
app/Services/ErrorSuggestionService.php
Normal file
@@ -0,0 +1,168 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Models\Tag;
|
||||
use App\Models\User;
|
||||
use App\Models\BlogPost;
|
||||
use App\Services\ThumbnailPresenter;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* ErrorSuggestionService
|
||||
*
|
||||
* Supplies lightweight contextual suggestions for error pages.
|
||||
* All queries are cheap, results cached to TTL 5 min.
|
||||
*/
|
||||
final class ErrorSuggestionService
|
||||
{
|
||||
private const CACHE_TTL = 300; // 5 minutes
|
||||
|
||||
// ── Trending artworks (max 6) ─────────────────────────────────────────────
|
||||
|
||||
public function trendingArtworks(int $limit = 6): Collection
|
||||
{
|
||||
$limit = min($limit, 6);
|
||||
|
||||
return Cache::remember("error_suggestions.artworks.{$limit}", self::CACHE_TTL, function () use ($limit) {
|
||||
return Artwork::query()
|
||||
->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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
|
||||
59
app/Services/NotFoundLogger.php
Normal file
59
app/Services/NotFoundLogger.php
Normal file
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* NotFoundLogger
|
||||
*
|
||||
* Logs 404 and 500 error events to dedicated log channels so they can
|
||||
* be tracked, aggregated and used to create redirect rules later.
|
||||
*
|
||||
* 404 → logged to 'not_found' channel (see config/logging.php daily driver)
|
||||
* 500 → logged to default channel with correlation ID
|
||||
*/
|
||||
final class NotFoundLogger
|
||||
{
|
||||
/**
|
||||
* Log a 404 hit: URL, referrer, user-agent, user ID.
|
||||
*/
|
||||
public function log404(Request $request): void
|
||||
{
|
||||
Log::channel(config('logging.not_found_channel', 'daily'))->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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user