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:
33
.env.example
33
.env.example
@@ -232,3 +232,36 @@ AWS_BUCKET=
|
||||
AWS_USE_PATH_STYLE_ENDPOINT=false
|
||||
|
||||
VITE_APP_NAME="${APP_NAME}"
|
||||
|
||||
# ─── Early-Stage Growth System ───────────────────────────────────────────────
|
||||
# Set NOVA_EARLY_GROWTH_ENABLED=false to instantly revert to normal behaviour.
|
||||
# NOVA_EARLY_GROWTH_MODE: off | light | aggressive
|
||||
NOVA_EARLY_GROWTH_ENABLED=false
|
||||
NOVA_EARLY_GROWTH_MODE=off
|
||||
|
||||
# Module toggles (only active when NOVA_EARLY_GROWTH_ENABLED=true)
|
||||
NOVA_EGS_ADAPTIVE_WINDOW=true
|
||||
NOVA_EGS_GRID_FILLER=true
|
||||
NOVA_EGS_SPOTLIGHT=true
|
||||
NOVA_EGS_ACTIVITY_LAYER=false
|
||||
|
||||
# AdaptiveTimeWindow thresholds
|
||||
NOVA_EGS_UPLOADS_PER_DAY_NARROW=10
|
||||
NOVA_EGS_UPLOADS_PER_DAY_WIDE=3
|
||||
NOVA_EGS_WINDOW_NARROW_DAYS=7
|
||||
NOVA_EGS_WINDOW_MEDIUM_DAYS=30
|
||||
NOVA_EGS_WINDOW_WIDE_DAYS=90
|
||||
|
||||
# GridFiller minimum items per page
|
||||
NOVA_EGS_GRID_MIN_RESULTS=12
|
||||
|
||||
# Auto-disable when site reaches organic scale
|
||||
NOVA_EGS_AUTO_DISABLE=false
|
||||
NOVA_EGS_AUTO_DISABLE_UPLOADS=50
|
||||
NOVA_EGS_AUTO_DISABLE_USERS=500
|
||||
|
||||
# Cache TTLs (seconds)
|
||||
NOVA_EGS_SPOTLIGHT_TTL=3600
|
||||
NOVA_EGS_BLEND_TTL=300
|
||||
NOVA_EGS_WINDOW_TTL=600
|
||||
NOVA_EGS_ACTIVITY_TTL=1800
|
||||
|
||||
15
README.md
15
README.md
@@ -421,6 +421,21 @@ curl -X POST "$CLIP_BASE_URL$CLIP_ANALYZE_ENDPOINT" \
|
||||
- AI tags appear on the artwork when services are healthy.
|
||||
- Failures are logged, but publish is unaffected.
|
||||
|
||||
## Queue workers
|
||||
|
||||
The contact form mails are queued. To process them you need a worker. Locally you can run a foreground worker:
|
||||
|
||||
```
|
||||
php artisan queue:work --sleep=3 --tries=3
|
||||
```
|
||||
|
||||
For production we provide example configs under `deploy/`:
|
||||
|
||||
- `deploy/supervisor/skinbase-queue.conf` — Supervisor config
|
||||
- `deploy/systemd/skinbase-queue.service` — systemd unit file
|
||||
|
||||
See `docs/QUEUE.md` for full setup steps and commands.
|
||||
|
||||
## License
|
||||
|
||||
The Laravel framework is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT).
|
||||
|
||||
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;
|
||||
@@ -30,6 +32,8 @@ final class DiscoverController extends Controller
|
||||
private readonly ArtworkService $artworkService,
|
||||
private readonly ArtworkSearchService $searchService,
|
||||
private readonly RecommendationService $recoService,
|
||||
private readonly FeedBlender $feedBlender,
|
||||
private readonly GridFiller $gridFiller,
|
||||
) {}
|
||||
|
||||
// ─── /discover/trending ──────────────────────────────────────────────────
|
||||
@@ -37,7 +41,9 @@ final class DiscoverController extends Controller
|
||||
public function trending(Request $request)
|
||||
{
|
||||
$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', [
|
||||
@@ -54,7 +60,9 @@ final class DiscoverController extends Controller
|
||||
public function rising(Request $request)
|
||||
{
|
||||
$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', [
|
||||
@@ -71,7 +79,11 @@ final class DiscoverController extends Controller
|
||||
public function fresh(Request $request)
|
||||
{
|
||||
$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', [
|
||||
@@ -88,7 +100,9 @@ final class DiscoverController extends Controller
|
||||
public function topRated(Request $request)
|
||||
{
|
||||
$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', [
|
||||
@@ -105,7 +119,9 @@ final class DiscoverController extends Controller
|
||||
public function mostDownloaded(Request $request)
|
||||
{
|
||||
$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)
|
||||
@@ -193,6 +210,7 @@ final class DiscoverController extends Controller
|
||||
'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,16 +73,11 @@ final class TagController extends Controller
|
||||
'url' => '/' . strtolower($type->slug),
|
||||
]);
|
||||
|
||||
return view('gallery.index', [
|
||||
'gallery_type' => 'tag',
|
||||
'mainCategories' => $mainCategories,
|
||||
'subcategories' => collect(),
|
||||
'contentType' => null,
|
||||
'category' => null,
|
||||
return view('tags.show', [
|
||||
'tag' => $tag,
|
||||
'artworks' => $artworks,
|
||||
'hero_title' => '#' . $tag->name,
|
||||
'hero_description'=> 'All artworks tagged "' . e($tag->name) . '".',
|
||||
'breadcrumbs' => collect(),
|
||||
'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),
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
@@ -257,9 +262,12 @@ final class ArtworkSearchService
|
||||
public function discoverTrending(int $perPage = 24): LengthAwarePaginator
|
||||
{
|
||||
$page = (int) request()->get('page', 1);
|
||||
$cutoff = now()->subDays(30)->toDateString();
|
||||
$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 . '"',
|
||||
@@ -278,9 +286,11 @@ final class ArtworkSearchService
|
||||
public function discoverRising(int $perPage = 24): LengthAwarePaginator
|
||||
{
|
||||
$page = (int) request()->get('page', 1);
|
||||
$cutoff = now()->subDays(30)->toDateString();
|
||||
$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;
|
||||
@@ -31,6 +33,7 @@ final class HomepageService
|
||||
private readonly ArtworkSearchService $search,
|
||||
private readonly UserPreferenceService $prefs,
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -23,5 +23,72 @@ return Application::configure(basePath: dirname(__DIR__))
|
||||
]);
|
||||
})
|
||||
->withExceptions(function (Exceptions $exceptions): void {
|
||||
//
|
||||
|
||||
// ── 404 / 410 / 403 — web HTML responses only ─────────────────────────
|
||||
$exceptions->render(function (
|
||||
\Symfony\Component\HttpKernel\Exception\HttpException $e,
|
||||
\Illuminate\Http\Request $request
|
||||
) {
|
||||
if ($request->expectsJson()) {
|
||||
return null; // Let Laravel produce the default JSON response.
|
||||
}
|
||||
|
||||
$status = $e->getStatusCode();
|
||||
|
||||
// 403 and 401 use their generic Blade views — no extra data needed.
|
||||
if ($status === 403) {
|
||||
return response(view('errors.403', ['message' => $e->getMessage() ?: null]), 403);
|
||||
}
|
||||
if ($status === 401) {
|
||||
return response(view('errors.401'), 401);
|
||||
}
|
||||
if ($status === 410) {
|
||||
return response(view('errors.410'), 410);
|
||||
}
|
||||
|
||||
// Generic 404 — smart URL-pattern routing to contextual views.
|
||||
if ($status === 404) {
|
||||
return app(\App\Http\Controllers\Web\ErrorController::class)
|
||||
->handleNotFound($request);
|
||||
}
|
||||
|
||||
return null; // Fallback to Laravel's default.
|
||||
});
|
||||
|
||||
// ── ModelNotFoundException → contextual 404 on web ───────────────────
|
||||
$exceptions->render(function (
|
||||
\Illuminate\Database\Eloquent\ModelNotFoundException $e,
|
||||
\Illuminate\Http\Request $request
|
||||
) {
|
||||
if ($request->expectsJson()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return app(\App\Http\Controllers\Web\ErrorController::class)
|
||||
->handleNotFound($request);
|
||||
});
|
||||
|
||||
// ── 500 server errors — log with correlation ID ───────────────────────
|
||||
$exceptions->render(function (
|
||||
\Throwable $e,
|
||||
\Illuminate\Http\Request $request
|
||||
) {
|
||||
if ($request->expectsJson()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Only handle truly unexpected server errors (not HTTP exceptions already handled above).
|
||||
if ($e instanceof \Symfony\Component\HttpKernel\Exception\HttpException) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$correlationId = app(\App\Services\NotFoundLogger::class)->log500($e, $request);
|
||||
} catch (\Throwable) {
|
||||
$correlationId = 'UNKNOWN';
|
||||
}
|
||||
|
||||
return response(view('errors.500', ['correlationId' => $correlationId]), 500);
|
||||
});
|
||||
|
||||
})->create();
|
||||
|
||||
78
config/early_growth.php
Normal file
78
config/early_growth.php
Normal file
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Early-Stage Growth System (EGS) Configuration
|
||||
*
|
||||
* Controls the Nova Early-Stage Growth System — a reversible, non-deceptive
|
||||
* layer that keeps the site feeling alive when organic traffic is low.
|
||||
*
|
||||
* TOGGLE:
|
||||
* .env NOVA_EARLY_GROWTH_ENABLED=true
|
||||
* .env NOVA_EARLY_GROWTH_MODE=light (off | light | aggressive)
|
||||
*
|
||||
* Set NOVA_EARLY_GROWTH_ENABLED=false (or NOVA_EARLY_GROWTH_MODE=off)
|
||||
* to revert all behaviour to standard production logic instantly.
|
||||
*/
|
||||
|
||||
return [
|
||||
|
||||
// ─── Master switch ────────────────────────────────────────────────────────
|
||||
'enabled' => (bool) env('NOVA_EARLY_GROWTH_ENABLED', false),
|
||||
|
||||
// operating mode: off | light | aggressive
|
||||
'mode' => env('NOVA_EARLY_GROWTH_MODE', 'off'),
|
||||
|
||||
// ─── Module toggles (each respects master 'enabled') ─────────────────────
|
||||
'adaptive_time_window' => (bool) env('NOVA_EGS_ADAPTIVE_WINDOW', true),
|
||||
'grid_filler' => (bool) env('NOVA_EGS_GRID_FILLER', true),
|
||||
'spotlight' => (bool) env('NOVA_EGS_SPOTLIGHT', true),
|
||||
'activity_layer' => (bool) env('NOVA_EGS_ACTIVITY_LAYER', false),
|
||||
|
||||
// ─── AdaptiveTimeWindow thresholds ───────────────────────────────────────
|
||||
// uploads_per_day is the 7-day rolling average of published artworks/day.
|
||||
'thresholds' => [
|
||||
'uploads_per_day_narrow' => (int) env('NOVA_EGS_UPLOADS_PER_DAY_NARROW', 10),
|
||||
'uploads_per_day_wide' => (int) env('NOVA_EGS_UPLOADS_PER_DAY_WIDE', 3),
|
||||
// Days to look back for trending / rising queries
|
||||
'window_narrow_days' => (int) env('NOVA_EGS_WINDOW_NARROW_DAYS', 7),
|
||||
'window_medium_days' => (int) env('NOVA_EGS_WINDOW_MEDIUM_DAYS', 30),
|
||||
'window_wide_days' => (int) env('NOVA_EGS_WINDOW_WIDE_DAYS', 90),
|
||||
],
|
||||
|
||||
// ─── FeedBlender ratios per mode ─────────────────────────────────────────
|
||||
// Values are proportions; they are normalised internally (sum need not equal 1).
|
||||
'blend_ratios' => [
|
||||
'light' => [
|
||||
'fresh' => 0.60,
|
||||
'curated' => 0.25,
|
||||
'spotlight' => 0.15,
|
||||
],
|
||||
'aggressive' => [
|
||||
'fresh' => 0.30,
|
||||
'curated' => 0.50,
|
||||
'spotlight' => 0.20,
|
||||
],
|
||||
],
|
||||
|
||||
// ─── GridFiller ──────────────────────────────────────────────────────────
|
||||
// Minimum number of items to display per grid page.
|
||||
'grid_min_results' => (int) env('NOVA_EGS_GRID_MIN_RESULTS', 12),
|
||||
|
||||
// ─── Auto-disable when site reaches organic scale ────────────────────────
|
||||
'auto_disable' => [
|
||||
'enabled' => (bool) env('NOVA_EGS_AUTO_DISABLE', false),
|
||||
'uploads_per_day' => (int) env('NOVA_EGS_AUTO_DISABLE_UPLOADS', 50),
|
||||
'active_users' => (int) env('NOVA_EGS_AUTO_DISABLE_USERS', 500),
|
||||
],
|
||||
|
||||
// ─── Cache TTLs (seconds) ─────────────────────────────────────────────────
|
||||
'cache_ttl' => [
|
||||
'spotlight' => (int) env('NOVA_EGS_SPOTLIGHT_TTL', 3600), // 1 h – rotated daily
|
||||
'feed_blend' => (int) env('NOVA_EGS_BLEND_TTL', 300), // 5 min
|
||||
'time_window' => (int) env('NOVA_EGS_WINDOW_TTL', 600), // 10 min
|
||||
'activity' => (int) env('NOVA_EGS_ACTIVITY_TTL', 1800), // 30 min
|
||||
],
|
||||
|
||||
];
|
||||
@@ -111,8 +111,8 @@ return [
|
||||
*/
|
||||
|
||||
'from' => [
|
||||
'address' => env('MAIL_FROM_ADDRESS', 'hello@example.com'),
|
||||
'name' => env('MAIL_FROM_NAME', 'Example'),
|
||||
'address' => env('MAIL_FROM_ADDRESS', 'gregor@klevze.com'),
|
||||
'name' => env('MAIL_FROM_NAME', 'Skinbase'),
|
||||
],
|
||||
|
||||
];
|
||||
|
||||
@@ -54,4 +54,13 @@ return [
|
||||
'timeout' => (int) env('TURNSTILE_TIMEOUT', 5),
|
||||
],
|
||||
|
||||
/*
|
||||
* Google AdSense
|
||||
* Set GOOGLE_ADSENSE_PUBLISHER_ID to your ca-pub-XXXXXXXXXXXXXXXX value.
|
||||
* Ads are only loaded after the user accepts cookies via the consent banner.
|
||||
*/
|
||||
'google_adsense' => [
|
||||
'publisher_id' => env('GOOGLE_ADSENSE_PUBLISHER_ID'),
|
||||
],
|
||||
|
||||
];
|
||||
|
||||
40
database/factories/BlogPostFactory.php
Normal file
40
database/factories/BlogPostFactory.php
Normal file
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\BlogPost;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* @extends Factory<BlogPost>
|
||||
*/
|
||||
final class BlogPostFactory extends Factory
|
||||
{
|
||||
protected $model = BlogPost::class;
|
||||
|
||||
public function definition(): array
|
||||
{
|
||||
$title = $this->faker->sentence(5);
|
||||
|
||||
return [
|
||||
'slug' => Str::slug($title) . '-' . $this->faker->unique()->numberBetween(1, 99999),
|
||||
'title' => $title,
|
||||
'body' => '<p>' . implode('</p><p>', $this->faker->paragraphs(3)) . '</p>',
|
||||
'excerpt' => $this->faker->sentence(15),
|
||||
'author_id' => null,
|
||||
'featured_image' => null,
|
||||
'meta_title' => null,
|
||||
'meta_description' => null,
|
||||
'is_published' => true,
|
||||
'published_at' => $this->faker->dateTimeBetween('-1 year', 'now'),
|
||||
];
|
||||
}
|
||||
|
||||
public function draft(): static
|
||||
{
|
||||
return $this->state(['is_published' => false, 'published_at' => null]);
|
||||
}
|
||||
}
|
||||
38
database/factories/PageFactory.php
Normal file
38
database/factories/PageFactory.php
Normal file
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\Page;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* @extends Factory<Page>
|
||||
*/
|
||||
final class PageFactory extends Factory
|
||||
{
|
||||
protected $model = Page::class;
|
||||
|
||||
public function definition(): array
|
||||
{
|
||||
$title = $this->faker->sentence(4);
|
||||
|
||||
return [
|
||||
'slug' => Str::slug($title) . '-' . $this->faker->unique()->numberBetween(1, 99999),
|
||||
'title' => $title,
|
||||
'body' => '<p>' . implode('</p><p>', $this->faker->paragraphs(2)) . '</p>',
|
||||
'layout' => 'default',
|
||||
'meta_title' => null,
|
||||
'meta_description' => null,
|
||||
'is_published' => true,
|
||||
'published_at' => $this->faker->dateTimeBetween('-1 year', 'now'),
|
||||
];
|
||||
}
|
||||
|
||||
public function draft(): static
|
||||
{
|
||||
return $this->state(['is_published' => false, 'published_at' => null]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration {
|
||||
public function up()
|
||||
{
|
||||
Schema::create('staff_applications', function (Blueprint $table) {
|
||||
$table->uuid('id')->primary();
|
||||
$table->string('topic')->index();
|
||||
$table->string('name');
|
||||
$table->string('email');
|
||||
$table->string('role')->nullable();
|
||||
$table->string('portfolio')->nullable();
|
||||
$table->text('message')->nullable();
|
||||
$table->json('payload')->nullable();
|
||||
$table->string('ip', 45)->nullable();
|
||||
$table->text('user_agent')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
public function down()
|
||||
{
|
||||
Schema::dropIfExists('staff_applications');
|
||||
}
|
||||
};
|
||||
32
database/migrations/2026_03_03_000001_create_pages_table.php
Normal file
32
database/migrations/2026_03_03_000001_create_pages_table.php
Normal file
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('pages', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('slug', 191)->unique();
|
||||
$table->string('title', 255);
|
||||
$table->mediumText('body');
|
||||
$table->string('layout', 50)->default('default'); // default | legal | help
|
||||
$table->string('meta_title', 255)->nullable();
|
||||
$table->text('meta_description')->nullable();
|
||||
$table->boolean('is_published')->default(false);
|
||||
$table->timestamp('published_at')->nullable();
|
||||
$table->timestamps();
|
||||
$table->softDeletes();
|
||||
|
||||
$table->index(['is_published', 'published_at']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('pages');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('blog_posts', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('slug', 191)->unique();
|
||||
$table->string('title', 255);
|
||||
$table->mediumText('body');
|
||||
$table->text('excerpt')->nullable();
|
||||
$table->foreignId('author_id')->nullable()
|
||||
->constrained('users')->nullOnDelete();
|
||||
$table->string('featured_image', 500)->nullable();
|
||||
$table->string('meta_title', 255)->nullable();
|
||||
$table->text('meta_description')->nullable();
|
||||
$table->boolean('is_published')->default(false);
|
||||
$table->timestamp('published_at')->nullable();
|
||||
$table->timestamps();
|
||||
$table->softDeletes();
|
||||
|
||||
$table->index(['is_published', 'published_at']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('blog_posts');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('bug_reports', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('user_id')->nullable()->constrained('users')->nullOnDelete();
|
||||
$table->string('subject');
|
||||
$table->text('description');
|
||||
$table->ipAddress('ip_address')->nullable();
|
||||
$table->string('user_agent', 512)->nullable();
|
||||
$table->enum('status', ['open', 'in_progress', 'resolved', 'closed'])->default('open');
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('bug_reports');
|
||||
}
|
||||
};
|
||||
10
deploy/supervisor/skinbase-queue.conf
Normal file
10
deploy/supervisor/skinbase-queue.conf
Normal file
@@ -0,0 +1,10 @@
|
||||
[program:skinbase-queue]
|
||||
command=/usr/bin/php /var/www/skinbase/artisan queue:work --sleep=3 --tries=3 --timeout=90 --queue=default
|
||||
process_name=%(program_name)s_%(process_num)02d
|
||||
numprocs=1
|
||||
autostart=true
|
||||
autorestart=true
|
||||
user=www-data
|
||||
redirect_stderr=true
|
||||
stdout_logfile=/var/log/skinbase/queue.log
|
||||
stopwaitsecs=3600
|
||||
17
deploy/systemd/skinbase-queue.service
Normal file
17
deploy/systemd/skinbase-queue.service
Normal file
@@ -0,0 +1,17 @@
|
||||
[Unit]
|
||||
Description=Skinbase Laravel Queue Worker
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
User=www-data
|
||||
Group=www-data
|
||||
Restart=always
|
||||
RestartSec=3
|
||||
WorkingDirectory=/var/www/skinbase
|
||||
ExecStart=/usr/bin/php /var/www/skinbase/artisan queue:work --sleep=3 --tries=3 --timeout=90 --queue=default
|
||||
StandardOutput=syslog
|
||||
StandardError=syslog
|
||||
SyslogIdentifier=skinbase-queue
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
104
docs/QUEUE.md
Normal file
104
docs/QUEUE.md
Normal file
@@ -0,0 +1,104 @@
|
||||
Queue worker setup
|
||||
==================
|
||||
|
||||
This document explains how to run Laravel queue workers for Skinbase and suggested Supervisor / systemd configs included in `deploy/`.
|
||||
|
||||
1) Choose a queue driver
|
||||
------------------------
|
||||
|
||||
Pick a driver in your `.env`, for example using the database driver (simple to run locally):
|
||||
|
||||
```
|
||||
QUEUE_CONNECTION=database
|
||||
```
|
||||
|
||||
Or use Redis for production:
|
||||
|
||||
```
|
||||
QUEUE_CONNECTION=redis
|
||||
```
|
||||
|
||||
2) Database queue (if using database driver)
|
||||
-------------------------------------------
|
||||
|
||||
Create the jobs table and run migrations:
|
||||
|
||||
```bash
|
||||
php artisan queue:table
|
||||
php artisan migrate
|
||||
```
|
||||
|
||||
3) Supervisor (recommended for many setups)
|
||||
-------------------------------------------
|
||||
|
||||
We provide an example Supervisor config at `deploy/supervisor/skinbase-queue.conf`.
|
||||
|
||||
To use it on a Debian/Ubuntu server:
|
||||
|
||||
```bash
|
||||
# copy the file to supervisor's config directory
|
||||
sudo cp deploy/supervisor/skinbase-queue.conf /etc/supervisor/conf.d/skinbase-queue.conf
|
||||
# make sure the logs dir exists
|
||||
sudo mkdir -p /var/log/skinbase
|
||||
sudo chown www-data:www-data /var/log/skinbase
|
||||
# tell supervisor to reload configs and start
|
||||
sudo supervisorctl reread
|
||||
sudo supervisorctl update
|
||||
sudo supervisorctl start skinbase-queue
|
||||
# check status
|
||||
sudo supervisorctl status skinbase-queue
|
||||
```
|
||||
|
||||
Adjust the `command` and `user` in the conf to match your deployment (path to PHP, project root and user).
|
||||
|
||||
4) systemd alternative
|
||||
----------------------
|
||||
|
||||
If you prefer systemd, an example unit is at `deploy/systemd/skinbase-queue.service`.
|
||||
|
||||
```bash
|
||||
sudo cp deploy/systemd/skinbase-queue.service /etc/systemd/system/skinbase-queue.service
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now skinbase-queue.service
|
||||
sudo systemctl status skinbase-queue.service
|
||||
```
|
||||
|
||||
Adjust `WorkingDirectory` and `User` in the unit to match your deployment.
|
||||
|
||||
5) Helpful artisan commands
|
||||
---------------------------
|
||||
|
||||
- Start a one-off worker (foreground):
|
||||
|
||||
```bash
|
||||
php artisan queue:work --sleep=3 --tries=3
|
||||
```
|
||||
|
||||
- Restart all workers gracefully (useful after deployments):
|
||||
|
||||
```bash
|
||||
php artisan queue:restart
|
||||
```
|
||||
|
||||
- Inspect failed jobs:
|
||||
|
||||
```bash
|
||||
php artisan queue:failed
|
||||
php artisan queue:retry {id}
|
||||
php artisan queue:flush
|
||||
```
|
||||
|
||||
6) Logging & monitoring
|
||||
-----------------------
|
||||
|
||||
- Supervisor example logs to `/var/log/skinbase/queue.log` (see `deploy/supervisor/skinbase-queue.conf`).
|
||||
- Use `journalctl -u skinbase-queue` for systemd logs.
|
||||
|
||||
7) Notes and troubleshooting
|
||||
---------------------------
|
||||
|
||||
- Ensure `QUEUE_CONNECTION` in `.env` matches the driver you've configured.
|
||||
- If using `database` driver, the `jobs` and `failed_jobs` tables must exist.
|
||||
- The mailable used for contact submissions is queued; if the queue worker is not running mails will accumulate in the queue table (or Redis).
|
||||
|
||||
Questions or prefer a different process manager? Tell me your target host and I can produce exact commands tailored to it.
|
||||
102
resources/js/Layouts/SettingsLayout.jsx
Normal file
102
resources/js/Layouts/SettingsLayout.jsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import React, { useState } from 'react'
|
||||
import { Link, usePage } from '@inertiajs/react'
|
||||
|
||||
const navItems = [
|
||||
{ label: 'Profile', href: '/dashboard/profile', icon: 'fa-solid fa-user' },
|
||||
// Future: { label: 'Notifications', href: '/dashboard/notifications', icon: 'fa-solid fa-bell' },
|
||||
// Future: { label: 'Privacy', href: '/dashboard/privacy', icon: 'fa-solid fa-shield-halved' },
|
||||
]
|
||||
|
||||
function NavLink({ item, active, onClick }) {
|
||||
return (
|
||||
<Link
|
||||
href={item.href}
|
||||
onClick={onClick}
|
||||
className={`flex items-center gap-3 px-4 py-2.5 rounded-xl text-sm font-medium transition-all duration-200 ${
|
||||
active
|
||||
? 'bg-accent/20 text-accent shadow-sm shadow-accent/10'
|
||||
: 'text-slate-400 hover:text-white hover:bg-white/5'
|
||||
}`}
|
||||
>
|
||||
<i className={`${item.icon} w-5 text-center text-base`} />
|
||||
<span>{item.label}</span>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarContent({ isActive, onNavigate }) {
|
||||
return (
|
||||
<>
|
||||
<div className="mb-6">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-wider text-slate-500 px-4 mb-2">Settings</h2>
|
||||
</div>
|
||||
|
||||
<nav className="space-y-1 flex-1">
|
||||
{navItems.map((item) => (
|
||||
<NavLink key={item.href} item={item} active={isActive(item.href)} onClick={onNavigate} />
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="mt-auto pt-6 space-y-2">
|
||||
<Link
|
||||
href="/studio"
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-xl text-sm text-slate-400 hover:text-white hover:bg-white/5 transition-colors"
|
||||
onClick={onNavigate}
|
||||
>
|
||||
<i className="fa-solid fa-palette w-5 text-center" />
|
||||
Creator Studio
|
||||
</Link>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default function SettingsLayout({ children, title }) {
|
||||
const { url } = usePage()
|
||||
const [mobileOpen, setMobileOpen] = useState(false)
|
||||
|
||||
const isActive = (href) => url.startsWith(href)
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-nova-900">
|
||||
{/* Mobile top bar */}
|
||||
<div className="lg:hidden flex items-center justify-between px-4 py-3 border-b border-white/10 bg-nova-900/80 backdrop-blur-xl sticky top-16 z-30">
|
||||
<h1 className="text-lg font-bold text-white">Settings</h1>
|
||||
<button
|
||||
onClick={() => setMobileOpen(!mobileOpen)}
|
||||
className="text-slate-400 hover:text-white p-2"
|
||||
aria-label="Toggle navigation"
|
||||
>
|
||||
<i className={`fa-solid ${mobileOpen ? 'fa-xmark' : 'fa-bars'} text-xl`} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Mobile nav overlay */}
|
||||
{mobileOpen && (
|
||||
<div className="lg:hidden fixed inset-0 z-40 bg-black/60 backdrop-blur-sm" onClick={() => setMobileOpen(false)}>
|
||||
<nav
|
||||
className="absolute left-0 top-0 bottom-0 w-72 bg-nova-900 border-r border-white/10 p-4 pt-20 space-y-1"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<SidebarContent isActive={isActive} onNavigate={() => setMobileOpen(false)} />
|
||||
</nav>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex">
|
||||
{/* Desktop sidebar */}
|
||||
<aside className="hidden lg:flex flex-col w-64 min-h-[calc(100vh-4rem)] border-r border-white/10 bg-nova-900/60 backdrop-blur-xl p-4 pt-6 sticky top-16 self-start">
|
||||
<SidebarContent isActive={isActive} />
|
||||
</aside>
|
||||
|
||||
{/* Main content */}
|
||||
<main className="flex-1 min-w-0 px-4 lg:px-8 pt-4 pb-8 max-w-4xl">
|
||||
{title && (
|
||||
<h1 className="text-2xl font-bold text-white mb-6">{title}</h1>
|
||||
)}
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -34,7 +34,7 @@ export default function HomeHero({ artwork, isLoggedIn }) {
|
||||
src={src}
|
||||
alt={artwork.title}
|
||||
className="absolute inset-0 h-full w-full object-cover transition-transform duration-700 group-hover:scale-[1.02]"
|
||||
fetchpriority="high"
|
||||
fetchPriority="high"
|
||||
decoding="async"
|
||||
onError={(e) => { e.currentTarget.src = FALLBACK }}
|
||||
/>
|
||||
|
||||
727
resources/js/Pages/Settings/ProfileEdit.jsx
Normal file
727
resources/js/Pages/Settings/ProfileEdit.jsx
Normal file
@@ -0,0 +1,727 @@
|
||||
import React, { useState, useRef, useCallback } from 'react'
|
||||
import { usePage } from '@inertiajs/react'
|
||||
import SettingsLayout from '../../Layouts/SettingsLayout'
|
||||
import TextInput from '../../components/ui/TextInput'
|
||||
import Textarea from '../../components/ui/Textarea'
|
||||
import Button from '../../components/ui/Button'
|
||||
import Toggle from '../../components/ui/Toggle'
|
||||
import Select from '../../components/ui/Select'
|
||||
import Modal from '../../components/ui/Modal'
|
||||
import { RadioGroup } from '../../components/ui/Radio'
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function getCsrfToken() {
|
||||
return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || ''
|
||||
}
|
||||
|
||||
const MONTHS = [
|
||||
{ value: '1', label: 'January' }, { value: '2', label: 'February' },
|
||||
{ value: '3', label: 'March' }, { value: '4', label: 'April' },
|
||||
{ value: '5', label: 'May' }, { value: '6', label: 'June' },
|
||||
{ value: '7', label: 'July' }, { value: '8', label: 'August' },
|
||||
{ value: '9', label: 'September' }, { value: '10', label: 'October' },
|
||||
{ value: '11', label: 'November' }, { value: '12', label: 'December' },
|
||||
]
|
||||
|
||||
const GENDER_OPTIONS = [
|
||||
{ value: 'm', label: 'Male' },
|
||||
{ value: 'f', label: 'Female' },
|
||||
{ value: 'x', label: 'Prefer not to say' },
|
||||
]
|
||||
|
||||
function buildDayOptions() {
|
||||
return Array.from({ length: 31 }, (_, i) => ({ value: String(i + 1), label: String(i + 1) }))
|
||||
}
|
||||
|
||||
function buildYearOptions() {
|
||||
const currentYear = new Date().getFullYear()
|
||||
const years = []
|
||||
for (let y = currentYear; y >= currentYear - 100; y--) {
|
||||
years.push({ value: String(y), label: String(y) })
|
||||
}
|
||||
return years
|
||||
}
|
||||
|
||||
// ─── Sub-components ──────────────────────────────────────────────────────────
|
||||
|
||||
function Section({ children, className = '' }) {
|
||||
return (
|
||||
<section className={`bg-nova-900/60 border border-white/10 rounded-2xl p-6 ${className}`}>
|
||||
{children}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function SectionTitle({ icon, children, description }) {
|
||||
return (
|
||||
<div className="mb-5">
|
||||
<h3 className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wider text-slate-400">
|
||||
{icon && <i className={`${icon} text-accent/70 text-[11px]`} />}
|
||||
{children}
|
||||
</h3>
|
||||
{description && <p className="text-xs text-slate-500 mt-1">{description}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Main Component ──────────────────────────────────────────────────────────
|
||||
|
||||
export default function ProfileEdit() {
|
||||
const { props } = usePage()
|
||||
const {
|
||||
user,
|
||||
avatarUrl: initialAvatarUrl,
|
||||
birthDay: initDay,
|
||||
birthMonth: initMonth,
|
||||
birthYear: initYear,
|
||||
countries = [],
|
||||
flash = {},
|
||||
} = props
|
||||
|
||||
// ── Profile State ──────────────────────────────────────────────────────────
|
||||
const [name, setName] = useState(user?.name || '')
|
||||
const [email, setEmail] = useState(user?.email || '')
|
||||
const [username, setUsername] = useState(user?.username || '')
|
||||
const [homepage, setHomepage] = useState(user?.homepage || user?.website || '')
|
||||
const [about, setAbout] = useState(user?.about_me || user?.about || '')
|
||||
const [signature, setSignature] = useState(user?.signature || '')
|
||||
const [description, setDescription] = useState(user?.description || '')
|
||||
const [day, setDay] = useState(initDay ? String(parseInt(initDay, 10)) : '')
|
||||
const [month, setMonth] = useState(initMonth ? String(parseInt(initMonth, 10)) : '')
|
||||
const [year, setYear] = useState(initYear ? String(initYear) : '')
|
||||
const [gender, setGender] = useState(() => {
|
||||
const g = (user?.gender || '').toLowerCase()
|
||||
if (g === 'm') return 'm'
|
||||
if (g === 'f') return 'f'
|
||||
if (g === 'x' || g === 'n') return 'x'
|
||||
return ''
|
||||
})
|
||||
const [country, setCountry] = useState(user?.country_code || user?.country || '')
|
||||
const [mailing, setMailing] = useState(!!user?.mlist)
|
||||
const [notify, setNotify] = useState(!!user?.friend_upload_notice)
|
||||
const [autoPost, setAutoPost] = useState(!!user?.auto_post_upload)
|
||||
|
||||
// Avatar
|
||||
const [avatarUrl, setAvatarUrl] = useState(initialAvatarUrl || '')
|
||||
const [avatarFile, setAvatarFile] = useState(null)
|
||||
const [avatarUploading, setAvatarUploading] = useState(false)
|
||||
const avatarInputRef = useRef(null)
|
||||
const [dragActive, setDragActive] = useState(false)
|
||||
|
||||
// Save state
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [profileErrors, setProfileErrors] = useState({})
|
||||
const [profileSaved, setProfileSaved] = useState(!!flash?.status)
|
||||
|
||||
// Password state
|
||||
const [currentPassword, setCurrentPassword] = useState('')
|
||||
const [newPassword, setNewPassword] = useState('')
|
||||
const [confirmPassword, setConfirmPassword] = useState('')
|
||||
const [passwordSaving, setPasswordSaving] = useState(false)
|
||||
const [passwordErrors, setPasswordErrors] = useState({})
|
||||
const [passwordSaved, setPasswordSaved] = useState(false)
|
||||
|
||||
// Delete account
|
||||
const [showDelete, setShowDelete] = useState(false)
|
||||
const [deletePassword, setDeletePassword] = useState('')
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
const [deleteError, setDeleteError] = useState('')
|
||||
|
||||
// ── Country Options ─────────────────────────────────────────────────────────
|
||||
const countryOptions = (countries || []).map((c) => ({
|
||||
value: c.country_code || c.code || c.id || '',
|
||||
label: c.country_name || c.name || '',
|
||||
}))
|
||||
|
||||
// ── Avatar Handlers ────────────────────────────────────────────────────────
|
||||
const handleAvatarSelect = (file) => {
|
||||
if (!file || !file.type.startsWith('image/')) return
|
||||
setAvatarFile(file)
|
||||
setAvatarUrl(URL.createObjectURL(file))
|
||||
}
|
||||
|
||||
const handleAvatarUpload = useCallback(async () => {
|
||||
if (!avatarFile) return
|
||||
setAvatarUploading(true)
|
||||
try {
|
||||
const fd = new FormData()
|
||||
fd.append('avatar', avatarFile)
|
||||
const res = await fetch('/avatar/upload', {
|
||||
method: 'POST',
|
||||
headers: { Accept: 'application/json', 'X-CSRF-TOKEN': getCsrfToken() },
|
||||
credentials: 'same-origin',
|
||||
body: fd,
|
||||
})
|
||||
const data = await res.json()
|
||||
if (res.ok && data.url) {
|
||||
setAvatarUrl(data.url)
|
||||
setAvatarFile(null)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Avatar upload failed:', err)
|
||||
} finally {
|
||||
setAvatarUploading(false)
|
||||
}
|
||||
}, [avatarFile])
|
||||
|
||||
const handleDrag = (e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
}
|
||||
|
||||
const handleDragIn = (e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setDragActive(true)
|
||||
}
|
||||
|
||||
const handleDragOut = (e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setDragActive(false)
|
||||
}
|
||||
|
||||
const handleDrop = (e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setDragActive(false)
|
||||
const file = e.dataTransfer?.files?.[0]
|
||||
if (file) handleAvatarSelect(file)
|
||||
}
|
||||
|
||||
// ── Profile Save ───────────────────────────────────────────────────────────
|
||||
const handleProfileSave = useCallback(async () => {
|
||||
setSaving(true)
|
||||
setProfileSaved(false)
|
||||
setProfileErrors({})
|
||||
try {
|
||||
const fd = new FormData()
|
||||
fd.append('_method', 'PUT')
|
||||
fd.append('email', email)
|
||||
fd.append('username', username)
|
||||
fd.append('name', name)
|
||||
if (homepage) fd.append('web', homepage)
|
||||
if (about) fd.append('about', about)
|
||||
if (signature) fd.append('signature', signature)
|
||||
if (description) fd.append('description', description)
|
||||
if (day) fd.append('day', day)
|
||||
if (month) fd.append('month', month)
|
||||
if (year) fd.append('year', year)
|
||||
if (gender) fd.append('gender', gender)
|
||||
if (country) fd.append('country', country)
|
||||
fd.append('mailing', mailing ? '1' : '0')
|
||||
fd.append('notify', notify ? '1' : '0')
|
||||
fd.append('auto_post_upload', autoPost ? '1' : '0')
|
||||
if (avatarFile) fd.append('avatar', avatarFile)
|
||||
|
||||
const res = await fetch('/profile', {
|
||||
method: 'POST',
|
||||
headers: { Accept: 'application/json', 'X-CSRF-TOKEN': getCsrfToken() },
|
||||
credentials: 'same-origin',
|
||||
body: fd,
|
||||
})
|
||||
|
||||
if (res.ok || res.status === 302) {
|
||||
setProfileSaved(true)
|
||||
setAvatarFile(null)
|
||||
setTimeout(() => setProfileSaved(false), 4000)
|
||||
} else {
|
||||
const data = await res.json().catch(() => ({}))
|
||||
if (data.errors) setProfileErrors(data.errors)
|
||||
else if (data.message) setProfileErrors({ _general: [data.message] })
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Profile save failed:', err)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}, [email, username, name, homepage, about, signature, description, day, month, year, gender, country, mailing, notify, autoPost, avatarFile])
|
||||
|
||||
// ── Password Change ────────────────────────────────────────────────────────
|
||||
const handlePasswordChange = useCallback(async () => {
|
||||
setPasswordSaving(true)
|
||||
setPasswordSaved(false)
|
||||
setPasswordErrors({})
|
||||
try {
|
||||
const res = await fetch('/profile/password', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json', 'X-CSRF-TOKEN': getCsrfToken() },
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify({
|
||||
current_password: currentPassword,
|
||||
password: newPassword,
|
||||
password_confirmation: confirmPassword,
|
||||
}),
|
||||
})
|
||||
|
||||
if (res.ok || res.status === 302) {
|
||||
setPasswordSaved(true)
|
||||
setCurrentPassword('')
|
||||
setNewPassword('')
|
||||
setConfirmPassword('')
|
||||
setTimeout(() => setPasswordSaved(false), 4000)
|
||||
} else {
|
||||
const data = await res.json().catch(() => ({}))
|
||||
if (data.errors) setPasswordErrors(data.errors)
|
||||
else if (data.message) setPasswordErrors({ _general: [data.message] })
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Password change failed:', err)
|
||||
} finally {
|
||||
setPasswordSaving(false)
|
||||
}
|
||||
}, [currentPassword, newPassword, confirmPassword])
|
||||
|
||||
// ── Delete Account ─────────────────────────────────────────────────────────
|
||||
const handleDeleteAccount = async () => {
|
||||
setDeleting(true)
|
||||
setDeleteError('')
|
||||
try {
|
||||
const res = await fetch('/profile', {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json', 'X-CSRF-TOKEN': getCsrfToken() },
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify({ password: deletePassword }),
|
||||
})
|
||||
if (res.ok || res.status === 302) {
|
||||
window.location.href = '/'
|
||||
} else {
|
||||
const data = await res.json().catch(() => ({}))
|
||||
setDeleteError(data.errors?.password?.[0] || data.message || 'Deletion failed.')
|
||||
}
|
||||
} catch (err) {
|
||||
setDeleteError('Request failed.')
|
||||
} finally {
|
||||
setDeleting(false)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Render ─────────────────────────────────────────────────────────────────
|
||||
return (
|
||||
<SettingsLayout title="Edit Profile">
|
||||
<div className="space-y-8">
|
||||
|
||||
{/* ── General Errors ── */}
|
||||
{profileErrors._general && (
|
||||
<div className="rounded-xl border border-red-500/30 bg-red-500/10 px-4 py-3 text-sm text-red-300">
|
||||
{profileErrors._general[0]}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ════════════════════════════════════════════════════════════════════
|
||||
AVATAR SECTION
|
||||
════════════════════════════════════════════════════════════════════ */}
|
||||
<Section>
|
||||
<SectionTitle icon="fa-solid fa-camera" description="JPG, PNG or WebP. Max 2 MB.">
|
||||
Avatar
|
||||
</SectionTitle>
|
||||
|
||||
<div className="flex items-center gap-6">
|
||||
{/* Preview */}
|
||||
<div className="relative shrink-0">
|
||||
<img
|
||||
src={avatarUrl}
|
||||
alt={username || 'Avatar'}
|
||||
className="w-24 h-24 rounded-full object-cover ring-2 ring-white/10 shadow-lg"
|
||||
/>
|
||||
{avatarUploading && (
|
||||
<div className="absolute inset-0 rounded-full bg-black/60 flex items-center justify-center">
|
||||
<div className="w-6 h-6 border-2 border-accent/30 border-t-accent rounded-full animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Dropzone */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => avatarInputRef.current?.click()}
|
||||
onDragEnter={handleDragIn}
|
||||
onDragLeave={handleDragOut}
|
||||
onDragOver={handleDrag}
|
||||
onDrop={handleDrop}
|
||||
className={[
|
||||
'w-full rounded-xl border-2 border-dashed px-5 py-5 text-left transition-all',
|
||||
dragActive
|
||||
? 'border-accent/50 bg-accent/10'
|
||||
: 'border-white/15 hover:border-white/25 hover:bg-white/[0.03]',
|
||||
].join(' ')}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="flex items-center justify-center w-10 h-10 rounded-lg bg-white/5 text-slate-400">
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||
<path d="M5.5 13a3.5 3.5 0 01-.369-6.98 4 4 0 117.753-1.977A4.5 4.5 0 0113.5 13H11V9.414l1.293 1.293a1 1 0 001.414-1.414l-3-3a1 1 0 00-1.414 0l-3 3a1 1 0 101.414 1.414L9 9.414V13H5.5z" />
|
||||
<path d="M9 13h2v5a1 1 0 11-2 0v-5z" />
|
||||
</svg>
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm text-white/90 font-medium">
|
||||
{avatarFile ? avatarFile.name : 'Drop an image or click to browse'}
|
||||
</p>
|
||||
<p className="text-[11px] text-slate-500 mt-0.5">
|
||||
{avatarFile ? 'Ready to upload with save' : 'Recommended: 256×256 px or larger'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
<input
|
||||
ref={avatarInputRef}
|
||||
type="file"
|
||||
className="hidden"
|
||||
accept="image/jpeg,image/png,image/webp"
|
||||
onChange={(e) => handleAvatarSelect(e.target.files?.[0])}
|
||||
/>
|
||||
{avatarFile && (
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<Button variant="accent" size="xs" loading={avatarUploading} onClick={handleAvatarUpload}>
|
||||
Upload now
|
||||
</Button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setAvatarFile(null); setAvatarUrl(initialAvatarUrl || '') }}
|
||||
className="text-[11px] text-slate-500 hover:text-white transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* ════════════════════════════════════════════════════════════════════
|
||||
ACCOUNT INFO
|
||||
════════════════════════════════════════════════════════════════════ */}
|
||||
<Section>
|
||||
<SectionTitle icon="fa-solid fa-user" description="Your public identity on Skinbase.">
|
||||
Account
|
||||
</SectionTitle>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||
<TextInput
|
||||
label="Username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
error={profileErrors.username?.[0]}
|
||||
hint={user?.username_changed_at ? `Last changed: ${new Date(user.username_changed_at).toLocaleDateString()}` : undefined}
|
||||
/>
|
||||
<TextInput
|
||||
label="Email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
error={profileErrors.email?.[0]}
|
||||
required
|
||||
/>
|
||||
<TextInput
|
||||
label="Display Name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Your real name (optional)"
|
||||
error={profileErrors.name?.[0]}
|
||||
/>
|
||||
<TextInput
|
||||
label="Website"
|
||||
type="url"
|
||||
value={homepage}
|
||||
onChange={(e) => setHomepage(e.target.value)}
|
||||
placeholder="https://"
|
||||
error={profileErrors.web?.[0] || profileErrors.homepage?.[0]}
|
||||
/>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* ════════════════════════════════════════════════════════════════════
|
||||
ABOUT & BIO
|
||||
════════════════════════════════════════════════════════════════════ */}
|
||||
<Section>
|
||||
<SectionTitle icon="fa-solid fa-pen-fancy" description="Tell the community about yourself.">
|
||||
About & Bio
|
||||
</SectionTitle>
|
||||
|
||||
<div className="space-y-5">
|
||||
<Textarea
|
||||
label="About Me"
|
||||
value={about}
|
||||
onChange={(e) => setAbout(e.target.value)}
|
||||
placeholder="Share something about yourself…"
|
||||
rows={4}
|
||||
error={profileErrors.about?.[0]}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||
<Textarea
|
||||
label="Signature"
|
||||
value={signature}
|
||||
onChange={(e) => setSignature(e.target.value)}
|
||||
placeholder="Forum signature"
|
||||
rows={3}
|
||||
error={profileErrors.signature?.[0]}
|
||||
/>
|
||||
<Textarea
|
||||
label="Description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Short bio / tagline"
|
||||
rows={3}
|
||||
error={profileErrors.description?.[0]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* ════════════════════════════════════════════════════════════════════
|
||||
PERSONAL DETAILS
|
||||
════════════════════════════════════════════════════════════════════ */}
|
||||
<Section>
|
||||
<SectionTitle icon="fa-solid fa-id-card" description="Optional details — only shown if you choose.">
|
||||
Personal Details
|
||||
</SectionTitle>
|
||||
|
||||
<div className="space-y-5">
|
||||
{/* Birthday */}
|
||||
<div>
|
||||
<label className="text-sm font-medium text-white/85 block mb-1.5">Birthday</label>
|
||||
<div className="grid grid-cols-3 gap-3 max-w-md">
|
||||
<Select
|
||||
placeholder="Day"
|
||||
value={day}
|
||||
onChange={(e) => setDay(e.target.value)}
|
||||
options={buildDayOptions()}
|
||||
size="sm"
|
||||
/>
|
||||
<Select
|
||||
placeholder="Month"
|
||||
value={month}
|
||||
onChange={(e) => setMonth(e.target.value)}
|
||||
options={MONTHS}
|
||||
size="sm"
|
||||
/>
|
||||
<Select
|
||||
placeholder="Year"
|
||||
value={year}
|
||||
onChange={(e) => setYear(e.target.value)}
|
||||
options={buildYearOptions()}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Gender */}
|
||||
<RadioGroup
|
||||
label="Gender"
|
||||
name="gender"
|
||||
options={GENDER_OPTIONS}
|
||||
value={gender}
|
||||
onChange={setGender}
|
||||
direction="horizontal"
|
||||
error={profileErrors.gender?.[0]}
|
||||
/>
|
||||
|
||||
{/* Country */}
|
||||
{countryOptions.length > 0 ? (
|
||||
<Select
|
||||
label="Country"
|
||||
value={country}
|
||||
onChange={(e) => setCountry(e.target.value)}
|
||||
options={countryOptions}
|
||||
placeholder="Select country"
|
||||
error={profileErrors.country?.[0]}
|
||||
/>
|
||||
) : (
|
||||
<TextInput
|
||||
label="Country"
|
||||
value={country}
|
||||
onChange={(e) => setCountry(e.target.value)}
|
||||
placeholder="Country code (e.g. US, DE, TR)"
|
||||
error={profileErrors.country?.[0]}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* ════════════════════════════════════════════════════════════════════
|
||||
PREFERENCES
|
||||
════════════════════════════════════════════════════════════════════ */}
|
||||
<Section>
|
||||
<SectionTitle icon="fa-solid fa-sliders" description="Control emails and sharing behavior.">
|
||||
Preferences
|
||||
</SectionTitle>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between py-1">
|
||||
<div>
|
||||
<p className="text-sm text-white/90 font-medium">Mailing List</p>
|
||||
<p className="text-xs text-slate-500">Receive occasional emails about Skinbase news</p>
|
||||
</div>
|
||||
<Toggle
|
||||
checked={mailing}
|
||||
onChange={(e) => setMailing(e.target.checked)}
|
||||
variant="accent"
|
||||
size="md"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-white/5" />
|
||||
|
||||
<div className="flex items-center justify-between py-1">
|
||||
<div>
|
||||
<p className="text-sm text-white/90 font-medium">Upload Notifications</p>
|
||||
<p className="text-xs text-slate-500">Get notified when people you follow upload new work</p>
|
||||
</div>
|
||||
<Toggle
|
||||
checked={notify}
|
||||
onChange={(e) => setNotify(e.target.checked)}
|
||||
variant="emerald"
|
||||
size="md"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-white/5" />
|
||||
|
||||
<div className="flex items-center justify-between py-1">
|
||||
<div>
|
||||
<p className="text-sm text-white/90 font-medium">Auto-post Uploads</p>
|
||||
<p className="text-xs text-slate-500">Automatically post to your feed when you publish artwork</p>
|
||||
</div>
|
||||
<Toggle
|
||||
checked={autoPost}
|
||||
onChange={(e) => setAutoPost(e.target.checked)}
|
||||
variant="sky"
|
||||
size="md"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* ── Save Profile Button ── */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="accent" size="md" loading={saving} onClick={handleProfileSave}>
|
||||
Save Profile
|
||||
</Button>
|
||||
{profileSaved && (
|
||||
<span className="text-sm text-emerald-400 flex items-center gap-1.5 animate-pulse">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
|
||||
<path fillRule="evenodd" d="M8 16A8 8 0 108 0a8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L7 8.586 5.707 7.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
|
||||
</svg>
|
||||
Profile updated
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ════════════════════════════════════════════════════════════════════
|
||||
CHANGE PASSWORD
|
||||
════════════════════════════════════════════════════════════════════ */}
|
||||
<Section className="mt-4">
|
||||
<SectionTitle icon="fa-solid fa-lock" description="Use a strong, unique password.">
|
||||
Change Password
|
||||
</SectionTitle>
|
||||
|
||||
{passwordErrors._general && (
|
||||
<div className="rounded-xl border border-red-500/30 bg-red-500/10 px-4 py-3 text-sm text-red-300 mb-4">
|
||||
{passwordErrors._general[0]}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-4 max-w-md">
|
||||
<TextInput
|
||||
label="Current Password"
|
||||
type="password"
|
||||
value={currentPassword}
|
||||
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||
error={passwordErrors.current_password?.[0]}
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
<TextInput
|
||||
label="New Password"
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
error={passwordErrors.password?.[0]}
|
||||
hint="Minimum 8 characters"
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
<TextInput
|
||||
label="Confirm New Password"
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
error={passwordErrors.password_confirmation?.[0]}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-3 pt-1">
|
||||
<Button variant="secondary" size="md" loading={passwordSaving} onClick={handlePasswordChange}>
|
||||
Update Password
|
||||
</Button>
|
||||
{passwordSaved && (
|
||||
<span className="text-sm text-emerald-400 flex items-center gap-1.5 animate-pulse">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
|
||||
<path fillRule="evenodd" d="M8 16A8 8 0 108 0a8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L7 8.586 5.707 7.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
|
||||
</svg>
|
||||
Password updated
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* ════════════════════════════════════════════════════════════════════
|
||||
DANGER ZONE
|
||||
════════════════════════════════════════════════════════════════════ */}
|
||||
<Section className="border-red-500/20">
|
||||
<SectionTitle icon="fa-solid fa-triangle-exclamation" description="Permanent actions that cannot be undone.">
|
||||
Danger Zone
|
||||
</SectionTitle>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-white/90 font-medium">Delete Account</p>
|
||||
<p className="text-xs text-slate-500">Remove your account and all associated data permanently.</p>
|
||||
</div>
|
||||
<Button variant="danger" size="sm" onClick={() => setShowDelete(true)}>
|
||||
Delete Account
|
||||
</Button>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* spacer for bottom padding */}
|
||||
<div className="h-4" />
|
||||
</div>
|
||||
|
||||
{/* ── Delete Confirmation Modal ── */}
|
||||
<Modal
|
||||
open={showDelete}
|
||||
onClose={() => setShowDelete(false)}
|
||||
title="Delete Account"
|
||||
size="sm"
|
||||
footer={
|
||||
<div className="flex items-center gap-3 ml-auto">
|
||||
<Button variant="ghost" size="sm" onClick={() => setShowDelete(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="danger" size="sm" loading={deleting} onClick={handleDeleteAccount}>
|
||||
Permanently Delete
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-slate-300">
|
||||
This action is <span className="text-red-400 font-semibold">irreversible</span>. All your artworks,
|
||||
comments, and profile data will be permanently deleted.
|
||||
</p>
|
||||
<TextInput
|
||||
label="Confirm your password"
|
||||
type="password"
|
||||
value={deletePassword}
|
||||
onChange={(e) => setDeletePassword(e.target.value)}
|
||||
error={deleteError}
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
</SettingsLayout>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,15 @@
|
||||
import React, { useState, useMemo, useRef, useEffect, useCallback } from 'react'
|
||||
import React, { useState, useMemo, useRef, useCallback } from 'react'
|
||||
import { usePage, Link } from '@inertiajs/react'
|
||||
import StudioLayout from '../../Layouts/StudioLayout'
|
||||
import MarkdownEditor from '../../components/ui/MarkdownEditor'
|
||||
import TextInput from '../../components/ui/TextInput'
|
||||
import Button from '../../components/ui/Button'
|
||||
import Toggle from '../../components/ui/Toggle'
|
||||
import Modal from '../../components/ui/Modal'
|
||||
import FormField from '../../components/ui/FormField'
|
||||
import TagPicker from '../../components/tags/TagPicker'
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function getCsrfToken() {
|
||||
return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || ''
|
||||
@@ -21,39 +30,55 @@ function getContentTypeVisualKey(slug) {
|
||||
function buildCategoryTree(contentTypes) {
|
||||
return (contentTypes || []).map((ct) => ({
|
||||
...ct,
|
||||
rootCategories: (ct.root_categories || []).map((rc) => ({
|
||||
rootCategories: (ct.categories || ct.root_categories || []).map((rc) => ({
|
||||
...rc,
|
||||
children: rc.children || [],
|
||||
})),
|
||||
}))
|
||||
}
|
||||
|
||||
// ─── Sub-components ──────────────────────────────────────────────────────────
|
||||
|
||||
/** Glass-morphism section card (Nova theme) */
|
||||
function Section({ children, className = '' }) {
|
||||
return (
|
||||
<section className={`bg-nova-900/60 border border-white/10 rounded-2xl p-6 ${className}`}>
|
||||
{children}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
/** Section heading */
|
||||
function SectionTitle({ icon, children }) {
|
||||
return (
|
||||
<h3 className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wider text-slate-400 mb-4">
|
||||
{icon && <i className={`${icon} text-accent/70 text-[11px]`} />}
|
||||
{children}
|
||||
</h3>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Main Component ──────────────────────────────────────────────────────────
|
||||
|
||||
export default function StudioArtworkEdit() {
|
||||
const { props } = usePage()
|
||||
const { artwork, contentTypes: rawContentTypes } = props
|
||||
|
||||
const contentTypes = useMemo(() => buildCategoryTree(rawContentTypes || []), [rawContentTypes])
|
||||
|
||||
// --- State ---
|
||||
// ── State ──────────────────────────────────────────────────────────────────
|
||||
const [contentTypeId, setContentTypeId] = useState(artwork?.content_type_id || null)
|
||||
const [categoryId, setCategoryId] = useState(artwork?.parent_category_id || null)
|
||||
const [subCategoryId, setSubCategoryId] = useState(artwork?.sub_category_id || null)
|
||||
const [title, setTitle] = useState(artwork?.title || '')
|
||||
const [description, setDescription] = useState(artwork?.description || '')
|
||||
const [tags, setTags] = useState(() => (artwork?.tags || []).map((t) => ({ id: t.id, name: t.name, slug: t.slug || t.name })))
|
||||
const [tagSlugs, setTagSlugs] = useState(() => (artwork?.tags || []).map((t) => t.slug || t.name))
|
||||
const [isPublic, setIsPublic] = useState(artwork?.is_public ?? true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [saved, setSaved] = useState(false)
|
||||
const [errors, setErrors] = useState({})
|
||||
|
||||
// Tag picker state
|
||||
const [tagQuery, setTagQuery] = useState('')
|
||||
const [tagResults, setTagResults] = useState([])
|
||||
const [tagLoading, setTagLoading] = useState(false)
|
||||
const tagInputRef = useRef(null)
|
||||
const tagSearchTimer = useRef(null)
|
||||
|
||||
// File replace state
|
||||
// File replace
|
||||
const fileInputRef = useRef(null)
|
||||
const [replacing, setReplacing] = useState(false)
|
||||
const [thumbUrl, setThumbUrl] = useState(artwork?.thumb_url_lg || artwork?.thumb_url || null)
|
||||
@@ -68,55 +93,19 @@ export default function StudioArtworkEdit() {
|
||||
const [changeNote, setChangeNote] = useState('')
|
||||
const [showChangeNote, setShowChangeNote] = useState(false)
|
||||
|
||||
// Version history modal state
|
||||
// Version history
|
||||
const [showHistory, setShowHistory] = useState(false)
|
||||
const [historyData, setHistoryData] = useState(null)
|
||||
const [historyLoading, setHistoryLoading] = useState(false)
|
||||
const [restoring, setRestoring] = useState(null) // version id being restored
|
||||
const [restoring, setRestoring] = useState(null)
|
||||
|
||||
// --- Tag search ---
|
||||
const searchTags = useCallback(async (q) => {
|
||||
setTagLoading(true)
|
||||
try {
|
||||
const params = new URLSearchParams()
|
||||
if (q) params.set('q', q)
|
||||
const res = await fetch(`/api/studio/tags/search?${params.toString()}`, {
|
||||
headers: { Accept: 'application/json', 'X-CSRF-TOKEN': getCsrfToken() },
|
||||
credentials: 'same-origin',
|
||||
})
|
||||
const data = await res.json()
|
||||
setTagResults(data || [])
|
||||
} catch {
|
||||
setTagResults([])
|
||||
} finally {
|
||||
setTagLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
clearTimeout(tagSearchTimer.current)
|
||||
tagSearchTimer.current = setTimeout(() => searchTags(tagQuery), 250)
|
||||
return () => clearTimeout(tagSearchTimer.current)
|
||||
}, [tagQuery, searchTags])
|
||||
|
||||
const toggleTag = (tag) => {
|
||||
setTags((prev) => {
|
||||
const exists = prev.find((t) => t.id === tag.id)
|
||||
return exists ? prev.filter((t) => t.id !== tag.id) : [...prev, { id: tag.id, name: tag.name, slug: tag.slug }]
|
||||
})
|
||||
}
|
||||
|
||||
const removeTag = (id) => {
|
||||
setTags((prev) => prev.filter((t) => t.id !== id))
|
||||
}
|
||||
|
||||
// --- Derived data ---
|
||||
// ── Derived ────────────────────────────────────────────────────────────────
|
||||
const selectedCT = contentTypes.find((ct) => ct.id === contentTypeId) || null
|
||||
const rootCategories = selectedCT?.rootCategories || []
|
||||
const selectedRoot = rootCategories.find((c) => c.id === categoryId) || null
|
||||
const subCategories = selectedRoot?.children || []
|
||||
|
||||
// --- Handlers ---
|
||||
// ── Handlers ───────────────────────────────────────────────────────────────
|
||||
const handleContentTypeChange = (id) => {
|
||||
setContentTypeId(id)
|
||||
setCategoryId(null)
|
||||
@@ -128,7 +117,7 @@ export default function StudioArtworkEdit() {
|
||||
setSubCategoryId(null)
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
const handleSave = useCallback(async () => {
|
||||
setSaving(true)
|
||||
setSaved(false)
|
||||
setErrors({})
|
||||
@@ -138,7 +127,7 @@ export default function StudioArtworkEdit() {
|
||||
description,
|
||||
is_public: isPublic,
|
||||
category_id: subCategoryId || categoryId || null,
|
||||
tags: tags.map((t) => t.slug || t.name),
|
||||
tags: tagSlugs,
|
||||
}
|
||||
const res = await fetch(`/api/studio/artworks/${artwork.id}`, {
|
||||
method: 'PUT',
|
||||
@@ -152,14 +141,13 @@ export default function StudioArtworkEdit() {
|
||||
} else {
|
||||
const data = await res.json()
|
||||
if (data.errors) setErrors(data.errors)
|
||||
console.error('Save failed:', data)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Save failed:', err)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
}, [title, description, isPublic, subCategoryId, categoryId, tagSlugs, artwork?.id])
|
||||
|
||||
const handleFileReplace = async (e) => {
|
||||
const file = e.target.files?.[0]
|
||||
@@ -202,8 +190,7 @@ export default function StudioArtworkEdit() {
|
||||
headers: { Accept: 'application/json', 'X-CSRF-TOKEN': getCsrfToken() },
|
||||
credentials: 'same-origin',
|
||||
})
|
||||
const data = await res.json()
|
||||
setHistoryData(data)
|
||||
setHistoryData(await res.json())
|
||||
} catch (err) {
|
||||
console.error('Failed to load version history:', err)
|
||||
} finally {
|
||||
@@ -212,7 +199,7 @@ export default function StudioArtworkEdit() {
|
||||
}
|
||||
|
||||
const handleRestoreVersion = async (versionId) => {
|
||||
if (!window.confirm('Restore this version? It will be cloned as the new current version.')) return
|
||||
if (!window.confirm('Restore this version? A copy will become the new current version.')) return
|
||||
setRestoring(versionId)
|
||||
try {
|
||||
const res = await fetch(`/api/studio/artworks/${artwork.id}/restore/${versionId}`, {
|
||||
@@ -222,7 +209,6 @@ export default function StudioArtworkEdit() {
|
||||
})
|
||||
const data = await res.json()
|
||||
if (res.ok && data.success) {
|
||||
alert(data.message)
|
||||
setVersionCount((n) => n + 1)
|
||||
setShowHistory(false)
|
||||
} else {
|
||||
@@ -235,160 +221,262 @@ export default function StudioArtworkEdit() {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Render ---
|
||||
// ── Render ─────────────────────────────────────────────────────────────────
|
||||
return (
|
||||
<StudioLayout title="Edit Artwork">
|
||||
|
||||
{/* ── Page Header ── */}
|
||||
<div className="flex items-center justify-between gap-4 mb-8">
|
||||
<div className="flex items-center gap-4 min-w-0">
|
||||
<Link
|
||||
href="/studio/artworks"
|
||||
className="inline-flex items-center gap-2 text-sm text-slate-400 hover:text-white mb-6 transition-colors"
|
||||
className="flex items-center justify-center w-9 h-9 rounded-xl border border-white/10 text-slate-400 hover:text-white hover:bg-white/5 transition-all shrink-0"
|
||||
aria-label="Back to artworks"
|
||||
>
|
||||
<i className="fa-solid fa-arrow-left" />
|
||||
Back to Artworks
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
||||
<path d="M10 3L5 8l5 5" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</Link>
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-lg font-bold text-white truncate">
|
||||
{title || 'Untitled artwork'}
|
||||
</h1>
|
||||
<p className="text-xs text-slate-500 mt-0.5">
|
||||
Editing ·{' '}
|
||||
<span className={isPublic ? 'text-emerald-400' : 'text-amber-400'}>
|
||||
{isPublic ? 'Published' : 'Draft'}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-w-3xl space-y-8">
|
||||
{/* ── Uploaded Asset ── */}
|
||||
<section className="bg-nova-900/60 border border-white/10 rounded-2xl p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wider text-slate-400">Uploaded Asset</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
{requiresReapproval && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-semibold bg-amber-500/20 text-amber-300 border border-amber-500/30">
|
||||
<i className="fa-solid fa-triangle-exclamation" /> Under Review
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{saved && (
|
||||
<span className="text-xs text-emerald-400 flex items-center gap-1 animate-pulse">
|
||||
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
|
||||
<path fillRule="evenodd" d="M8 16A8 8 0 108 0a8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L7 8.586 5.707 7.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
|
||||
</svg>
|
||||
Saved
|
||||
</span>
|
||||
)}
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-bold bg-accent/20 text-accent border border-accent/30">
|
||||
<Button variant="accent" size="sm" loading={saving} onClick={handleSave}>
|
||||
Save changes
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Two-column Layout ── */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-[340px_1fr] gap-6 items-start">
|
||||
|
||||
{/* ─────────── LEFT SIDEBAR ─────────── */}
|
||||
<div className="space-y-6 lg:sticky lg:top-6">
|
||||
|
||||
{/* Preview Card */}
|
||||
<Section>
|
||||
<SectionTitle icon="fa-solid fa-image">Preview</SectionTitle>
|
||||
|
||||
{/* Thumbnail */}
|
||||
<div className="relative aspect-square rounded-xl overflow-hidden bg-white/5 border border-white/10 mb-4">
|
||||
{thumbUrl ? (
|
||||
<img
|
||||
src={thumbUrl}
|
||||
alt={title || 'Artwork preview'}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center text-slate-600">
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden="true">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" />
|
||||
<circle cx="8.5" cy="8.5" r="1.5" fill="currentColor" />
|
||||
<path d="M21 15l-5-5L5 21" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
{replacing && (
|
||||
<div className="absolute inset-0 bg-black/60 flex items-center justify-center">
|
||||
<div className="w-7 h-7 border-2 border-accent/30 border-t-accent rounded-full animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* File Metadata */}
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm font-medium text-white truncate" title={fileMeta.name}>{fileMeta.name}</p>
|
||||
<div className="flex flex-wrap gap-x-3 gap-y-1 text-[11px] text-slate-500">
|
||||
{fileMeta.width > 0 && (
|
||||
<span className="flex items-center gap-1">
|
||||
<svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor" className="text-slate-600" aria-hidden="true">
|
||||
<path d="M2 3a1 1 0 011-1h10a1 1 0 011 1v10a1 1 0 01-1 1H3a1 1 0 01-1-1V3zm2 1v8h8V4H4z" />
|
||||
</svg>
|
||||
{fileMeta.width} × {fileMeta.height}
|
||||
</span>
|
||||
)}
|
||||
<span className="flex items-center gap-1">
|
||||
<svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor" className="text-slate-600" aria-hidden="true">
|
||||
<path d="M4 1.5a.5.5 0 00-1 0V3H1.5a.5.5 0 000 1h11a.5.5 0 000-1H11V1.5a.5.5 0 00-1 0V3H6V1.5a.5.5 0 00-1 0V3H4V1.5z" />
|
||||
<path d="M1.5 5v8.5A1.5 1.5 0 003 15h10a1.5 1.5 0 001.5-1.5V5h-13z" />
|
||||
</svg>
|
||||
{formatBytes(fileMeta.size)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Version + History */}
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
<span className="inline-flex items-center gap-1 text-[10px] font-bold text-accent bg-accent/15 px-2 py-0.5 rounded-full border border-accent/20">
|
||||
v{versionCount}
|
||||
</span>
|
||||
{versionCount > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={loadVersionHistory}
|
||||
className="text-xs text-slate-400 hover:text-white transition-colors flex items-center gap-1"
|
||||
className="inline-flex items-center gap-1.5 text-[11px] text-slate-400 hover:text-accent transition-colors"
|
||||
>
|
||||
<i className="fa-solid fa-clock-rotate-left text-[10px]" /> History
|
||||
<svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
|
||||
<path fillRule="evenodd" d="M8 3.5a4.5 4.5 0 00-4.04 2.51.75.75 0 01-1.34-.67A6 6 0 1114 8a.75.75 0 01-1.5 0A4.5 4.5 0 008 3.5z" clipRule="evenodd" />
|
||||
<path fillRule="evenodd" d="M4.75.75a.75.75 0 00-.75.75v3.5c0 .414.336.75.75.75h3.5a.75.75 0 000-1.5H5.5V1.5a.75.75 0 00-.75-.75z" clipRule="evenodd" />
|
||||
</svg>
|
||||
History
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{requiresReapproval && (
|
||||
<p className="text-[11px] text-amber-400/90 flex items-center gap-1.5 mt-1">
|
||||
<svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
|
||||
<path d="M8.982 1.566a1.13 1.13 0 00-1.964 0L.165 13.233c-.457.778.091 1.767.982 1.767h13.706c.891 0 1.439-.989.982-1.767L8.982 1.566zM8 5c.535 0 .954.462.9.995l-.35 3.507a.552.552 0 01-1.1 0L7.1 5.995A.905.905 0 018 5zm.002 6a1 1 0 100 2 1 1 0 000-2z" />
|
||||
</svg>
|
||||
Requires re-approval after replace
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-5">
|
||||
{thumbUrl ? (
|
||||
<img src={thumbUrl} alt={title} className="w-32 h-32 rounded-xl object-cover bg-nova-800 flex-shrink-0" />
|
||||
) : (
|
||||
<div className="w-32 h-32 rounded-xl bg-nova-800 flex items-center justify-center text-slate-600 flex-shrink-0">
|
||||
<i className="fa-solid fa-image text-2xl" />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 min-w-0 space-y-1">
|
||||
<p className="text-sm text-white font-medium truncate">{fileMeta.name}</p>
|
||||
<p className="text-xs text-slate-400">{formatBytes(fileMeta.size)}</p>
|
||||
{fileMeta.width > 0 && (
|
||||
<p className="text-xs text-slate-400">{fileMeta.width} × {fileMeta.height} px</p>
|
||||
)}
|
||||
|
||||
{/* Replace File */}
|
||||
<div className="mt-4 pt-4 border-t border-white/8 space-y-2.5">
|
||||
{showChangeNote && (
|
||||
<textarea
|
||||
<TextInput
|
||||
value={changeNote}
|
||||
onChange={(e) => setChangeNote(e.target.value)}
|
||||
rows={2}
|
||||
maxLength={500}
|
||||
placeholder="What changed? (optional)"
|
||||
className="mt-2 w-full px-3 py-2 rounded-lg bg-white/5 border border-white/10 text-white text-xs focus:outline-none focus:ring-2 focus:ring-accent/50 resize-none"
|
||||
placeholder="Change note (optional)…"
|
||||
size="sm"
|
||||
/>
|
||||
)}
|
||||
<input ref={fileInputRef} type="file" accept="image/*" className="hidden" onChange={handleFileReplace} />
|
||||
<div className="flex items-center gap-3 mt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShowChangeNote((s) => !s)
|
||||
if (!showChangeNote) fileInputRef.current?.click()
|
||||
}}
|
||||
disabled={replacing}
|
||||
className="inline-flex items-center gap-1.5 text-xs text-accent hover:text-accent/80 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<i className={replacing ? 'fa-solid fa-spinner fa-spin' : 'fa-solid fa-arrow-up-from-bracket'} />
|
||||
{replacing ? 'Replacing…' : 'Replace file'}
|
||||
</button>
|
||||
{showChangeNote && (
|
||||
<button
|
||||
type="button"
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="xs"
|
||||
loading={replacing}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={replacing}
|
||||
className="inline-flex items-center gap-1.5 text-xs bg-accent/20 hover:bg-accent/30 text-accent px-2.5 py-1 rounded-lg transition-colors disabled:opacity-50"
|
||||
>
|
||||
<i className="fa-solid fa-upload" /> Choose file
|
||||
{replacing ? 'Replacing…' : 'Replace file'}
|
||||
</Button>
|
||||
{!showChangeNote && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowChangeNote(true)}
|
||||
className="text-[11px] text-slate-500 hover:text-white transition-colors"
|
||||
>
|
||||
+ note
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<input ref={fileInputRef} type="file" className="hidden" accept="image/*" onChange={handleFileReplace} />
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* Quick Links */}
|
||||
<Section className="py-3 px-4">
|
||||
<Link
|
||||
href={`/studio/artworks/${artwork?.id}/analytics`}
|
||||
className="flex items-center gap-3 py-2 text-sm text-slate-400 hover:text-white transition-colors group"
|
||||
>
|
||||
<span className="flex items-center justify-center w-8 h-8 rounded-lg bg-white/5 group-hover:bg-accent/15 transition-colors">
|
||||
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor" className="text-slate-500 group-hover:text-accent transition-colors" aria-hidden="true">
|
||||
<path d="M1 11a1 1 0 011-1h2a1 1 0 011 1v3a1 1 0 01-1 1H2a1 1 0 01-1-1v-3zm5-4a1 1 0 011-1h2a1 1 0 011 1v7a1 1 0 01-1 1H7a1 1 0 01-1-1V7zm5-5a1 1 0 011-1h2a1 1 0 011 1v12a1 1 0 01-1 1h-2a1 1 0 01-1-1V2z" />
|
||||
</svg>
|
||||
</span>
|
||||
View Analytics
|
||||
</Link>
|
||||
</Section>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ─────────── RIGHT MAIN FORM ─────────── */}
|
||||
<div className="space-y-6">
|
||||
|
||||
{/* ── Content Type ── */}
|
||||
<section className="bg-nova-900/60 border border-white/10 rounded-2xl p-6">
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wider text-slate-400 mb-4">Content Type</h3>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 gap-3">
|
||||
<Section>
|
||||
<SectionTitle icon="fa-solid fa-palette">Content Type</SectionTitle>
|
||||
<div className="grid grid-cols-3 sm:grid-cols-5 gap-2">
|
||||
{contentTypes.map((ct) => {
|
||||
const active = ct.id === contentTypeId
|
||||
const vk = getContentTypeVisualKey(ct.slug)
|
||||
const isActive = contentTypeId === ct.id
|
||||
const visualKey = getContentTypeVisualKey(ct.slug)
|
||||
return (
|
||||
<button
|
||||
key={ct.id}
|
||||
type="button"
|
||||
onClick={() => handleContentTypeChange(ct.id)}
|
||||
className={`relative flex flex-col items-center gap-2 rounded-xl border-2 p-4 transition-all cursor-pointer
|
||||
${active ? 'border-emerald-400/70 bg-emerald-400/15 shadow-lg shadow-emerald-400/10' : 'border-white/10 bg-white/5 hover:border-white/20'}`}
|
||||
className={[
|
||||
'group flex flex-col items-center gap-2 rounded-xl border p-3 text-center transition-all',
|
||||
isActive
|
||||
? 'border-emerald-400/50 bg-emerald-400/10 ring-1 ring-emerald-400/30'
|
||||
: 'border-white/10 bg-white/[0.03] hover:border-white/20 hover:bg-white/[0.06]',
|
||||
].join(' ')}
|
||||
>
|
||||
<img src={`/gfx/mascot_${vk}.webp`} alt={ct.name} className="w-14 h-14 object-contain" />
|
||||
<span className={`text-xs font-semibold ${active ? 'text-emerald-300' : 'text-slate-300'}`}>{ct.name}</span>
|
||||
{active && (
|
||||
<span className="absolute top-1.5 right-1.5 w-5 h-5 rounded-full bg-emerald-500 flex items-center justify-center">
|
||||
<i className="fa-solid fa-check text-[10px] text-white" />
|
||||
<img
|
||||
src={`/gfx/mascot_${visualKey}.webp`}
|
||||
alt=""
|
||||
className="h-10 w-10 object-contain"
|
||||
onError={(e) => { e.target.style.display = 'none' }}
|
||||
/>
|
||||
<span className={`text-xs font-medium ${isActive ? 'text-emerald-300' : 'text-slate-400 group-hover:text-white'}`}>
|
||||
{ct.name}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
</Section>
|
||||
|
||||
{/* ── Category ── */}
|
||||
{rootCategories.length > 0 && (
|
||||
<section className="bg-nova-900/60 border border-white/10 rounded-2xl p-6 space-y-5">
|
||||
<div>
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wider text-slate-400 mb-3">Category</h3>
|
||||
<Section className="space-y-4">
|
||||
<SectionTitle icon="fa-solid fa-layer-group">Category</SectionTitle>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{rootCategories.map((cat) => {
|
||||
const active = cat.id === categoryId
|
||||
const isActive = categoryId === cat.id
|
||||
return (
|
||||
<button
|
||||
key={cat.id}
|
||||
type="button"
|
||||
onClick={() => handleCategoryChange(cat.id)}
|
||||
className={`px-4 py-2 rounded-full text-sm font-medium border transition-all cursor-pointer
|
||||
${active ? 'border-purple-600/90 bg-purple-700/35 text-purple-200' : 'border-white/10 bg-white/5 text-slate-300 hover:border-white/20'}`}
|
||||
className={[
|
||||
'px-3 py-1.5 rounded-lg text-xs font-medium border transition-all',
|
||||
isActive
|
||||
? 'border-purple-400/50 bg-purple-400/15 text-purple-300'
|
||||
: 'border-white/10 bg-white/[0.03] text-slate-400 hover:text-white hover:bg-white/[0.06]',
|
||||
].join(' ')}
|
||||
>
|
||||
{cat.name}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Subcategory */}
|
||||
{subCategories.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wider text-slate-400 mb-3">Subcategory</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<div className="space-y-2 pl-1 border-l-2 border-white/5 ml-2">
|
||||
<h4 className="text-[11px] font-semibold uppercase tracking-wider text-slate-500 pl-3">Subcategory</h4>
|
||||
<div className="flex flex-wrap gap-2 pl-3">
|
||||
{subCategories.map((sub) => {
|
||||
const active = sub.id === subCategoryId
|
||||
const isActive = subCategoryId === sub.id
|
||||
return (
|
||||
<button
|
||||
key={sub.id}
|
||||
type="button"
|
||||
onClick={() => setSubCategoryId(active ? null : sub.id)}
|
||||
className={`px-4 py-2 rounded-full text-sm font-medium border transition-all cursor-pointer
|
||||
${active ? 'border-cyan-600/90 bg-cyan-700/35 text-cyan-200' : 'border-white/10 bg-white/5 text-slate-300 hover:border-white/20'}`}
|
||||
onClick={() => setSubCategoryId(sub.id)}
|
||||
className={[
|
||||
'px-3 py-1.5 rounded-lg text-xs font-medium border transition-all',
|
||||
isActive
|
||||
? 'border-cyan-400/50 bg-cyan-400/15 text-cyan-300'
|
||||
: 'border-white/10 bg-white/[0.03] text-slate-400 hover:text-white hover:bg-white/[0.06]',
|
||||
].join(' ')}
|
||||
>
|
||||
{sub.name}
|
||||
</button>
|
||||
@@ -397,249 +485,158 @@ export default function StudioArtworkEdit() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{errors.category_id && <p className="text-xs text-red-400">{errors.category_id[0]}</p>}
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* ── Basics ── */}
|
||||
<section className="bg-nova-900/60 border border-white/10 rounded-2xl p-6 space-y-5">
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wider text-slate-400 mb-1">Basics</h3>
|
||||
{/* ── Details (Title + Description) ── */}
|
||||
<Section className="space-y-5">
|
||||
<SectionTitle icon="fa-solid fa-pen-fancy">Details</SectionTitle>
|
||||
|
||||
<div>
|
||||
<label className="text-xs font-medium text-slate-400 mb-1.5 block">Title</label>
|
||||
<input
|
||||
type="text"
|
||||
<TextInput
|
||||
label="Title"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
maxLength={120}
|
||||
className="w-full px-4 py-3 rounded-xl bg-white/5 border border-white/10 text-white text-sm focus:outline-none focus:ring-2 focus:ring-accent/50"
|
||||
placeholder="Give your artwork a title"
|
||||
error={errors.title?.[0]}
|
||||
required
|
||||
/>
|
||||
{errors.title && <p className="text-xs text-red-400 mt-1">{errors.title[0]}</p>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-xs font-medium text-slate-400 mb-1.5 block">Description</label>
|
||||
<textarea
|
||||
<FormField label="Description" htmlFor="artwork-description">
|
||||
<MarkdownEditor
|
||||
id="artwork-description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
rows={5}
|
||||
className="w-full px-4 py-3 rounded-xl bg-white/5 border border-white/10 text-white text-sm focus:outline-none focus:ring-2 focus:ring-accent/50 resize-y"
|
||||
onChange={setDescription}
|
||||
placeholder="Describe your artwork, tools, inspiration…"
|
||||
rows={6}
|
||||
error={errors.description?.[0]}
|
||||
/>
|
||||
{errors.description && <p className="text-xs text-red-400 mt-1">{errors.description[0]}</p>}
|
||||
</div>
|
||||
</section>
|
||||
</FormField>
|
||||
</Section>
|
||||
|
||||
{/* ── Tags ── */}
|
||||
<section className="bg-nova-900/60 border border-white/10 rounded-2xl p-6 space-y-4">
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wider text-slate-400">Tags</h3>
|
||||
|
||||
{/* Search input */}
|
||||
<div className="relative">
|
||||
<i className="fa-solid fa-magnifying-glass absolute left-3 top-1/2 -translate-y-1/2 text-slate-500 text-sm pointer-events-none" />
|
||||
<input
|
||||
ref={tagInputRef}
|
||||
type="text"
|
||||
value={tagQuery}
|
||||
onChange={(e) => setTagQuery(e.target.value)}
|
||||
className="w-full py-2.5 rounded-xl bg-white/5 border border-white/10 text-white text-sm focus:outline-none focus:ring-2 focus:ring-accent/50"
|
||||
style={{ paddingLeft: '2.5rem' }}
|
||||
placeholder="Search tags…"
|
||||
<Section className="space-y-4">
|
||||
<SectionTitle icon="fa-solid fa-tags">Tags</SectionTitle>
|
||||
<TagPicker
|
||||
value={tagSlugs}
|
||||
onChange={setTagSlugs}
|
||||
searchEndpoint="/api/studio/tags/search"
|
||||
popularEndpoint="/api/studio/tags/search"
|
||||
error={errors.tags?.[0]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Selected tag chips */}
|
||||
{tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{tags.map((tag) => (
|
||||
<span
|
||||
key={tag.id}
|
||||
className="inline-flex items-center gap-1 px-2.5 py-1 rounded-lg text-xs font-medium bg-accent/20 text-accent"
|
||||
>
|
||||
{tag.name}
|
||||
<button
|
||||
onClick={() => removeTag(tag.id)}
|
||||
className="ml-0.5 w-4 h-4 rounded-full hover:bg-white/10 flex items-center justify-center"
|
||||
>
|
||||
<i className="fa-solid fa-xmark text-[10px]" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Results list */}
|
||||
<div className="max-h-48 overflow-y-auto sb-scrollbar space-y-0.5 rounded-xl bg-white/[0.02] border border-white/5 p-1">
|
||||
{tagLoading && (
|
||||
<div className="flex items-center justify-center py-4">
|
||||
<div className="w-5 h-5 border-2 border-accent/30 border-t-accent rounded-full animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!tagLoading && tagResults.length === 0 && (
|
||||
<p className="text-center text-sm text-slate-500 py-4">
|
||||
{tagQuery ? 'No tags found' : 'Type to search tags'}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!tagLoading &&
|
||||
tagResults.map((tag) => {
|
||||
const isSelected = tags.some((t) => t.id === tag.id)
|
||||
return (
|
||||
<button
|
||||
key={tag.id}
|
||||
type="button"
|
||||
onClick={() => toggleTag(tag)}
|
||||
className={`w-full flex items-center justify-between px-3 py-2 rounded-lg text-sm transition-all ${
|
||||
isSelected
|
||||
? 'bg-accent/10 text-accent'
|
||||
: 'text-slate-300 hover:bg-white/5 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<i
|
||||
className={`fa-${isSelected ? 'solid fa-circle-check' : 'regular fa-circle'} text-xs ${
|
||||
isSelected ? 'text-accent' : 'text-slate-500'
|
||||
}`}
|
||||
/>
|
||||
{tag.name}
|
||||
</span>
|
||||
<span className="text-xs text-slate-500">{tag.usage_count?.toLocaleString() ?? 0} uses</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-slate-500">{tags.length}/15 tags selected</p>
|
||||
{errors.tags && <p className="text-xs text-red-400">{errors.tags[0]}</p>}
|
||||
</section>
|
||||
</Section>
|
||||
|
||||
{/* ── Visibility ── */}
|
||||
<section className="bg-nova-900/60 border border-white/10 rounded-2xl p-6">
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wider text-slate-400 mb-4">Visibility</h3>
|
||||
<div className="flex items-center gap-6">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input type="radio" checked={isPublic} onChange={() => setIsPublic(true)} className="text-accent focus:ring-accent/50" />
|
||||
<span className="text-sm text-white">Published</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input type="radio" checked={!isPublic} onChange={() => setIsPublic(false)} className="text-accent focus:ring-accent/50" />
|
||||
<span className="text-sm text-white">Draft</span>
|
||||
</label>
|
||||
<Section>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<SectionTitle icon="fa-solid fa-eye">Visibility</SectionTitle>
|
||||
<p className="text-xs text-slate-500 -mt-2">
|
||||
{isPublic
|
||||
? 'Your artwork is visible to everyone'
|
||||
: 'Your artwork is only visible to you'}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── Actions ── */}
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="px-6 py-2.5 rounded-xl bg-accent hover:bg-accent/90 text-white font-semibold text-sm transition-all shadow-lg shadow-accent/25 disabled:opacity-50"
|
||||
>
|
||||
{saving ? 'Saving…' : 'Save changes'}
|
||||
</button>
|
||||
<Toggle
|
||||
checked={isPublic}
|
||||
onChange={(e) => setIsPublic(e.target.checked)}
|
||||
label={isPublic ? 'Published' : 'Draft'}
|
||||
variant={isPublic ? 'emerald' : 'accent'}
|
||||
size="md"
|
||||
/>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* ── Bottom Save Bar (mobile) ── */}
|
||||
<div className="flex items-center justify-between gap-3 py-2 lg:hidden">
|
||||
<Button variant="accent" loading={saving} onClick={handleSave}>
|
||||
Save changes
|
||||
</Button>
|
||||
{saved && (
|
||||
<span className="text-sm text-emerald-400 flex items-center gap-1">
|
||||
<i className="fa-solid fa-check" /> Saved
|
||||
<span className="text-xs text-emerald-400 flex items-center gap-1">
|
||||
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
|
||||
<path fillRule="evenodd" d="M8 16A8 8 0 108 0a8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L7 8.586 5.707 7.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
|
||||
</svg>
|
||||
Saved
|
||||
</span>
|
||||
)}
|
||||
|
||||
<Link
|
||||
href={`/studio/artworks/${artwork?.id}/analytics`}
|
||||
className="ml-auto px-4 py-2.5 rounded-xl border border-white/10 text-slate-400 hover:text-white hover:bg-white/5 text-sm transition-all"
|
||||
>
|
||||
<i className="fa-solid fa-chart-line mr-2" />
|
||||
Analytics
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Version History Modal ── */}
|
||||
{showHistory && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm p-4"
|
||||
onClick={(e) => { if (e.target === e.currentTarget) setShowHistory(false) }}
|
||||
<Modal
|
||||
open={showHistory}
|
||||
onClose={() => setShowHistory(false)}
|
||||
title="Version History"
|
||||
size="lg"
|
||||
footer={
|
||||
<p className="text-xs text-slate-500 mr-auto">
|
||||
Restoring creates a new version — nothing is deleted.
|
||||
</p>
|
||||
}
|
||||
>
|
||||
<div className="bg-nova-900 border border-white/10 rounded-2xl shadow-2xl w-full max-w-lg max-h-[80vh] flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-white/10">
|
||||
<h2 className="text-sm font-semibold text-white flex items-center gap-2">
|
||||
<i className="fa-solid fa-clock-rotate-left text-accent" />
|
||||
Version History
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => setShowHistory(false)}
|
||||
className="w-7 h-7 rounded-full hover:bg-white/10 flex items-center justify-center text-slate-400 hover:text-white transition-colors"
|
||||
>
|
||||
<i className="fa-solid fa-xmark text-xs" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="overflow-y-auto flex-1 sb-scrollbar p-4 space-y-3">
|
||||
{historyLoading && (
|
||||
<div className="flex items-center justify-center py-10">
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="w-6 h-6 border-2 border-accent/30 border-t-accent rounded-full animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!historyLoading && historyData && historyData.versions.map((v) => (
|
||||
{!historyLoading && historyData && (
|
||||
<div className="space-y-3">
|
||||
{historyData.versions.map((v) => (
|
||||
<div
|
||||
key={v.id}
|
||||
className={`rounded-xl border p-4 transition-all ${
|
||||
className={[
|
||||
'rounded-xl border p-4 transition-all',
|
||||
v.is_current
|
||||
? 'border-accent/40 bg-accent/10'
|
||||
: 'border-white/10 bg-white/[0.03] hover:bg-white/[0.06]'
|
||||
}`}
|
||||
: 'border-white/10 bg-white/[0.03] hover:bg-white/[0.06]',
|
||||
].join(' ')}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-xs font-bold text-white">v{v.version_number}</span>
|
||||
{v.is_current && (
|
||||
<span className="text-[10px] font-semibold px-1.5 py-0.5 rounded-full bg-accent/20 text-accent border border-accent/30">Current</span>
|
||||
<span className="text-[10px] font-semibold px-1.5 py-0.5 rounded-full bg-accent/20 text-accent border border-accent/30">
|
||||
Current
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-[11px] text-slate-400">
|
||||
{v.created_at ? new Date(v.created_at).toLocaleString() : ''}
|
||||
</p>
|
||||
{v.width && (
|
||||
<p className="text-[11px] text-slate-400">{v.width} × {v.height} px · {formatBytes(v.file_size)}</p>
|
||||
<p className="text-[11px] text-slate-400">
|
||||
{v.width} × {v.height} px · {formatBytes(v.file_size)}
|
||||
</p>
|
||||
)}
|
||||
{v.change_note && (
|
||||
<p className="text-xs text-slate-300 mt-1 italic">“{v.change_note}”</p>
|
||||
)}
|
||||
</div>
|
||||
{!v.is_current && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={restoring === v.id}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
loading={restoring === v.id}
|
||||
onClick={() => handleRestoreVersion(v.id)}
|
||||
className="flex-shrink-0 inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium bg-white/5 hover:bg-accent/20 text-slate-300 hover:text-accent border border-white/10 hover:border-accent/30 transition-all disabled:opacity-50"
|
||||
>
|
||||
{restoring === v.id
|
||||
? <><i className="fa-solid fa-spinner fa-spin" /> Restoring…</>
|
||||
: <><i className="fa-solid fa-rotate-left" /> Restore</>
|
||||
}
|
||||
</button>
|
||||
Restore
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{!historyLoading && historyData && historyData.versions.length === 0 && (
|
||||
{historyData.versions.length === 0 && (
|
||||
<p className="text-sm text-slate-500 text-center py-8">No version history yet.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-6 py-4 border-t border-white/10">
|
||||
<p className="text-xs text-slate-500">
|
||||
Older versions are preserved. Restoring creates a new version—nothing is deleted.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
</StudioLayout>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -10,11 +10,28 @@ export default function EmbeddedArtworkCard({ artwork }) {
|
||||
const artUrl = `/art/${artwork.id}/${slugify(artwork.title)}`
|
||||
const authorUrl = `/@${artwork.author.username}`
|
||||
|
||||
const handleCardClick = (e) => {
|
||||
// Don't navigate when clicking the author link
|
||||
if (e.defaultPrevented) return
|
||||
window.location.href = artUrl
|
||||
}
|
||||
|
||||
const handleKeyDown = (e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
window.location.href = artUrl
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<a
|
||||
href={artUrl}
|
||||
className="group flex gap-3 rounded-xl border border-white/[0.08] bg-black/30 p-3 hover:border-sky-500/30 transition-colors"
|
||||
title={artwork.title}
|
||||
// Outer element is a div to avoid <a> inside <a> — navigation handled via onClick
|
||||
<div
|
||||
role="link"
|
||||
tabIndex={0}
|
||||
aria-label={artwork.title}
|
||||
onClick={handleCardClick}
|
||||
onKeyDown={handleKeyDown}
|
||||
className="group flex gap-3 rounded-xl border border-white/[0.08] bg-black/30 p-3 hover:border-sky-500/30 transition-colors cursor-pointer"
|
||||
>
|
||||
{/* Thumbnail */}
|
||||
<div className="w-20 h-16 rounded-lg overflow-hidden shrink-0 bg-white/5">
|
||||
@@ -45,7 +62,7 @@ export default function EmbeddedArtworkCard({ artwork }) {
|
||||
</a>
|
||||
<span className="text-[10px] text-slate-600 mt-1 uppercase tracking-wider">Artwork</span>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ function slugify(str) {
|
||||
* React version of resources/views/components/artwork-card.blade.php
|
||||
* Keeps identical HTML structure so existing CSS (nova-card, nova-card-media, etc.) applies.
|
||||
*/
|
||||
export default function ArtworkCard({ art, loading = 'lazy', fetchpriority = null }) {
|
||||
export default function ArtworkCard({ art, loading = 'lazy', fetchPriority = null }) {
|
||||
const imgRef = useRef(null);
|
||||
const mediaRef = useRef(null);
|
||||
|
||||
@@ -119,7 +119,7 @@ export default function ArtworkCard({ art, loading = 'lazy', fetchpriority = nul
|
||||
sizes="(max-width: 768px) 50vw, (max-width: 1280px) 33vw, 20vw"
|
||||
loading={loading}
|
||||
decoding={loading === 'eager' ? 'sync' : 'async'}
|
||||
fetchPriority={fetchpriority || undefined}
|
||||
fetchPriority={fetchPriority || undefined}
|
||||
alt={title}
|
||||
width={hasDimensions ? art.width : undefined}
|
||||
height={hasDimensions ? art.height : undefined}
|
||||
|
||||
@@ -297,7 +297,7 @@ function MasonryGallery({
|
||||
key={`${art.id}-${idx}`}
|
||||
art={art}
|
||||
loading={idx < 8 ? 'eager' : 'lazy'}
|
||||
fetchpriority={idx === 0 ? 'high' : null}
|
||||
fetchPriority={idx === 0 ? 'high' : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
181
resources/js/components/ui/MarkdownEditor.jsx
Normal file
181
resources/js/components/ui/MarkdownEditor.jsx
Normal file
@@ -0,0 +1,181 @@
|
||||
import React, { useCallback, useRef, useState } from 'react'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
|
||||
function ToolbarButton({ title, onClick, children }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
title={title}
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault()
|
||||
onClick()
|
||||
}}
|
||||
className="inline-flex h-7 min-w-7 items-center justify-center rounded-md px-1.5 text-xs font-semibold text-white/55 transition hover:bg-white/10 hover:text-white"
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export default function MarkdownEditor({ id, value, onChange, placeholder, error, rows = 5 }) {
|
||||
const [tab, setTab] = useState('write')
|
||||
const textareaRef = useRef(null)
|
||||
|
||||
const wrapSelection = useCallback((before, after) => {
|
||||
const textarea = textareaRef.current
|
||||
if (!textarea) return
|
||||
|
||||
const current = String(value || '')
|
||||
const start = textarea.selectionStart
|
||||
const end = textarea.selectionEnd
|
||||
const selected = current.slice(start, end)
|
||||
const replacement = before + (selected || 'text') + after
|
||||
const next = current.slice(0, start) + replacement + current.slice(end)
|
||||
onChange?.(next)
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
textarea.focus()
|
||||
textarea.selectionStart = selected ? start + replacement.length : start + before.length
|
||||
textarea.selectionEnd = selected ? start + replacement.length : start + before.length + 4
|
||||
})
|
||||
}, [onChange, value])
|
||||
|
||||
const prefixLines = useCallback((prefix) => {
|
||||
const textarea = textareaRef.current
|
||||
if (!textarea) return
|
||||
|
||||
const current = String(value || '')
|
||||
const start = textarea.selectionStart
|
||||
const end = textarea.selectionEnd
|
||||
const selected = current.slice(start, end)
|
||||
const lines = (selected || '').split('\n')
|
||||
const normalized = (lines.length ? lines : ['']).map((line) => `${prefix}${line}`).join('\n')
|
||||
const next = current.slice(0, start) + normalized + current.slice(end)
|
||||
onChange?.(next)
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
textarea.focus()
|
||||
textarea.selectionStart = start
|
||||
textarea.selectionEnd = start + normalized.length
|
||||
})
|
||||
}, [onChange, value])
|
||||
|
||||
const insertLink = useCallback(() => {
|
||||
const textarea = textareaRef.current
|
||||
if (!textarea) return
|
||||
|
||||
const current = String(value || '')
|
||||
const start = textarea.selectionStart
|
||||
const end = textarea.selectionEnd
|
||||
const selected = current.slice(start, end)
|
||||
const replacement = selected && /^https?:\/\//i.test(selected)
|
||||
? `[link](${selected})`
|
||||
: `[${selected || 'link'}](https://)`
|
||||
const next = current.slice(0, start) + replacement + current.slice(end)
|
||||
onChange?.(next)
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
textarea.focus()
|
||||
})
|
||||
}, [onChange, value])
|
||||
|
||||
const handleKeyDown = useCallback((event) => {
|
||||
const withModifier = event.ctrlKey || event.metaKey
|
||||
if (!withModifier) return
|
||||
|
||||
switch (event.key.toLowerCase()) {
|
||||
case 'b':
|
||||
event.preventDefault()
|
||||
wrapSelection('**', '**')
|
||||
break
|
||||
case 'i':
|
||||
event.preventDefault()
|
||||
wrapSelection('*', '*')
|
||||
break
|
||||
case 'k':
|
||||
event.preventDefault()
|
||||
insertLink()
|
||||
break
|
||||
case 'e':
|
||||
event.preventDefault()
|
||||
wrapSelection('`', '`')
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
}, [insertLink, wrapSelection])
|
||||
|
||||
return (
|
||||
<div className={`rounded-xl border bg-white/10 ${error ? 'border-red-300/60' : 'border-white/15'}`}>
|
||||
<div className="flex items-center justify-between border-b border-white/10 px-2 py-1.5">
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTab('write')}
|
||||
className={`rounded-md px-2.5 py-1 text-xs font-medium transition ${tab === 'write' ? 'bg-white/12 text-white' : 'text-white/55 hover:text-white/80'}`}
|
||||
>
|
||||
Write
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTab('preview')}
|
||||
className={`rounded-md px-2.5 py-1 text-xs font-medium transition ${tab === 'preview' ? 'bg-white/12 text-white' : 'text-white/55 hover:text-white/80'}`}
|
||||
>
|
||||
Preview
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{tab === 'write' && (
|
||||
<>
|
||||
<div className="flex items-center gap-1 border-b border-white/10 px-2 py-1">
|
||||
<ToolbarButton title="Bold (Ctrl+B)" onClick={() => wrapSelection('**', '**')}>B</ToolbarButton>
|
||||
<ToolbarButton title="Italic (Ctrl+I)" onClick={() => wrapSelection('*', '*')}>I</ToolbarButton>
|
||||
<ToolbarButton title="Inline code (Ctrl+E)" onClick={() => wrapSelection('`', '`')}>{'</>'}</ToolbarButton>
|
||||
<ToolbarButton title="Link (Ctrl+K)" onClick={insertLink}>Link</ToolbarButton>
|
||||
<span className="mx-1 h-4 w-px bg-white/10" aria-hidden="true" />
|
||||
<ToolbarButton title="Bulleted list" onClick={() => prefixLines('- ')}>• List</ToolbarButton>
|
||||
<ToolbarButton title="Quote" onClick={() => prefixLines('> ')}>❝</ToolbarButton>
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
id={id}
|
||||
ref={textareaRef}
|
||||
value={value}
|
||||
onChange={(event) => onChange?.(event.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
rows={rows}
|
||||
className="w-full resize-y bg-transparent px-3 py-2 text-sm text-white placeholder-white/45 focus:outline-none"
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
|
||||
<p className="px-3 pb-2 text-[11px] text-white/45">
|
||||
Markdown supported · Ctrl+B bold · Ctrl+I italic · Ctrl+K link
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
{tab === 'preview' && (
|
||||
<div className="min-h-[132px] px-3 py-2">
|
||||
{String(value || '').trim() ? (
|
||||
<div className="prose prose-invert prose-sm max-w-none text-white/85 [&_a]:text-sky-300 [&_a]:no-underline hover:[&_a]:underline [&_blockquote]:border-l-2 [&_blockquote]:border-white/20 [&_blockquote]:pl-3 [&_code]:rounded [&_code]:bg-white/10 [&_code]:px-1 [&_code]:py-0.5 [&_ul]:pl-4 [&_ol]:pl-4">
|
||||
<ReactMarkdown
|
||||
allowedElements={['p', 'strong', 'em', 'a', 'code', 'pre', 'ul', 'ol', 'li', 'blockquote', 'br']}
|
||||
unwrapDisallowed
|
||||
components={{
|
||||
a: ({ href, children }) => (
|
||||
<a href={href} target="_blank" rel="noopener noreferrer nofollow">{children}</a>
|
||||
),
|
||||
}}
|
||||
>
|
||||
{String(value || '')}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm italic text-white/35">Nothing to preview</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,186 +1,7 @@
|
||||
import React, { useCallback, useRef, useState } from 'react'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import React from 'react'
|
||||
import TagPicker from '../tags/TagPicker'
|
||||
import Checkbox from '../../Components/ui/Checkbox'
|
||||
|
||||
function ToolbarButton({ title, onClick, children }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
title={title}
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault()
|
||||
onClick()
|
||||
}}
|
||||
className="inline-flex h-7 min-w-7 items-center justify-center rounded-md px-1.5 text-xs font-semibold text-white/55 transition hover:bg-white/10 hover:text-white"
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function MarkdownEditor({ id, value, onChange, placeholder, error }) {
|
||||
const [tab, setTab] = useState('write')
|
||||
const textareaRef = useRef(null)
|
||||
|
||||
const wrapSelection = useCallback((before, after) => {
|
||||
const textarea = textareaRef.current
|
||||
if (!textarea) return
|
||||
|
||||
const current = String(value || '')
|
||||
const start = textarea.selectionStart
|
||||
const end = textarea.selectionEnd
|
||||
const selected = current.slice(start, end)
|
||||
const replacement = before + (selected || 'text') + after
|
||||
const next = current.slice(0, start) + replacement + current.slice(end)
|
||||
onChange?.(next)
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
textarea.focus()
|
||||
textarea.selectionStart = selected ? start + replacement.length : start + before.length
|
||||
textarea.selectionEnd = selected ? start + replacement.length : start + before.length + 4
|
||||
})
|
||||
}, [onChange, value])
|
||||
|
||||
const prefixLines = useCallback((prefix) => {
|
||||
const textarea = textareaRef.current
|
||||
if (!textarea) return
|
||||
|
||||
const current = String(value || '')
|
||||
const start = textarea.selectionStart
|
||||
const end = textarea.selectionEnd
|
||||
const selected = current.slice(start, end)
|
||||
const lines = (selected || '').split('\n')
|
||||
const normalized = (lines.length ? lines : ['']).map((line) => `${prefix}${line}`).join('\n')
|
||||
const next = current.slice(0, start) + normalized + current.slice(end)
|
||||
onChange?.(next)
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
textarea.focus()
|
||||
textarea.selectionStart = start
|
||||
textarea.selectionEnd = start + normalized.length
|
||||
})
|
||||
}, [onChange, value])
|
||||
|
||||
const insertLink = useCallback(() => {
|
||||
const textarea = textareaRef.current
|
||||
if (!textarea) return
|
||||
|
||||
const current = String(value || '')
|
||||
const start = textarea.selectionStart
|
||||
const end = textarea.selectionEnd
|
||||
const selected = current.slice(start, end)
|
||||
const replacement = selected && /^https?:\/\//i.test(selected)
|
||||
? `[link](${selected})`
|
||||
: `[${selected || 'link'}](https://)`
|
||||
const next = current.slice(0, start) + replacement + current.slice(end)
|
||||
onChange?.(next)
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
textarea.focus()
|
||||
})
|
||||
}, [onChange, value])
|
||||
|
||||
const handleKeyDown = useCallback((event) => {
|
||||
const withModifier = event.ctrlKey || event.metaKey
|
||||
if (!withModifier) return
|
||||
|
||||
switch (event.key.toLowerCase()) {
|
||||
case 'b':
|
||||
event.preventDefault()
|
||||
wrapSelection('**', '**')
|
||||
break
|
||||
case 'i':
|
||||
event.preventDefault()
|
||||
wrapSelection('*', '*')
|
||||
break
|
||||
case 'k':
|
||||
event.preventDefault()
|
||||
insertLink()
|
||||
break
|
||||
case 'e':
|
||||
event.preventDefault()
|
||||
wrapSelection('`', '`')
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
}, [insertLink, wrapSelection])
|
||||
|
||||
return (
|
||||
<div className={`mt-2 rounded-xl border bg-white/10 ${error ? 'border-red-300/60' : 'border-white/15'}`}>
|
||||
<div className="flex items-center justify-between border-b border-white/10 px-2 py-1.5">
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTab('write')}
|
||||
className={`rounded-md px-2.5 py-1 text-xs font-medium transition ${tab === 'write' ? 'bg-white/12 text-white' : 'text-white/55 hover:text-white/80'}`}
|
||||
>
|
||||
Write
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTab('preview')}
|
||||
className={`rounded-md px-2.5 py-1 text-xs font-medium transition ${tab === 'preview' ? 'bg-white/12 text-white' : 'text-white/55 hover:text-white/80'}`}
|
||||
>
|
||||
Preview
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{tab === 'write' && (
|
||||
<>
|
||||
<div className="flex items-center gap-1 border-b border-white/10 px-2 py-1">
|
||||
<ToolbarButton title="Bold (Ctrl+B)" onClick={() => wrapSelection('**', '**')}>B</ToolbarButton>
|
||||
<ToolbarButton title="Italic (Ctrl+I)" onClick={() => wrapSelection('*', '*')}>I</ToolbarButton>
|
||||
<ToolbarButton title="Inline code (Ctrl+E)" onClick={() => wrapSelection('`', '`')}>{'</>'}</ToolbarButton>
|
||||
<ToolbarButton title="Link (Ctrl+K)" onClick={insertLink}>Link</ToolbarButton>
|
||||
<span className="mx-1 h-4 w-px bg-white/10" aria-hidden="true" />
|
||||
<ToolbarButton title="Bulleted list" onClick={() => prefixLines('- ')}>• List</ToolbarButton>
|
||||
<ToolbarButton title="Quote" onClick={() => prefixLines('> ')}>❝</ToolbarButton>
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
id={id}
|
||||
ref={textareaRef}
|
||||
value={value}
|
||||
onChange={(event) => onChange?.(event.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
rows={5}
|
||||
className="w-full resize-y bg-transparent px-3 py-2 text-sm text-white placeholder-white/45 focus:outline-none"
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
|
||||
<p className="px-3 pb-2 text-[11px] text-white/45">
|
||||
Markdown supported · Ctrl+B bold · Ctrl+I italic · Ctrl+K link
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
{tab === 'preview' && (
|
||||
<div className="min-h-[132px] px-3 py-2">
|
||||
{String(value || '').trim() ? (
|
||||
<div className="prose prose-invert prose-sm max-w-none text-white/85 [&_a]:text-sky-300 [&_a]:no-underline hover:[&_a]:underline [&_blockquote]:border-l-2 [&_blockquote]:border-white/20 [&_blockquote]:pl-3 [&_code]:rounded [&_code]:bg-white/10 [&_code]:px-1 [&_code]:py-0.5 [&_ul]:pl-4 [&_ol]:pl-4">
|
||||
<ReactMarkdown
|
||||
allowedElements={['p', 'strong', 'em', 'a', 'code', 'pre', 'ul', 'ol', 'li', 'blockquote', 'br']}
|
||||
unwrapDisallowed
|
||||
components={{
|
||||
a: ({ href, children }) => (
|
||||
<a href={href} target="_blank" rel="noopener noreferrer nofollow">{children}</a>
|
||||
),
|
||||
}}
|
||||
>
|
||||
{String(value || '')}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm italic text-white/35">Nothing to preview</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
import MarkdownEditor from '../ui/MarkdownEditor'
|
||||
|
||||
export default function UploadSidebar({
|
||||
title = 'Artwork details',
|
||||
|
||||
@@ -388,9 +388,13 @@ export default function useUploadMachine({
|
||||
|
||||
const { mode = 'now', publishAt = null, timezone = null, visibility = 'public' } = opts
|
||||
|
||||
const resolvedCategoryId = metadata.subCategoryId || metadata.rootCategoryId || null
|
||||
|
||||
const buildPayload = () => ({
|
||||
title: String(metadata.title || '').trim() || undefined,
|
||||
description: String(metadata.description || '').trim() || null,
|
||||
category: resolvedCategoryId ? String(resolvedCategoryId) : null,
|
||||
tags: Array.isArray(metadata.tags) ? metadata.tags : [],
|
||||
mode,
|
||||
...(mode === 'schedule' && publishAt ? { publish_at: publishAt } : {}),
|
||||
...(timezone ? { timezone } : {}),
|
||||
|
||||
@@ -2,6 +2,14 @@
|
||||
// - dropdown menus via [data-dropdown]
|
||||
// - mobile menu toggle via [data-mobile-toggle] + #mobileMenu
|
||||
|
||||
// Alpine.js — powers x-data/x-show/@click in Blade layouts (e.g. cookie banner, toasts).
|
||||
// Guard: don't start a second instance if app.js already loaded Alpine on this page.
|
||||
import Alpine from 'alpinejs';
|
||||
if (!window.Alpine) {
|
||||
window.Alpine = Alpine;
|
||||
Alpine.start();
|
||||
}
|
||||
|
||||
// Gallery navigation context: stores artwork list for prev/next on artwork page
|
||||
import './lib/nav-context.js';
|
||||
|
||||
|
||||
18
resources/js/settings.jsx
Normal file
18
resources/js/settings.jsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import './bootstrap'
|
||||
import React from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { createInertiaApp } from '@inertiajs/react'
|
||||
|
||||
import ProfileEdit from './Pages/Settings/ProfileEdit'
|
||||
|
||||
const pages = {
|
||||
'Settings/ProfileEdit': ProfileEdit,
|
||||
}
|
||||
|
||||
createInertiaApp({
|
||||
resolve: (name) => pages[name],
|
||||
setup({ el, App, props }) {
|
||||
const root = createRoot(el)
|
||||
root.render(<App {...props} />)
|
||||
},
|
||||
})
|
||||
156
resources/views/admin/early-growth/index.blade.php
Normal file
156
resources/views/admin/early-growth/index.blade.php
Normal file
@@ -0,0 +1,156 @@
|
||||
@extends('layouts.nova.content-layout')
|
||||
|
||||
@section('page-content')
|
||||
<div class="max-w-4xl space-y-8">
|
||||
|
||||
{{-- ── Header ── --}}
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="text-2xl font-bold text-white">Early-Stage Growth System</h2>
|
||||
<p class="mt-1 text-sm text-neutral-400">
|
||||
A non-deceptive layer that keeps Nova feeling alive when uploads are sparse.
|
||||
Toggle via <code class="text-sky-400">.env</code> — no deployment required for mode changes.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{{-- Cache flush button --}}
|
||||
<form method="POST" action="{{ route('admin.early-growth.cache.flush') }}" onsubmit="return confirm('Flush all EGS caches?')">
|
||||
@csrf
|
||||
@method('DELETE')
|
||||
<button type="submit"
|
||||
class="inline-flex items-center gap-2 rounded-lg bg-neutral-800 px-4 py-2 text-sm font-medium text-white
|
||||
hover:bg-neutral-700 border border-neutral-700 transition">
|
||||
🔄 Flush EGS Cache
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@if(session('success'))
|
||||
<div class="rounded-lg bg-green-900/40 border border-green-700 px-4 py-3 text-green-300 text-sm">
|
||||
{{ session('success') }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- ── Live Status ── --}}
|
||||
<div class="rounded-lg border border-neutral-800 bg-neutral-900 p-6 space-y-4">
|
||||
<h3 class="text-sm font-semibold uppercase tracking-widest text-neutral-400">Live Status</h3>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4 sm:grid-cols-3">
|
||||
@php
|
||||
$pill = fn(bool $on) => $on
|
||||
? '<span class="inline-block rounded-full bg-emerald-800 px-3 py-0.5 text-xs font-semibold text-emerald-200">ON</span>'
|
||||
: '<span class="inline-block rounded-full bg-neutral-700 px-3 py-0.5 text-xs font-semibold text-neutral-400">OFF</span>';
|
||||
@endphp
|
||||
|
||||
<div class="rounded-lg bg-neutral-800/50 p-4">
|
||||
<p class="text-xs text-neutral-500 mb-1">System</p>
|
||||
{!! $pill($status['enabled']) !!}
|
||||
</div>
|
||||
<div class="rounded-lg bg-neutral-800/50 p-4">
|
||||
<p class="text-xs text-neutral-500 mb-1">Mode</p>
|
||||
<span class="text-sm font-mono font-semibold {{ $mode === 'aggressive' ? 'text-amber-400' : ($mode === 'light' ? 'text-sky-400' : 'text-neutral-400') }}">
|
||||
{{ strtoupper($mode) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="rounded-lg bg-neutral-800/50 p-4">
|
||||
<p class="text-xs text-neutral-500 mb-1">Adaptive Window</p>
|
||||
{!! $pill($status['adaptive_window']) !!}
|
||||
</div>
|
||||
<div class="rounded-lg bg-neutral-800/50 p-4">
|
||||
<p class="text-xs text-neutral-500 mb-1">Grid Filler</p>
|
||||
{!! $pill($status['grid_filler']) !!}
|
||||
</div>
|
||||
<div class="rounded-lg bg-neutral-800/50 p-4">
|
||||
<p class="text-xs text-neutral-500 mb-1">Spotlight</p>
|
||||
{!! $pill($status['spotlight']) !!}
|
||||
</div>
|
||||
<div class="rounded-lg bg-neutral-800/50 p-4">
|
||||
<p class="text-xs text-neutral-500 mb-1">Activity Layer</p>
|
||||
{!! $pill($status['activity_layer']) !!}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- ── Upload Stats ── --}}
|
||||
<div class="rounded-lg border border-neutral-800 bg-neutral-900 p-6 space-y-4">
|
||||
<h3 class="text-sm font-semibold uppercase tracking-widest text-neutral-400">Upload Metrics</h3>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="rounded-lg bg-neutral-800/50 p-4">
|
||||
<p class="text-xs text-neutral-500 mb-1">Uploads / day (7-day avg)</p>
|
||||
<p class="text-2xl font-bold text-white">{{ number_format($uploads_per_day, 1) }}</p>
|
||||
</div>
|
||||
<div class="rounded-lg bg-neutral-800/50 p-4">
|
||||
<p class="text-xs text-neutral-500 mb-1">Active trending window</p>
|
||||
<p class="text-2xl font-bold text-white">{{ $window_days }}d</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- ── Activity Signals ── --}}
|
||||
@if($status['activity_layer'] && !empty($activity))
|
||||
<div class="rounded-lg border border-neutral-800 bg-neutral-900 p-6 space-y-3">
|
||||
<h3 class="text-sm font-semibold uppercase tracking-widest text-neutral-400">Activity Signals</h3>
|
||||
<ul class="space-y-2">
|
||||
@foreach($activity as $signal)
|
||||
<li class="text-sm text-neutral-200">{{ $signal['icon'] }} {{ $signal['text'] }}</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- ── ENV Toggles ── --}}
|
||||
<div class="rounded-lg border border-neutral-800 bg-neutral-900 p-6 space-y-4">
|
||||
<h3 class="text-sm font-semibold uppercase tracking-widest text-neutral-400">ENV Configuration</h3>
|
||||
<p class="text-xs text-neutral-500">Edit <code class="text-sky-400">.env</code> to change these values. Run <code class="text-sky-400">php artisan config:clear</code> after changes.</p>
|
||||
<div class="overflow-hidden rounded-lg border border-neutral-800">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-neutral-800 bg-neutral-800/40">
|
||||
<th class="px-4 py-2 text-left text-xs text-neutral-400">Variable</th>
|
||||
<th class="px-4 py-2 text-left text-xs text-neutral-400">Current Value</th>
|
||||
<th class="px-4 py-2 text-left text-xs text-neutral-400">Effect</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach($env_toggles as $t)
|
||||
<tr class="border-b border-neutral-800/50">
|
||||
<td class="px-4 py-2 font-mono text-sky-400">{{ $t['key'] }}</td>
|
||||
<td class="px-4 py-2 font-mono text-white">{{ $t['current'] }}</td>
|
||||
<td class="px-4 py-2 text-neutral-400">
|
||||
@switch($t['key'])
|
||||
@case('NOVA_EARLY_GROWTH_ENABLED') Master switch. Set to <code>false</code> to disable entire system. @break
|
||||
@case('NOVA_EARLY_GROWTH_MODE') <code>off</code> / <code>light</code> / <code>aggressive</code> @break
|
||||
@case('NOVA_EGS_ADAPTIVE_WINDOW') Widen trending window when uploads low. @break
|
||||
@case('NOVA_EGS_GRID_FILLER') Backfill page-1 grids to 12 items. @break
|
||||
@case('NOVA_EGS_SPOTLIGHT') Daily-rotating curated picks. @break
|
||||
@case('NOVA_EGS_ACTIVITY_LAYER') Real activity summary badges. @break
|
||||
@endswitch
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg bg-neutral-800/30 border border-neutral-700 p-4 text-xs text-neutral-400 space-y-1">
|
||||
<p><strong class="text-white">To enable (light mode):</strong></p>
|
||||
<pre class="text-sky-400 font-mono">NOVA_EARLY_GROWTH_ENABLED=true
|
||||
NOVA_EARLY_GROWTH_MODE=light</pre>
|
||||
<p class="mt-2"><strong class="text-white">To disable instantly:</strong></p>
|
||||
<pre class="text-sky-400 font-mono">NOVA_EARLY_GROWTH_ENABLED=false</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- ── Cache Keys Reference ── --}}
|
||||
<div class="rounded-lg border border-neutral-800 bg-neutral-900 p-6 space-y-3">
|
||||
<h3 class="text-sm font-semibold uppercase tracking-widest text-neutral-400">Cache Keys</h3>
|
||||
<ul class="space-y-1">
|
||||
@foreach($cache_keys as $key)
|
||||
<li class="font-mono text-xs text-neutral-400">{{ $key }}</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
<p class="text-xs text-neutral-600">Use the "Flush EGS Cache" button above to clear these in one action.</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@endsection
|
||||
36
resources/views/admin/staff_applications/index.blade.php
Normal file
36
resources/views/admin/staff_applications/index.blade.php
Normal file
@@ -0,0 +1,36 @@
|
||||
@extends('layouts.nova.content-layout')
|
||||
|
||||
@section('page-content')
|
||||
<div class="max-w-5xl">
|
||||
<h2 class="text-xl font-semibold text-white mb-4">Staff / Contact Submissions</h2>
|
||||
|
||||
<div class="overflow-hidden rounded-lg border border-neutral-800 bg-nova-900 p-4">
|
||||
<table class="w-full table-auto text-left text-sm">
|
||||
<thead>
|
||||
<tr class="text-neutral-400">
|
||||
<th class="py-2">When</th>
|
||||
<th class="py-2">Topic</th>
|
||||
<th class="py-2">Name</th>
|
||||
<th class="py-2">Email</th>
|
||||
<th class="py-2">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@forelse($items as $i)
|
||||
<tr class="border-t border-neutral-800">
|
||||
<td class="py-3 text-neutral-400">{{ $i->created_at->toDayDateTimeString() }}</td>
|
||||
<td class="py-3">{{ ucfirst($i->topic) }}</td>
|
||||
<td class="py-3">{{ $i->name }}</td>
|
||||
<td class="py-3">{{ $i->email }}</td>
|
||||
<td class="py-3"><a class="text-sky-400 hover:underline" href="{{ route('admin.applications.show', $i->id) }}">View</a></td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr><td colspan="5" class="py-6 text-neutral-400">No submissions yet.</td></tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="mt-4">{{ $items->links() }}</div>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
36
resources/views/admin/staff_applications/show.blade.php
Normal file
36
resources/views/admin/staff_applications/show.blade.php
Normal file
@@ -0,0 +1,36 @@
|
||||
@extends('layouts.nova.content-layout')
|
||||
|
||||
@section('page-content')
|
||||
<div class="max-w-3xl">
|
||||
<h2 class="text-xl font-semibold text-white mb-2">Submission</h2>
|
||||
|
||||
<div class="rounded-lg border border-neutral-800 bg-nova-900 p-6">
|
||||
<dl class="grid grid-cols-1 gap-4 text-sm text-neutral-300">
|
||||
<div>
|
||||
<dt class="text-neutral-400">Topic</dt>
|
||||
<dd>{{ ucfirst($item->topic) }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-neutral-400">Name</dt>
|
||||
<dd>{{ $item->name }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-neutral-400">Email</dt>
|
||||
<dd>{{ $item->email }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-neutral-400">Portfolio</dt>
|
||||
<dd>{{ $item->portfolio }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-neutral-400">Message</dt>
|
||||
<dd class="whitespace-pre-line">{{ $item->message }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-neutral-400">Received</dt>
|
||||
<dd>{{ $item->created_at->toDayDateTimeString() }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
28
resources/views/components/ad-unit.blade.php
Normal file
28
resources/views/components/ad-unit.blade.php
Normal file
@@ -0,0 +1,28 @@
|
||||
{{--
|
||||
<x-ad-unit slot="1234567890" />
|
||||
<x-ad-unit slot="1234567890" format="rectangle" class="my-6" />
|
||||
|
||||
Props:
|
||||
slot — AdSense ad slot ID (required)
|
||||
format — AdSense data-ad-format (default: auto)
|
||||
class — additional wrapper classes
|
||||
|
||||
Renders nothing when:
|
||||
- GOOGLE_ADSENSE_PUBLISHER_ID is not set in .env
|
||||
- User has not given consent (handled client-side via CSS class .ads-disabled)
|
||||
--}}
|
||||
@php
|
||||
$publisherId = config('services.google_adsense.publisher_id');
|
||||
@endphp
|
||||
|
||||
@if($publisherId)
|
||||
<div class="ad-unit-wrapper {{ $attributes->get('class', '') }}">
|
||||
<ins class="adsbygoogle"
|
||||
style="display:block"
|
||||
data-ad-client="{{ $publisherId }}"
|
||||
data-ad-slot="{{ $slot }}"
|
||||
data-ad-format="{{ $format ?? 'auto' }}"
|
||||
data-full-width-responsive="true"></ins>
|
||||
<script>(adsbygoogle = window.adsbygoogle || []).push({});</script>
|
||||
</div>
|
||||
@endif
|
||||
19
resources/views/components/breadcrumbs.blade.php
Normal file
19
resources/views/components/breadcrumbs.blade.php
Normal file
@@ -0,0 +1,19 @@
|
||||
{{--
|
||||
Breadcrumb component with schema.org structured data.
|
||||
|
||||
@param \Illuminate\Support\Collection $breadcrumbs
|
||||
Collection of objects with ->name and ->url properties.
|
||||
--}}
|
||||
@if(isset($breadcrumbs) && $breadcrumbs->isNotEmpty())
|
||||
<nav class="flex items-center gap-1.5 flex-wrap text-sm text-neutral-400" aria-label="Breadcrumb">
|
||||
<a class="hover:text-white transition-colors" href="/">Home</a>
|
||||
@foreach($breadcrumbs as $crumb)
|
||||
<span class="opacity-40" aria-hidden="true">›</span>
|
||||
@if(!$loop->last)
|
||||
<a class="hover:text-white transition-colors" href="{{ $crumb->url }}">{{ $crumb->name }}</a>
|
||||
@else
|
||||
<span class="text-white/70">{{ $crumb->name }}</span>
|
||||
@endif
|
||||
@endforeach
|
||||
</nav>
|
||||
@endif
|
||||
5
resources/views/components/centered-content.blade.php
Normal file
5
resources/views/components/centered-content.blade.php
Normal file
@@ -0,0 +1,5 @@
|
||||
@props(['max' => '3xl'])
|
||||
|
||||
<div {{ $attributes->merge(['class' => "mx-auto px-6 md:px-10 max-w-{$max}"]) }}>
|
||||
{{ $slot }}
|
||||
</div>
|
||||
13
resources/views/components/hero.blade.php
Normal file
13
resources/views/components/hero.blade.php
Normal file
@@ -0,0 +1,13 @@
|
||||
<div class="max-w-4xl mx-auto py-12 text-center">
|
||||
@if(isset($title))
|
||||
<h1 class="text-3xl font-extrabold text-white">{{ $title }}</h1>
|
||||
@endif
|
||||
|
||||
@if(isset($subtitle))
|
||||
<p class="mt-3 text-sm text-neutral-300">{{ $subtitle }}</p>
|
||||
@endif
|
||||
|
||||
@if($slot->isNotEmpty())
|
||||
<div class="mt-6">{{ $slot }}</div>
|
||||
@endif
|
||||
</div>
|
||||
82
resources/views/emails/staff_application_received.blade.php
Normal file
82
resources/views/emails/staff_application_received.blade.php
Normal file
@@ -0,0 +1,82 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<style>
|
||||
body { font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial; color:#0f172a; }
|
||||
.container { max-width:700px; margin:24px auto; background:#ffffff; border-radius:8px; padding:20px; box-shadow:0 6px 18px rgba(2,6,23,0.08); }
|
||||
.brand { display:flex; align-items:center; gap:12px; }
|
||||
.brand img { height:40px; }
|
||||
.title { margin-top:16px; font-size:20px; font-weight:700; color:#0b1220; }
|
||||
.meta { margin-top:8px; color:#475569; font-size:13px; }
|
||||
.section { margin-top:18px; }
|
||||
.label { font-weight:600; color:#0b1220; font-size:13px; }
|
||||
.value { margin-top:6px; color:#0f172a; }
|
||||
.footer { margin-top:22px; color:#64748b; font-size:12px; }
|
||||
a { color:#0ea5e9; text-decoration:none; }
|
||||
.pill { display:inline-block; padding:4px 8px; border-radius:999px; background:#f1f5f9; color:#0b1220; font-size:12px; }
|
||||
pre { white-space:pre-wrap; font-family:inherit; font-size:13px; color:#0f172a; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="brand">
|
||||
<img src="{{ asset('gfx/skinbase_logo.png') }}" alt="Skinbase">
|
||||
<div>
|
||||
<div style="font-weight:700;">Skinbase</div>
|
||||
<div style="font-size:12px;color:#64748b;">New staff application / contact form submission</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="title">New {{ $topicLabel }}: {{ $application->name }}</div>
|
||||
|
||||
<div class="meta">Received {{ $application->created_at->toDayDateTimeString() }}</div>
|
||||
|
||||
<div class="section">
|
||||
<div class="label">Details</div>
|
||||
<div class="value">
|
||||
<div><span class="pill">Topic</span> {{ $topicLabel }}</div>
|
||||
<div><strong>Name:</strong> {{ $application->name }}</div>
|
||||
<div><strong>Email:</strong> <a href="mailto:{{ $application->email }}">{{ $application->email }}</a></div>
|
||||
@if($application->role)<div><strong>Role:</strong> {{ $application->role }}</div>@endif
|
||||
@if($application->portfolio)<div><strong>Portfolio:</strong> <a href="{{ $application->portfolio }}">{{ $application->portfolio }}</a></div>@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if($application->topic === 'bug')
|
||||
<div class="section">
|
||||
<div class="label">Bug report</div>
|
||||
<div class="value">
|
||||
@if($application->payload['data']['affected_url'] ?? false)
|
||||
<div><strong>Affected URL:</strong> <a href="{{ $application->payload['data']['affected_url'] }}">{{ $application->payload['data']['affected_url'] }}</a></div>
|
||||
@endif
|
||||
@if($application->payload['data']['steps'] ?? false)
|
||||
<div style="margin-top:8px"><strong>Steps to reproduce:</strong>
|
||||
<pre>{{ $application->payload['data']['steps'] }}</pre>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="section">
|
||||
<div class="label">Message</div>
|
||||
<div class="value"><pre>{{ $application->message }}</pre></div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<div class="label">Technical</div>
|
||||
<div class="value">
|
||||
<div><strong>IP:</strong> {{ $application->ip }}</div>
|
||||
<div style="margin-top:6px"><strong>User agent:</strong> {{ $application->user_agent }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<div>If you prefer to manage submissions in the admin UI, open: <a href="{{ url('/admin/applications') }}">/admin/applications</a></div>
|
||||
<div style="margin-top:8px">— Skinbase</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,22 @@
|
||||
New {{ $topicLabel }}: {{ $application->name }}
|
||||
|
||||
Received: {{ $application->created_at->toDayDateTimeString() }}
|
||||
|
||||
Topic: {{ $topicLabel }}
|
||||
Name: {{ $application->name }}
|
||||
Email: {{ $application->email }}
|
||||
@if($application->role)
|
||||
Role: {{ $application->role }}
|
||||
@endif
|
||||
@if($application->portfolio)
|
||||
Portfolio: {{ $application->portfolio }}
|
||||
@endif
|
||||
|
||||
Message:
|
||||
{{ $application->message }}
|
||||
|
||||
Technical:
|
||||
IP: {{ $application->ip }}
|
||||
User agent: {{ $application->user_agent }}
|
||||
|
||||
Manage submissions: {{ url('/admin/applications') }}
|
||||
28
resources/views/errors/401.blade.php
Normal file
28
resources/views/errors/401.blade.php
Normal file
@@ -0,0 +1,28 @@
|
||||
{{--
|
||||
401 — Unauthorized
|
||||
Use for: routes that require authentication when user is not logged in.
|
||||
--}}
|
||||
@extends('errors._layout', [
|
||||
'error_code' => 401,
|
||||
'error_title' => 'Sign In Required',
|
||||
'error_message' => 'Please sign in to access this page.',
|
||||
])
|
||||
|
||||
@section('badge', 'Unauthorized')
|
||||
|
||||
@section('primary-cta')
|
||||
<a href="/login"
|
||||
class="inline-flex items-center gap-2 rounded-xl bg-sky-500 hover:bg-sky-400 text-white font-semibold px-6 py-3 text-sm shadow-lg shadow-sky-900/30 transition-colors">
|
||||
<i class="fas fa-sign-in-alt" aria-hidden="true"></i>
|
||||
Sign In
|
||||
</a>
|
||||
@endsection
|
||||
|
||||
@section('secondary-ctas')
|
||||
<a href="/register" class="rounded-xl border border-white/10 hover:border-white/25 text-white/70 hover:text-white px-4 py-2 text-sm transition-colors">
|
||||
Create Account
|
||||
</a>
|
||||
<a href="/" class="rounded-xl border border-white/10 hover:border-white/25 text-white/70 hover:text-white px-4 py-2 text-sm transition-colors">
|
||||
Home
|
||||
</a>
|
||||
@endsection
|
||||
43
resources/views/errors/403.blade.php
Normal file
43
resources/views/errors/403.blade.php
Normal file
@@ -0,0 +1,43 @@
|
||||
{{--
|
||||
403 — Forbidden
|
||||
Use for: private artwork, banned content, region restrictions.
|
||||
Shows login button if user is a guest; profile/discover if logged in.
|
||||
--}}
|
||||
@extends('errors._layout', [
|
||||
'error_code' => 403,
|
||||
'error_title' => 'Access Denied',
|
||||
'error_message' => $message ?? 'You do not have permission to view this content.',
|
||||
])
|
||||
|
||||
@section('badge', 'Forbidden')
|
||||
|
||||
@section('primary-cta')
|
||||
@guest
|
||||
<a href="/login"
|
||||
class="inline-flex items-center gap-2 rounded-xl bg-sky-500 hover:bg-sky-400 text-white font-semibold px-6 py-3 text-sm shadow-lg shadow-sky-900/30 transition-colors">
|
||||
<i class="fas fa-sign-in-alt" aria-hidden="true"></i>
|
||||
Sign In
|
||||
</a>
|
||||
@else
|
||||
<a href="/discover/trending"
|
||||
class="inline-flex items-center gap-2 rounded-xl bg-sky-500 hover:bg-sky-400 text-white font-semibold px-6 py-3 text-sm shadow-lg shadow-sky-900/30 transition-colors">
|
||||
<i class="fas fa-compass" aria-hidden="true"></i>
|
||||
Back to Discover
|
||||
</a>
|
||||
@endguest
|
||||
@endsection
|
||||
|
||||
@section('secondary-ctas')
|
||||
@guest
|
||||
<a href="/register" class="rounded-xl border border-white/10 hover:border-white/25 text-white/70 hover:text-white px-4 py-2 text-sm transition-colors">
|
||||
Create Account
|
||||
</a>
|
||||
@else
|
||||
<a href="/@{{ Auth::user()->username }}" class="rounded-xl border border-white/10 hover:border-white/25 text-white/70 hover:text-white px-4 py-2 text-sm transition-colors">
|
||||
My Profile
|
||||
</a>
|
||||
@endguest
|
||||
<a href="/" class="rounded-xl border border-white/10 hover:border-white/25 text-white/70 hover:text-white px-4 py-2 text-sm transition-colors">
|
||||
Home
|
||||
</a>
|
||||
@endsection
|
||||
90
resources/views/errors/404.blade.php
Normal file
90
resources/views/errors/404.blade.php
Normal file
@@ -0,0 +1,90 @@
|
||||
{{--
|
||||
404 — Generic Not Found
|
||||
Returned when a route has no match.
|
||||
Includes: trending artworks (6), top tags (10), discover CTA.
|
||||
--}}
|
||||
@extends('errors._layout', [
|
||||
'error_code' => 404,
|
||||
'error_title' => '404 — Lost in the Nova',
|
||||
'error_message' => 'This page drifted into deep space. Let\'s get you back on track.',
|
||||
])
|
||||
|
||||
@section('badge', 'Page Not Found')
|
||||
|
||||
@section('primary-cta')
|
||||
<a href="/discover/trending"
|
||||
class="inline-flex items-center gap-2 rounded-xl bg-sky-500 hover:bg-sky-400 text-white font-semibold px-6 py-3 text-sm shadow-lg shadow-sky-900/30 transition-colors">
|
||||
<i class="fas fa-compass" aria-hidden="true"></i>
|
||||
Explore Discover
|
||||
</a>
|
||||
@endsection
|
||||
|
||||
@section('secondary-ctas')
|
||||
<a href="/explore/wallpapers" class="rounded-xl border border-white/10 hover:border-white/25 text-white/70 hover:text-white px-4 py-2 text-sm transition-colors">
|
||||
Browse Wallpapers
|
||||
</a>
|
||||
<a href="/search" class="rounded-xl border border-white/10 hover:border-white/25 text-white/70 hover:text-white px-4 py-2 text-sm transition-colors">
|
||||
<i class="fas fa-search mr-1.5" aria-hidden="true"></i> Search
|
||||
</a>
|
||||
<a href="/" class="rounded-xl border border-white/10 hover:border-white/25 text-white/70 hover:text-white px-4 py-2 text-sm transition-colors">
|
||||
Home
|
||||
</a>
|
||||
@endsection
|
||||
|
||||
@section('recovery')
|
||||
|
||||
{{-- Trending artworks --}}
|
||||
@if(isset($trendingArtworks) && $trendingArtworks->count())
|
||||
<div class="mb-12">
|
||||
<h2 class="text-sm font-semibold text-white/40 uppercase tracking-widest mb-4">Trending Right Now</h2>
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-3">
|
||||
@foreach($trendingArtworks->take(6) as $artwork)
|
||||
@include('errors._artwork-card', ['artwork' => $artwork])
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Top tags --}}
|
||||
@if(isset($trendingTags) && $trendingTags->count())
|
||||
<div class="mb-12">
|
||||
<h2 class="text-sm font-semibold text-white/40 uppercase tracking-widest mb-4">Popular Tags</h2>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
@foreach($trendingTags->take(10) as $tag)
|
||||
<a href="/tag/{{ $tag->slug }}"
|
||||
class="rounded-full bg-white/5 hover:bg-sky-500/20 border border-white/8 hover:border-sky-500/30 text-white/70 hover:text-sky-300 px-3 py-1 text-xs font-medium transition-colors">
|
||||
#{{ $tag->name }}
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Explore categories --}}
|
||||
<div>
|
||||
<h2 class="text-sm font-semibold text-white/40 uppercase tracking-widest mb-4">Explore Categories</h2>
|
||||
<div class="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||
<a href="/explore/wallpapers"
|
||||
class="rounded-xl bg-white/3 hover:bg-sky-500/10 border border-white/5 hover:border-sky-500/20 px-4 py-3 flex items-center gap-3 transition-all group">
|
||||
<i class="fas fa-image text-sky-400/60 group-hover:text-sky-400 text-lg" aria-hidden="true"></i>
|
||||
<span class="text-sm font-medium text-white/80 group-hover:text-white">Wallpapers</span>
|
||||
</a>
|
||||
<a href="/explore/skins"
|
||||
class="rounded-xl bg-white/3 hover:bg-sky-500/10 border border-white/5 hover:border-sky-500/20 px-4 py-3 flex items-center gap-3 transition-all group">
|
||||
<i class="fas fa-paint-brush text-sky-400/60 group-hover:text-sky-400 text-lg" aria-hidden="true"></i>
|
||||
<span class="text-sm font-medium text-white/80 group-hover:text-white">Skins</span>
|
||||
</a>
|
||||
<a href="/explore/photography"
|
||||
class="rounded-xl bg-white/3 hover:bg-sky-500/10 border border-white/5 hover:border-sky-500/20 px-4 py-3 flex items-center gap-3 transition-all group">
|
||||
<i class="fas fa-camera text-sky-400/60 group-hover:text-sky-400 text-lg" aria-hidden="true"></i>
|
||||
<span class="text-sm font-medium text-white/80 group-hover:text-white">Photography</span>
|
||||
</a>
|
||||
<a href="/explore/other"
|
||||
class="rounded-xl bg-white/3 hover:bg-sky-500/10 border border-white/5 hover:border-sky-500/20 px-4 py-3 flex items-center gap-3 transition-all group">
|
||||
<i class="fas fa-layer-group text-sky-400/60 group-hover:text-sky-400 text-lg" aria-hidden="true"></i>
|
||||
<span class="text-sm font-medium text-white/80 group-hover:text-white">Other</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@endsection
|
||||
29
resources/views/errors/410.blade.php
Normal file
29
resources/views/errors/410.blade.php
Normal file
@@ -0,0 +1,29 @@
|
||||
{{--
|
||||
410 — Gone
|
||||
Use for permanently deleted artworks, DMCA removed content, deleted blog posts.
|
||||
Minimal content — no heavy suggestions needed by spec.
|
||||
--}}
|
||||
@extends('errors._layout', [
|
||||
'error_code' => 410,
|
||||
'error_title' => 'Content Permanently Removed',
|
||||
'error_message' => 'This content has been permanently removed and is no longer available.',
|
||||
])
|
||||
|
||||
@section('badge', 'Gone')
|
||||
|
||||
@section('primary-cta')
|
||||
<a href="/discover/trending"
|
||||
class="inline-flex items-center gap-2 rounded-xl bg-sky-500 hover:bg-sky-400 text-white font-semibold px-6 py-3 text-sm shadow-lg shadow-sky-900/30 transition-colors">
|
||||
<i class="fas fa-compass" aria-hidden="true"></i>
|
||||
Explore Discover
|
||||
</a>
|
||||
@endsection
|
||||
|
||||
@section('secondary-ctas')
|
||||
<a href="/" class="rounded-xl border border-white/10 hover:border-white/25 text-white/70 hover:text-white px-4 py-2 text-sm transition-colors">
|
||||
Return Home
|
||||
</a>
|
||||
<a href="/search" class="rounded-xl border border-white/10 hover:border-white/25 text-white/70 hover:text-white px-4 py-2 text-sm transition-colors">
|
||||
<i class="fas fa-search mr-1.5" aria-hidden="true"></i> Search
|
||||
</a>
|
||||
@endsection
|
||||
24
resources/views/errors/419.blade.php
Normal file
24
resources/views/errors/419.blade.php
Normal file
@@ -0,0 +1,24 @@
|
||||
{{--
|
||||
419 — Page Expired (CSRF token mismatch)
|
||||
--}}
|
||||
@extends('errors._layout', [
|
||||
'error_code' => 419,
|
||||
'error_title' => 'Page Expired',
|
||||
'error_message' => 'Your session has expired. Please refresh the page and try again.',
|
||||
])
|
||||
|
||||
@section('badge', 'Session Expired')
|
||||
|
||||
@section('primary-cta')
|
||||
<button onclick="window.location.reload()"
|
||||
class="inline-flex items-center gap-2 rounded-xl bg-sky-500 hover:bg-sky-400 text-white font-semibold px-6 py-3 text-sm shadow-lg shadow-sky-900/30 transition-colors cursor-pointer">
|
||||
<i class="fas fa-redo" aria-hidden="true"></i>
|
||||
Refresh Page
|
||||
</button>
|
||||
@endsection
|
||||
|
||||
@section('secondary-ctas')
|
||||
<a href="/" class="rounded-xl border border-white/10 hover:border-white/25 text-white/70 hover:text-white px-4 py-2 text-sm transition-colors">
|
||||
Return Home
|
||||
</a>
|
||||
@endsection
|
||||
40
resources/views/errors/500.blade.php
Normal file
40
resources/views/errors/500.blade.php
Normal file
@@ -0,0 +1,40 @@
|
||||
{{--
|
||||
500 — Server Error
|
||||
Shows a user-friendly message and a reference/correlation ID.
|
||||
Never shows a stack trace in production.
|
||||
--}}
|
||||
@extends('errors._layout', [
|
||||
'error_code' => 500,
|
||||
'error_title' => 'Something Went Wrong in the Nova',
|
||||
'error_message' => 'An unexpected error occurred. Our team has been notified and is on it.',
|
||||
])
|
||||
|
||||
@section('badge', 'Server Error')
|
||||
|
||||
@section('primary-cta')
|
||||
<button onclick="window.location.reload()"
|
||||
class="inline-flex items-center gap-2 rounded-xl bg-sky-500 hover:bg-sky-400 text-white font-semibold px-6 py-3 text-sm shadow-lg shadow-sky-900/30 transition-colors cursor-pointer">
|
||||
<i class="fas fa-redo" aria-hidden="true"></i>
|
||||
Try Again
|
||||
</button>
|
||||
@endsection
|
||||
|
||||
@section('secondary-ctas')
|
||||
<a href="/" class="rounded-xl border border-white/10 hover:border-white/25 text-white/70 hover:text-white px-4 py-2 text-sm transition-colors">
|
||||
Return Home
|
||||
</a>
|
||||
<a href="/contact" class="rounded-xl border border-white/10 hover:border-white/25 text-white/70 hover:text-white px-4 py-2 text-sm transition-colors">
|
||||
Report Issue
|
||||
</a>
|
||||
@endsection
|
||||
|
||||
@section('recovery')
|
||||
@if(isset($correlationId))
|
||||
<div class="flex justify-center">
|
||||
<div class="inline-flex items-center gap-2 rounded-xl bg-white/4 border border-white/8 px-5 py-3 text-xs text-white/40">
|
||||
<i class="fas fa-fingerprint text-white/25" aria-hidden="true"></i>
|
||||
Reference ID: <span class="font-mono font-semibold text-white/60 select-all">{{ $correlationId }}</span>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
@endsection
|
||||
18
resources/views/errors/503.blade.php
Normal file
18
resources/views/errors/503.blade.php
Normal file
@@ -0,0 +1,18 @@
|
||||
{{--
|
||||
503 — Service Unavailable / Maintenance Mode
|
||||
--}}
|
||||
@extends('errors._layout', [
|
||||
'error_code' => 503,
|
||||
'error_title' => 'We\'ll Be Right Back',
|
||||
'error_message' => 'Skinbase is under scheduled maintenance. Check back soon.',
|
||||
])
|
||||
|
||||
@section('badge', 'Maintenance')
|
||||
|
||||
@section('primary-cta')
|
||||
<button onclick="window.location.reload()"
|
||||
class="inline-flex items-center gap-2 rounded-xl bg-sky-500 hover:bg-sky-400 text-white font-semibold px-6 py-3 text-sm shadow-lg shadow-sky-900/30 transition-colors cursor-pointer">
|
||||
<i class="fas fa-redo" aria-hidden="true"></i>
|
||||
Check Again
|
||||
</button>
|
||||
@endsection
|
||||
22
resources/views/errors/_artwork-card.blade.php
Normal file
22
resources/views/errors/_artwork-card.blade.php
Normal file
@@ -0,0 +1,22 @@
|
||||
{{--
|
||||
Shared: artwork suggestion card for error pages.
|
||||
Expects $artwork array: [id, title, author, url, thumb]
|
||||
--}}
|
||||
<a href="{{ $artwork['url'] }}" class="group relative rounded-xl overflow-hidden bg-nova-800 border border-white/5 hover:border-sky-500/30 transition-all duration-200 block">
|
||||
@if($artwork['thumb'])
|
||||
<div class="aspect-video w-full overflow-hidden bg-nova-700">
|
||||
<img src="{{ $artwork['thumb'] }}"
|
||||
alt="{{ $artwork['title'] }}"
|
||||
loading="lazy"
|
||||
class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300 opacity-80 group-hover:opacity-100" />
|
||||
</div>
|
||||
@else
|
||||
<div class="aspect-video w-full bg-gradient-to-br from-nova-700 to-nova-800 flex items-center justify-center">
|
||||
<i class="fas fa-image text-white/20 text-3xl" aria-hidden="true"></i>
|
||||
</div>
|
||||
@endif
|
||||
<div class="p-3">
|
||||
<p class="text-sm font-semibold text-white truncate">{{ $artwork['title'] }}</p>
|
||||
<p class="text-xs text-white/50 truncate mt-0.5">by {{ $artwork['author'] }}</p>
|
||||
</div>
|
||||
</a>
|
||||
71
resources/views/errors/_layout.blade.php
Normal file
71
resources/views/errors/_layout.blade.php
Normal file
@@ -0,0 +1,71 @@
|
||||
{{--
|
||||
Error Layout — extends nova.blade.php
|
||||
Shared structure for all error pages (404, 410, 403, 401, 500, contextual variants).
|
||||
|
||||
Enforces:
|
||||
• noindex
|
||||
• No canonical link
|
||||
• Dark Nova design
|
||||
• Full navigation visible
|
||||
• Recovery CTAs
|
||||
|
||||
Variables:
|
||||
$error_code int HTTP status code
|
||||
$error_title string Short headline
|
||||
$error_message string Friendly sentence
|
||||
--}}
|
||||
@extends('layouts.nova')
|
||||
|
||||
@php
|
||||
$code = $error_code ?? 404;
|
||||
$title = $error_title ?? 'Page Not Found';
|
||||
$message = $error_message ?? 'This page drifted into deep space.';
|
||||
@endphp
|
||||
|
||||
@push('head')
|
||||
{{-- SEO: never index error pages --}}
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
@endpush
|
||||
|
||||
@section('content')
|
||||
<div class="min-h-[70vh] flex flex-col items-center justify-center px-4 py-16">
|
||||
|
||||
{{-- Hero block --}}
|
||||
<div class="text-center max-w-xl mx-auto">
|
||||
|
||||
{{-- Code glow --}}
|
||||
<div class="text-8xl font-extrabold text-sky-500/20 select-none leading-none mb-2">{{ $code }}</div>
|
||||
|
||||
{{-- Gradient badge --}}
|
||||
<div class="inline-flex items-center gap-2 rounded-full bg-sky-500/10 border border-sky-500/20 px-4 py-1 text-xs font-semibold text-sky-400 uppercase tracking-widest mb-6">
|
||||
@yield('badge', 'Error')
|
||||
</div>
|
||||
|
||||
<h1 class="text-3xl sm:text-4xl font-extrabold text-white leading-tight mb-4">
|
||||
{{ $title }}
|
||||
</h1>
|
||||
|
||||
<p class="text-white/60 text-base sm:text-lg leading-relaxed mb-8">
|
||||
{{ $message }}
|
||||
</p>
|
||||
|
||||
{{-- Primary CTA --}}
|
||||
@yield('primary-cta')
|
||||
|
||||
{{-- Secondary CTAs --}}
|
||||
@hasSection('secondary-ctas')
|
||||
<div class="flex flex-wrap justify-center gap-3 mt-4">
|
||||
@yield('secondary-ctas')
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
{{-- Contextual recovery section --}}
|
||||
@hasSection('recovery')
|
||||
<div class="w-full max-w-5xl mx-auto mt-16">
|
||||
@yield('recovery')
|
||||
</div>
|
||||
@endif
|
||||
|
||||
</div>
|
||||
@endsection
|
||||
@@ -0,0 +1,99 @@
|
||||
{{--
|
||||
Artwork Not Found (contextual) — HTTP 404 or 403
|
||||
Shown when:
|
||||
- Artwork ID not found at all → HTTP 404
|
||||
- Artwork exists but is private/unapproved → HTTP 403 ($isForbidden=true)
|
||||
Separate view for permanently deleted → errors/410.blade.php
|
||||
|
||||
Variables:
|
||||
$isForbidden bool true when private/403
|
||||
$trendingArtworks Collection (max 6)
|
||||
$creatorArtworks Collection (max 6, optional)
|
||||
$creatorUsername string|null
|
||||
--}}
|
||||
@php
|
||||
$isForbidden = $isForbidden ?? false;
|
||||
$errorCode = $isForbidden ? 403 : 404;
|
||||
$errorTitle = $isForbidden ? 'Access Denied' : 'Artwork Not Found';
|
||||
$errorMessage = $isForbidden
|
||||
? 'This artwork is private and not publicly available.'
|
||||
: 'This artwork is no longer available, or the link may be broken.';
|
||||
$badgeLabel = $isForbidden ? 'Private Artwork' : 'Artwork Not Found';
|
||||
@endphp
|
||||
@extends('errors._layout', [
|
||||
'error_code' => $errorCode,
|
||||
'error_title' => $errorTitle,
|
||||
'error_message' => $errorMessage,
|
||||
])
|
||||
|
||||
@section('badge', $badgeLabel)
|
||||
|
||||
@section('primary-cta')
|
||||
@if($isForbidden)
|
||||
@guest
|
||||
<a href="/login"
|
||||
class="inline-flex items-center gap-2 rounded-xl bg-sky-500 hover:bg-sky-400 text-white font-semibold px-6 py-3 text-sm shadow-lg shadow-sky-900/30 transition-colors">
|
||||
<i class="fas fa-sign-in-alt" aria-hidden="true"></i>
|
||||
Sign In to View
|
||||
</a>
|
||||
@else
|
||||
<a href="/discover/trending"
|
||||
class="inline-flex items-center gap-2 rounded-xl bg-sky-500 hover:bg-sky-400 text-white font-semibold px-6 py-3 text-sm shadow-lg shadow-sky-900/30 transition-colors">
|
||||
<i class="fas fa-compass" aria-hidden="true"></i>
|
||||
Explore Discover
|
||||
</a>
|
||||
@endguest
|
||||
@else
|
||||
<a href="/discover/trending"
|
||||
class="inline-flex items-center gap-2 rounded-xl bg-sky-500 hover:bg-sky-400 text-white font-semibold px-6 py-3 text-sm shadow-lg shadow-sky-900/30 transition-colors">
|
||||
<i class="fas fa-compass" aria-hidden="true"></i>
|
||||
Explore Discover
|
||||
</a>
|
||||
@endif
|
||||
@endsection
|
||||
|
||||
@section('secondary-ctas')
|
||||
<a href="/explore/wallpapers" class="rounded-xl border border-white/10 hover:border-white/25 text-white/70 hover:text-white px-4 py-2 text-sm transition-colors">
|
||||
Browser Wallpapers
|
||||
</a>
|
||||
<a href="/search" class="rounded-xl border border-white/10 hover:border-white/25 text-white/70 hover:text-white px-4 py-2 text-sm transition-colors">
|
||||
<i class="fas fa-search mr-1.5" aria-hidden="true"></i> Search
|
||||
</a>
|
||||
@endsection
|
||||
|
||||
@section('recovery')
|
||||
|
||||
{{-- Creator's other artworks (if we have a hint about the creator) --}}
|
||||
@if(isset($creatorArtworks) && $creatorArtworks->count())
|
||||
<div class="mb-12">
|
||||
<h2 class="text-sm font-semibold text-white/40 uppercase tracking-widest mb-4">
|
||||
More from this Creator
|
||||
</h2>
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-3">
|
||||
@foreach($creatorArtworks->take(6) as $artwork)
|
||||
@include('errors._artwork-card', ['artwork' => $artwork])
|
||||
@endforeach
|
||||
</div>
|
||||
@if(isset($creatorUsername))
|
||||
<div class="mt-3">
|
||||
<a href="/@{{ $creatorUsername }}" class="text-xs text-sky-400 hover:text-sky-300 transition-colors">
|
||||
View full gallery →
|
||||
</a>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Trending artworks --}}
|
||||
@if(isset($trendingArtworks) && $trendingArtworks->count())
|
||||
<div>
|
||||
<h2 class="text-sm font-semibold text-white/40 uppercase tracking-widest mb-4">Trending Wallpapers</h2>
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-3">
|
||||
@foreach($trendingArtworks->take(6) as $artwork)
|
||||
@include('errors._artwork-card', ['artwork' => $artwork])
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@endsection
|
||||
52
resources/views/errors/contextual/blog-not-found.blade.php
Normal file
52
resources/views/errors/contextual/blog-not-found.blade.php
Normal file
@@ -0,0 +1,52 @@
|
||||
{{--
|
||||
Blog Post Not Found — Contextual 404
|
||||
Shown at /blog/:slug when post doesn't exist or is unpublished.
|
||||
|
||||
Variables:
|
||||
$latestPosts Collection (max 6)
|
||||
--}}
|
||||
@extends('errors._layout', [
|
||||
'error_code' => 404,
|
||||
'error_title' => 'Article Not Found',
|
||||
'error_message' => 'This article is no longer available or the link has changed.',
|
||||
])
|
||||
|
||||
@section('badge', 'Article Not Found')
|
||||
|
||||
@section('primary-cta')
|
||||
<a href="/blog"
|
||||
class="inline-flex items-center gap-2 rounded-xl bg-sky-500 hover:bg-sky-400 text-white font-semibold px-6 py-3 text-sm shadow-lg shadow-sky-900/30 transition-colors">
|
||||
<i class="fas fa-newspaper" aria-hidden="true"></i>
|
||||
Visit Blog
|
||||
</a>
|
||||
@endsection
|
||||
|
||||
@section('secondary-ctas')
|
||||
<a href="/" class="rounded-xl border border-white/10 hover:border-white/25 text-white/70 hover:text-white px-4 py-2 text-sm transition-colors">
|
||||
Home
|
||||
</a>
|
||||
@endsection
|
||||
|
||||
@section('recovery')
|
||||
|
||||
@if(isset($latestPosts) && $latestPosts->count())
|
||||
<div>
|
||||
<h2 class="text-sm font-semibold text-white/40 uppercase tracking-widest mb-4">Latest Articles</h2>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
@foreach($latestPosts->take(6) as $post)
|
||||
<a href="{{ $post['url'] }}"
|
||||
class="flex flex-col gap-2 rounded-xl p-4 bg-white/3 hover:bg-white/7 border border-white/5 hover:border-sky-500/20 transition-all">
|
||||
<p class="text-sm font-semibold text-white leading-snug">{{ $post['title'] }}</p>
|
||||
@if(!empty($post['excerpt']))
|
||||
<p class="text-xs text-white/50 leading-relaxed flex-1">{{ $post['excerpt'] }}</p>
|
||||
@endif
|
||||
@if(!empty($post['published_at']))
|
||||
<p class="text-xs text-white/30 mt-auto">{{ $post['published_at'] }}</p>
|
||||
@endif
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@endsection
|
||||
@@ -0,0 +1,94 @@
|
||||
{{--
|
||||
Creator Not Found — Contextual 404
|
||||
Shown at /@:username when user doesn't exist.
|
||||
|
||||
Variables:
|
||||
$requestedUsername string|null
|
||||
$trendingCreators Collection (max 6)
|
||||
$recentCreators Collection (max 6)
|
||||
--}}
|
||||
@extends('errors._layout', [
|
||||
'error_code' => 404,
|
||||
'error_title' => 'Creator Not Found',
|
||||
'error_message' => isset($requestedUsername)
|
||||
? 'The creator "@' . $requestedUsername . '" does not exist on Skinbase.'
|
||||
: 'This creator profile does not exist.',
|
||||
])
|
||||
|
||||
@section('badge', 'Creator Not Found')
|
||||
|
||||
@section('primary-cta')
|
||||
{{-- Inline creator search --}}
|
||||
<form action="/search" method="GET" class="flex items-center gap-2 w-full max-w-sm mx-auto mb-2">
|
||||
<input
|
||||
type="text"
|
||||
name="q"
|
||||
placeholder="Search for a creator…"
|
||||
value="{{ isset($requestedUsername) ? '@'.$requestedUsername : '' }}"
|
||||
class="flex-1 rounded-xl bg-white/8 border border-white/12 focus:border-sky-500/50 focus:ring-0 text-sm text-white placeholder-white/30 px-4 py-2.5 outline-none transition-colors"
|
||||
/>
|
||||
<button type="submit"
|
||||
class="rounded-xl bg-sky-500 hover:bg-sky-400 text-white px-4 py-2.5 text-sm font-semibold transition-colors shrink-0">
|
||||
<i class="fas fa-search" aria-hidden="true"></i>
|
||||
</button>
|
||||
</form>
|
||||
@endsection
|
||||
|
||||
@section('secondary-ctas')
|
||||
<a href="/creators/top" class="rounded-xl border border-white/10 hover:border-white/25 text-white/70 hover:text-white px-4 py-2 text-sm transition-colors">
|
||||
<i class="fas fa-trophy mr-1.5" aria-hidden="true"></i> Top Creators
|
||||
</a>
|
||||
<a href="/register" class="rounded-xl border border-white/10 hover:border-white/25 text-white/70 hover:text-white px-4 py-2 text-sm transition-colors">
|
||||
<i class="fas fa-star mr-1.5" aria-hidden="true"></i> Join Skinbase
|
||||
</a>
|
||||
@endsection
|
||||
|
||||
@section('recovery')
|
||||
|
||||
{{-- Trending creators --}}
|
||||
@if(isset($trendingCreators) && $trendingCreators->count())
|
||||
<div class="mb-12">
|
||||
<h2 class="text-sm font-semibold text-white/40 uppercase tracking-widest mb-4">Top Creators</h2>
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-4">
|
||||
@foreach($trendingCreators->take(6) as $creator)
|
||||
<a href="{{ $creator['url'] }}"
|
||||
class="flex flex-col items-center gap-2 rounded-xl p-4 bg-white/3 hover:bg-white/7 border border-white/5 hover:border-sky-500/20 transition-all text-center group">
|
||||
<img src="{{ $creator['avatar_url'] }}"
|
||||
alt="{{ $creator['name'] }}"
|
||||
loading="lazy"
|
||||
class="w-14 h-14 rounded-full object-cover ring-2 ring-white/10 group-hover:ring-sky-500/30 transition-all"
|
||||
onerror="this.src='https://files.skinbase.org/default/avatar_default.webp'" />
|
||||
<div>
|
||||
<p class="text-sm font-semibold text-white truncate max-w-[100px]">{{ $creator['name'] }}</p>
|
||||
<p class="text-xs text-white/40">{{ $creator['artworks_count'] }} uploads</p>
|
||||
</div>
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Recently joined creators --}}
|
||||
@if(isset($recentCreators) && $recentCreators->count())
|
||||
<div>
|
||||
<h2 class="text-sm font-semibold text-white/40 uppercase tracking-widest mb-4">Recently Joined</h2>
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-4">
|
||||
@foreach($recentCreators->take(6) as $creator)
|
||||
<a href="{{ $creator['url'] }}"
|
||||
class="flex flex-col items-center gap-2 rounded-xl p-4 bg-white/3 hover:bg-white/7 border border-white/5 hover:border-sky-500/20 transition-all text-center group">
|
||||
<img src="{{ $creator['avatar_url'] }}"
|
||||
alt="{{ $creator['name'] }}"
|
||||
loading="lazy"
|
||||
class="w-14 h-14 rounded-full object-cover ring-2 ring-white/10 group-hover:ring-sky-500/30 transition-all"
|
||||
onerror="this.src='https://files.skinbase.org/default/avatar_default.webp'" />
|
||||
<div>
|
||||
<p class="text-sm font-semibold text-white truncate max-w-[100px]">{{ $creator['name'] }}</p>
|
||||
<p class="text-xs text-white/40">{{ $creator['artworks_count'] }} uploads</p>
|
||||
</div>
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@endsection
|
||||
31
resources/views/errors/contextual/page-not-found.blade.php
Normal file
31
resources/views/errors/contextual/page-not-found.blade.php
Normal file
@@ -0,0 +1,31 @@
|
||||
{{--
|
||||
Static Page Not Found — Contextual 404
|
||||
Shown at /pages/:slug or /about|/help|/contact when page not in DB.
|
||||
--}}
|
||||
@extends('errors._layout', [
|
||||
'error_code' => 404,
|
||||
'error_title' => 'Page Not Found',
|
||||
'error_message' => 'This page was removed or renamed. Try one of the links below.',
|
||||
])
|
||||
|
||||
@section('badge', 'Page Not Found')
|
||||
|
||||
@section('primary-cta')
|
||||
<a href="/help"
|
||||
class="inline-flex items-center gap-2 rounded-xl bg-sky-500 hover:bg-sky-400 text-white font-semibold px-6 py-3 text-sm shadow-lg shadow-sky-900/30 transition-colors">
|
||||
<i class="fas fa-question-circle" aria-hidden="true"></i>
|
||||
Help Center
|
||||
</a>
|
||||
@endsection
|
||||
|
||||
@section('secondary-ctas')
|
||||
<a href="/about" class="rounded-xl border border-white/10 hover:border-white/25 text-white/70 hover:text-white px-4 py-2 text-sm transition-colors">
|
||||
About
|
||||
</a>
|
||||
<a href="/contact" class="rounded-xl border border-white/10 hover:border-white/25 text-white/70 hover:text-white px-4 py-2 text-sm transition-colors">
|
||||
Contact
|
||||
</a>
|
||||
<a href="/" class="rounded-xl border border-white/10 hover:border-white/25 text-white/70 hover:text-white px-4 py-2 text-sm transition-colors">
|
||||
Home
|
||||
</a>
|
||||
@endsection
|
||||
70
resources/views/errors/contextual/tag-not-found.blade.php
Normal file
70
resources/views/errors/contextual/tag-not-found.blade.php
Normal file
@@ -0,0 +1,70 @@
|
||||
{{--
|
||||
Tag Not Found — Contextual 404
|
||||
Shown at /tag/:slug when slug not in DB.
|
||||
|
||||
Variables:
|
||||
$requestedSlug string
|
||||
$similarTags Collection (max 10)
|
||||
$trendingTags Collection (max 10)
|
||||
--}}
|
||||
@extends('errors._layout', [
|
||||
'error_code' => 404,
|
||||
'error_title' => 'Tag Not Found',
|
||||
'error_message' => 'The tag "' . ($requestedSlug ?? '') . '" doesn\'t exist yet.',
|
||||
])
|
||||
|
||||
@section('badge', 'Tag Not Found')
|
||||
|
||||
@section('primary-cta')
|
||||
<a href="/tags"
|
||||
class="inline-flex items-center gap-2 rounded-xl bg-sky-500 hover:bg-sky-400 text-white font-semibold px-6 py-3 text-sm shadow-lg shadow-sky-900/30 transition-colors">
|
||||
<i class="fas fa-tags" aria-hidden="true"></i>
|
||||
Browse All Tags
|
||||
</a>
|
||||
@endsection
|
||||
|
||||
@section('secondary-ctas')
|
||||
<a href="/discover/trending" class="rounded-xl border border-white/10 hover:border-white/25 text-white/70 hover:text-white px-4 py-2 text-sm transition-colors">
|
||||
Trending
|
||||
</a>
|
||||
<a href="/search" class="rounded-xl border border-white/10 hover:border-white/25 text-white/70 hover:text-white px-4 py-2 text-sm transition-colors">
|
||||
<i class="fas fa-search mr-1.5" aria-hidden="true"></i> Search
|
||||
</a>
|
||||
@endsection
|
||||
|
||||
@section('recovery')
|
||||
|
||||
{{-- Similar tags --}}
|
||||
@if(isset($similarTags) && $similarTags->count())
|
||||
<div class="mb-10">
|
||||
<h2 class="text-sm font-semibold text-white/40 uppercase tracking-widest mb-4">Similar Tags</h2>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
@foreach($similarTags->take(10) as $tag)
|
||||
<a href="/tag/{{ $tag->slug }}"
|
||||
class="rounded-full bg-sky-500/10 hover:bg-sky-500/20 border border-sky-500/20 hover:border-sky-500/40 text-sky-300 hover:text-sky-200 px-3 py-1 text-xs font-medium transition-colors">
|
||||
#{{ $tag->name }}
|
||||
@if($tag->artworks_count ?? null)
|
||||
<span class="text-sky-400/60 ml-1">{{ number_format($tag->artworks_count) }}</span>
|
||||
@endif
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Trending tags --}}
|
||||
@if(isset($trendingTags) && $trendingTags->count())
|
||||
<div>
|
||||
<h2 class="text-sm font-semibold text-white/40 uppercase tracking-widest mb-4">Popular Tags</h2>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
@foreach($trendingTags->take(10) as $tag)
|
||||
<a href="/tag/{{ $tag->slug }}"
|
||||
class="rounded-full bg-white/5 hover:bg-sky-500/20 border border-white/8 hover:border-sky-500/30 text-white/70 hover:text-sky-300 px-3 py-1 text-xs font-medium transition-colors">
|
||||
#{{ $tag->name }}
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@endsection
|
||||
@@ -31,6 +31,22 @@
|
||||
<meta name="twitter:card" content="summary" />
|
||||
<meta name="twitter:title" content="{{ $page_title ?? ($hero_title ?? 'Skinbase') }}" />
|
||||
<meta name="twitter:description" content="{{ $page_meta_description ?? '' }}" />
|
||||
|
||||
{{-- Breadcrumb structured data --}}
|
||||
@if(isset($breadcrumbs) && $breadcrumbs->isNotEmpty())
|
||||
<script type="application/ld+json">
|
||||
{!! json_encode([
|
||||
'@context' => 'https://schema.org',
|
||||
'@type' => 'BreadcrumbList',
|
||||
'itemListElement' => $breadcrumbs->values()->map(fn ($crumb, $i) => [
|
||||
'@type' => 'ListItem',
|
||||
'position' => $i + 1,
|
||||
'name' => $crumb->name,
|
||||
'item' => url($crumb->url),
|
||||
])->all(),
|
||||
], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) !!}
|
||||
</script>
|
||||
@endif
|
||||
@endpush
|
||||
|
||||
@php
|
||||
@@ -76,19 +92,11 @@
|
||||
<div class="relative px-6 py-10 md:px-10 md:py-14">
|
||||
|
||||
{{-- Breadcrumb --}}
|
||||
<nav class="flex items-center gap-1.5 flex-wrap text-sm text-neutral-400" aria-label="Breadcrumb">
|
||||
<a class="hover:text-white transition-colors" href="/browse">Gallery</a>
|
||||
@if(isset($contentType) && $contentType)
|
||||
<span class="opacity-40" aria-hidden="true">›</span>
|
||||
<a class="hover:text-white transition-colors" href="/{{ $contentType->slug }}">{{ $contentType->name }}</a>
|
||||
@endif
|
||||
@if(($gallery_type ?? null) === 'category')
|
||||
@foreach($breadcrumbs as $crumb)
|
||||
<span class="opacity-40" aria-hidden="true">›</span>
|
||||
<a class="hover:text-white transition-colors" href="{{ $crumb->url }}">{{ $crumb->name }}</a>
|
||||
@endforeach
|
||||
@endif
|
||||
</nav>
|
||||
@include('components.breadcrumbs', ['breadcrumbs' => collect(array_filter([
|
||||
isset($contentType) && $contentType ? (object) ['name' => 'Explore', 'url' => '/explore'] : null,
|
||||
isset($contentType) && $contentType ? (object) ['name' => $contentType->name, 'url' => '/explore/' . strtolower($contentType->slug)] : (object) ['name' => 'Explore', 'url' => '/explore'],
|
||||
...(($gallery_type ?? null) === 'category' && isset($breadcrumbs) ? $breadcrumbs->all() : []),
|
||||
]))])
|
||||
|
||||
{{-- Glass title panel --}}
|
||||
<div class="mt-4 py-5">
|
||||
|
||||
@@ -44,8 +44,62 @@
|
||||
.auth-card { max-width: 720px; margin-left: auto; margin-right: auto; }
|
||||
.auth-card h1 { font-size: 1.25rem; line-height: 1.2; }
|
||||
.auth-card p { color: rgba(203,213,225,0.9); }
|
||||
/* Global heading styles for better hierarchy */
|
||||
h1, h2, h3, h4, h5, h6 { color: #ffffff; margin-top: 1rem; margin-bottom: 0.5rem; }
|
||||
h1 { font-size: 2.25rem; line-height: 1.05; font-weight: 800; letter-spacing: -0.02em; }
|
||||
h2 { font-size: 1.5rem; line-height: 1.15; font-weight: 700; letter-spacing: -0.01em; }
|
||||
h3 { font-size: 1.125rem; line-height: 1.2; font-weight: 600; }
|
||||
h4 { font-size: 1rem; line-height: 1.25; font-weight: 600; }
|
||||
h5 { font-size: 0.95rem; line-height: 1.25; font-weight: 600; }
|
||||
h6 { font-size: 0.85rem; line-height: 1.3; font-weight: 600; text-transform: uppercase; opacity: 0.85; }
|
||||
|
||||
/* Prose (typography plugin) overrides */
|
||||
.prose h1 { font-size: 2.25rem; }
|
||||
.prose h2 { font-size: 1.5rem; }
|
||||
.prose h3 { font-size: 1.125rem; }
|
||||
.prose h4, .prose h5, .prose h6 { font-weight: 600; }
|
||||
|
||||
/* Alpine: hide x-cloak elements until Alpine picks them up */
|
||||
[x-cloak] { display: none !important; }
|
||||
</style>
|
||||
@stack('head')
|
||||
|
||||
@if(config('services.google_adsense.publisher_id'))
|
||||
{{-- Google AdSense — consent-gated loader --}}
|
||||
{{-- Script is only injected after the user accepts all cookies. --}}
|
||||
{{-- If consent was given on a previous visit it fires on page load. --}}
|
||||
<script>
|
||||
(function () {
|
||||
var PUB = '{{ config('services.google_adsense.publisher_id') }}';
|
||||
var SCRIPT_ID = 'adsense-js';
|
||||
|
||||
function injectAdsense() {
|
||||
if (document.getElementById(SCRIPT_ID)) return;
|
||||
var s = document.createElement('script');
|
||||
s.id = SCRIPT_ID;
|
||||
s.async = true;
|
||||
s.crossOrigin = 'anonymous';
|
||||
s.src = 'https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=' + PUB;
|
||||
document.head.appendChild(s);
|
||||
}
|
||||
|
||||
// Expose so Alpine consent banner can trigger immediately on accept
|
||||
window.sbLoadAds = injectAdsense;
|
||||
|
||||
// If the user already consented on a previous visit, load straight away
|
||||
if (localStorage.getItem('sb_cookie_consent') === 'all') {
|
||||
injectAdsense();
|
||||
}
|
||||
|
||||
// Handle consent granted in another tab
|
||||
window.addEventListener('storage', function (e) {
|
||||
if (e.key === 'sb_cookie_consent' && e.newValue === 'all') {
|
||||
injectAdsense();
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
@endif
|
||||
</head>
|
||||
@php
|
||||
$authBgRoutes = [
|
||||
@@ -107,6 +161,60 @@
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
{{-- Cookie Consent Banner --}}
|
||||
<div
|
||||
x-data="{
|
||||
show: false,
|
||||
init() {
|
||||
if (!localStorage.getItem('sb_cookie_consent')) {
|
||||
this.show = true;
|
||||
}
|
||||
},
|
||||
accept() {
|
||||
localStorage.setItem('sb_cookie_consent', 'all');
|
||||
this.show = false;
|
||||
if (typeof window.sbLoadAds === 'function') window.sbLoadAds();
|
||||
},
|
||||
essential() {
|
||||
localStorage.setItem('sb_cookie_consent', 'essential');
|
||||
this.show = false;
|
||||
}
|
||||
}"
|
||||
x-show="show"
|
||||
x-cloak
|
||||
x-transition:enter="transition ease-out duration-300"
|
||||
x-transition:enter-start="opacity-0 translate-y-4"
|
||||
x-transition:enter-end="opacity-100 translate-y-0"
|
||||
x-transition:leave="transition ease-in duration-200"
|
||||
x-transition:leave-start="opacity-100 translate-y-0"
|
||||
x-transition:leave-end="opacity-0 translate-y-4"
|
||||
class="fixed bottom-0 left-0 right-0 z-50 border-t border-orange-400/30 bg-orange-950/50 backdrop-blur-2xl px-4 md:px-8 py-5"
|
||||
role="dialog"
|
||||
aria-label="Cookie consent"
|
||||
aria-live="polite"
|
||||
>
|
||||
<div class="max-w-6xl mx-auto flex flex-col sm:flex-row sm:items-center gap-3 sm:gap-6">
|
||||
<div class="flex items-start gap-3 flex-1">
|
||||
<span class="text-orange-400 mt-0.5 shrink-0 text-lg">🍪</span>
|
||||
<p class="text-sm text-orange-100/90 leading-relaxed">
|
||||
We use <strong class="text-white">essential cookies</strong> to keep you logged in and protect your session.
|
||||
With your permission we also load <strong class="text-white">advertising cookies</strong> from third-party networks.
|
||||
<a href="/privacy-policy#cookies" class="text-orange-300 hover:text-orange-200 hover:underline ml-1">Learn more ↗</a>
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<button
|
||||
@click="essential()"
|
||||
class="rounded-lg border border-orange-400/40 px-4 py-2 text-sm text-orange-200 hover:text-white hover:border-orange-400/70 hover:bg-white/5 transition-colors"
|
||||
>Essential only</button>
|
||||
<button
|
||||
@click="accept()"
|
||||
class="rounded-lg bg-orange-500 hover:bg-orange-400 px-4 py-2 text-sm font-semibold text-white shadow-lg shadow-orange-900/40 transition-colors"
|
||||
>Accept all</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@stack('scripts')
|
||||
</body>
|
||||
</html>
|
||||
|
||||
83
resources/views/layouts/nova/content-layout.blade.php
Normal file
83
resources/views/layouts/nova/content-layout.blade.php
Normal file
@@ -0,0 +1,83 @@
|
||||
{{--
|
||||
ContentLayout — minimal hero for tags directory, blog, static pages, legal.
|
||||
Used by /tags, /blog/*, /pages/*, /about, /help, /legal/*
|
||||
|
||||
Expected variables:
|
||||
$page_title, $page_meta_description, $page_canonical, $page_robots
|
||||
$breadcrumbs (collection, optional)
|
||||
Content via @yield('page-content')
|
||||
--}}
|
||||
@extends('layouts.nova')
|
||||
|
||||
@push('head')
|
||||
@isset($page_canonical)
|
||||
<link rel="canonical" href="{{ $page_canonical }}" />
|
||||
@endisset
|
||||
<meta name="robots" content="{{ $page_robots ?? 'index,follow' }}">
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:url" content="{{ $page_canonical ?? url()->current() }}" />
|
||||
<meta property="og:title" content="{{ $page_title ?? 'Skinbase' }}" />
|
||||
<meta property="og:description" content="{{ $page_meta_description ?? '' }}" />
|
||||
<meta property="og:site_name" content="Skinbase" />
|
||||
|
||||
{{-- Breadcrumb structured data --}}
|
||||
@if(isset($breadcrumbs) && $breadcrumbs->isNotEmpty())
|
||||
<script type="application/ld+json">
|
||||
{!! json_encode([
|
||||
'@context' => 'https://schema.org',
|
||||
'@type' => 'BreadcrumbList',
|
||||
'itemListElement' => $breadcrumbs->values()->map(fn ($crumb, $i) => [
|
||||
'@type' => 'ListItem',
|
||||
'position' => $i + 1,
|
||||
'name' => $crumb->name,
|
||||
'item' => url($crumb->url),
|
||||
])->all(),
|
||||
], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) !!}
|
||||
</script>
|
||||
@endif
|
||||
@endpush
|
||||
|
||||
@section('content')
|
||||
|
||||
{{-- Minimal hero --}}
|
||||
@if(!empty($center_content))
|
||||
<x-centered-content :max="$center_max ?? '3xl'" class="pt-10 pb-6" style="padding-top:2.5rem;padding-bottom:1.5rem;">
|
||||
{{-- Breadcrumbs --}}
|
||||
@include('components.breadcrumbs', ['breadcrumbs' => $breadcrumbs ?? collect()])
|
||||
|
||||
<div class="mt-4">
|
||||
<h1 class="text-3xl font-bold text-white leading-tight">
|
||||
{{ $hero_title ?? $page_title ?? 'Skinbase' }}
|
||||
</h1>
|
||||
@isset($hero_description)
|
||||
<p class="mt-1 text-sm text-white/50 max-w-xl">{{ $hero_description }}</p>
|
||||
@endisset
|
||||
</div>
|
||||
</x-centered-content>
|
||||
|
||||
{{-- Page body (centered) --}}
|
||||
<x-centered-content :max="$center_max ?? '3xl'" class="pb-16">
|
||||
@yield('page-content')
|
||||
</x-centered-content>
|
||||
@else
|
||||
<div class="px-6 pt-10 pb-6 md:px-10">
|
||||
{{-- Breadcrumbs --}}
|
||||
@include('components.breadcrumbs', ['breadcrumbs' => $breadcrumbs ?? collect()])
|
||||
|
||||
<div class="mt-4">
|
||||
<h1 class="text-3xl font-bold text-white leading-tight">
|
||||
{{ $hero_title ?? $page_title ?? 'Skinbase' }}
|
||||
</h1>
|
||||
@isset($hero_description)
|
||||
<p class="mt-1 text-sm text-white/50 max-w-xl">{{ $hero_description }}</p>
|
||||
@endisset
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Page body --}}
|
||||
<div class="px-6 pb-16 md:px-10">
|
||||
@yield('page-content')
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@endsection
|
||||
47
resources/views/layouts/nova/discover-layout.blade.php
Normal file
47
resources/views/layouts/nova/discover-layout.blade.php
Normal file
@@ -0,0 +1,47 @@
|
||||
{{--
|
||||
DiscoverLayout — compact header + mode pills.
|
||||
Used by /discover/* pages.
|
||||
|
||||
Expected variables:
|
||||
$page_title, $description, $icon, $section
|
||||
Content via @yield('discover-content')
|
||||
--}}
|
||||
@extends('layouts.nova')
|
||||
|
||||
@push('head')
|
||||
@isset($page_canonical)
|
||||
<link rel="canonical" href="{{ $page_canonical }}" />
|
||||
@endisset
|
||||
<meta name="robots" content="{{ $page_robots ?? 'index,follow' }}">
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:url" content="{{ $page_canonical ?? url()->current() }}" />
|
||||
<meta property="og:title" content="{{ $page_title ?? 'Discover — Skinbase' }}" />
|
||||
<meta property="og:description" content="{{ $description ?? '' }}" />
|
||||
<meta property="og:site_name" content="Skinbase" />
|
||||
@endpush
|
||||
|
||||
@section('content')
|
||||
|
||||
{{-- Compact header --}}
|
||||
<div class="px-6 pt-10 pb-6 md:px-10">
|
||||
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-widest text-white/30 mb-1">Discover</p>
|
||||
<h1 class="text-3xl font-bold text-white leading-tight flex items-center gap-3">
|
||||
<i class="fa-solid {{ $icon ?? 'fa-compass' }} text-sky-400 text-2xl"></i>
|
||||
{{ $page_title ?? 'Discover' }}
|
||||
</h1>
|
||||
@isset($description)
|
||||
<p class="mt-1 text-sm text-white/50">{{ $description }}</p>
|
||||
@endisset
|
||||
</div>
|
||||
|
||||
{{-- Mode pills --}}
|
||||
@include('web.discover._nav', ['section' => $section ?? ''])
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Page body --}}
|
||||
@yield('discover-content')
|
||||
|
||||
@endsection
|
||||
166
resources/views/layouts/nova/explore-layout.blade.php
Normal file
166
resources/views/layouts/nova/explore-layout.blade.php
Normal file
@@ -0,0 +1,166 @@
|
||||
{{--
|
||||
ExploreLayout — hero header + mode tabs + filters + paginated grid.
|
||||
Used by /explore/*, /tag/:slug, and gallery pages.
|
||||
|
||||
Expected variables:
|
||||
$hero_title, $hero_description, $breadcrumbs (collection),
|
||||
$current_sort, $sort_options, $artworks,
|
||||
$contentTypes (collection, optional), $activeType (string, optional)
|
||||
$page_title, $page_meta_description, $page_canonical, $page_robots
|
||||
--}}
|
||||
@extends('layouts.nova')
|
||||
|
||||
@php
|
||||
use App\Banner;
|
||||
|
||||
$seoPage = max(1, (int) request()->query('page', 1));
|
||||
$seoBase = url()->current();
|
||||
$seoQ = request()->query(); unset($seoQ['page']);
|
||||
$seoUrl = fn(int $p) => $seoBase . ($p > 1
|
||||
? '?' . http_build_query(array_merge($seoQ, ['page' => $p]))
|
||||
: (count($seoQ) ? '?' . http_build_query($seoQ) : ''));
|
||||
$seoPrev = $seoPage > 1 ? $seoUrl($seoPage - 1) : null;
|
||||
$seoNext = (isset($artworks) && method_exists($artworks, 'nextPageUrl'))
|
||||
? $artworks->nextPageUrl() : null;
|
||||
@endphp
|
||||
|
||||
@push('head')
|
||||
<link rel="canonical" href="{{ $page_canonical ?? $seoUrl($seoPage) }}">
|
||||
@if($seoPrev)<link rel="prev" href="{{ $seoPrev }}">@endif
|
||||
@if($seoNext)<link rel="next" href="{{ $seoNext }}">@endif
|
||||
<meta name="robots" content="{{ $page_robots ?? 'index,follow' }}">
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:url" content="{{ $page_canonical ?? $seoUrl(1) }}" />
|
||||
<meta property="og:title" content="{{ $page_title ?? ($hero_title ?? 'Skinbase') }}" />
|
||||
<meta property="og:description" content="{{ $page_meta_description ?? '' }}" />
|
||||
<meta property="og:site_name" content="Skinbase" />
|
||||
|
||||
{{-- Breadcrumb structured data --}}
|
||||
@if(isset($breadcrumbs) && $breadcrumbs->isNotEmpty())
|
||||
<script type="application/ld+json">
|
||||
{!! json_encode([
|
||||
'@context' => 'https://schema.org',
|
||||
'@type' => 'BreadcrumbList',
|
||||
'itemListElement' => $breadcrumbs->values()->map(fn ($crumb, $i) => [
|
||||
'@type' => 'ListItem',
|
||||
'position' => $i + 1,
|
||||
'name' => $crumb->name,
|
||||
'item' => url($crumb->url),
|
||||
])->all(),
|
||||
], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) !!}
|
||||
</script>
|
||||
@endif
|
||||
@endpush
|
||||
|
||||
@section('content')
|
||||
<div class="container-fluid legacy-page">
|
||||
@php Banner::ShowResponsiveAd(); @endphp
|
||||
|
||||
<div class="pt-0">
|
||||
<div class="mx-auto w-full">
|
||||
<div class="relative min-h-[calc(100vh-64px)]">
|
||||
<main class="w-full">
|
||||
|
||||
{{-- ══ HERO HEADER ══ --}}
|
||||
<div class="relative overflow-hidden nb-hero-radial">
|
||||
<div class="absolute inset-0 nb-hero-gradient" aria-hidden="true"></div>
|
||||
<div class="absolute inset-0 opacity-20 bg-[radial-gradient(ellipse_80%_60%_at_50%_-10%,#E07A2130,transparent)]" aria-hidden="true"></div>
|
||||
|
||||
<div class="relative px-6 py-10 md:px-10 md:py-14">
|
||||
|
||||
{{-- Breadcrumbs --}}
|
||||
@include('components.breadcrumbs', ['breadcrumbs' => $breadcrumbs ?? collect()])
|
||||
|
||||
{{-- Title panel --}}
|
||||
<div class="mt-4 py-5">
|
||||
<h1 class="text-3xl md:text-4xl font-bold tracking-tight text-white/95 leading-tight">
|
||||
{{ $hero_title ?? 'Explore' }}
|
||||
</h1>
|
||||
@if(!empty($hero_description))
|
||||
<p class="mt-2 text-sm leading-6 text-neutral-400 max-w-xl">{!! $hero_description !!}</p>
|
||||
@endif
|
||||
|
||||
@if(is_object($artworks) && method_exists($artworks, 'total') && $artworks->total() > 0)
|
||||
<div class="mt-3 flex items-center gap-1.5 text-xs text-neutral-500">
|
||||
<svg class="h-3.5 w-3.5 text-accent/70" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<span>{{ number_format($artworks->total()) }} artworks</span>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
{{-- Content type chips (Explore only) --}}
|
||||
@if(isset($contentTypes) && $contentTypes->isNotEmpty())
|
||||
<div class="flex flex-wrap gap-2 mt-2">
|
||||
@foreach($contentTypes as $ct)
|
||||
<a href="{{ $ct->url }}"
|
||||
class="inline-flex items-center px-3 py-1.5 rounded-lg text-sm font-medium transition-colors
|
||||
{{ ($activeType ?? '') === $ct->slug
|
||||
? 'bg-sky-600 text-white'
|
||||
: 'bg-white/[0.05] text-white/60 hover:bg-white/[0.1] hover:text-white' }}">
|
||||
{{ $ct->name }}
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
|
||||
</div>
|
||||
<div class="absolute left-0 right-0 bottom-0 h-16 nb-hero-fade pointer-events-none" aria-hidden="true"></div>
|
||||
</div>
|
||||
|
||||
{{-- ══ RANKING TABS ══ --}}
|
||||
@php
|
||||
$rankingTabs = $sort_options ?? [
|
||||
['value' => 'trending', 'label' => '🔥 Trending'],
|
||||
['value' => 'new-hot', 'label' => '🚀 New & Hot'],
|
||||
['value' => 'best', 'label' => '⭐ Best'],
|
||||
['value' => 'latest', 'label' => '🕐 Latest'],
|
||||
];
|
||||
$activeTab = $current_sort ?? 'trending';
|
||||
@endphp
|
||||
|
||||
<div class="sticky top-0 z-30 border-b border-white/10 bg-nova-900/90 backdrop-blur-md" id="gallery-ranking-tabs">
|
||||
<div class="px-6 md:px-10">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<nav class="flex items-center gap-0 -mb-px nb-scrollbar-none overflow-x-auto" role="tablist">
|
||||
@foreach($rankingTabs as $tab)
|
||||
@php $isActive = $activeTab === $tab['value']; @endphp
|
||||
<button
|
||||
role="tab"
|
||||
aria-selected="{{ $isActive ? 'true' : 'false' }}"
|
||||
data-rank-tab="{{ $tab['value'] }}"
|
||||
class="gallery-rank-tab relative flex items-center gap-1.5 whitespace-nowrap px-5 py-4 text-sm font-medium transition-colors focus-visible:outline-none {{ $isActive ? 'text-white' : 'text-neutral-400 hover:text-white' }}"
|
||||
>
|
||||
{{ $tab['label'] }}
|
||||
<span class="nb-tab-indicator absolute bottom-0 left-0 right-0 h-0.5 {{ $isActive ? 'bg-accent scale-x-100' : 'bg-transparent scale-x-0' }} transition-transform duration-300 origin-left rounded-full"></span>
|
||||
</button>
|
||||
@endforeach
|
||||
</nav>
|
||||
|
||||
<button id="gallery-filter-panel-toggle" type="button"
|
||||
class="hidden md:flex items-center gap-2 shrink-0 rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-sm text-white/80 hover:bg-white/10 hover:text-white transition-colors"
|
||||
aria-haspopup="dialog" aria-expanded="false">
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2a1 1 0 01-.293.707L13 13.414V19a1 1 0 01-.553.894l-4 2A1 1 0 017 21v-7.586L3.293 6.707A1 1 0 013 6V4z" /></svg>
|
||||
Filters
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- ══ ARTWORK GRID ══ --}}
|
||||
@yield('explore-grid')
|
||||
|
||||
{{-- ══ PAGINATION ══ --}}
|
||||
@if(is_object($artworks) && method_exists($artworks, 'links'))
|
||||
<div class="px-6 md:px-10 py-8 flex justify-center">
|
||||
{{ $artworks->withQueryString()->links() }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
@@ -7,12 +7,18 @@
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-x-6 gap-y-2 text-sm text-neutral-400">
|
||||
<a class="hover:text-white" href="/bug-report">Bug Report</a>
|
||||
<a class="hover:text-white" href="/contact">Contact / Apply</a>
|
||||
<a class="hover:text-white" href="/rss-feeds">RSS Feeds</a>
|
||||
<a class="hover:text-white" href="/faq">FAQ</a>
|
||||
<a class="hover:text-white" href="/rules-and-guidelines">Rules and Guidelines</a>
|
||||
<a class="hover:text-white" href="/staff">Staff</a>
|
||||
<a class="hover:text-white" href="/privacy-policy">Privacy Policy</a>
|
||||
<a class="hover:text-white" href="/terms-of-service">Terms of Service</a>
|
||||
<button
|
||||
x-data
|
||||
@click="localStorage.removeItem('sb_cookie_consent'); window.location.reload()"
|
||||
class="hover:text-white cursor-pointer bg-transparent border-0 p-0 text-sm text-neutral-400"
|
||||
>Cookie Preferences</button>
|
||||
</div>
|
||||
|
||||
<div class="text-xs text-neutral-400">© 2026 Skinbase.org</div>
|
||||
|
||||
@@ -220,7 +220,9 @@
|
||||
$toolbarUsername = strtolower((string) (Auth::user()->username ?? ''));
|
||||
$routeUpload = Route::has('upload') ? route('upload') : '/upload';
|
||||
$routeDashboardFavorites = Route::has('dashboard.favorites') ? route('dashboard.favorites') : '/dashboard/favorites';
|
||||
$routeEditProfile = Route::has('settings') ? route('settings') : '/settings';
|
||||
$routeEditProfile = Route::has('dashboard.profile')
|
||||
? route('dashboard.profile')
|
||||
: (Route::has('settings') ? route('settings') : '/settings');
|
||||
$routePublicProfile = Route::has('profile.show') ? route('profile.show', ['username' => $toolbarUsername]) : '/@'.$toolbarUsername;
|
||||
@endphp
|
||||
|
||||
@@ -343,7 +345,7 @@
|
||||
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="{{ $mobileProfile }}">
|
||||
<i class="fa-solid fa-circle-user w-4 text-center text-sb-muted"></i>View Profile
|
||||
</a>
|
||||
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="{{ Route::has('settings') ? route('settings') : '/settings' }}">
|
||||
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="{{ Route::has('dashboard.profile') ? route('dashboard.profile') : (Route::has('settings') ? route('settings') : '/settings') }}">
|
||||
<i class="fa-solid fa-cog w-4 text-center text-sb-muted"></i>Settings
|
||||
</a>
|
||||
@if(in_array(strtolower((string) (Auth::user()->role ?? '')), ['admin', 'moderator'], true))
|
||||
|
||||
28
resources/views/rss/feed.blade.php
Normal file
28
resources/views/rss/feed.blade.php
Normal file
@@ -0,0 +1,28 @@
|
||||
<?php echo '<?xml version="1.0" encoding="UTF-8"?>'; ?>
|
||||
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:media="http://search.yahoo.com/mrss/">
|
||||
<channel>
|
||||
<title>{{ htmlspecialchars($channelTitle) }}</title>
|
||||
<link>{{ $channelLink }}</link>
|
||||
<description>{{ htmlspecialchars($channelDescription) }}</description>
|
||||
<language>en-us</language>
|
||||
<lastBuildDate>{{ $buildDate }}</lastBuildDate>
|
||||
<atom:link href="{{ $feedUrl }}" rel="self" type="application/rss+xml" />
|
||||
@foreach ($artworks as $artwork)
|
||||
<item>
|
||||
<title><![CDATA[{{ $artwork->title }}]]></title>
|
||||
<link>{{ url('/art/' . $artwork->id . '/' . ($artwork->slug ?? '')) }}</link>
|
||||
<guid isPermaLink="true">{{ url('/art/' . $artwork->id . '/' . ($artwork->slug ?? '')) }}</guid>
|
||||
<pubDate>{{ $artwork->published_at?->toRfc2822String() }}</pubDate>
|
||||
<author><![CDATA[{{ $artwork->user?->username ?? 'Unknown' }}]]></author>
|
||||
@if ($artwork->description)
|
||||
<description><![CDATA[{{ strip_tags($artwork->description) }}]]></description>
|
||||
@endif
|
||||
@php $thumb = $artwork->thumbUrl('sm'); @endphp
|
||||
@if ($thumb)
|
||||
<media:thumbnail url="{{ $thumb }}" />
|
||||
<media:content url="{{ $thumb }}" medium="image" />
|
||||
@endif
|
||||
</item>
|
||||
@endforeach
|
||||
</channel>
|
||||
</rss>
|
||||
18
resources/views/settings.blade.php
Normal file
18
resources/views/settings.blade.php
Normal file
@@ -0,0 +1,18 @@
|
||||
@extends('layouts.nova')
|
||||
|
||||
@push('head')
|
||||
<meta name="csrf-token" content="{{ csrf_token() }}" />
|
||||
@vite(['resources/js/settings.jsx'])
|
||||
<style>
|
||||
body.page-settings main { padding-top: 4rem; }
|
||||
</style>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
document.body.classList.add('page-settings')
|
||||
})
|
||||
</script>
|
||||
@endpush
|
||||
|
||||
@section('content')
|
||||
@inertia
|
||||
@endsection
|
||||
86
resources/views/web/apply.blade.php
Normal file
86
resources/views/web/apply.blade.php
Normal file
@@ -0,0 +1,86 @@
|
||||
@extends('layouts.nova.content-layout')
|
||||
|
||||
@php
|
||||
$page_title = 'Apply to Join the Team';
|
||||
$hero_description = "We're always grateful for volunteers who want to help.";
|
||||
$center_content = true;
|
||||
$center_max = '3xl';
|
||||
@endphp
|
||||
|
||||
@section('page-content')
|
||||
<div class="max-w-3xl">
|
||||
@if(session('success'))
|
||||
<div class="mb-4 rounded-lg bg-emerald-800/20 border border-emerald-700 p-4 text-emerald-200">
|
||||
{{ session('success') }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<form x-data='{ topic: @json(old('topic','apply')) }' method="POST" action="{{ route('contact.submit') }}" class="space-y-4">
|
||||
@csrf
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-neutral-200">Reason for contact</label>
|
||||
<select name="topic" x-model="topic" class="mt-1 block w-full rounded bg-white/2 border border-white/10 p-2 text-white">
|
||||
<option value="apply" {{ old('topic') === 'apply' ? 'selected' : '' }}>Apply to join the team</option>
|
||||
<option value="bug" {{ old('topic') === 'bug' ? 'selected' : '' }}>Report a bug / site issue</option>
|
||||
<option value="contact" {{ old('topic') === 'contact' ? 'selected' : '' }}>General contact / question</option>
|
||||
<option value="other" {{ old('topic') === 'other' ? 'selected' : '' }}>Other</option>
|
||||
</select>
|
||||
@error('topic') <p class="text-rose-400 text-sm mt-1">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-neutral-200">Full name</label>
|
||||
<input name="name" value="{{ old('name') }}" class="mt-1 block w-full rounded bg-white/2 border border-white/10 p-2 text-white" required />
|
||||
@error('name') <p class="text-rose-400 text-sm mt-1">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-neutral-200">Email</label>
|
||||
<input name="email" value="{{ old('email') }}" type="email" class="mt-1 block w-full rounded bg-white/2 border border-white/10 p-2 text-white" required />
|
||||
@error('email') <p class="text-rose-400 text-sm mt-1">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
|
||||
<div x-show="topic === 'apply'" x-cloak>
|
||||
<label class="block text-sm font-medium text-neutral-200">Role you're applying for</label>
|
||||
<input name="role" value="{{ old('role') }}" placeholder="e.g. Moderator, Community Manager" class="mt-1 block w-full rounded bg-white/2 border border-white/10 p-2 text-white" />
|
||||
@error('role') <p class="text-rose-400 text-sm mt-1">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
|
||||
<div x-show="topic === 'apply'" x-cloak>
|
||||
<label class="block text-sm font-medium text-neutral-200">Portfolio / profile (optional)</label>
|
||||
<input name="portfolio" value="{{ old('portfolio') }}" placeholder="https://" class="mt-1 block w-full rounded bg-white/2 border border-white/10 p-2 text-white" />
|
||||
@error('portfolio') <p class="text-rose-400 text-sm mt-1">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-neutral-200">Tell us about yourself</label>
|
||||
<textarea name="message" rows="6" class="mt-1 block w-full rounded bg-white/2 border border-white/10 p-2 text-white">{{ old('message') }}</textarea>
|
||||
@error('message') <p class="text-rose-400 text-sm mt-1">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
|
||||
{{-- Bug-specific fields --}}
|
||||
<div x-show="topic === 'bug'" x-cloak>
|
||||
<label class="block text-sm font-medium text-neutral-200">Affected URL (optional)</label>
|
||||
<input name="affected_url" value="{{ old('affected_url') }}" placeholder="https://" class="mt-1 block w-full rounded bg-white/2 border border-white/10 p-2 text-white" />
|
||||
@error('affected_url') <p class="text-rose-400 text-sm mt-1">{{ $message }}</p> @enderror
|
||||
|
||||
<label class="block text-sm font-medium text-neutral-200 mt-3">Steps to reproduce (optional)</label>
|
||||
<textarea name="steps" rows="4" class="mt-1 block w-full rounded bg-white/2 border border-white/10 p-2 text-white">{{ old('steps') }}</textarea>
|
||||
@error('steps') <p class="text-rose-400 text-sm mt-1">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
|
||||
{{-- Honeypot field (hidden from real users) --}}
|
||||
<div style="display:none;" aria-hidden="true">
|
||||
<label>Website</label>
|
||||
<input type="text" name="website" value="" autocomplete="off" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end">
|
||||
<button type="submit" class="inline-flex items-center gap-2 rounded bg-sky-500 px-4 py-2 text-sm font-medium text-white hover:bg-sky-600">Submit</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<p class="mt-6 text-sm text-neutral-400">By submitting this form you consent to Skinbase storing your application details for review.</p>
|
||||
</div>
|
||||
@endsection
|
||||
54
resources/views/web/blog/index.blade.php
Normal file
54
resources/views/web/blog/index.blade.php
Normal file
@@ -0,0 +1,54 @@
|
||||
{{--
|
||||
Blog index — uses ContentLayout.
|
||||
--}}
|
||||
@extends('layouts.nova.content-layout')
|
||||
|
||||
@php
|
||||
$hero_title = 'Blog';
|
||||
$hero_description = 'News, tutorials and community stories from the Skinbase team.';
|
||||
@endphp
|
||||
|
||||
@section('page-content')
|
||||
|
||||
@if($posts->isNotEmpty())
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
@foreach($posts as $post)
|
||||
<a href="{{ $post->url }}"
|
||||
class="group block rounded-xl border border-white/[0.06] bg-white/[0.02] overflow-hidden hover:bg-white/[0.05] transition-colors">
|
||||
@if($post->featured_image)
|
||||
<div class="aspect-video bg-nova-800 overflow-hidden">
|
||||
<img src="{{ $post->featured_image }}" alt="{{ $post->title }}" class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300" loading="lazy" />
|
||||
</div>
|
||||
@else
|
||||
<div class="aspect-video bg-gradient-to-br from-sky-900/30 to-purple-900/30 flex items-center justify-center">
|
||||
<i class="fa-solid fa-newspaper text-3xl text-white/20"></i>
|
||||
</div>
|
||||
@endif
|
||||
<div class="p-5">
|
||||
<h2 class="text-lg font-semibold text-white group-hover:text-sky-300 transition-colors line-clamp-2">
|
||||
{{ $post->title }}
|
||||
</h2>
|
||||
@if($post->excerpt)
|
||||
<p class="mt-2 text-sm text-white/50 line-clamp-3">{{ $post->excerpt }}</p>
|
||||
@endif
|
||||
@if($post->published_at)
|
||||
<time class="mt-3 block text-xs text-white/30" datetime="{{ $post->published_at->toIso8601String() }}">
|
||||
{{ $post->published_at->format('M j, Y') }}
|
||||
</time>
|
||||
@endif
|
||||
</div>
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
<div class="mt-10 flex justify-center">
|
||||
{{ $posts->withQueryString()->links() }}
|
||||
</div>
|
||||
@else
|
||||
<div class="rounded-xl border border-white/[0.06] bg-white/[0.02] px-8 py-12 text-center">
|
||||
<i class="fa-solid fa-newspaper text-4xl text-white/20 mb-4"></i>
|
||||
<p class="text-white/40 text-sm">No blog posts published yet. Check back soon!</p>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@endsection
|
||||
60
resources/views/web/blog/show.blade.php
Normal file
60
resources/views/web/blog/show.blade.php
Normal file
@@ -0,0 +1,60 @@
|
||||
{{--
|
||||
Blog post — uses ContentLayout.
|
||||
--}}
|
||||
@extends('layouts.nova.content-layout')
|
||||
|
||||
@php
|
||||
$hero_title = $post->title;
|
||||
@endphp
|
||||
|
||||
@push('head')
|
||||
{{-- Article structured data --}}
|
||||
<script type="application/ld+json">
|
||||
{!! json_encode([
|
||||
'@context' => 'https://schema.org',
|
||||
'@type' => 'Article',
|
||||
'headline' => $post->title,
|
||||
'datePublished' => $post->published_at?->toIso8601String(),
|
||||
'dateModified' => $post->updated_at?->toIso8601String(),
|
||||
'author' => [
|
||||
'@type' => 'Organization',
|
||||
'name' => 'Skinbase',
|
||||
],
|
||||
'publisher' => [
|
||||
'@type' => 'Organization',
|
||||
'name' => 'Skinbase',
|
||||
],
|
||||
'description' => $post->meta_description ?: $post->excerpt ?: '',
|
||||
'mainEntityOfPage' => $post->url,
|
||||
], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) !!}
|
||||
</script>
|
||||
@endpush
|
||||
|
||||
@section('page-content')
|
||||
|
||||
<article class="max-w-3xl">
|
||||
@if($post->featured_image)
|
||||
<div class="rounded-xl overflow-hidden mb-8">
|
||||
<img src="{{ $post->featured_image }}" alt="{{ $post->title }}" class="w-full" />
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if($post->published_at)
|
||||
<time class="block text-sm text-white/40 mb-4" datetime="{{ $post->published_at->toIso8601String() }}">
|
||||
{{ $post->published_at->format('F j, Y') }}
|
||||
</time>
|
||||
@endif
|
||||
|
||||
<div class="prose prose-invert prose-headings:text-white prose-a:text-sky-400 prose-p:text-white/70 max-w-none">
|
||||
{!! $post->body !!}
|
||||
</div>
|
||||
|
||||
<div class="mt-12 pt-8 border-t border-white/10">
|
||||
<a href="/blog" class="inline-flex items-center gap-2 text-sm text-sky-400 hover:text-sky-300 transition-colors">
|
||||
<i class="fa-solid fa-arrow-left text-xs"></i>
|
||||
Back to Blog
|
||||
</a>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
@endsection
|
||||
75
resources/views/web/bug-report.blade.php
Normal file
75
resources/views/web/bug-report.blade.php
Normal file
@@ -0,0 +1,75 @@
|
||||
@extends('layouts.nova.content-layout')
|
||||
|
||||
@section('page-content')
|
||||
|
||||
@if ($success)
|
||||
<div class="mb-6 rounded-lg bg-green-500/10 border border-green-500/30 text-green-400 px-5 py-4 text-sm">
|
||||
Your report was submitted successfully. Thank you — we'll look into it as soon as possible.
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="max-w-2xl">
|
||||
@guest
|
||||
<div class="rounded-lg bg-nova-800 border border-neutral-700 px-6 py-8 text-center">
|
||||
<svg class="mx-auto mb-3 h-10 w-10 text-neutral-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
|
||||
d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z"/>
|
||||
</svg>
|
||||
<p class="text-neutral-400 text-sm mb-4">You need to be signed in to submit a bug report.</p>
|
||||
<a href="{{ route('login') }}" class="inline-block rounded-md bg-accent px-5 py-2 text-sm font-medium text-white hover:opacity-90">
|
||||
Sign In
|
||||
</a>
|
||||
</div>
|
||||
@else
|
||||
<p class="text-neutral-400 text-sm mb-6">
|
||||
Found a bug or have a suggestion? Fill out the form below and our team will review it.
|
||||
For security issues, please contact us directly via email.
|
||||
</p>
|
||||
|
||||
<form action="{{ route('bug-report.submit') }}" method="POST" class="space-y-5">
|
||||
@csrf
|
||||
|
||||
<div>
|
||||
<label for="subject" class="block text-sm font-medium text-neutral-300 mb-1">Subject</label>
|
||||
<input
|
||||
type="text"
|
||||
id="subject"
|
||||
name="subject"
|
||||
required
|
||||
maxlength="255"
|
||||
value="{{ old('subject') }}"
|
||||
placeholder="Brief summary of the issue"
|
||||
class="w-full rounded-md bg-nova-800 border border-neutral-700 px-4 py-2.5 text-sm text-white placeholder-neutral-500 focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent"
|
||||
>
|
||||
@error('subject')
|
||||
<p class="mt-1 text-xs text-red-400">{{ $message }}</p>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="description" class="block text-sm font-medium text-neutral-300 mb-1">Description</label>
|
||||
<textarea
|
||||
id="description"
|
||||
name="description"
|
||||
required
|
||||
rows="7"
|
||||
maxlength="5000"
|
||||
placeholder="Describe the bug in detail — steps to reproduce, what you expected, and what actually happened."
|
||||
class="w-full rounded-md bg-nova-800 border border-neutral-700 px-4 py-2.5 text-sm text-white placeholder-neutral-500 focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent resize-y"
|
||||
>{{ old('description') }}</textarea>
|
||||
@error('description')
|
||||
<p class="mt-1 text-xs text-red-400">{{ $message }}</p>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button type="submit"
|
||||
class="rounded-md bg-accent px-6 py-2.5 text-sm font-medium text-white hover:opacity-90 transition-opacity">
|
||||
Submit Report
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
@endguest
|
||||
</div>
|
||||
|
||||
@endsection
|
||||
@@ -38,7 +38,7 @@
|
||||
$profileUrl = ($creator->username ?? null)
|
||||
? '/@' . $creator->username
|
||||
: '/profile/' . (int) $creator->user_id;
|
||||
$avatarUrl = \App\Support\AvatarUrl::forUser((int) $creator->user_id, null, 40);
|
||||
$avatarUrl = \App\Support\AvatarUrl::forUser((int) $creator->user_id, $creator->avatar_hash ?? null, 40);
|
||||
@endphp
|
||||
<div class="grid grid-cols-[3rem_1fr_auto] items-center gap-4 px-5 py-4
|
||||
{{ $rank <= 3 ? 'bg-white/[0.015]' : '' }} hover:bg-white/[0.03] transition-colors">
|
||||
@@ -62,7 +62,7 @@
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm font-medium text-white truncate">{{ $creator->uname }}</p>
|
||||
@if($creator->username ?? null)
|
||||
<p class="text-xs text-white/40 truncate">@{{ $creator->username }}</p>
|
||||
<p class="text-xs text-white/40 truncate">{{ '@' . $creator->username }}</p>
|
||||
@endif
|
||||
</div>
|
||||
</a>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user