more fixes

This commit is contained in:
2026-03-12 07:22:38 +01:00
parent 547215cbe8
commit 4f576ceb04
226 changed files with 14380 additions and 4453 deletions

8
.env.cpad Normal file
View File

@@ -0,0 +1,8 @@
# cPad Configuration
# Template: custom
CPAD_DEBUG=false
CPAD_CACHE_ENABLED=true
CPAD_LOG_LEVEL=WARNING
CPAD_SECURITY_LEVEL=MAXIMUM
CPAD_BACKUP_ENABLED=true

1
.gitignore vendored
View File

@@ -27,3 +27,4 @@ Thumbs.db
oldSite
packages
/packages/*
/public/admin/*

View File

@@ -0,0 +1,108 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
use Klevze\ControlPanel\Facades\FileManager;
use Klevze\ControlPanel\Core\Utils\Translation as TranslationUtil;
class ExportMissingTranslations extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'translations:export-missing {file=admin} {--out=}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Export missing translations for a file (e.g. admin) into a CSV';
private $translationURL = "https://cPad.dev/api/translation/get/list";
private $token = 'Ddt06xvjYX1TK792H4jAtld8UhgVORYIpkB7nBX6';
public function handle(): int
{
$type = $this->argument('file') ?? 'admin';
$this->info('Exporting missing translations for: ' . $type);
// Gather files to scan
$files = [];
$files = array_merge(
FileManager::getFileList(app_path(), true),
FileManager::getFileList(base_path('packages'), true),
FileManager::getFileList(resource_path(), true)
);
$tempTranslations = [];
foreach ($files as $file) {
$res = TranslationUtil::findTranslations($file, $type);
if (!empty($res) && is_array($res)) {
$tempTranslations[] = $res;
}
}
$tempTranslations = collect($tempTranslations)->collapse();
$missing = [];
foreach ($tempTranslations as $keycode => $row) {
$exists = DB::table('translations')->where('keycode', $keycode)->where('file', $type)->exists();
if (! $exists) {
$missing[] = $keycode;
}
}
$this->info('Found ' . count($missing) . ' missing keys');
// Fetch suggested translations from external service for sl and en
$suggestions = [];
if (!empty($missing)) {
$payload = [
'keys' => $missing,
'languages' => ['sl', 'en'],
];
try {
$resp = Http::withToken($this->token)->post($this->translationURL, $payload);
if ($resp->successful()) {
$suggestions = $resp->json();
} else {
$this->warn('Translation suggestion service returned ' . $resp->status());
}
} catch (\Throwable $e) {
$this->warn('Failed to call suggestion service: ' . $e->getMessage());
}
}
// Build CSV
$out = $this->option('out') ?: storage_path('app/translations_missing_' . $type . '.csv');
$fh = fopen($out, 'w');
if (! $fh) {
$this->error('Failed to open output file: ' . $out);
return 1;
}
// Header
fputcsv($fh, ['file','keycode','suggested_sl','suggested_en','placeholder']);
foreach ($missing as $key) {
$s_sl = $suggestions[$key]['sl'] ?? '';
$s_en = $suggestions[$key]['en'] ?? '';
$placeholder = $type . '.' . $key;
fputcsv($fh, [$type, $key, $s_sl, $s_en, $placeholder]);
}
fclose($fh);
$this->info('CSV exported to: ' . $out);
return 0;
}
}

View File

@@ -0,0 +1,206 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\Story;
use App\Models\StoryTag;
use App\Models\User;
use App\Notifications\StoryStatusNotification;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Illuminate\View\View;
class StoryAdminController extends Controller
{
public function index(): View
{
$stories = Story::query()
->with(['creator'])
->latest('created_at')
->paginate(25);
return view('admin.stories.index', ['stories' => $stories]);
}
public function review(): View
{
$stories = Story::query()
->with(['creator'])
->where('status', 'pending_review')
->orderByDesc('submitted_for_review_at')
->paginate(25);
return view('admin.stories.review', ['stories' => $stories]);
}
public function create(): View
{
return view('admin.stories.create', [
'creators' => User::query()->orderBy('username')->limit(200)->get(['id', 'username']),
'tags' => StoryTag::query()->orderBy('name')->get(['id', 'name']),
]);
}
public function store(Request $request): RedirectResponse
{
$validated = $request->validate([
'creator_id' => ['required', 'integer', 'exists:users,id'],
'title' => ['required', 'string', 'max:255'],
'excerpt' => ['nullable', 'string', 'max:500'],
'cover_image' => ['nullable', 'string', 'max:500'],
'content' => ['required', 'string'],
'story_type' => ['required', 'in:creator_story,tutorial,interview,project_breakdown,announcement,resource'],
'status' => ['required', Rule::in(['draft', 'pending_review', 'published', 'scheduled', 'archived', 'rejected'])],
'tags' => ['nullable', 'array'],
'tags.*' => ['integer', 'exists:story_tags,id'],
]);
$story = Story::query()->create([
'creator_id' => (int) $validated['creator_id'],
'title' => $validated['title'],
'slug' => $this->uniqueSlug($validated['title']),
'excerpt' => $validated['excerpt'] ?? null,
'cover_image' => $validated['cover_image'] ?? null,
'content' => $validated['content'],
'story_type' => $validated['story_type'],
'reading_time' => max(1, (int) ceil(str_word_count(strip_tags((string) $validated['content'])) / 200)),
'status' => $validated['status'],
'published_at' => $validated['status'] === 'published' ? now() : null,
'submitted_for_review_at' => $validated['status'] === 'pending_review' ? now() : null,
]);
if (! empty($validated['tags'])) {
$story->tags()->sync($validated['tags']);
}
return redirect()->route('admin.stories.edit', ['story' => $story->id])
->with('status', 'Story created.');
}
public function edit(Story $story): View
{
$story->load('tags');
return view('admin.stories.edit', [
'story' => $story,
'creators' => User::query()->orderBy('username')->limit(200)->get(['id', 'username']),
'tags' => StoryTag::query()->orderBy('name')->get(['id', 'name']),
]);
}
public function update(Request $request, Story $story): RedirectResponse
{
$validated = $request->validate([
'creator_id' => ['required', 'integer', 'exists:users,id'],
'title' => ['required', 'string', 'max:255'],
'excerpt' => ['nullable', 'string', 'max:500'],
'cover_image' => ['nullable', 'string', 'max:500'],
'content' => ['required', 'string'],
'story_type' => ['required', 'in:creator_story,tutorial,interview,project_breakdown,announcement,resource'],
'status' => ['required', Rule::in(['draft', 'pending_review', 'published', 'scheduled', 'archived', 'rejected'])],
'tags' => ['nullable', 'array'],
'tags.*' => ['integer', 'exists:story_tags,id'],
]);
$story->update([
'creator_id' => (int) $validated['creator_id'],
'title' => $validated['title'],
'excerpt' => $validated['excerpt'] ?? null,
'cover_image' => $validated['cover_image'] ?? null,
'content' => $validated['content'],
'story_type' => $validated['story_type'],
'reading_time' => max(1, (int) ceil(str_word_count(strip_tags((string) $validated['content'])) / 200)),
'status' => $validated['status'],
'published_at' => $validated['status'] === 'published' ? ($story->published_at ?? now()) : $story->published_at,
'submitted_for_review_at' => $validated['status'] === 'pending_review' ? ($story->submitted_for_review_at ?? now()) : $story->submitted_for_review_at,
]);
$story->tags()->sync($validated['tags'] ?? []);
return back()->with('status', 'Story updated.');
}
public function destroy(Story $story): RedirectResponse
{
$story->delete();
return redirect()->route('admin.stories.index')->with('status', 'Story deleted.');
}
public function publish(Story $story): RedirectResponse
{
$story->update([
'status' => 'published',
'published_at' => $story->published_at ?? now(),
'reviewed_at' => now(),
]);
$story->creator?->notify(new StoryStatusNotification($story, 'published'));
return back()->with('status', 'Story published.');
}
public function show(Story $story): View
{
return view('admin.stories.show', [
'story' => $story->load(['creator', 'tags']),
]);
}
public function approve(Request $request, Story $story): RedirectResponse
{
$story->update([
'status' => 'published',
'published_at' => $story->published_at ?? now(),
'reviewed_at' => now(),
'reviewed_by_id' => (int) $request->user()->id,
'rejected_reason' => null,
]);
$story->creator?->notify(new StoryStatusNotification($story, 'approved'));
return back()->with('status', 'Story approved and published.');
}
public function reject(Request $request, Story $story): RedirectResponse
{
$validated = $request->validate([
'reason' => ['required', 'string', 'max:1000'],
]);
$story->update([
'status' => 'rejected',
'reviewed_at' => now(),
'reviewed_by_id' => (int) $request->user()->id,
'rejected_reason' => $validated['reason'],
]);
$story->creator?->notify(new StoryStatusNotification($story, 'rejected', $validated['reason']));
return back()->with('status', 'Story rejected and creator notified.');
}
public function moderateComments(): View
{
return view('admin.stories.comments-moderation');
}
private function uniqueSlug(string $title): string
{
$base = Str::slug($title);
$slug = $base;
$n = 2;
while (Story::query()->where('slug', $slug)->exists()) {
$slug = $base . '-' . $n;
$n++;
}
return $slug;
}
}

View File

@@ -10,6 +10,7 @@ use App\Support\UsernamePolicy;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Symfony\Component\HttpFoundation\Response;
final class UsernameApprovalController extends Controller
@@ -124,6 +125,9 @@ final class UsernameApprovalController extends Controller
$user->username = $requested;
$user->username_changed_at = now();
if (Schema::hasColumn('users', 'last_username_change_at')) {
$user->last_username_change_at = now();
}
$user->save();
if ($old !== '') {

View File

@@ -86,7 +86,9 @@ final class ArtworkDownloadController extends Controller
'artwork_id' => $artwork->id,
'user_id' => $request->user()?->id,
'ip' => $bin !== false ? $bin : null,
'user_agent' => mb_substr((string) $request->userAgent(), 0, 512),
'ip_address' => mb_substr((string) $ip, 0, 45),
'user_agent' => mb_substr((string) $request->userAgent(), 0, 1024),
'referer' => mb_substr((string) $request->headers->get('referer'), 0, 65535),
'created_at' => now(),
]);
} catch (\Throwable) {

View File

@@ -48,7 +48,7 @@ class NotificationController extends Controller
public function readAll(Request $request): JsonResponse
{
$request->user()->unreadNotifications->markAsRead();
$request->user()->unreadNotifications()->update(['read_at' => now()]);
return response()->json(['message' => 'All notifications marked as read.']);
}

View File

@@ -6,6 +6,7 @@ use App\Http\Controllers\Controller;
use App\Models\ConversationParticipant;
use App\Models\Message;
use App\Models\Report;
use App\Models\Story;
use App\Models\User;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
@@ -17,7 +18,7 @@ class ReportController extends Controller
$user = $request->user();
$data = $request->validate([
'target_type' => 'required|in:message,conversation,user',
'target_type' => 'required|in:message,conversation,user,story',
'target_id' => 'required|integer|min:1',
'reason' => 'required|string|max:120',
'details' => 'nullable|string|max:4000',
@@ -49,6 +50,10 @@ class ReportController extends Controller
User::query()->findOrFail($targetId);
}
if ($targetType === 'story') {
Story::query()->findOrFail($targetId);
}
$report = Report::query()->create([
'reporter_id' => $user->id,
'target_type' => $targetType,

View File

@@ -7,7 +7,7 @@ namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Story;
use App\Models\StoryTag;
use App\Models\StoryAuthor;
use App\Models\User;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
@@ -36,7 +36,7 @@ final class StoriesApiController extends Controller
$stories = Cache::remember($cacheKey, 300, fn () =>
Story::published()
->with('author', 'tags')
->with('creator.profile', 'tags')
->orderByDesc('published_at')
->paginate($perPage, ['*'], 'page', $page)
);
@@ -60,7 +60,7 @@ final class StoriesApiController extends Controller
{
$story = Cache::remember('stories:api:' . $slug, 600, fn () =>
Story::published()
->with('author', 'tags')
->with('creator.profile', 'tags')
->where('slug', $slug)
->firstOrFail()
);
@@ -76,7 +76,7 @@ final class StoriesApiController extends Controller
{
$story = Cache::remember('stories:api:featured', 300, fn () =>
Story::published()->featured()
->with('author', 'tags')
->with('creator.profile', 'tags')
->orderByDesc('published_at')
->first()
);
@@ -99,8 +99,8 @@ final class StoriesApiController extends Controller
$stories = Cache::remember("stories:api:tag:{$tag}:{$page}", 300, fn () =>
Story::published()
->with('author', 'tags')
->whereHas('tags', fn ($q) => $q->where('stories_tags.id', $storyTag->id))
->with('creator.profile', 'tags')
->whereHas('tags', fn ($q) => $q->where('story_tags.id', $storyTag->id))
->orderByDesc('published_at')
->paginate(12, ['*'], 'page', $page)
);
@@ -123,21 +123,20 @@ final class StoriesApiController extends Controller
*/
public function byAuthor(Request $request, string $username): JsonResponse
{
$author = StoryAuthor::whereHas('user', fn ($q) => $q->where('username', $username))->first()
?? StoryAuthor::where('name', $username)->firstOrFail();
$author = User::query()->whereRaw('LOWER(username) = ?', [strtolower($username)])->firstOrFail();
$page = (int) $request->get('page', 1);
$stories = Cache::remember("stories:api:author:{$author->id}:{$page}", 300, fn () =>
Story::published()
->with('author', 'tags')
->where('author_id', $author->id)
->with('creator.profile', 'tags')
->where('creator_id', $author->id)
->orderByDesc('published_at')
->paginate(12, ['*'], 'page', $page)
);
return response()->json([
'author' => $this->formatAuthor($author),
'author' => $this->formatCreator($author),
'data' => $stories->getCollection()->map(fn (Story $s) => $this->formatCard($s)),
'meta' => [
'current_page' => $stories->currentPage(),
@@ -159,7 +158,7 @@ final class StoriesApiController extends Controller
'title' => $story->title,
'excerpt' => $story->excerpt,
'cover_image' => $story->cover_url,
'author' => $story->author ? $this->formatAuthor($story->author) : null,
'author' => $story->creator ? $this->formatCreator($story->creator) : null,
'tags' => $story->tags->map(fn ($t) => ['id' => $t->id, 'slug' => $t->slug, 'name' => $t->name, 'url' => $t->url]),
'views' => $story->views,
'featured' => $story->featured,
@@ -175,14 +174,18 @@ final class StoriesApiController extends Controller
]);
}
private function formatAuthor(StoryAuthor $author): array
private function formatCreator(User $creator): array
{
$avatarHash = $creator->profile?->avatar_hash;
return [
'id' => $author->id,
'name' => $author->name,
'avatar_url' => $author->avatar_url,
'bio' => $author->bio,
'profile_url' => $author->profile_url,
'id' => $creator->id,
'name' => $creator->username ?? $creator->name,
'avatar_url' => $avatarHash
? \App\Support\AvatarUrl::forUser((int) $creator->id, $avatarHash, 96)
: \App\Support\AvatarUrl::default(),
'bio' => $creator->profile?->about,
'profile_url' => '/@' . strtolower((string) ($creator->username ?? $creator->id)),
];
}
}

View File

@@ -0,0 +1,133 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Models\Artwork;
use App\Models\ArtworkDownload;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Schema;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
final class ArtworkDownloadController extends Controller
{
/**
* Allowed original file extensions for secure server-side download.
*
* @var array<int, string>
*/
private const ALLOWED_EXTENSIONS = [
'jpg',
'jpeg',
'png',
'gif',
'webp',
'bmp',
'tiff',
];
public function __invoke(Request $request, int $id): BinaryFileResponse
{
$artwork = Artwork::query()->find($id);
if (! $artwork) {
abort(404);
}
$hash = strtolower((string) $artwork->hash);
$ext = strtolower(ltrim((string) $artwork->file_ext, '.'));
if (! $this->isValidHash($hash) || ! in_array($ext, self::ALLOWED_EXTENSIONS, true)) {
abort(404);
}
$filePath = $this->resolveOriginalPath($hash, $ext);
if (! File::isFile($filePath)) {
Log::warning('Artwork original file missing for download.', [
'artwork_id' => $artwork->id,
'hash' => $hash,
'ext' => $ext,
'resolved_path' => $filePath,
]);
abort(404);
}
$this->recordDownload($request, $artwork->id);
$this->incrementDownloadCountIfAvailable($artwork->id);
$downloadName = $this->buildDownloadFilename((string) $artwork->file_name, $ext);
return response()->download($filePath, $downloadName);
}
private function resolveOriginalPath(string $hash, string $ext): string
{
$firstDir = substr($hash, 0, 2);
$secondDir = substr($hash, 2, 2);
$root = rtrim((string) config('uploads.storage_root'), DIRECTORY_SEPARATOR);
return $root
. DIRECTORY_SEPARATOR . 'original'
. DIRECTORY_SEPARATOR . $firstDir
. DIRECTORY_SEPARATOR . $secondDir
. DIRECTORY_SEPARATOR . $hash . '.' . $ext;
}
private function recordDownload(Request $request, int $artworkId): void
{
try {
$ipAddress = $request->ip();
$ipBinary = $ipAddress ? @inet_pton($ipAddress) : false;
ArtworkDownload::query()->create([
'artwork_id' => $artworkId,
'user_id' => $request->user()?->id,
'ip' => $ipBinary !== false ? $ipBinary : null,
'ip_address' => $ipAddress,
'user_agent' => mb_substr((string) $request->userAgent(), 0, 1024),
'referer' => mb_substr((string) $request->headers->get('referer'), 0, 65535),
]);
} catch (\Throwable $exception) {
Log::warning('Failed to record artwork download analytics.', [
'artwork_id' => $artworkId,
'error' => $exception->getMessage(),
]);
}
}
private function incrementDownloadCountIfAvailable(int $artworkId): void
{
if (! Schema::hasColumn('artworks', 'download_count')) {
return;
}
Artwork::query()->whereKey($artworkId)->increment('download_count');
}
private function isValidHash(string $hash): bool
{
return $hash !== '' && preg_match('/^[a-f0-9]+$/', $hash) === 1;
}
private function buildDownloadFilename(string $fileName, string $ext): string
{
$name = trim($fileName);
$name = str_replace(['/', '\\'], '-', $name);
$name = preg_replace('/[\x00-\x1F\x7F]/', '', $name) ?? '';
$name = preg_replace('/\s+/', ' ', $name) ?? '';
$name = trim((string) $name, ". \t\n\r\0\x0B");
if ($name === '') {
$name = 'artwork';
}
if (strtolower((string) pathinfo($name, PATHINFO_EXTENSION)) !== $ext) {
$name .= '.' . $ext;
}
return $name;
}
}

View File

@@ -28,7 +28,7 @@ class AuthenticatedSessionController extends Controller
$request->session()->regenerate();
return redirect()->intended(route('dashboard', absolute: false));
return redirect()->intended('/');
}
/**

View File

@@ -169,6 +169,7 @@ class OAuthController extends Controller
'is_active' => true,
'onboarding_step' => 'username',
'username_changed_at' => now(),
'last_username_change_at' => now(),
]);
$this->createSocialAccount(

View File

@@ -105,6 +105,7 @@ class RegisteredUserController extends Controller
'is_active' => false,
'onboarding_step' => 'email',
'username_changed_at' => now(),
'last_username_change_at' => now(),
]);
}

View File

@@ -35,7 +35,7 @@ class SetupUsernameController extends Controller
], [
'username.required' => 'Please choose a username to continue.',
'username.unique' => 'This username is already taken.',
'username.regex' => 'Use only letters, numbers, underscores, or hyphens.',
'username.regex' => 'Use only letters, numbers, and underscores.',
'username.min' => 'Username must be at least 3 characters.',
'username.max' => 'Username must be at most 20 characters.',
]);
@@ -86,6 +86,7 @@ class SetupUsernameController extends Controller
'username' => strtolower($candidate),
'onboarding_step' => 'complete',
'username_changed_at' => now(),
'last_username_change_at' => now(),
])->save();
});

View File

@@ -0,0 +1,284 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Models\Artwork;
use App\Models\Story;
use App\Models\User;
use App\Support\AvatarUrl;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
final class DashboardController extends Controller
{
public function index(Request $request)
{
$user = $request->user();
return view('dashboard', [
'page_title' => 'Dashboard',
'dashboard_user_name' => $user?->username ?: $user?->name ?: 'Creator',
'dashboard_is_creator' => Artwork::query()->where('user_id', $user->id)->exists(),
]);
}
public function activity(Request $request): JsonResponse
{
$user = $request->user();
$notificationItems = $user->notifications()
->latest()
->limit(12)
->get()
->map(function ($notification): array {
return [
'id' => (string) $notification->id,
'type' => 'notification',
'message' => $this->notificationMessage((array) $notification->data),
'reference_id' => (string) ($notification->id ?? ''),
'created_at' => $notification->created_at?->toIso8601String(),
'is_unread' => $notification->read_at === null,
'actor' => null,
];
});
$followItems = DB::table('user_followers as uf')
->join('users as follower', 'follower.id', '=', 'uf.follower_id')
->leftJoin('user_profiles as fp', 'fp.user_id', '=', 'follower.id')
->where('uf.user_id', $user->id)
->select([
'uf.follower_id as actor_id',
'follower.username as actor_username',
'follower.name as actor_name',
'fp.avatar_hash as actor_avatar_hash',
'uf.created_at',
])
->orderByDesc('uf.created_at')
->limit(10)
->get()
->map(function ($row): array {
return [
'id' => 'follow-' . (string) $row->actor_id . '-' . Carbon::parse((string) $row->created_at)->timestamp,
'type' => 'new_follower',
'message' => 'started following you',
'reference_id' => (string) $row->actor_id,
'created_at' => Carbon::parse((string) $row->created_at)->toIso8601String(),
'is_unread' => false,
'actor' => [
'id' => (int) $row->actor_id,
'name' => $row->actor_name,
'username' => $row->actor_username,
'avatar' => AvatarUrl::forUser((int) $row->actor_id, $row->actor_avatar_hash, 64),
],
];
});
$commentItems = DB::table('artwork_comments as c')
->join('artworks as a', 'a.id', '=', 'c.artwork_id')
->join('users as commenter', 'commenter.id', '=', 'c.user_id')
->leftJoin('user_profiles as cp', 'cp.user_id', '=', 'commenter.id')
->where('a.user_id', $user->id)
->where('c.user_id', '!=', $user->id)
->where('c.is_approved', true)
->whereNull('c.deleted_at')
->select([
'c.id as comment_id',
'c.created_at',
'a.id as artwork_id',
'a.slug as artwork_slug',
'a.title as artwork_title',
'commenter.id as actor_id',
'commenter.username as actor_username',
'commenter.name as actor_name',
'cp.avatar_hash as actor_avatar_hash',
])
->orderByDesc('c.created_at')
->limit(10)
->get()
->map(function ($row): array {
return [
'id' => 'comment-' . (string) $row->comment_id,
'type' => 'comment',
'message' => 'commented on your artwork',
'reference_id' => (string) $row->artwork_id,
'created_at' => Carbon::parse((string) $row->created_at)->toIso8601String(),
'is_unread' => false,
'actor' => [
'id' => (int) $row->actor_id,
'name' => $row->actor_name,
'username' => $row->actor_username,
'avatar' => AvatarUrl::forUser((int) $row->actor_id, $row->actor_avatar_hash, 64),
],
'context' => [
'artwork_id' => (int) $row->artwork_id,
'artwork_title' => $row->artwork_title,
'artwork_url' => '/art/' . $row->artwork_id . '/' . $row->artwork_slug,
],
];
});
$items = collect()
->concat($notificationItems)
->concat($followItems)
->concat($commentItems)
->sortByDesc(fn (array $item) => (string) ($item['created_at'] ?? ''))
->take(20)
->values();
return response()->json([
'data' => $items,
]);
}
public function analytics(Request $request): JsonResponse
{
$user = $request->user();
$artworksCount = Artwork::query()->where('user_id', $user->id)->count();
$storyAggregate = Story::query()
->where('creator_id', $user->id)
->selectRaw('COUNT(*) as total_stories, COALESCE(SUM(views),0) as total_story_views, COALESCE(SUM(likes_count),0) as total_story_likes')
->first();
$stats = $user->statistics;
$followersCount = (int) ($stats?->followers_count ?? $user->followers()->count());
$artworkLikes = (int) ($stats?->favorites_received_count ?? 0);
$storyLikes = (int) ($storyAggregate?->total_story_likes ?? 0);
return response()->json([
'data' => [
'is_creator' => $artworksCount > 0,
'total_artworks' => $artworksCount,
'total_stories' => (int) ($storyAggregate?->total_stories ?? 0),
'total_story_views' => (int) ($storyAggregate?->total_story_views ?? 0),
'total_followers' => $followersCount,
'total_likes' => $artworkLikes + $storyLikes,
],
]);
}
public function trendingArtworks(): JsonResponse
{
$cacheKey = 'dashboard:trending-artworks:v1';
$data = Cache::remember($cacheKey, 300, function (): array {
return Artwork::query()
->select('artworks.*')
->leftJoin('artwork_stats as s', 's.artwork_id', '=', 'artworks.id')
->public()
->with(['user.profile', 'stats'])
->orderByRaw('COALESCE(s.ranking_score, 0) DESC')
->orderByRaw('COALESCE(s.heat_score, 0) DESC')
->orderByRaw('COALESCE(s.favorites, 0) DESC')
->orderByRaw('COALESCE(s.views, 0) DESC')
->orderByRaw('COALESCE(s.comments_count, 0) DESC')
->limit(8)
->get()
->map(function (Artwork $artwork): array {
return [
'id' => $artwork->id,
'title' => $artwork->title,
'url' => '/art/' . $artwork->id . '/' . $artwork->slug,
'thumbnail' => $artwork->thumbUrl('md') ?? $artwork->thumbnail_url,
'likes' => (int) ($artwork->stats?->favorites ?? 0),
'views' => (int) ($artwork->stats?->views ?? 0),
'comments' => (int) ($artwork->stats?->comments_count ?? 0),
'creator' => [
'id' => (int) $artwork->user_id,
'username' => $artwork->user?->username,
'name' => $artwork->user?->name,
'url' => $artwork->user?->username ? '/@' . $artwork->user->username : null,
],
];
})
->values()
->all();
});
return response()->json(['data' => $data]);
}
public function recommendedCreators(Request $request): JsonResponse
{
$user = $request->user();
$cacheKey = 'dashboard:recommended-creators:' . $user->id . ':v1';
$data = Cache::remember($cacheKey, 600, function () use ($user): array {
$followingIds = DB::table('user_followers')
->where('follower_id', $user->id)
->pluck('user_id')
->map(fn ($id) => (int) $id)
->all();
$excludeIds = array_values(array_unique(array_merge([$user->id], $followingIds)));
return User::query()
->from('users')
->leftJoin('user_profiles as up', 'up.user_id', '=', 'users.id')
->leftJoin('user_statistics as us', 'us.user_id', '=', 'users.id')
->where('users.is_active', true)
->whereNotIn('users.id', $excludeIds)
->whereExists(function ($q): void {
$q->select(DB::raw(1))
->from('artworks')
->whereColumn('artworks.user_id', 'users.id')
->where('artworks.is_public', true)
->where('artworks.is_approved', true)
->whereNull('artworks.deleted_at');
})
->select([
'users.id',
'users.username',
'users.name',
'up.avatar_hash',
DB::raw('COALESCE(us.followers_count, 0) as followers_count'),
DB::raw('COALESCE(us.uploads_count, 0) as uploads_count'),
])
->orderByDesc('followers_count')
->orderByDesc('uploads_count')
->limit(6)
->get()
->map(function ($row): array {
$username = (string) ($row->username ?? '');
return [
'id' => (int) $row->id,
'username' => $username,
'name' => $row->name,
'url' => $username !== '' ? '/@' . $username : null,
'avatar' => AvatarUrl::forUser((int) $row->id, $row->avatar_hash, 64),
'followers_count' => (int) $row->followers_count,
'uploads_count' => (int) $row->uploads_count,
];
})
->values()
->all();
});
return response()->json(['data' => $data]);
}
private function notificationMessage(array $payload): string
{
$title = trim((string) ($payload['title'] ?? ''));
if ($title !== '') {
return $title;
}
$message = trim((string) ($payload['message'] ?? ''));
if ($message !== '') {
return $message;
}
$type = trim((string) ($payload['type'] ?? 'Notification'));
return $type !== '' ? $type : 'New notification';
}
}

View File

@@ -0,0 +1,157 @@
<?php
namespace App\Http\Controllers\News;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use cPad\Plugins\News\Models\NewsArticle;
use cPad\Plugins\News\Models\NewsCategory;
use cPad\Plugins\News\Models\NewsTag;
use cPad\Plugins\News\Models\NewsView;
class NewsController extends Controller
{
// -----------------------------------------------------------------------
// Homepage — /news
// -----------------------------------------------------------------------
public function index(Request $request)
{
$perPage = config('news.articles_per_page', 12);
$featured = NewsArticle::with('author', 'category')
->published()
->featured()
->orderByDesc('published_at')
->first();
$query = NewsArticle::with('author', 'category')
->published()
->orderByDesc('published_at');
if ($featured) {
$query->where('id', '!=', $featured->id);
}
$articles = $query->paginate($perPage);
$categories = NewsCategory::active()->withCount('publishedArticles')->ordered()->get();
$trending = NewsArticle::published()
->orderByDesc('views')
->limit(config('news.trending_limit', 5))
->get(['id', 'title', 'slug', 'views', 'published_at']);
$tags = NewsTag::has('articles')->orderBy('name')->get();
return view('news.index', [
'featured' => $featured,
'articles' => $articles,
'categories' => $categories,
'trending' => $trending,
'tags' => $tags,
]);
}
// -----------------------------------------------------------------------
// Category page — /news/category/{slug}
// -----------------------------------------------------------------------
public function category(Request $request, string $slug)
{
$category = NewsCategory::where('slug', $slug)->where('is_active', true)->firstOrFail();
$perPage = config('news.articles_per_page', 12);
$articles = NewsArticle::with('author', 'category')
->published()
->byCategory($category->id)
->orderByDesc('published_at')
->paginate($perPage);
$categories = NewsCategory::active()->withCount('publishedArticles')->ordered()->get();
return view('news.category', [
'category' => $category,
'articles' => $articles,
'categories' => $categories,
]);
}
// -----------------------------------------------------------------------
// Tag page — /news/tag/{slug}
// -----------------------------------------------------------------------
public function tag(Request $request, string $slug)
{
$tag = NewsTag::where('slug', $slug)->firstOrFail();
$perPage = config('news.articles_per_page', 12);
$articles = NewsArticle::with('author', 'category')
->published()
->whereHas('tags', fn ($q) => $q->where('news_tags.slug', $slug))
->orderByDesc('published_at')
->paginate($perPage);
$categories = NewsCategory::active()->withCount('publishedArticles')->ordered()->get();
return view('news.tag', [
'tag' => $tag,
'articles' => $articles,
'categories' => $categories,
]);
}
// -----------------------------------------------------------------------
// Article page — /news/{slug}
// -----------------------------------------------------------------------
public function show(Request $request, string $slug)
{
$article = NewsArticle::with('author', 'category', 'tags')
->published()
->where('slug', $slug)
->firstOrFail();
// Track view (once per session / IP)
$this->trackView($request, $article);
// Related articles (same category, excluding current)
$related = NewsArticle::with('author')
->published()
->when($article->category_id, fn ($q) => $q->where('category_id', $article->category_id))
->where('id', '!=', $article->id)
->orderByDesc('published_at')
->limit(config('news.related_limit', 4))
->get();
return view('news.show', [
'article' => $article,
'related' => $related,
]);
}
// -----------------------------------------------------------------------
// Helpers
// -----------------------------------------------------------------------
private function trackView(Request $request, NewsArticle $article): void
{
$ip = $request->ip();
$userId = Auth::id();
$session = 'news_view_' . $article->id;
if ($request->session()->has($session)) {
return;
}
NewsView::create([
'article_id' => $article->id,
'user_id' => $userId,
'ip' => $ip,
'created_at' => now(),
]);
$article->incrementViews();
$request->session()->put($session, true);
}
}

View File

@@ -0,0 +1,74 @@
<?php
namespace App\Http\Controllers\News;
use App\Http\Controllers\Controller;
use Illuminate\Http\Response;
use cPad\Plugins\News\Models\NewsArticle;
class NewsRssController extends Controller
{
/**
* Generate RSS 2.0 feed for published news articles.
* Endpoint: GET /rss/news
*/
public function feed(): Response
{
$articles = NewsArticle::with('author', 'category')
->published()
->orderByDesc('published_at')
->limit(config('news.rss_limit', 25))
->get();
$xml = $this->buildRss($articles);
return response($xml, 200, [
'Content-Type' => 'application/rss+xml; charset=UTF-8',
]);
}
private function buildRss($articles): string
{
$siteUrl = config('app.url');
$title = e(config('news.rss_title', 'News'));
$description = e(config('news.rss_description', 'Latest news.'));
$now = now()->toRfc2822String();
$items = '';
foreach ($articles as $article) {
$link = e(url('/news/' . $article->slug));
$pubDate = $article->published_at?->toRfc2822String() ?? $now;
$articleTitle = e($article->title);
$excerpt = e(strip_tags((string) ($article->excerpt ?? '')));
$category = e((string) ($article->category?->name ?? ''));
$author = e((string) ($article->author?->name ?? ''));
$items .= <<<ITEM
<item>
<title><![CDATA[{$articleTitle}]]></title>
<link>{$link}</link>
<guid isPermaLink="true">{$link}</guid>
<description><![CDATA[{$excerpt}]]></description>
<pubDate>{$pubDate}</pubDate>
<author>{$author}</author>
<category>{$category}</category>
</item>
ITEM;
}
return <<<XML
<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
<channel>
<title>{$title}</title>
<link>{$siteUrl}/news</link>
<description>{$description}</description>
<language>en-us</language>
<lastBuildDate>{$now}</lastBuildDate>
<atom:link href="{$siteUrl}/rss/news" rel="self" type="application/rss+xml"/>
{$items} </channel>
</rss>
XML;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -341,8 +341,23 @@ final class StudioArtworksApiController extends Controller
// 4. Update the artwork's file-serving fields (hash drives thumbnail URLs)
$origExt = strtolower(pathinfo($originalPath, PATHINFO_EXTENSION) ?: '');
$displayFileName = $origFilename;
$clientName = basename(str_replace('\\', '/', (string) $file->getClientOriginalName()));
$clientName = preg_replace('/[\x00-\x1F\x7F]/', '', (string) $clientName) ?? '';
$clientName = trim((string) $clientName);
if ($clientName !== '') {
$clientExt = strtolower((string) pathinfo($clientName, PATHINFO_EXTENSION));
if ($clientExt === '' && $origExt !== '') {
$clientName .= '.' . $origExt;
}
$displayFileName = $clientName;
}
$artwork->update([
'file_name' => $origFilename,
'file_name' => $displayFileName,
'file_path' => '',
'file_size' => $size,
'mime_type' => $origMime,

View File

@@ -31,11 +31,15 @@ class AvatarController extends Controller
$file = $request->file('avatar');
try {
$hash = $this->service->storeFromUploadedFile($user->id, $file);
$hash = $this->service->storeFromUploadedFile(
(int) $user->id,
$file,
(string) $request->input('avatar_position', 'center')
);
return response()->json([
'success' => true,
'hash' => $hash,
'url' => AvatarUrl::forUser((int) $user->id, $hash, 128),
'url' => AvatarUrl::forUser((int) $user->id, $hash, 256),
], 200);
} catch (RuntimeException $e) {
logger()->warning('Avatar upload validation failed', [

View File

@@ -4,9 +4,20 @@ namespace App\Http\Controllers\User;
use App\Http\Controllers\Controller;
use App\Http\Requests\ProfileUpdateRequest;
use App\Http\Requests\Settings\RequestEmailChangeRequest;
use App\Http\Requests\Settings\UpdateAccountSectionRequest;
use App\Http\Requests\Settings\UpdateNotificationsSectionRequest;
use App\Http\Requests\Settings\UpdatePersonalSectionRequest;
use App\Http\Requests\Settings\UpdateProfileSectionRequest;
use App\Http\Requests\Settings\UpdateSecurityPasswordRequest;
use App\Http\Requests\Settings\VerifyEmailChangeRequest;
use App\Mail\EmailChangedSecurityAlertMail;
use App\Mail\EmailChangeVerificationCodeMail;
use App\Models\Artwork;
use App\Models\ProfileComment;
use App\Models\Story;
use App\Models\User;
use App\Services\AvatarService;
use App\Services\ArtworkService;
use App\Services\FollowService;
use App\Services\ThumbnailPresenter;
@@ -14,6 +25,7 @@ use App\Services\ThumbnailService;
use App\Services\UsernameApprovalService;
use App\Services\UserStatsService;
use App\Support\AvatarUrl;
use App\Support\CoverUrl;
use App\Support\UsernamePolicy;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
@@ -24,6 +36,7 @@ use Illuminate\Support\Facades\Redirect;
use Illuminate\Support\Facades\Schema;
use Illuminate\View\View;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Mail;
use Illuminate\Validation\Rules\Password as PasswordRule;
use Inertia\Inertia;
@@ -127,6 +140,16 @@ class ProfileController extends Controller
public function editSettings(Request $request)
{
$user = $request->user();
$cooldownDays = $this->usernameCooldownDays();
$lastUsernameChangeAt = $this->lastUsernameChangeAt($user);
$usernameCooldownRemainingDays = 0;
if ($lastUsernameChangeAt !== null) {
$nextAllowedChangeAt = $lastUsernameChangeAt->copy()->addDays($cooldownDays);
if ($nextAllowedChangeAt->isFuture()) {
$usernameCooldownRemainingDays = now()->diffInDays($nextAllowedChangeAt);
}
}
// Parse birth date parts
$birthDay = null;
@@ -176,9 +199,15 @@ class ProfileController extends Controller
// Avatar URL
$avatarHash = $profileData['avatar_hash'] ?? $user->icon ?? null;
$avatarUrl = !empty($avatarHash)
? AvatarUrl::forUser((int) $user->id, $avatarHash, 128)
? AvatarUrl::forUser((int) $user->id, $avatarHash, 256)
: AvatarUrl::default();
$emailNotifications = (bool) ($profileData['email_notifications'] ?? $profileData['mlist'] ?? $user->mlist ?? true);
$uploadNotifications = (bool) ($profileData['upload_notifications'] ?? $profileData['friend_upload_notice'] ?? $user->friend_upload_notice ?? true);
$followerNotifications = (bool) ($profileData['follower_notifications'] ?? true);
$commentNotifications = (bool) ($profileData['comment_notifications'] ?? true);
$newsletter = (bool) ($profileData['newsletter'] ?? $profileData['mlist'] ?? $user->mlist ?? false);
return Inertia::render('Settings/ProfileEdit', [
'user' => [
'id' => $user->id,
@@ -190,16 +219,23 @@ class ProfileController extends Controller
'signature' => $user->signature ?? null,
'description' => $user->description ?? null,
'gender' => $user->gender ?? null,
'birthday' => $user->birth ?? 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,
'email_notifications' => $emailNotifications,
'upload_notifications' => $uploadNotifications,
'follower_notifications' => $followerNotifications,
'comment_notifications' => $commentNotifications,
'newsletter' => $newsletter,
'last_username_change_at' => $user->last_username_change_at,
'username_changed_at' => $user->username_changed_at,
],
'avatarUrl' => $avatarUrl,
'birthDay' => $birthDay,
'birthMonth' => $birthMonth,
'birthYear' => $birthYear,
'usernameCooldownDays' => $cooldownDays,
'usernameCooldownRemainingDays' => $usernameCooldownRemainingDays,
'usernameCooldownActive' => $usernameCooldownRemainingDays > 0,
'countries' => $countries->values(),
'flash' => [
'status' => session('status'),
@@ -208,6 +244,331 @@ class ProfileController extends Controller
])->rootView('settings');
}
public function updateProfileSection(UpdateProfileSectionRequest $request, AvatarService $avatarService): RedirectResponse|JsonResponse
{
$user = $request->user();
$validated = $request->validated();
$user->name = (string) $validated['display_name'];
$user->save();
$profileUpdates = [
'website' => $validated['website'] ?? null,
'about' => $validated['bio'] ?? null,
'signature' => $validated['signature'] ?? null,
'description' => $validated['description'] ?? null,
];
$avatarUrl = AvatarUrl::forUser((int) $user->id, null, 256);
if (!empty($validated['remove_avatar'])) {
$avatarService->removeAvatar((int) $user->id);
$avatarUrl = AvatarUrl::default();
}
if ($request->hasFile('avatar')) {
$hash = $avatarService->storeFromUploadedFile(
(int) $user->id,
$request->file('avatar'),
(string) ($validated['avatar_position'] ?? 'center')
);
$avatarUrl = AvatarUrl::forUser((int) $user->id, $hash, 256);
}
$this->persistProfileUpdates((int) $user->id, $profileUpdates);
return $this->settingsResponse(
$request,
'Profile updated successfully.',
['avatarUrl' => $avatarUrl]
);
}
public function updateAccountSection(UpdateAccountSectionRequest $request): RedirectResponse|JsonResponse
{
return $this->updateUsername($request);
}
public function updateUsername(UpdateAccountSectionRequest $request): RedirectResponse|JsonResponse
{
$user = $request->user();
$validated = $request->validated();
$incomingUsername = UsernamePolicy::normalize((string) $validated['username']);
$currentUsername = UsernamePolicy::normalize((string) ($user->username ?? ''));
if ($incomingUsername !== '' && $incomingUsername !== $currentUsername) {
$similar = UsernamePolicy::similarReserved($incomingUsername);
if ($similar !== null && ! UsernamePolicy::hasApprovedOverride($incomingUsername, (int) $user->id)) {
$this->usernameApprovalService->submit($user, $incomingUsername, 'profile_update', [
'current_username' => $currentUsername,
]);
return $this->usernameValidationError($request, 'This username is too similar to a reserved name and requires manual approval.');
}
$cooldownDays = $this->usernameCooldownDays();
$isAdmin = method_exists($user, 'isAdmin') ? $user->isAdmin() : false;
$lastUsernameChangeAt = $this->lastUsernameChangeAt($user);
if (! $isAdmin && $lastUsernameChangeAt !== null && $lastUsernameChangeAt->gt(now()->subDays($cooldownDays))) {
$remainingDays = now()->diffInDays($lastUsernameChangeAt->copy()->addDays($cooldownDays));
return $this->usernameValidationError($request, "You can change your username again in {$remainingDays} days.");
}
$user->username = $incomingUsername;
$user->username_changed_at = now();
if (Schema::hasColumn('users', 'last_username_change_at')) {
$user->last_username_change_at = now();
}
$this->storeUsernameHistory((int) $user->id, $currentUsername);
$this->storeUsernameRedirect((int) $user->id, $currentUsername, $incomingUsername);
}
$user->save();
return $this->settingsResponse($request, 'Account updated successfully.');
}
public function requestEmailChange(RequestEmailChangeRequest $request): RedirectResponse|JsonResponse
{
if (! Schema::hasTable('email_changes')) {
return response()->json([
'errors' => [
'new_email' => ['Email change is not available right now.'],
],
], 422);
}
$user = $request->user();
$validated = $request->validated();
$newEmail = strtolower((string) $validated['new_email']);
$code = str_pad((string) random_int(0, 999999), 6, '0', STR_PAD_LEFT);
$expiresInMinutes = 10;
DB::table('email_changes')->where('user_id', (int) $user->id)->delete();
DB::table('email_changes')->insert([
'user_id' => (int) $user->id,
'new_email' => $newEmail,
'verification_code' => hash('sha256', $code),
'expires_at' => now()->addMinutes($expiresInMinutes),
'created_at' => now(),
'updated_at' => now(),
]);
Mail::to($newEmail)->queue(new EmailChangeVerificationCodeMail($code, $expiresInMinutes));
return $this->settingsResponse($request, 'Verification code sent to your new email address.');
}
public function verifyEmailChange(VerifyEmailChangeRequest $request): RedirectResponse|JsonResponse
{
if (! Schema::hasTable('email_changes')) {
return response()->json([
'errors' => [
'code' => ['Email change verification is not available right now.'],
],
], 422);
}
$user = $request->user();
$validated = $request->validated();
$codeHash = hash('sha256', (string) $validated['code']);
$change = DB::table('email_changes')
->where('user_id', (int) $user->id)
->whereNull('used_at')
->orderByDesc('id')
->first();
if (! $change) {
return response()->json(['errors' => ['code' => ['No pending email change request found.']]], 422);
}
if (now()->greaterThan($change->expires_at)) {
DB::table('email_changes')->where('id', $change->id)->delete();
return response()->json(['errors' => ['code' => ['Verification code has expired. Please request a new one.']]], 422);
}
if (! hash_equals((string) $change->verification_code, $codeHash)) {
return response()->json(['errors' => ['code' => ['Verification code is invalid.']]], 422);
}
$newEmail = strtolower((string) $change->new_email);
$oldEmail = strtolower((string) ($user->email ?? ''));
DB::transaction(function () use ($user, $change, $newEmail): void {
$lockedUser = User::query()->whereKey((int) $user->id)->lockForUpdate()->firstOrFail();
$lockedUser->email = $newEmail;
$lockedUser->email_verified_at = now();
$lockedUser->save();
DB::table('email_changes')
->where('id', (int) $change->id)
->update([
'used_at' => now(),
'updated_at' => now(),
]);
DB::table('email_changes')
->where('user_id', (int) $user->id)
->where('id', '!=', (int) $change->id)
->delete();
});
if ($oldEmail !== '' && $oldEmail !== $newEmail) {
Mail::to($oldEmail)->queue(new EmailChangedSecurityAlertMail($newEmail));
}
return $this->settingsResponse($request, 'Email updated successfully.', [
'email' => $newEmail,
]);
}
public function updatePersonalSection(UpdatePersonalSectionRequest $request): RedirectResponse|JsonResponse
{
$validated = $request->validated();
$profileUpdates = [
'birthdate' => $validated['birthday'] ?? null,
'country_code' => $validated['country'] ?? null,
];
if (!empty($validated['gender'])) {
$profileUpdates['gender'] = strtoupper((string) $validated['gender']);
}
$this->persistProfileUpdates((int) $request->user()->id, $profileUpdates);
return $this->settingsResponse($request, 'Personal details saved successfully.');
}
public function updateNotificationsSection(UpdateNotificationsSectionRequest $request): RedirectResponse|JsonResponse
{
$validated = $request->validated();
$userId = (int) $request->user()->id;
$profileUpdates = [
'email_notifications' => (bool) $validated['email_notifications'],
'upload_notifications' => (bool) $validated['upload_notifications'],
'follower_notifications' => (bool) $validated['follower_notifications'],
'comment_notifications' => (bool) $validated['comment_notifications'],
'newsletter' => (bool) $validated['newsletter'],
// Legacy compatibility mappings.
'mlist' => (bool) $validated['newsletter'],
'friend_upload_notice' => (bool) $validated['upload_notifications'],
];
$this->persistProfileUpdates($userId, $profileUpdates);
return $this->settingsResponse($request, 'Notification settings saved successfully.');
}
public function updateSecurityPassword(UpdateSecurityPasswordRequest $request): RedirectResponse|JsonResponse
{
$validated = $request->validated();
$user = $request->user();
$user->password = Hash::make((string) $validated['new_password']);
$user->save();
return $this->settingsResponse($request, 'Password updated successfully.');
}
private function settingsResponse(Request $request, string $message, array $payload = []): RedirectResponse|JsonResponse
{
if ($request->expectsJson()) {
return response()->json([
'success' => true,
'message' => $message,
...$payload,
]);
}
return Redirect::back()->with('status', $message);
}
private function persistProfileUpdates(int $userId, array $updates): void
{
if ($updates === [] || !Schema::hasTable('user_profiles')) {
return;
}
$filtered = [];
foreach ($updates as $column => $value) {
if (Schema::hasColumn('user_profiles', $column)) {
$filtered[$column] = $value;
}
}
if ($filtered === []) {
return;
}
DB::table('user_profiles')->updateOrInsert(['user_id' => $userId], $filtered);
}
private function usernameCooldownDays(): int
{
return max(1, (int) config('usernames.rename_cooldown_days', 30));
}
private function lastUsernameChangeAt(User $user): ?\Illuminate\Support\Carbon
{
return $user->last_username_change_at ?? $user->username_changed_at;
}
private function usernameValidationError(Request $request, string $message): RedirectResponse|JsonResponse
{
$error = ['username' => [$message]];
if ($request->expectsJson()) {
return response()->json(['errors' => $error], 422);
}
return Redirect::back()->withErrors($error);
}
private function storeUsernameHistory(int $userId, string $oldUsername): void
{
if ($oldUsername === '' || ! Schema::hasTable('username_history')) {
return;
}
$payload = [
'user_id' => $userId,
'old_username' => $oldUsername,
'created_at' => now(),
];
if (Schema::hasColumn('username_history', 'changed_at')) {
$payload['changed_at'] = now();
}
if (Schema::hasColumn('username_history', 'updated_at')) {
$payload['updated_at'] = now();
}
DB::table('username_history')->insert($payload);
}
private function storeUsernameRedirect(int $userId, string $oldUsername, string $newUsername): void
{
if ($oldUsername === '' || ! Schema::hasTable('username_redirects')) {
return;
}
DB::table('username_redirects')->updateOrInsert(
['old_username' => $oldUsername],
[
'new_username' => $newUsername,
'user_id' => $userId,
'updated_at' => now(),
'created_at' => now(),
]
);
}
public function update(ProfileUpdateRequest $request, \App\Services\AvatarService $avatarService): RedirectResponse|JsonResponse
{
$user = $request->user();
@@ -238,10 +599,11 @@ class ProfileController extends Controller
return Redirect::back()->withErrors($error);
}
$cooldownDays = (int) config('usernames.rename_cooldown_days', 90);
$cooldownDays = $this->usernameCooldownDays();
$isAdmin = method_exists($user, 'isAdmin') ? $user->isAdmin() : false;
$lastUsernameChangeAt = $this->lastUsernameChangeAt($user);
if (! $isAdmin && $user->username_changed_at !== null && $user->username_changed_at->gt(now()->subDays($cooldownDays))) {
if (! $isAdmin && $lastUsernameChangeAt !== null && $lastUsernameChangeAt->gt(now()->subDays($cooldownDays))) {
$error = ['username' => ["Username can only be changed once every {$cooldownDays} days."]];
if ($request->expectsJson()) {
return response()->json(['errors' => $error], 422);
@@ -251,26 +613,12 @@ class ProfileController extends Controller
$user->username = $incomingUsername;
$user->username_changed_at = now();
DB::table('username_history')->insert([
'user_id' => (int) $user->id,
'old_username' => $currentUsername,
'changed_at' => now(),
'created_at' => now(),
'updated_at' => now(),
]);
if ($currentUsername !== '') {
DB::table('username_redirects')->updateOrInsert(
['old_username' => $currentUsername],
[
'new_username' => $incomingUsername,
'user_id' => (int) $user->id,
'updated_at' => now(),
'created_at' => now(),
]
);
if (Schema::hasColumn('users', 'last_username_change_at')) {
$user->last_username_change_at = now();
}
$this->storeUsernameHistory((int) $user->id, $currentUsername);
$this->storeUsernameRedirect((int) $user->id, $currentUsername, $incomingUsername);
}
}
@@ -579,6 +927,37 @@ class ProfileController extends Controller
]);
}
$creatorStories = Story::query()
->published()
->with(['tags'])
->where('creator_id', $user->id)
->latest('published_at')
->limit(6)
->get([
'id',
'slug',
'title',
'excerpt',
'cover_image',
'reading_time',
'views',
'likes_count',
'comments_count',
'published_at',
])
->map(fn (Story $story) => [
'id' => $story->id,
'slug' => $story->slug,
'title' => $story->title,
'excerpt' => $story->excerpt,
'cover_url' => $story->cover_url,
'reading_time' => $story->reading_time,
'views' => (int) $story->views,
'likes_count' => (int) $story->likes_count,
'comments_count' => (int) $story->comments_count,
'published_at' => $story->published_at?->toISOString(),
]);
// ── Profile data ─────────────────────────────────────────────────────
$profile = $user->profile;
@@ -593,15 +972,8 @@ class ProfileController extends Controller
$countryName = $countryName ?? strtoupper((string) $profile->country_code);
}
// ── Hero background artwork ─────────────────────────────────────────
$heroBgUrl = Artwork::public()
->published()
->where('user_id', $user->id)
->whereNotNull('hash')
->whereNotNull('thumb_ext')
->inRandomOrder()
->limit(1)
->first()?->thumbUrl('lg');
// ── Cover image hero (preferred) ────────────────────────────────────
$heroBgUrl = CoverUrl::forUser($user->cover_hash, $user->cover_ext, $user->updated_at?->timestamp ?? time());
// ── Increment profile views (async-safe, ignore errors) ──────────────
if (! $isOwner) {
@@ -645,6 +1017,8 @@ class ProfileController extends Controller
'username' => $user->username,
'name' => $user->name,
'avatar_url' => $avatarUrl,
'cover_url' => $heroBgUrl,
'cover_position'=> (int) ($user->cover_position ?? 50),
'created_at' => $user->created_at?->toISOString(),
'last_visit_at' => $user->last_visit_at ? (string) $user->last_visit_at : null,
],
@@ -666,6 +1040,7 @@ class ProfileController extends Controller
'viewerIsFollowing' => $viewerIsFollowing,
'heroBgUrl' => $heroBgUrl,
'profileComments' => $profileComments->values(),
'creatorStories' => $creatorStories->values(),
'countryName' => $countryName,
'isOwner' => $isOwner,
'auth' => $authData,

View File

@@ -0,0 +1,252 @@
<?php
namespace App\Http\Controllers\User;
use App\Http\Controllers\Controller;
use App\Support\CoverUrl;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\File;
use Intervention\Image\Drivers\Gd\Driver as GdDriver;
use Intervention\Image\Drivers\Imagick\Driver as ImagickDriver;
use Intervention\Image\Encoders\JpegEncoder;
use Intervention\Image\Encoders\PngEncoder;
use Intervention\Image\Encoders\WebpEncoder;
use Intervention\Image\ImageManager;
use RuntimeException;
class ProfileCoverController extends Controller
{
private const ALLOWED_MIME_TYPES = ['image/jpeg', 'image/png', 'image/webp'];
private const MAX_FILE_SIZE_KB = 5120;
private const TARGET_WIDTH = 1920;
private const TARGET_HEIGHT = 480;
private const MIN_UPLOAD_WIDTH = 640;
private const MIN_UPLOAD_HEIGHT = 160;
private ?ImageManager $manager = null;
public function __construct()
{
try {
$this->manager = extension_loaded('gd')
? new ImageManager(new GdDriver())
: new ImageManager(new ImagickDriver());
} catch (\Throwable) {
$this->manager = null;
}
}
public function upload(Request $request): JsonResponse
{
$user = $request->user();
if (! $user) {
return response()->json(['error' => 'Unauthorized'], 401);
}
$validated = $request->validate([
'cover' => [
'required',
'file',
'image',
'max:' . self::MAX_FILE_SIZE_KB,
'mimes:jpg,jpeg,png,webp',
'mimetypes:image/jpeg,image/png,image/webp',
],
]);
/** @var UploadedFile $file */
$file = $validated['cover'];
try {
$stored = $this->storeCoverFile($file);
$this->deleteCoverFile((string) $user->cover_hash, (string) $user->cover_ext);
$user->forceFill([
'cover_hash' => $stored['hash'],
'cover_ext' => $stored['ext'],
'cover_position' => 50,
])->save();
return response()->json([
'success' => true,
'cover_url' => CoverUrl::forUser($user->cover_hash, $user->cover_ext, time()),
'cover_position' => (int) $user->cover_position,
]);
} catch (RuntimeException $e) {
return response()->json([
'error' => 'Validation failed',
'message' => $e->getMessage(),
], 422);
} catch (\Throwable $e) {
logger()->error('Profile cover upload failed', [
'user_id' => (int) $user->id,
'message' => $e->getMessage(),
]);
return response()->json(['error' => 'Processing failed'], 500);
}
}
public function updatePosition(Request $request): JsonResponse
{
$user = $request->user();
if (! $user) {
return response()->json(['error' => 'Unauthorized'], 401);
}
$validated = $request->validate([
'position' => ['required', 'integer', 'min:0', 'max:100'],
]);
if (! $user->cover_hash || ! $user->cover_ext) {
return response()->json(['error' => 'No cover image to update.'], 422);
}
$user->forceFill([
'cover_position' => (int) $validated['position'],
])->save();
return response()->json([
'success' => true,
'cover_position' => (int) $user->cover_position,
]);
}
public function destroy(Request $request): JsonResponse
{
$user = $request->user();
if (! $user) {
return response()->json(['error' => 'Unauthorized'], 401);
}
$this->deleteCoverFile((string) $user->cover_hash, (string) $user->cover_ext);
$user->forceFill([
'cover_hash' => null,
'cover_ext' => null,
'cover_position' => 50,
])->save();
return response()->json([
'success' => true,
'cover_url' => null,
'cover_position' => 50,
]);
}
private function storageRoot(): string
{
return rtrim((string) config('uploads.storage_root'), DIRECTORY_SEPARATOR);
}
private function coverDirectory(string $hash): string
{
$p1 = substr($hash, 0, 2);
$p2 = substr($hash, 2, 2);
return $this->storageRoot()
. DIRECTORY_SEPARATOR . 'covers'
. DIRECTORY_SEPARATOR . $p1
. DIRECTORY_SEPARATOR . $p2;
}
private function coverPath(string $hash, string $ext): string
{
return $this->coverDirectory($hash) . DIRECTORY_SEPARATOR . $hash . '.' . $ext;
}
/**
* @return array{hash: string, ext: string}
*/
private function storeCoverFile(UploadedFile $file): array
{
$this->assertImageManager();
$uploadPath = (string) ($file->getRealPath() ?: $file->getPathname());
if ($uploadPath === '' || ! is_readable($uploadPath)) {
throw new RuntimeException('Unable to resolve uploaded image path.');
}
$raw = file_get_contents($uploadPath);
if ($raw === false || $raw === '') {
throw new RuntimeException('Unable to read uploaded image.');
}
$finfo = new \finfo(FILEINFO_MIME_TYPE);
$mime = strtolower((string) $finfo->buffer($raw));
if (! in_array($mime, self::ALLOWED_MIME_TYPES, true)) {
throw new RuntimeException('Unsupported image mime type.');
}
$size = @getimagesizefromstring($raw);
if (! is_array($size) || ($size[0] ?? 0) < 1 || ($size[1] ?? 0) < 1) {
throw new RuntimeException('Uploaded file is not a valid image.');
}
$width = (int) ($size[0] ?? 0);
$height = (int) ($size[1] ?? 0);
if ($width < self::MIN_UPLOAD_WIDTH || $height < self::MIN_UPLOAD_HEIGHT) {
throw new RuntimeException(sprintf(
'Image is too small. Minimum required size is %dx%d.',
self::MIN_UPLOAD_WIDTH,
self::MIN_UPLOAD_HEIGHT,
));
}
$ext = $mime === 'image/jpeg' ? 'jpg' : ($mime === 'image/png' ? 'png' : 'webp');
$image = $this->manager->read($raw);
$processed = $image->cover(self::TARGET_WIDTH, self::TARGET_HEIGHT, 'center');
$encoded = $this->encodeByExtension($processed, $ext);
$hash = hash('sha256', $encoded);
$dir = $this->coverDirectory($hash);
if (! File::exists($dir)) {
File::makeDirectory($dir, 0755, true);
}
File::put($this->coverPath($hash, $ext), $encoded);
return ['hash' => $hash, 'ext' => $ext];
}
private function encodeByExtension($image, string $ext): string
{
return match ($ext) {
'jpg' => (string) $image->encode(new JpegEncoder(85)),
'png' => (string) $image->encode(new PngEncoder()),
default => (string) $image->encode(new WebpEncoder(85)),
};
}
private function deleteCoverFile(string $hash, string $ext): void
{
$trimHash = trim($hash);
$trimExt = strtolower(trim($ext));
if ($trimHash === '' || $trimExt === '') {
return;
}
$path = $this->coverPath($trimHash, $trimExt);
if (is_file($path)) {
@unlink($path);
}
}
private function assertImageManager(): void
{
if ($this->manager !== null) {
return;
}
throw new RuntimeException('Image processing is not available on this environment.');
}
}

View File

@@ -32,23 +32,33 @@ final class ExploreController extends Controller
/** 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'],
'fresh' => ['trending_score_7d:desc', 'favorites_count:desc', 'created_at:desc'],
'top-rated' => ['awards_received_count:desc', 'favorites_count:desc'],
'latest' => ['created_at:desc'],
// Legacy aliases kept for backward compatibility.
'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,
'fresh' => 120,
'top-rated'=> 600,
'latest' => 120,
'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'],
['value' => 'trending', 'label' => '🔥 Trending'],
['value' => 'fresh', 'label' => '🚀 New & Hot'],
['value' => 'top-rated', 'label' => '⭐ Best'],
['value' => 'latest', 'label' => '🕐 Latest'],
];
private const SORT_ALIASES = [
'new-hot' => 'fresh',
'best' => 'top-rated',
];
public function __construct(
@@ -81,23 +91,25 @@ final class ExploreController extends Controller
? $this->spotlight->getSpotlight(6)->map(fn ($a) => $this->presentArtwork($a))
: collect();
$contentTypes = $this->contentTypeLinks();
$mainCategories = $this->mainCategories();
$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',
return view('gallery.index', [
'gallery_type' => 'browse',
'mainCategories' => $mainCategories,
'subcategories' => $mainCategories,
'contentType' => null,
'category' => null,
'artworks' => $artworks,
'spotlight' => $spotlightItems,
'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_meta_keywords' => 'explore, wallpapers, skins, photography, artworks, skinbase',
'page_canonical' => $seo['canonical'],
'page_rel_prev' => $seo['prev'],
'page_rel_next' => $seo['next'],
@@ -117,6 +129,11 @@ final class ExploreController extends Controller
// "artworks" is the umbrella — search all types
$isAll = $type === 'artworks';
// Canonical URLs for content types are /skins, /wallpapers, /photography, /other.
if (! $isAll) {
return redirect()->to($this->canonicalTypeUrl($request, $type), 301);
}
$sort = $this->resolveSort($request);
$perPage = $this->resolvePerPage($request);
$page = max(1, (int) $request->query('page', 1));
@@ -142,26 +159,44 @@ final class ExploreController extends Controller
? $this->spotlight->getSpotlight(6)->map(fn ($a) => $this->presentArtwork($a))
: collect();
$contentTypes = $this->contentTypeLinks();
$mainCategories = $this->mainCategories();
$contentType = null;
$subcategories = $mainCategories;
if (! $isAll) {
$contentType = ContentType::where('slug', $type)->first();
$subcategories = $contentType
? $contentType->rootCategories()->orderBy('sort_order')->orderBy('name')->get()
: collect();
}
if ($isAll) {
$humanType = 'Artworks';
} else {
$humanType = $contentType?->name ?? ucfirst($type);
}
$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([
return view('gallery.index', [
'gallery_type' => $isAll ? 'browse' : 'content-type',
'mainCategories' => $mainCategories,
'subcategories' => $subcategories,
'contentType' => $contentType,
'category' => null,
'artworks' => $artworks,
'spotlight' => $spotlightItems,
'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_title' => "{$humanType} - Explore - Skinbase",
'page_meta_description' => "Discover the best {$humanType} artworks on Skinbase. Browse trending, new and top-rated.",
'page_meta_keywords' => strtolower($type) . ', explore, skinbase, artworks, wallpapers, skins, photography',
'page_canonical' => $seo['canonical'],
'page_rel_prev' => $seo['prev'],
'page_rel_next' => $seo['next'],
@@ -173,6 +208,14 @@ final class ExploreController extends Controller
public function byTypeMode(Request $request, string $type, string $mode)
{
$type = strtolower($type);
if ($type !== 'artworks') {
$query = $request->query();
$query['sort'] = $this->normalizeSort((string) $mode);
return redirect()->to($this->canonicalTypeUrl($request, $type, $query), 301);
}
// Rewrite the sort via the URL segment and delegate
$request->query->set('sort', $mode);
return $this->byType($request, $type);
@@ -180,24 +223,49 @@ final class ExploreController extends Controller
// ── Helpers ──────────────────────────────────────────────────────────
private function contentTypeLinks(): Collection
private function mainCategories(): Collection
{
return collect([
(object) ['name' => 'All Artworks', 'slug' => 'artworks', 'url' => '/explore/artworks'],
...ContentType::orderBy('id')->get(['name', 'slug'])->map(fn ($ct) => (object) [
$categories = ContentType::orderBy('id')
->get(['name', 'slug'])
->map(fn ($ct) => (object) [
'name' => $ct->name,
'slug' => $ct->slug,
'url' => '/explore/' . strtolower($ct->slug),
]),
'url' => '/' . strtolower($ct->slug),
]);
return $categories->push((object) [
'name' => 'Members',
'slug' => 'members',
'url' => '/members',
]);
}
private function resolveSort(Request $request): string
{
$s = (string) $request->query('sort', 'trending');
$s = $this->normalizeSort((string) $request->query('sort', 'trending'));
return array_key_exists($s, self::SORT_MAP) ? $s : 'trending';
}
private function normalizeSort(string $sort): string
{
$sort = strtolower($sort);
return self::SORT_ALIASES[$sort] ?? $sort;
}
private function canonicalTypeUrl(Request $request, string $type, ?array $query = null): string
{
$query = $query ?? $request->query();
if (isset($query['sort'])) {
$query['sort'] = $this->normalizeSort((string) $query['sort']);
if ($query['sort'] === 'trending') {
unset($query['sort']);
}
}
return url('/' . $type) . ($query ? ('?' . http_build_query($query)) : '');
}
private function resolvePerPage(Request $request): int
{
$v = (int) ($request->query('per_page') ?: $request->query('limit') ?: 24);

View File

@@ -25,22 +25,16 @@ final class SearchController extends Controller
'downloads' => 'downloads:desc',
];
$artworks = null;
$popular = collect();
if ($q !== '') {
$artworks = $this->search->search($q, [
$artworks = $q !== ''
? $this->search->search($q, [
'sort' => ($sortMap[$sort] ?? 'created_at:desc'),
]);
} else {
$popular = $this->search->popular(16)->getCollection();
}
])
: $this->search->popular(24);
return view('search.index', [
'q' => $q,
'sort' => $sort,
'artworks' => $artworks ?? collect()->paginate(0),
'popular' => $popular,
'artworks' => $artworks,
'page_title' => $q !== '' ? 'Search: ' . $q . ' — Skinbase' : 'Search — Skinbase',
'page_meta_description' => 'Search Skinbase for artworks, photography, wallpapers and skins.',
'page_robots' => 'noindex,follow',

View File

@@ -0,0 +1,34 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Middleware\HandleCors as BaseHandleCors;
use Illuminate\Http\Request;
class ConditionalCors
{
/**
* Handle an incoming request.
*/
public function handle(Request $request, Closure $next)
{
$paths = config('cors.paths', null);
// If paths are empty the CORS config intentionally disables CORS.
if (is_array($paths) && count($paths) === 0) {
return $next($request);
}
// Fallback to env if config wasn't populated for some reason.
$enabled = env('CP_ENABLE_CORS', true);
if (! $enabled) {
return $next($request);
}
$handler = app(BaseHandleCors::class);
return $handler->handle($request, $next);
}
}

View File

@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class EnsureCreatorAccess
{
public function handle(Request $request, Closure $next): Response
{
$user = $request->user();
if ($user === null) {
abort(403, 'Authentication required.');
}
$role = strtolower((string) ($user->role ?? 'user'));
$isCreatorRole = in_array($role, ['creator', 'user', 'admin', 'moderator', 'mod'], true);
if (! $isCreatorRole || (property_exists($user, 'is_active') && $user->is_active === false)) {
abort(403, 'Creator access is required.');
}
return $next($request);
}
}

View File

@@ -22,6 +22,7 @@ class AvatarUploadRequest extends FormRequest
'mimes:jpg,jpeg,png,webp',
'mimetypes:image/jpeg,image/png,image/webp',
],
'avatar_position' => ['nullable', 'in:top-left,top,top-right,left,center,right,bottom-left,bottom,bottom-right'],
];
}
}

View File

@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Settings;
use App\Models\User;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class RequestEmailChangeRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
protected function prepareForValidation(): void
{
if ($this->has('new_email')) {
$this->merge([
'new_email' => strtolower(trim((string) $this->input('new_email'))),
]);
}
}
public function rules(): array
{
return [
'new_email' => [
'required',
'string',
'lowercase',
'email',
'max:255',
Rule::unique(User::class, 'email')->ignore((int) $this->user()->id),
function (string $attribute, mixed $value, \Closure $fail): void {
if (strtolower((string) $value) === strtolower((string) $this->user()->email)) {
$fail('Please enter a different email address.');
}
},
],
];
}
}

View File

@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Settings;
use App\Http\Requests\UsernameRequest;
use Illuminate\Foundation\Http\FormRequest;
class UpdateAccountSectionRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
protected function prepareForValidation(): void
{
if ($this->has('username')) {
$this->merge([
'username' => \App\Support\UsernamePolicy::normalize((string) $this->input('username')),
]);
}
}
public function rules(): array
{
return [
'username' => ['required', ...UsernameRequest::rulesFor((int) $this->user()->id)],
];
}
}

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Settings;
use Illuminate\Foundation\Http\FormRequest;
class UpdateNotificationsSectionRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'email_notifications' => ['required', 'boolean'],
'upload_notifications' => ['required', 'boolean'],
'follower_notifications' => ['required', 'boolean'],
'comment_notifications' => ['required', 'boolean'],
'newsletter' => ['required', 'boolean'],
];
}
}

View File

@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Settings;
use Illuminate\Foundation\Http\FormRequest;
class UpdatePersonalSectionRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'birthday' => ['nullable', 'date', 'before:today'],
'gender' => ['nullable', 'in:m,f,x,M,F,X'],
'country' => ['nullable', 'string', 'max:10'],
];
}
}

View File

@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Settings;
use Illuminate\Foundation\Http\FormRequest;
class UpdateProfileSectionRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'display_name' => ['required', 'string', 'max:60'],
'website' => ['nullable', 'url', 'max:255'],
'bio' => ['nullable', 'string', 'max:200'],
'signature' => ['nullable', 'string', 'max:1000'],
'description' => ['nullable', 'string', 'max:1000'],
'avatar' => ['nullable', 'file', 'image', 'max:2048', 'mimes:jpg,jpeg,png,webp', 'mimetypes:image/jpeg,image/png,image/webp'],
'remove_avatar' => ['nullable', 'boolean'],
'avatar_position' => ['nullable', 'in:top-left,top,top-right,left,center,right,bottom-left,bottom,bottom-right'],
];
}
}

View File

@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Settings;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rules\Password;
class UpdateSecurityPasswordRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'current_password' => ['required', 'current_password'],
'new_password' => ['required', 'confirmed', Password::min(8)],
];
}
}

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Settings;
use Illuminate\Foundation\Http\FormRequest;
class VerifyEmailChangeRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
protected function prepareForValidation(): void
{
if ($this->has('code')) {
$this->merge([
'code' => preg_replace('/\D+/', '', (string) $this->input('code')),
]);
}
}
public function rules(): array
{
return [
'code' => ['required', 'digits:6'],
];
}
}

View File

@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace App\Mail;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
class EmailChangeVerificationCodeMail extends Mailable implements ShouldQueue
{
use Queueable, SerializesModels;
public int $tries = 3;
public function __construct(
public readonly string $code,
public readonly int $expiresInMinutes,
) {
$this->onQueue('mail');
}
public function envelope(): Envelope
{
return new Envelope(
subject: 'Skinbase email change verification code',
);
}
public function content(): Content
{
return new Content(
view: 'emails.email-change-verification-code',
with: [
'code' => $this->code,
'expiresInMinutes' => $this->expiresInMinutes,
],
);
}
public function attachments(): array
{
return [];
}
}

View File

@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace App\Mail;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
class EmailChangedSecurityAlertMail extends Mailable implements ShouldQueue
{
use Queueable, SerializesModels;
public int $tries = 3;
public function __construct(public readonly string $newEmail)
{
$this->onQueue('mail');
}
public function envelope(): Envelope
{
return new Envelope(
subject: 'Your Skinbase email address was changed',
);
}
public function content(): Content
{
return new Content(
view: 'emails.email-changed-security-alert',
with: [
'newEmail' => $this->newEmail,
'supportEmail' => (string) config('mail.from.address', 'support@skinbase.org'),
],
);
}
public function attachments(): array
{
return [];
}
}

View File

@@ -1,4 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
@@ -14,11 +17,18 @@ class ArtworkDownload extends Model
{
protected $table = 'artwork_downloads';
public $timestamps = false;
const CREATED_AT = 'created_at';
const UPDATED_AT = null;
protected $fillable = [
'artwork_id',
'user_id',
'ip',
'ip_address',
'user_agent',
'referer',
];
public function artwork(): BelongsTo

View File

@@ -87,7 +87,7 @@ class ForumCategory extends Model
}
if ($slug !== '') {
return '/images/forum/defaults/' . $slug . '.jpg';
return '/images/forum/' . $slug . '.webp';
}
return $default;

View File

@@ -15,7 +15,18 @@ class ForumPost extends Model
protected $table = 'forum_posts';
protected $fillable = [
'id','thread_id','user_id','content','is_edited','edited_at'
'id',
'thread_id',
'topic_id',
'user_id',
'content',
'is_edited',
'edited_at',
'spam_score',
'quality_score',
'flagged',
'flagged_reason',
'moderation_checked',
];
public $incrementing = true;
@@ -23,6 +34,10 @@ class ForumPost extends Model
protected $casts = [
'is_edited' => 'boolean',
'edited_at' => 'datetime',
'spam_score' => 'integer',
'quality_score' => 'integer',
'flagged' => 'boolean',
'moderation_checked' => 'boolean',
];
public function thread(): BelongsTo

View File

@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Notification extends Model
{
use HasFactory;
protected $table = 'notifications';
protected $fillable = [
'user_id',
'type',
'data',
'read_at',
];
protected $casts = [
'data' => 'array',
'read_at' => 'datetime',
'created_at' => 'datetime',
'updated_at' => 'datetime',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class, 'user_id');
}
public function markAsRead(): void
{
if ($this->read_at === null) {
$this->forceFill(['read_at' => now()])->save();
}
}
}

View File

@@ -4,11 +4,16 @@ declare(strict_types=1);
namespace App\Models;
use App\Models\StoryLike;
use App\Models\StoryView;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Model;
/**
* Story editorial content replacing the legacy Interviews module.
* Creator Story model.
*
* @property int $id
* @property string $slug
@@ -16,10 +21,10 @@ use Illuminate\Database\Eloquent\Model;
* @property string|null $excerpt
* @property string|null $content
* @property string|null $cover_image
* @property int|null $author_id
* @property int|null $creator_id
* @property int $views
* @property bool $featured
* @property string $status draft|published
* @property string $status draft|pending_review|published|scheduled|archived|rejected
* @property \Carbon\Carbon|null $published_at
* @property int|null $legacy_interview_id
*/
@@ -35,38 +40,81 @@ class Story extends Model
'excerpt',
'content',
'cover_image',
'author_id',
'creator_id',
'story_type',
'reading_time',
'views',
'likes_count',
'comments_count',
'featured',
'status',
'published_at',
'scheduled_for',
'meta_title',
'meta_description',
'canonical_url',
'og_image',
'submitted_for_review_at',
'reviewed_at',
'reviewed_by_id',
'rejected_reason',
'legacy_interview_id',
];
protected $casts = [
'featured' => 'boolean',
'published_at' => 'datetime',
'scheduled_for' => 'datetime',
'submitted_for_review_at' => 'datetime',
'reviewed_at' => 'datetime',
'views' => 'integer',
'likes_count' => 'integer',
'comments_count' => 'integer',
'reading_time' => 'integer',
];
// ── Relations ────────────────────────────────────────────────────────
public function author()
public function creator(): BelongsTo
{
return $this->belongsTo(StoryAuthor::class, 'author_id');
return $this->belongsTo(User::class, 'creator_id');
}
public function tags()
// Legacy alias used by older views/controllers.
public function author(): BelongsTo
{
return $this->belongsToMany(StoryTag::class, 'stories_tag_relation', 'story_id', 'tag_id');
return $this->creator();
}
public function tags(): BelongsToMany
{
return $this->belongsToMany(StoryTag::class, 'relation_story_tags', 'story_id', 'tag_id');
}
public function storyViews(): HasMany
{
return $this->hasMany(StoryView::class, 'story_id');
}
public function storyLikes(): HasMany
{
return $this->hasMany(StoryLike::class, 'story_id');
}
// ── Scopes ───────────────────────────────────────────────────────────
public function scopePublished($query)
{
return $query->where('status', 'published')
->where(fn ($q) => $q->whereNull('published_at')->orWhere('published_at', '<=', now()));
return $query
->where(function ($q): void {
$q->where('status', 'published')
->orWhere(function ($scheduled): void {
$scheduled->where('status', 'scheduled')
->whereNotNull('published_at')
->where('published_at', '<=', now());
});
})
->where(fn ($q) => $q->whereNull('published_at')->orWhere('published_at', '<=', now()));
}
public function scopeFeatured($query)
@@ -95,6 +143,10 @@ class Story extends Model
*/
public function getReadingTimeAttribute(): int
{
if (! empty($this->attributes['reading_time'])) {
return max(1, (int) $this->attributes['reading_time']);
}
$wordCount = str_word_count(strip_tags((string) $this->content));
return max(1, (int) ceil($wordCount / 200));

32
app/Models/StoryLike.php Normal file
View File

@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class StoryLike extends Model
{
use HasFactory;
public const UPDATED_AT = null;
protected $fillable = [
'story_id',
'user_id',
'created_at',
];
public function story(): BelongsTo
{
return $this->belongsTo(Story::class, 'story_id');
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class, 'user_id');
}
}

View File

@@ -5,10 +5,11 @@ declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Model;
/**
* Story Tag editorial tag for the Stories system.
* Story tag entity for creator stories.
*
* @property int $id
* @property string $slug
@@ -18,7 +19,7 @@ class StoryTag extends Model
{
use HasFactory;
protected $table = 'stories_tags';
protected $table = 'story_tags';
protected $fillable = [
'slug',
@@ -27,9 +28,9 @@ class StoryTag extends Model
// ── Relations ────────────────────────────────────────────────────────
public function stories()
public function stories(): BelongsToMany
{
return $this->belongsToMany(Story::class, 'stories_tag_relation', 'tag_id', 'story_id');
return $this->belongsToMany(Story::class, 'relation_story_tags', 'tag_id', 'story_id');
}
// ── Accessors ────────────────────────────────────────────────────────

33
app/Models/StoryView.php Normal file
View File

@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class StoryView extends Model
{
use HasFactory;
public const UPDATED_AT = null;
protected $fillable = [
'story_id',
'user_id',
'ip_address',
'created_at',
];
public function story(): BelongsTo
{
return $this->belongsTo(Story::class, 'story_id');
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class, 'user_id');
}
}

View File

@@ -11,6 +11,7 @@ use App\Models\SocialAccount;
use App\Models\Conversation;
use App\Models\ConversationParticipant;
use App\Models\Message;
use App\Models\Notification;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
@@ -30,6 +31,7 @@ class User extends Authenticatable
protected $fillable = [
'username',
'username_changed_at',
'last_username_change_at',
'onboarding_step',
'name',
'email',
@@ -38,6 +40,10 @@ class User extends Authenticatable
'verification_send_window_started_at',
'is_active',
'needs_password_reset',
'cover_hash',
'cover_ext',
'cover_position',
'trust_score',
'password',
'role',
'allow_messages_from',
@@ -66,7 +72,10 @@ class User extends Authenticatable
'verification_send_window_started_at' => 'datetime',
'verification_send_count_24h' => 'integer',
'username_changed_at' => 'datetime',
'last_username_change_at' => 'datetime',
'deleted_at' => 'datetime',
'cover_position' => 'integer',
'trust_score' => 'integer',
'password' => 'hashed',
'allow_messages_from' => 'string',
];
@@ -139,6 +148,19 @@ class User extends Authenticatable
return $this->hasMany(Message::class, 'sender_id');
}
/**
* Skinbase notifications are keyed by user_id (non-polymorphic table).
*/
public function notifications(): HasMany
{
return $this->hasMany(Notification::class, 'user_id')->latest();
}
public function unreadNotifications(): HasMany
{
return $this->notifications()->whereNull('read_at');
}
/**
* Check if this user allows receiving messages from the given user.
*/
@@ -221,6 +243,11 @@ class User extends Authenticatable
return $this->hasMany(Post::class)->orderByDesc('created_at');
}
public function stories(): HasMany
{
return $this->hasMany(Story::class, 'creator_id')->orderByDesc('published_at');
}
// ─── Meilisearch ──────────────────────────────────────────────────────────
/**

View File

@@ -30,12 +30,22 @@ class UserProfile extends Model
'gender',
'website',
'auto_post_upload',
'email_notifications',
'upload_notifications',
'follower_notifications',
'comment_notifications',
'newsletter',
];
protected $casts = [
'birthdate' => 'date',
'avatar_updated_at'=> 'datetime',
'auto_post_upload' => 'boolean',
'email_notifications' => 'boolean',
'upload_notifications' => 'boolean',
'follower_notifications' => 'boolean',
'comment_notifications' => 'boolean',
'newsletter' => 'boolean',
];
public $timestamps = true;

View File

@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace App\Notifications;
use App\Models\Story;
use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Notification;
class StoryStatusNotification extends Notification
{
use Queueable;
public function __construct(
private readonly Story $story,
private readonly string $event,
private readonly ?string $reason = null,
) {
}
public function via(object $notifiable): array
{
return ['database'];
}
public function toDatabase(object $notifiable): array
{
$message = match ($this->event) {
'approved' => 'Your story "' . $this->story->title . '" was approved and published.',
'rejected' => 'Your story "' . $this->story->title . '" was rejected. Update it and resubmit for review.',
'published' => 'Your story "' . $this->story->title . '" is now published.',
default => 'Story update: "' . $this->story->title . '" status changed.',
};
return [
'type' => 'story.' . $this->event,
'story_id' => $this->story->id,
'title' => $this->story->title,
'slug' => $this->story->slug,
'status' => $this->story->status,
'reason' => $this->reason,
'message' => $message,
'url' => route('creator.stories.edit', ['story' => $this->story->id]),
];
}
}

View File

@@ -63,6 +63,8 @@ class AppServiceProvider extends ServiceProvider
$this->configureAuthRateLimiters();
$this->configureUploadRateLimiters();
$this->configureMessagingRateLimiters();
$this->configureDownloadRateLimiter();
$this->configureSettingsRateLimiters();
$this->configureMailFailureLogging();
ArtworkAward::observe(ArtworkAwardObserver::class);
@@ -143,6 +145,20 @@ class AppServiceProvider extends ServiceProvider
$view->with(compact('userId','uploadCount', 'favCount', 'msgCount', 'noticeCount', 'avatarHash', 'displayName'));
});
// Replace the framework HandleCors with our ConditionalCors so the
// CP_ENABLE_CORS / config('cors.paths') toggle takes effect.
try {
$middlewareConfig = $this->app->make(\Illuminate\Foundation\Configuration\Middleware::class);
$middlewareConfig->replace(
\Illuminate\Http\Middleware\HandleCors::class,
\App\Http\Middleware\ConditionalCors::class
);
} catch (\Throwable $_) {
// Fallback: push to kernel if replace isn't available in this app instance
$this->app->make(\Illuminate\Contracts\Http\Kernel::class)
->pushMiddleware(\App\Http\Middleware\ConditionalCors::class);
}
}
private function configureAuthRateLimiters(): void
@@ -244,4 +260,40 @@ class AppServiceProvider extends ServiceProvider
];
});
}
private function configureDownloadRateLimiter(): void
{
RateLimiter::for('downloads', function (Request $request): array {
$userId = $request->user()?->id;
// Higher user-based allowance prevents false positives for active users,
// while IP limit still protects guest endpoints from bursts.
return [
Limit::perMinute(60)->by('downloads:user:' . ($userId ?? 'guest')),
Limit::perMinute(120)->by('downloads:ip:' . $request->ip()),
];
});
}
private function configureSettingsRateLimiters(): void
{
RateLimiter::for('username-check', function (Request $request): Limit {
$key = 'username-check:ip:' . $request->ip();
if (method_exists(Limit::class, 'perSecond')) {
return Limit::perSecond(5)->by($key);
}
return Limit::perMinute(300)->by($key);
});
RateLimiter::for('email-change-request', function (Request $request): Limit {
$userId = $request->user()?->id;
$key = $userId !== null
? 'email-change-request:user:' . $userId
: 'email-change-request:ip:' . $request->ip();
return Limit::perHour(1)->by($key);
});
}
}

View File

@@ -30,5 +30,9 @@ class AuthServiceProvider extends ServiceProvider
public function boot(): void
{
$this->registerPolicies();
Gate::define('moderate-forum', static function ($user): bool {
return method_exists($user, 'isAdmin') && ($user->isAdmin() || $user->isModerator());
});
}
}

View File

@@ -18,6 +18,18 @@ class AvatarService
private const ALLOWED_MIME_TYPES = ['image/jpeg', 'image/png', 'image/webp'];
private const ALLOWED_POSITIONS = [
'top-left',
'top',
'top-right',
'left',
'center',
'right',
'bottom-left',
'bottom',
'bottom-right',
];
protected $sizes = [
'xs' => 32,
'sm' => 64,
@@ -50,13 +62,13 @@ class AvatarService
}
}
public function storeFromUploadedFile(int $userId, UploadedFile $file): string
public function storeFromUploadedFile(int $userId, UploadedFile $file, string $position = 'center'): string
{
$this->assertImageManagerAvailable();
$this->assertStorageIsAllowed();
$binary = $this->assertSecureImageUpload($file);
return $this->storeFromBinary($userId, $binary);
return $this->storeFromBinary($userId, $binary, $position);
}
public function storeFromLegacyFile(int $userId, string $path): ?string
@@ -76,10 +88,26 @@ class AvatarService
return $this->storeFromBinary($userId, $binary);
}
private function storeFromBinary(int $userId, string $binary): string
public function removeAvatar(int $userId): void
{
$diskName = (string) config('avatars.disk', 's3');
Storage::disk($diskName)->deleteDirectory("avatars/{$userId}");
UserProfile::query()->updateOrCreate(
['user_id' => $userId],
[
'avatar_hash' => null,
'avatar_mime' => null,
'avatar_updated_at' => Carbon::now(),
]
);
}
private function storeFromBinary(int $userId, string $binary, string $position = 'center'): string
{
$image = $this->readImageFromBinary($binary);
$image = $this->normalizeImage($image);
$cropPosition = $this->normalizePosition($position);
$diskName = (string) config('avatars.disk', 's3');
$disk = Storage::disk($diskName);
@@ -87,7 +115,7 @@ class AvatarService
$hashSeed = '';
foreach ($this->sizes as $size) {
$variant = $image->cover($size, $size);
$variant = $image->cover($size, $size, $cropPosition);
$encoded = (string) $variant->encode(new WebpEncoder($this->quality));
$disk->put("{$basePath}/{$size}.webp", $encoded, [
'visibility' => 'public',
@@ -110,6 +138,17 @@ class AvatarService
return $hash;
}
private function normalizePosition(string $position): string
{
$normalized = strtolower(trim($position));
if (in_array($normalized, self::ALLOWED_POSITIONS, true)) {
return $normalized;
}
return 'center';
}
private function normalizeImage($image)
{
try {

View File

@@ -18,21 +18,22 @@ class ThumbnailService
}
/**
* Canonical size keys (upload-agent spec §8): thumb · sq · md · lg · xl
* 'sm' is kept as a backwards-compatible alias for 'thumb'.
* Canonical size keys: xs · sm · md · lg · xl (+ legacy thumb/sq support).
*/
protected const VALID_SIZES = ['thumb', 'sq', 'sm', 'md', 'lg', 'xl'];
protected const VALID_SIZES = ['xs', 'sm', 'md', 'lg', 'xl', 'thumb', 'sq'];
/** Size aliases: legacy 'sm' maps to the 'thumb' CDN directory. */
protected const SIZE_ALIAS = ['sm' => 'thumb'];
/** Size aliases for backwards compatibility with old callers. */
protected const SIZE_ALIAS = [];
protected const THUMB_SIZES = [
'thumb' => ['height' => 320, 'quality' => 78, 'dir' => 'thumb'],
'xs' => ['height' => 160, 'quality' => 74, 'dir' => 'xs'],
'sm' => ['height' => 320, 'quality' => 78, 'dir' => 'sm'],
'sq' => ['height' => 512, 'quality' => 82, 'dir' => 'sq', 'square' => true],
'sm' => ['height' => 320, 'quality' => 78, 'dir' => 'thumb'], // alias for thumb
'md' => ['height' => 1024, 'quality' => 82, 'dir' => 'md'],
'lg' => ['height' => 1920, 'quality' => 85, 'dir' => 'lg'],
'xl' => ['height' => 2560, 'quality' => 90, 'dir' => 'xl'],
// Legacy compatibility for older paths still expecting /thumb/.
'thumb' => ['height' => 320, 'quality' => 78, 'dir' => 'thumb'],
];
/**

View File

@@ -129,8 +129,25 @@ final class UploadPipelineService
$height = is_array($dimensions) && isset($dimensions[1]) ? (int) $dimensions[1] : 1;
$origExt = strtolower(pathinfo($originalPath, PATHINFO_EXTENSION) ?: '');
$downloadFileName = $origFilename;
if (is_string($originalFileName) && trim($originalFileName) !== '') {
$candidate = basename(str_replace('\\', '/', $originalFileName));
$candidate = preg_replace('/[\x00-\x1F\x7F]/', '', (string) $candidate) ?? '';
$candidate = trim((string) $candidate);
if ($candidate !== '') {
$candidateExt = strtolower((string) pathinfo($candidate, PATHINFO_EXTENSION));
if ($candidateExt === '' && $origExt !== '') {
$candidate .= '.' . $origExt;
}
$downloadFileName = $candidate;
}
}
Artwork::query()->whereKey($artworkId)->update([
'file_name' => $origFilename,
'file_name' => $downloadFileName,
'file_path' => '',
'file_size' => (int) filesize($originalPath),
'mime_type' => $origMime,

62
app/Support/CoverUrl.php Normal file
View File

@@ -0,0 +1,62 @@
<?php
namespace App\Support;
class CoverUrl
{
private const DEFAULT_FILES_CDN = 'https://files.skinbase.org';
public static function forUser(?string $hash, ?string $ext, ?int $version = null): ?string
{
$coverHash = trim((string) $hash);
$coverExt = strtolower(trim((string) $ext));
if ($coverHash === '' || $coverExt === '') {
return null;
}
$base = self::resolveBaseUrl();
$p1 = substr($coverHash, 0, 2);
$p2 = substr($coverHash, 2, 2);
$v = $version ?? time();
return sprintf('%s/covers/%s/%s/%s.%s?v=%s', $base, $p1, $p2, $coverHash, $coverExt, $v);
}
private static function resolveBaseUrl(): string
{
$configured = trim((string) config('cdn.files_url', self::DEFAULT_FILES_CDN));
// If a non-default CDN/files host is configured, always respect it.
if ($configured !== '' && $configured !== self::DEFAULT_FILES_CDN) {
return rtrim($configured, '/');
}
// Local/dev fallback: derive a web path from uploads.storage_root when it lives under public/.
$local = self::deriveLocalBaseFromStorageRoot();
if ($local !== null) {
return $local;
}
return rtrim($configured !== '' ? $configured : self::DEFAULT_FILES_CDN, '/');
}
private static function deriveLocalBaseFromStorageRoot(): ?string
{
$storageRoot = str_replace('\\', '/', rtrim((string) config('uploads.storage_root'), DIRECTORY_SEPARATOR));
$publicRoot = str_replace('\\', '/', rtrim((string) public_path(), DIRECTORY_SEPARATOR));
$appUrl = rtrim((string) config('app.url'), '/');
if ($storageRoot === '' || $publicRoot === '' || $appUrl === '') {
return null;
}
if (! str_starts_with(strtolower($storageRoot), strtolower($publicRoot))) {
return null;
}
$suffix = trim((string) substr($storageRoot, strlen($publicRoot)), '/');
return $suffix === '' ? $appUrl : ($appUrl . '/' . $suffix);
}
}

View File

@@ -2,35 +2,12 @@
namespace App\Support;
use cPad\Plugins\Forum\Services\ForumContentRenderer;
class ForumPostContent
{
public static function render(?string $raw): string
{
$content = (string) ($raw ?? '');
if ($content === '') {
return '';
}
$allowedTags = '<p><br><strong><em><b><i><u><ul><ol><li><blockquote><code><pre><a><img>';
$sanitized = strip_tags($content, $allowedTags);
$sanitized = preg_replace('/\son\w+\s*=\s*"[^"]*"/i', '', $sanitized) ?? $sanitized;
$sanitized = preg_replace('/\son\w+\s*=\s*\'[^\']*\'/i', '', $sanitized) ?? $sanitized;
$sanitized = preg_replace('/\s(href|src)\s*=\s*"\s*javascript:[^"]*"/i', ' $1="#"', $sanitized) ?? $sanitized;
$sanitized = preg_replace('/\s(href|src)\s*=\s*\'\s*javascript:[^\']*\'/i', ' $1="#"', $sanitized) ?? $sanitized;
$linked = preg_replace_callback(
'/(?<!["\'>])(https?:\/\/[^\s<]+)/i',
static function (array $matches): string {
$url = $matches[1] ?? '';
$escapedUrl = e($url);
return '<a href="' . $escapedUrl . '" target="_blank" rel="noopener noreferrer" class="text-sky-300 hover:text-sky-200 underline">' . $escapedUrl . '</a>';
},
$sanitized,
);
return (string) ($linked ?? $sanitized);
return app(ForumContentRenderer::class)->render($raw);
}
}

View File

@@ -23,7 +23,7 @@ final class UsernamePolicy
public static function regex(): string
{
return (string) config('usernames.regex', '/^[a-zA-Z0-9_-]+$/');
return (string) config('usernames.regex', '/^[a-zA-Z0-9_]{3,20}$/');
}
/**
@@ -31,7 +31,12 @@ final class UsernamePolicy
*/
public static function reserved(): array
{
return array_values(array_unique(array_map(static fn (string $v): string => strtolower(trim($v)), (array) config('usernames.reserved', []))));
$pool = [
...(array) config('usernames.reserved', []),
...(array) config('skinbase.reserved_usernames', []),
];
return array_values(array_unique(array_map(static fn (string $v): string => strtolower(trim($v)), $pool)));
}
public static function normalize(string $value): string

View File

@@ -21,6 +21,7 @@ return Application::configure(basePath: dirname(__DIR__))
$middleware->alias([
'admin.moderation' => \App\Http\Middleware\EnsureAdminOrModerator::class,
'creator.access' => \App\Http\Middleware\EnsureCreatorAccess::class,
'ensure.onboarding.complete'=> \App\Http\Middleware\EnsureOnboardingComplete::class,
'onboarding' => \App\Http\Middleware\EnsureOnboardingComplete::class,
'normalize.username' => \App\Http\Middleware\NormalizeUsername::class,

View File

@@ -3,4 +3,7 @@
return [
App\Providers\AppServiceProvider::class,
App\Providers\AuthServiceProvider::class,
Klevze\ControlPanel\ServiceProvider::class,
cPad\Plugins\News\ServiceProvider::class,
cPad\Plugins\Forum\ServiceProvider::class,
];

View File

@@ -10,8 +10,12 @@
"license": "MIT",
"require": {
"php": "^8.2",
"alexusmai/laravel-file-manager": "*",
"composer/installers": "^2.3",
"gumlet/php-image-resize": "*",
"inertiajs/inertia-laravel": "^1.0",
"intervention/image": "^3.11",
"jenssegers/agent": "*",
"laravel/framework": "^12.0",
"laravel/scout": "^10.24",
"laravel/socialite": "^5.24",
@@ -19,7 +23,8 @@
"league/commonmark": "^2.8",
"meilisearch/meilisearch-php": "^1.16",
"predis/predis": "^3.4",
"socialiteproviders/discord": "^4.2"
"socialiteproviders/discord": "^4.2",
"yajra/laravel-datatables-oracle": "*"
},
"require-dev": {
"fakerphp/faker": "^1.23",
@@ -38,12 +43,16 @@
"psr-4": {
"App\\": "app/",
"Database\\Factories\\": "database/factories/",
"Database\\Seeders\\": "database/seeders/"
"Database\\Seeders\\": "database/seeders/",
"Klevze\\ControlPanel\\": "packages/klevze/ControlPanel/",
"cPad\\Plugins\\": "packages/klevze/Plugins/"
}
},
"autoload-dev": {
"psr-4": {
"Tests\\": "tests/"
"Tests\\": "tests/",
"Klevze\\ControlPanel\\": "packages/klevze/ControlPanel/",
"cPad\\Plugins\\": "packages/klevze/Plugins/"
}
},
"scripts": {
@@ -92,6 +101,7 @@
"preferred-install": "dist",
"sort-packages": true,
"allow-plugins": {
"composer/installers": true,
"pestphp/pest-plugin": true,
"php-http/discovery": true
}

1223
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -40,6 +40,11 @@ return [
'driver' => 'session',
'provider' => 'users',
],
// ControlPanel guard used by the ControlPanel package
'controlpanel' => [
'driver' => 'session',
'provider' => 'controlpanel_users',
],
],
/*
@@ -65,6 +70,12 @@ return [
'model' => env('AUTH_MODEL', App\Models\User::class),
],
// Provider for ControlPanel users
'controlpanel_users' => [
'driver' => 'eloquent',
'model' => Klevze\ControlPanel\Models\Auth\User::class,
],
// 'users' => [
// 'driver' => 'database',
// 'table' => 'users',

27
config/controlpanel.php Normal file
View File

@@ -0,0 +1,27 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| cPad Control Panel Configuration
|--------------------------------------------------------------------------
|
| This file contains the configuration options for cPad Control Panel.
| You can publish the full configuration using:
| php artisan vendor:publish --provider="Klevze\ControlPanel\ServiceProvider"
|
*/
'enabled' => env('CPAD_ENABLED', true),
'debug' => env('CPAD_DEBUG', false),
'route_prefix' => env('CPAD_ROUTE_PREFIX', 'admin'),
'middleware' => ['web', 'auth'],
'permissions' => [
'enabled' => true,
'super_admin_role' => 'super-admin',
],
];

34
config/cors.php Normal file
View File

@@ -0,0 +1,34 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Cross-Origin Resource Sharing (CORS) Configuration
|
| This file configures CORS for the application. Toggle CORS on/off using
| the `CP_ENABLE_CORS` environment variable. When disabled the `paths`
| array is empty and the CORS middleware will not apply to any routes.
|--------------------------------------------------------------------------
*/
'paths' => env('CP_ENABLE_CORS', true)
? [
'api/*',
'sanctum/csrf-cookie',
]
: [],
'allowed_methods' => ['*'],
'allowed_origins' => ['*'],
'allowed_origins_patterns' => [],
'allowed_headers' => ['*'],
'exposed_headers' => [],
'max_age' => 0,
'supports_credentials' => false,
];

23
config/cp.php Normal file
View File

@@ -0,0 +1,23 @@
<?php
return [
'logo' => '/admin/images/cp/logo.png',
'footer' => '2020 © <a href="mailto:info@klevze.net">info@klevze.net</a>',
'flag_folder' => '/admin/images/flags/languages/16/',
'flag_folder_24' => '/admin/images/flags/languages/24/',
'flag_folder_32' => '/admin/images/flags/languages/32/',
'flag_folder_64' => '/admin/images/flags/languages/64/',
'admin_path' => '/admin',
'webroot' => '/cp',
'theme' => 'adminlte',
//'theme' => 'porto',
'login' => [
'footer' => '2020 &copy; klevze.net',
'logo' => '/admin/images/cp/logo.png',
],
'tinymce' => [
'apikey' => 'xbqp7qz3idlwqmbessgwzlptb87ffxwphdgadio4dyp72sbw',
],
];

14
config/cpad.php Normal file
View File

@@ -0,0 +1,14 @@
<?php
return array (
'debug' => false,
'cache_enabled' => true,
'log_level' => 'warning',
'features' =>
array (
0 => 'core',
1 => 'security',
),
'security_level' => 'maximum',
'backup_enabled' => true,
);

166
config/file-manager.php Normal file
View File

@@ -0,0 +1,166 @@
<?php
use Alexusmai\LaravelFileManager\Services\ConfigService\DefaultConfigRepository;
use Alexusmai\LaravelFileManager\Services\ACLService\ConfigACLRepository;
return [
/**
* Set Config repository
*
* Default - DefaultConfigRepository get config from this file
*/
'configRepository' => DefaultConfigRepository::class,
/**
* ACL rules repository
*
* Default - ConfigACLRepository (see rules in - aclRules)
*/
'aclRepository' => ConfigACLRepository::class,
//********* Default configuration for DefaultConfigRepository **************
/**
* LFM Route prefix
* !!! WARNING - if you change it, you should compile frontend with new prefix(baseUrl) !!!
*/
'routePrefix' => 'file-manager',
/**
* List of disk names that you want to use
* (from config/filesystems)
*/
'diskList' => ['public'],
/**
* Default disk for left manager
*
* null - auto select the first disk in the disk list
*/
'leftDisk' => null,
/**
* Default disk for right manager
*
* null - auto select the first disk in the disk list
*/
'rightDisk' => null,
/**
* Default path for left manager
*
* null - root directory
*/
'leftPath' => null,
/**
* Default path for right manager
*
* null - root directory
*/
'rightPath' => null,
/**
* File manager modules configuration
*
* 1 - only one file manager window
* 2 - one file manager window with directories tree module
* 3 - two file manager windows
*/
'windowsConfig' => 2,
/**
* File upload - Max file size in KB
*
* null - no restrictions
*/
'maxUploadFileSize' => null,
/**
* File upload - Allow these file types
*
* [] - no restrictions
*/
'allowFileTypes' => [],
/**
* Show / Hide system files and folders
*/
'hiddenFiles' => true,
/***************************************************************************
* Middleware
*
* Add your middleware name to array -> ['web', 'auth', 'admin']
* !!!! RESTRICT ACCESS FOR NON ADMIN USERS !!!!
*/
'middleware' => ['web'],
/***************************************************************************
* ACL mechanism ON/OFF
*
* default - false(OFF)
*/
'acl' => false,
/**
* Hide files and folders from file-manager if user doesn't have access
*
* ACL access level = 0
*/
'aclHideFromFM' => true,
/**
* ACL strategy
*
* blacklist - Allow everything(access - 2 - r/w) that is not forbidden by the ACL rules list
*
* whitelist - Deny anything(access - 0 - deny), that not allowed by the ACL rules list
*/
'aclStrategy' => 'blacklist',
/**
* ACL Rules cache
*
* null or value in minutes
*/
'aclRulesCache' => null,
//********* Default configuration for DefaultConfigRepository END **********
/***************************************************************************
* ACL rules list - used for default ACL repository (ConfigACLRepository)
*
* 1 it's user ID
* null - for not authenticated user
*
* 'disk' => 'disk-name'
*
* 'path' => 'folder-name'
* 'path' => 'folder1*' - select folder1, folder12, folder1/sub-folder, ...
* 'path' => 'folder2/*' - select folder2/sub-folder,... but not select folder2 !!!
* 'path' => 'folder-name/file-name.jpg'
* 'path' => 'folder-name/*.jpg'
*
* * - wildcard
*
* access: 0 - deny, 1 - read, 2 - read/write
*/
'aclRules' => [
null => [
//['disk' => 'public', 'path' => '/', 'access' => 2],
],
1 => [
//['disk' => 'public', 'path' => 'images/arch*.jpg', 'access' => 2],
//['disk' => 'public', 'path' => 'files/*', 'access' => 1],
],
],
/**
* Enable slugification of filenames of uploaded files.
*
*/
'slugifyNames' => false,
];

22
config/skinbase.php Normal file
View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
return [
'reserved_usernames' => [
'admin',
'administrator',
'support',
'staff',
'system',
'root',
'api',
'cdn',
'upload',
'settings',
'login',
'logout',
'register',
'skinbase',
],
];

View File

@@ -5,8 +5,8 @@ declare(strict_types=1);
return [
'min' => 3,
'max' => 20,
'regex' => '/^[a-zA-Z0-9_-]+$/',
'rename_cooldown_days' => 90,
'regex' => '/^[a-zA-Z0-9_]{3,20}$/',
'rename_cooldown_days' => 30,
'similarity_threshold' => 2,
'reserved' => [
'admin',

View File

@@ -12,11 +12,15 @@ return new class extends Migration {
$table->foreignId('artwork_id')->constrained()->cascadeOnDelete();
$table->foreignId('user_id')->nullable()->index();
$table->binary('ip', 16);
$table->string('user_agent')->nullable();
// Legacy binary IP is kept for existing analytics/tests compatibility.
$table->binary('ip', 16)->nullable();
$table->string('ip_address', 45)->nullable();
$table->text('user_agent')->nullable();
$table->text('referer')->nullable();
$table->timestamp('created_at')->useCurrent();
$table->index('created_at');
$table->index(['artwork_id', 'created_at'], 'idx_downloads_artwork');
});
}

View File

@@ -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
{
/**
* Run the migrations.
*/
public function up(): void
{
return;
Schema::table('contents', function (Blueprint $table) {
$table->text('grid_data')->nullable()->after('views');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('contents', function (Blueprint $table) {
$table->dropColumn('grid_data');
});
}
};

View File

@@ -0,0 +1,54 @@
<?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::table('artwork_downloads', function (Blueprint $table) {
if (! Schema::hasColumn('artwork_downloads', 'ip_address')) {
$table->string('ip_address', 45)->nullable()->after('ip');
}
if (! Schema::hasColumn('artwork_downloads', 'referer')) {
$table->text('referer')->nullable()->after('user_agent');
}
if (! Schema::hasColumn('artwork_downloads', 'created_at')) {
$table->timestamp('created_at')->useCurrent();
}
});
try {
Schema::table('artwork_downloads', function (Blueprint $table) {
$table->index('created_at', 'artwork_downloads_created_at_idx');
});
} catch (\Throwable) {
// Index may already exist depending on historical migration state.
}
}
public function down(): void
{
try {
Schema::table('artwork_downloads', function (Blueprint $table) {
$table->dropIndex('artwork_downloads_created_at_idx');
});
} catch (\Throwable) {
// Ignore when index is absent.
}
Schema::table('artwork_downloads', function (Blueprint $table) {
if (Schema::hasColumn('artwork_downloads', 'ip_address')) {
$table->dropColumn('ip_address');
}
if (Schema::hasColumn('artwork_downloads', 'referer')) {
$table->dropColumn('referer');
}
});
}
};

View File

@@ -0,0 +1,41 @@
<?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::table('users', function (Blueprint $table): void {
if (! Schema::hasColumn('users', 'cover_hash')) {
$table->string('cover_hash', 64)->nullable()->after('last_visit_at');
}
if (! Schema::hasColumn('users', 'cover_ext')) {
$table->string('cover_ext', 10)->nullable()->after('cover_hash');
}
if (! Schema::hasColumn('users', 'cover_position')) {
$table->unsignedTinyInteger('cover_position')->default(50)->after('cover_ext');
}
});
}
public function down(): void
{
Schema::table('users', function (Blueprint $table): void {
if (Schema::hasColumn('users', 'cover_position')) {
$table->dropColumn('cover_position');
}
if (Schema::hasColumn('users', 'cover_ext')) {
$table->dropColumn('cover_ext');
}
if (Schema::hasColumn('users', 'cover_hash')) {
$table->dropColumn('cover_hash');
}
});
}
};

View File

@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void
{
if (!Schema::hasTable('user_profiles')) {
return;
}
Schema::table('user_profiles', function (Blueprint $table) {
if (!Schema::hasColumn('user_profiles', 'email_notifications')) {
$table->boolean('email_notifications')->default(true)->after('auto_post_upload');
}
if (!Schema::hasColumn('user_profiles', 'upload_notifications')) {
$table->boolean('upload_notifications')->default(true)->after('email_notifications');
}
if (!Schema::hasColumn('user_profiles', 'follower_notifications')) {
$table->boolean('follower_notifications')->default(true)->after('upload_notifications');
}
if (!Schema::hasColumn('user_profiles', 'comment_notifications')) {
$table->boolean('comment_notifications')->default(true)->after('follower_notifications');
}
if (!Schema::hasColumn('user_profiles', 'newsletter')) {
$table->boolean('newsletter')->default(false)->after('comment_notifications');
}
});
}
public function down(): void
{
if (!Schema::hasTable('user_profiles')) {
return;
}
Schema::table('user_profiles', function (Blueprint $table) {
foreach (['newsletter', 'comment_notifications', 'follower_notifications', 'upload_notifications', 'email_notifications'] as $column) {
if (Schema::hasColumn('user_profiles', $column)) {
$table->dropColumn($column);
}
}
});
}
};

View File

@@ -0,0 +1,25 @@
<?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::table('users', function (Blueprint $table) {
if (! Schema::hasColumn('users', 'last_username_change_at')) {
$table->timestamp('last_username_change_at')->nullable()->after('username_changed_at');
}
});
}
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
if (Schema::hasColumn('users', 'last_username_change_at')) {
$table->dropColumn('last_username_change_at');
}
});
}
};

View File

@@ -0,0 +1,33 @@
<?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
{
if (Schema::hasTable('email_changes')) {
return;
}
Schema::create('email_changes', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained('users')->cascadeOnDelete();
$table->string('new_email', 255);
$table->string('verification_code', 128);
$table->timestamp('expires_at');
$table->timestamp('used_at')->nullable();
$table->timestamps();
$table->index(['user_id', 'expires_at']);
$table->index(['user_id', 'created_at']);
$table->index('new_email');
});
}
public function down(): void
{
Schema::dropIfExists('email_changes');
}
};

View File

@@ -0,0 +1,98 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (Schema::hasTable('stories')) {
Schema::table('stories', function (Blueprint $table): void {
if (! Schema::hasColumn('stories', 'creator_id')) {
$table->foreignId('creator_id')->nullable()->after('id')->constrained('users')->nullOnDelete();
}
if (! Schema::hasColumn('stories', 'story_type')) {
$table->enum('story_type', [
'creator_story',
'tutorial',
'interview',
'project_breakdown',
'announcement',
'resource',
])->default('creator_story')->after('content');
}
if (! Schema::hasColumn('stories', 'reading_time')) {
$table->unsignedInteger('reading_time')->default(1)->after('story_type');
}
if (! Schema::hasColumn('stories', 'likes_count')) {
$table->unsignedInteger('likes_count')->default(0)->after('views');
}
if (! Schema::hasColumn('stories', 'comments_count')) {
$table->unsignedInteger('comments_count')->default(0)->after('likes_count');
}
});
if (Schema::hasColumn('stories', 'author_id') && Schema::hasTable('stories_authors')) {
DB::statement(<<<'SQL'
UPDATE stories s
INNER JOIN stories_authors sa ON sa.id = s.author_id
SET s.creator_id = sa.user_id
WHERE s.creator_id IS NULL
AND sa.user_id IS NOT NULL
SQL);
}
}
if (! Schema::hasTable('story_views')) {
Schema::create('story_views', function (Blueprint $table): void {
$table->id();
$table->foreignId('story_id')->constrained('stories')->cascadeOnDelete();
$table->foreignId('user_id')->nullable()->constrained('users')->nullOnDelete();
$table->string('ip_address', 45)->nullable();
$table->timestamp('created_at')->useCurrent();
$table->index(['story_id', 'created_at']);
$table->index(['user_id', 'created_at']);
});
}
if (! Schema::hasTable('story_likes')) {
Schema::create('story_likes', function (Blueprint $table): void {
$table->id();
$table->foreignId('story_id')->constrained('stories')->cascadeOnDelete();
$table->foreignId('user_id')->constrained('users')->cascadeOnDelete();
$table->timestamp('created_at')->useCurrent();
$table->unique(['story_id', 'user_id']);
$table->index(['story_id', 'created_at']);
});
}
}
public function down(): void
{
Schema::dropIfExists('story_likes');
Schema::dropIfExists('story_views');
if (Schema::hasTable('stories')) {
Schema::table('stories', function (Blueprint $table): void {
if (Schema::hasColumn('stories', 'creator_id')) {
$table->dropConstrainedForeignId('creator_id');
}
foreach (['story_type', 'reading_time', 'likes_count', 'comments_count'] as $column) {
if (Schema::hasColumn('stories', $column)) {
$table->dropColumn($column);
}
}
});
}
}
};

View File

@@ -0,0 +1,62 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (! Schema::hasTable('story_tags')) {
Schema::create('story_tags', function (Blueprint $table): void {
$table->id();
$table->string('name', 120)->unique();
$table->string('slug', 140)->unique();
$table->timestamps();
});
}
if (! Schema::hasTable('relation_story_tags')) {
Schema::create('relation_story_tags', function (Blueprint $table): void {
$table->foreignId('story_id')->constrained('stories')->cascadeOnDelete();
$table->foreignId('tag_id')->constrained('story_tags')->cascadeOnDelete();
$table->primary(['story_id', 'tag_id']);
});
}
if (Schema::hasTable('stories_tags')) {
$legacyTags = DB::table('stories_tags')->get();
foreach ($legacyTags as $legacyTag) {
DB::table('story_tags')->insertOrIgnore([
'name' => (string) $legacyTag->name,
'slug' => (string) $legacyTag->slug,
'created_at' => $legacyTag->created_at ?? now(),
'updated_at' => $legacyTag->updated_at ?? now(),
]);
}
}
if (Schema::hasTable('stories_tag_relation')) {
$legacyRelation = DB::table('stories_tag_relation as relation')
->join('stories_tags as legacy_tag', 'legacy_tag.id', '=', 'relation.tag_id')
->join('story_tags as new_tag', 'new_tag.slug', '=', 'legacy_tag.slug')
->get(['relation.story_id', 'new_tag.id as tag_id']);
foreach ($legacyRelation as $pair) {
DB::table('relation_story_tags')->insertOrIgnore([
'story_id' => (int) $pair->story_id,
'tag_id' => (int) $pair->tag_id,
]);
}
}
}
public function down(): void
{
Schema::dropIfExists('relation_story_tags');
Schema::dropIfExists('story_tags');
}
};

View File

@@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (! Schema::hasTable('stories') || ! Schema::hasColumn('stories', 'status')) {
return;
}
if (DB::getDriverName() === 'mysql') {
DB::statement("ALTER TABLE stories MODIFY status ENUM('draft','published','scheduled','archived') NOT NULL DEFAULT 'draft'");
}
}
public function down(): void
{
if (! Schema::hasTable('stories') || ! Schema::hasColumn('stories', 'status')) {
return;
}
if (DB::getDriverName() === 'mysql') {
DB::statement("ALTER TABLE stories MODIFY status ENUM('draft','published') NOT NULL DEFAULT 'draft'");
}
}
};

View File

@@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (! Schema::hasTable('reports') || ! Schema::hasColumn('reports', 'target_type')) {
return;
}
if (DB::getDriverName() === 'mysql') {
DB::statement("ALTER TABLE reports MODIFY target_type ENUM('message','conversation','user','story') NOT NULL");
}
}
public function down(): void
{
if (! Schema::hasTable('reports') || ! Schema::hasColumn('reports', 'target_type')) {
return;
}
if (DB::getDriverName() === 'mysql') {
DB::statement("ALTER TABLE reports MODIFY target_type ENUM('message','conversation','user') NOT NULL");
}
}
};

View File

@@ -0,0 +1,96 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (! Schema::hasTable('stories')) {
return;
}
if (DB::getDriverName() === 'mysql' && Schema::hasColumn('stories', 'status')) {
DB::statement("ALTER TABLE stories MODIFY status ENUM('draft','pending_review','published','scheduled','archived','rejected') NOT NULL DEFAULT 'draft'");
}
Schema::table('stories', function (Blueprint $table): void {
if (! Schema::hasColumn('stories', 'scheduled_for')) {
$table->timestamp('scheduled_for')->nullable()->after('published_at');
}
if (! Schema::hasColumn('stories', 'meta_title')) {
$table->string('meta_title', 255)->nullable()->after('reading_time');
}
if (! Schema::hasColumn('stories', 'meta_description')) {
$table->string('meta_description', 300)->nullable()->after('meta_title');
}
if (! Schema::hasColumn('stories', 'canonical_url')) {
$table->string('canonical_url', 500)->nullable()->after('meta_description');
}
if (! Schema::hasColumn('stories', 'og_image')) {
$table->string('og_image', 500)->nullable()->after('canonical_url');
}
if (! Schema::hasColumn('stories', 'submitted_for_review_at')) {
$table->timestamp('submitted_for_review_at')->nullable()->after('scheduled_for');
}
if (! Schema::hasColumn('stories', 'reviewed_at')) {
$table->timestamp('reviewed_at')->nullable()->after('submitted_for_review_at');
}
if (! Schema::hasColumn('stories', 'reviewed_by_id')) {
$table->foreignId('reviewed_by_id')->nullable()->after('reviewed_at')->constrained('users')->nullOnDelete();
}
if (! Schema::hasColumn('stories', 'rejected_reason')) {
$table->text('rejected_reason')->nullable()->after('reviewed_by_id');
}
$table->index(['status', 'submitted_for_review_at'], 'idx_stories_review_queue');
$table->index(['creator_id', 'status', 'updated_at'], 'idx_stories_creator_status_updated');
});
}
public function down(): void
{
if (! Schema::hasTable('stories')) {
return;
}
Schema::table('stories', function (Blueprint $table): void {
if (Schema::hasColumn('stories', 'reviewed_by_id')) {
$table->dropConstrainedForeignId('reviewed_by_id');
}
foreach ([
'scheduled_for',
'meta_title',
'meta_description',
'canonical_url',
'og_image',
'submitted_for_review_at',
'reviewed_at',
'rejected_reason',
] as $column) {
if (Schema::hasColumn('stories', $column)) {
$table->dropColumn($column);
}
}
$table->dropIndex('idx_stories_review_queue');
$table->dropIndex('idx_stories_creator_status_updated');
});
if (DB::getDriverName() === 'mysql' && Schema::hasColumn('stories', 'status')) {
DB::statement("ALTER TABLE stories MODIFY status ENUM('draft','published','scheduled','archived') NOT NULL DEFAULT 'draft'");
}
}
};

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

View File

@@ -1,150 +0,0 @@
# Page snapshot
```yaml
- generic [active] [ref=e1]:
- banner [ref=e2]:
- generic [ref=e3]:
- link "Skinbase.org Skinbase.org" [ref=e4] [cursor=pointer]:
- /url: /
- img "Skinbase.org" [ref=e5]
- generic [ref=e6]: Skinbase.org
- navigation "Main navigation" [ref=e7]:
- button "Discover" [ref=e9] [cursor=pointer]:
- text: Discover
- img [ref=e10]
- button "Browse" [ref=e13] [cursor=pointer]:
- text: Browse
- img [ref=e14]
- button "Creators" [ref=e17] [cursor=pointer]:
- text: Creators
- img [ref=e18]
- button "Community" [ref=e21] [cursor=pointer]:
- text: Community
- img [ref=e22]
- generic [ref=e26]:
- button "Open search" [ref=e27] [cursor=pointer]:
- img [ref=e28]
- generic [ref=e30]: Search
- generic [ref=e31]: CtrlK
- search:
- generic:
- img
- searchbox "Search"
- generic:
- generic: Esc
- button "Close search":
- img
- generic [ref=e32]:
- link "Join" [ref=e33] [cursor=pointer]:
- /url: /register
- link "Sign in" [ref=e34] [cursor=pointer]:
- /url: /login
- text:                 
- main [ref=e35]:
- generic [ref=e36]:
- generic [ref=e37]:
- generic [ref=e38]: "500"
- generic [ref=e39]: Server Error
- heading "Something Went Wrong in the Nova" [level=1] [ref=e40]
- paragraph [ref=e41]: An unexpected error occurred. Our team has been notified and is on it.
- button "Try Again" [ref=e42] [cursor=pointer]:
- generic [ref=e43]: 
- text: Try Again
- generic [ref=e44]:
- link "Return Home" [ref=e45] [cursor=pointer]:
- /url: /
- link "Report Issue" [ref=e46] [cursor=pointer]:
- /url: /contact
- generic [ref=e49]:
- generic [ref=e50]: 
- text: "Reference ID:"
- generic [ref=e51]: GDWS0QTD
- contentinfo [ref=e52]:
- generic [ref=e53]:
- generic [ref=e54]:
- img "Skinbase" [ref=e55]
- generic [ref=e56]: Skinbase
- generic [ref=e57]:
- link "Contact / Apply" [ref=e58] [cursor=pointer]:
- /url: /contact
- link "RSS Feeds" [ref=e59] [cursor=pointer]:
- /url: /rss-feeds
- link "FAQ" [ref=e60] [cursor=pointer]:
- /url: /faq
- link "Rules and Guidelines" [ref=e61] [cursor=pointer]:
- /url: /rules-and-guidelines
- link "Staff" [ref=e62] [cursor=pointer]:
- /url: /staff
- link "Privacy Policy" [ref=e63] [cursor=pointer]:
- /url: /privacy-policy
- link "Terms of Service" [ref=e64] [cursor=pointer]:
- /url: /terms-of-service
- button "Cookie Preferences" [ref=e65] [cursor=pointer]
- generic [ref=e66]: © 2026 Skinbase.org
- dialog "Cookie consent" [ref=e67]:
- generic [ref=e68]:
- generic [ref=e69]:
- generic [ref=e70]: 🍪
- paragraph [ref=e71]:
- text: We use
- strong [ref=e72]: essential cookies
- text: to keep you logged in and protect your session. With your permission we also load
- strong [ref=e73]: advertising cookies
- text: from third-party networks.
- link "Learn more ↗" [ref=e74] [cursor=pointer]:
- /url: /privacy-policy#cookies
- generic [ref=e75]:
- button "Essential only" [ref=e76] [cursor=pointer]
- button "Accept all" [ref=e77] [cursor=pointer]
- generic [ref=e78]:
- generic [ref=e80]:
- generic [ref=e82]:
- generic [ref=e83] [cursor=pointer]:
- generic: Request
- generic [ref=e84]: "500"
- generic [ref=e85] [cursor=pointer]:
- generic: Exceptions
- generic [ref=e86]: "1"
- generic [ref=e87] [cursor=pointer]:
- generic: Messages
- generic [ref=e88]: "1"
- generic [ref=e89] [cursor=pointer]:
- generic: Timeline
- generic [ref=e90] [cursor=pointer]:
- generic: Views
- generic [ref=e91]: "5"
- generic [ref=e92] [cursor=pointer]:
- generic: Queries
- generic [ref=e93]: "3"
- generic [ref=e94]:
- generic [ref=e102] [cursor=pointer]: GET /dashboard/artworks
- generic [ref=e103] [cursor=pointer]:
- generic: 960ms
- generic [ref=e105] [cursor=pointer]:
- generic: 28MB
- generic [ref=e107] [cursor=pointer]:
- generic: 12.x
- generic [ref=e109]:
- generic [ref=e111]:
- generic:
- list
- generic [ref=e113]:
- list [ref=e114]
- textbox "Search" [ref=e117]
- generic [ref=e118]:
- list
- generic [ref=e120]:
- list
- list [ref=e125]
- generic [ref=e127]:
- generic:
- list
- generic [ref=e129]:
- list [ref=e130]
- textbox "Search" [ref=e133]
- generic [ref=e134]:
- list
- generic [ref=e136]:
- generic:
- list
```

View File

@@ -1,150 +0,0 @@
# Page snapshot
```yaml
- generic [active] [ref=e1]:
- banner [ref=e2]:
- generic [ref=e3]:
- link "Skinbase.org Skinbase.org" [ref=e4] [cursor=pointer]:
- /url: /
- img "Skinbase.org" [ref=e5]
- generic [ref=e6]: Skinbase.org
- navigation "Main navigation" [ref=e7]:
- button "Discover" [ref=e9] [cursor=pointer]:
- text: Discover
- img [ref=e10]
- button "Browse" [ref=e13] [cursor=pointer]:
- text: Browse
- img [ref=e14]
- button "Creators" [ref=e17] [cursor=pointer]:
- text: Creators
- img [ref=e18]
- button "Community" [ref=e21] [cursor=pointer]:
- text: Community
- img [ref=e22]
- generic [ref=e26]:
- button "Open search" [ref=e27] [cursor=pointer]:
- img [ref=e28]
- generic [ref=e30]: Search
- generic [ref=e31]: CtrlK
- search:
- generic:
- img
- searchbox "Search"
- generic:
- generic: Esc
- button "Close search":
- img
- generic [ref=e32]:
- link "Join" [ref=e33] [cursor=pointer]:
- /url: /register
- link "Sign in" [ref=e34] [cursor=pointer]:
- /url: /login
- text:                 
- main [ref=e35]:
- generic [ref=e36]:
- generic [ref=e37]:
- generic [ref=e38]: "500"
- generic [ref=e39]: Server Error
- heading "Something Went Wrong in the Nova" [level=1] [ref=e40]
- paragraph [ref=e41]: An unexpected error occurred. Our team has been notified and is on it.
- button "Try Again" [ref=e42] [cursor=pointer]:
- generic [ref=e43]: 
- text: Try Again
- generic [ref=e44]:
- link "Return Home" [ref=e45] [cursor=pointer]:
- /url: /
- link "Report Issue" [ref=e46] [cursor=pointer]:
- /url: /contact
- generic [ref=e49]:
- generic [ref=e50]: 
- text: "Reference ID:"
- generic [ref=e51]: KNHHJPDS
- contentinfo [ref=e52]:
- generic [ref=e53]:
- generic [ref=e54]:
- img "Skinbase" [ref=e55]
- generic [ref=e56]: Skinbase
- generic [ref=e57]:
- link "Contact / Apply" [ref=e58] [cursor=pointer]:
- /url: /contact
- link "RSS Feeds" [ref=e59] [cursor=pointer]:
- /url: /rss-feeds
- link "FAQ" [ref=e60] [cursor=pointer]:
- /url: /faq
- link "Rules and Guidelines" [ref=e61] [cursor=pointer]:
- /url: /rules-and-guidelines
- link "Staff" [ref=e62] [cursor=pointer]:
- /url: /staff
- link "Privacy Policy" [ref=e63] [cursor=pointer]:
- /url: /privacy-policy
- link "Terms of Service" [ref=e64] [cursor=pointer]:
- /url: /terms-of-service
- button "Cookie Preferences" [ref=e65] [cursor=pointer]
- generic [ref=e66]: © 2026 Skinbase.org
- dialog "Cookie consent" [ref=e67]:
- generic [ref=e68]:
- generic [ref=e69]:
- generic [ref=e70]: 🍪
- paragraph [ref=e71]:
- text: We use
- strong [ref=e72]: essential cookies
- text: to keep you logged in and protect your session. With your permission we also load
- strong [ref=e73]: advertising cookies
- text: from third-party networks.
- link "Learn more ↗" [ref=e74] [cursor=pointer]:
- /url: /privacy-policy#cookies
- generic [ref=e75]:
- button "Essential only" [ref=e76] [cursor=pointer]
- button "Accept all" [ref=e77] [cursor=pointer]
- generic [ref=e78]:
- generic [ref=e80]:
- generic [ref=e82]:
- generic [ref=e83] [cursor=pointer]:
- generic: Request
- generic [ref=e84]: "500"
- generic [ref=e85] [cursor=pointer]:
- generic: Exceptions
- generic [ref=e86]: "1"
- generic [ref=e87] [cursor=pointer]:
- generic: Messages
- generic [ref=e88]: "1"
- generic [ref=e89] [cursor=pointer]:
- generic: Timeline
- generic [ref=e90] [cursor=pointer]:
- generic: Views
- generic [ref=e91]: "5"
- generic [ref=e92] [cursor=pointer]:
- generic: Queries
- generic [ref=e93]: "3"
- generic [ref=e94]:
- generic [ref=e102] [cursor=pointer]: GET /statistics
- generic [ref=e103] [cursor=pointer]:
- generic: 956ms
- generic [ref=e105] [cursor=pointer]:
- generic: 28MB
- generic [ref=e107] [cursor=pointer]:
- generic: 12.x
- generic [ref=e109]:
- generic [ref=e111]:
- generic:
- list
- generic [ref=e113]:
- list [ref=e114]
- textbox "Search" [ref=e117]
- generic [ref=e118]:
- list
- generic [ref=e120]:
- list
- list [ref=e125]
- generic [ref=e127]:
- generic:
- list
- generic [ref=e129]:
- list [ref=e130]
- textbox "Search" [ref=e133]
- generic [ref=e134]:
- list
- generic [ref=e136]:
- generic:
- list
```

View File

@@ -1,150 +0,0 @@
# Page snapshot
```yaml
- generic [active] [ref=e1]:
- banner [ref=e2]:
- generic [ref=e3]:
- link "Skinbase.org Skinbase.org" [ref=e4] [cursor=pointer]:
- /url: /
- img "Skinbase.org" [ref=e5]
- generic [ref=e6]: Skinbase.org
- navigation "Main navigation" [ref=e7]:
- button "Discover" [ref=e9] [cursor=pointer]:
- text: Discover
- img [ref=e10]
- button "Browse" [ref=e13] [cursor=pointer]:
- text: Browse
- img [ref=e14]
- button "Creators" [ref=e17] [cursor=pointer]:
- text: Creators
- img [ref=e18]
- button "Community" [ref=e21] [cursor=pointer]:
- text: Community
- img [ref=e22]
- generic [ref=e26]:
- button "Open search" [ref=e27] [cursor=pointer]:
- img [ref=e28]
- generic [ref=e30]: Search
- generic [ref=e31]: CtrlK
- search:
- generic:
- img
- searchbox "Search"
- generic:
- generic: Esc
- button "Close search":
- img
- generic [ref=e32]:
- link "Join" [ref=e33] [cursor=pointer]:
- /url: /register
- link "Sign in" [ref=e34] [cursor=pointer]:
- /url: /login
- text:                 
- main [ref=e35]:
- generic [ref=e36]:
- generic [ref=e37]:
- generic [ref=e38]: "500"
- generic [ref=e39]: Server Error
- heading "Something Went Wrong in the Nova" [level=1] [ref=e40]
- paragraph [ref=e41]: An unexpected error occurred. Our team has been notified and is on it.
- button "Try Again" [ref=e42] [cursor=pointer]:
- generic [ref=e43]: 
- text: Try Again
- generic [ref=e44]:
- link "Return Home" [ref=e45] [cursor=pointer]:
- /url: /
- link "Report Issue" [ref=e46] [cursor=pointer]:
- /url: /contact
- generic [ref=e49]:
- generic [ref=e50]: 
- text: "Reference ID:"
- generic [ref=e51]: HPLARFKK
- contentinfo [ref=e52]:
- generic [ref=e53]:
- generic [ref=e54]:
- img "Skinbase" [ref=e55]
- generic [ref=e56]: Skinbase
- generic [ref=e57]:
- link "Contact / Apply" [ref=e58] [cursor=pointer]:
- /url: /contact
- link "RSS Feeds" [ref=e59] [cursor=pointer]:
- /url: /rss-feeds
- link "FAQ" [ref=e60] [cursor=pointer]:
- /url: /faq
- link "Rules and Guidelines" [ref=e61] [cursor=pointer]:
- /url: /rules-and-guidelines
- link "Staff" [ref=e62] [cursor=pointer]:
- /url: /staff
- link "Privacy Policy" [ref=e63] [cursor=pointer]:
- /url: /privacy-policy
- link "Terms of Service" [ref=e64] [cursor=pointer]:
- /url: /terms-of-service
- button "Cookie Preferences" [ref=e65] [cursor=pointer]
- generic [ref=e66]: © 2026 Skinbase.org
- dialog "Cookie consent" [ref=e67]:
- generic [ref=e68]:
- generic [ref=e69]:
- generic [ref=e70]: 🍪
- paragraph [ref=e71]:
- text: We use
- strong [ref=e72]: essential cookies
- text: to keep you logged in and protect your session. With your permission we also load
- strong [ref=e73]: advertising cookies
- text: from third-party networks.
- link "Learn more ↗" [ref=e74] [cursor=pointer]:
- /url: /privacy-policy#cookies
- generic [ref=e75]:
- button "Essential only" [ref=e76] [cursor=pointer]
- button "Accept all" [ref=e77] [cursor=pointer]
- generic [ref=e78]:
- generic [ref=e80]:
- generic [ref=e82]:
- generic [ref=e83] [cursor=pointer]:
- generic: Request
- generic [ref=e84]: "500"
- generic [ref=e85] [cursor=pointer]:
- generic: Exceptions
- generic [ref=e86]: "1"
- generic [ref=e87] [cursor=pointer]:
- generic: Messages
- generic [ref=e88]: "1"
- generic [ref=e89] [cursor=pointer]:
- generic: Timeline
- generic [ref=e90] [cursor=pointer]:
- generic: Views
- generic [ref=e91]: "5"
- generic [ref=e92] [cursor=pointer]:
- generic: Queries
- generic [ref=e93]: "3"
- generic [ref=e94]:
- generic [ref=e102] [cursor=pointer]: GET /mybuddies
- generic [ref=e103] [cursor=pointer]:
- generic: 683ms
- generic [ref=e105] [cursor=pointer]:
- generic: 28MB
- generic [ref=e107] [cursor=pointer]:
- generic: 12.x
- generic [ref=e109]:
- generic [ref=e111]:
- generic:
- list
- generic [ref=e113]:
- list [ref=e114]
- textbox "Search" [ref=e117]
- generic [ref=e118]:
- list
- generic [ref=e120]:
- list
- list [ref=e125]
- generic [ref=e127]:
- generic:
- list
- generic [ref=e129]:
- list [ref=e130]
- textbox "Search" [ref=e133]
- generic [ref=e134]:
- list
- generic [ref=e136]:
- generic:
- list
```

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

View File

@@ -1,150 +0,0 @@
# Page snapshot
```yaml
- generic [active] [ref=e1]:
- banner [ref=e2]:
- generic [ref=e3]:
- link "Skinbase.org Skinbase.org" [ref=e4] [cursor=pointer]:
- /url: /
- img "Skinbase.org" [ref=e5]
- generic [ref=e6]: Skinbase.org
- navigation "Main navigation" [ref=e7]:
- button "Discover" [ref=e9] [cursor=pointer]:
- text: Discover
- img [ref=e10]
- button "Browse" [ref=e13] [cursor=pointer]:
- text: Browse
- img [ref=e14]
- button "Creators" [ref=e17] [cursor=pointer]:
- text: Creators
- img [ref=e18]
- button "Community" [ref=e21] [cursor=pointer]:
- text: Community
- img [ref=e22]
- generic [ref=e26]:
- button "Open search" [ref=e27] [cursor=pointer]:
- img [ref=e28]
- generic [ref=e30]: Search
- generic [ref=e31]: CtrlK
- search:
- generic:
- img
- searchbox "Search"
- generic:
- generic: Esc
- button "Close search":
- img
- generic [ref=e32]:
- link "Join" [ref=e33] [cursor=pointer]:
- /url: /register
- link "Sign in" [ref=e34] [cursor=pointer]:
- /url: /login
- text:                 
- main [ref=e35]:
- generic [ref=e36]:
- generic [ref=e37]:
- generic [ref=e38]: "500"
- generic [ref=e39]: Server Error
- heading "Something Went Wrong in the Nova" [level=1] [ref=e40]
- paragraph [ref=e41]: An unexpected error occurred. Our team has been notified and is on it.
- button "Try Again" [ref=e42] [cursor=pointer]:
- generic [ref=e43]: 
- text: Try Again
- generic [ref=e44]:
- link "Return Home" [ref=e45] [cursor=pointer]:
- /url: /
- link "Report Issue" [ref=e46] [cursor=pointer]:
- /url: /contact
- generic [ref=e49]:
- generic [ref=e50]: 
- text: "Reference ID:"
- generic [ref=e51]: 4IBKNGTZ
- contentinfo [ref=e52]:
- generic [ref=e53]:
- generic [ref=e54]:
- img "Skinbase" [ref=e55]
- generic [ref=e56]: Skinbase
- generic [ref=e57]:
- link "Contact / Apply" [ref=e58] [cursor=pointer]:
- /url: /contact
- link "RSS Feeds" [ref=e59] [cursor=pointer]:
- /url: /rss-feeds
- link "FAQ" [ref=e60] [cursor=pointer]:
- /url: /faq
- link "Rules and Guidelines" [ref=e61] [cursor=pointer]:
- /url: /rules-and-guidelines
- link "Staff" [ref=e62] [cursor=pointer]:
- /url: /staff
- link "Privacy Policy" [ref=e63] [cursor=pointer]:
- /url: /privacy-policy
- link "Terms of Service" [ref=e64] [cursor=pointer]:
- /url: /terms-of-service
- button "Cookie Preferences" [ref=e65] [cursor=pointer]
- generic [ref=e66]: © 2026 Skinbase.org
- dialog "Cookie consent" [ref=e67]:
- generic [ref=e68]:
- generic [ref=e69]:
- generic [ref=e70]: 🍪
- paragraph [ref=e71]:
- text: We use
- strong [ref=e72]: essential cookies
- text: to keep you logged in and protect your session. With your permission we also load
- strong [ref=e73]: advertising cookies
- text: from third-party networks.
- link "Learn more ↗" [ref=e74] [cursor=pointer]:
- /url: /privacy-policy#cookies
- generic [ref=e75]:
- button "Essential only" [ref=e76] [cursor=pointer]
- button "Accept all" [ref=e77] [cursor=pointer]
- generic [ref=e78]:
- generic [ref=e80]:
- generic [ref=e82]:
- generic [ref=e83] [cursor=pointer]:
- generic: Request
- generic [ref=e84]: "500"
- generic [ref=e85] [cursor=pointer]:
- generic: Exceptions
- generic [ref=e86]: "1"
- generic [ref=e87] [cursor=pointer]:
- generic: Messages
- generic [ref=e88]: "1"
- generic [ref=e89] [cursor=pointer]:
- generic: Timeline
- generic [ref=e90] [cursor=pointer]:
- generic: Views
- generic [ref=e91]: "5"
- generic [ref=e92] [cursor=pointer]:
- generic: Queries
- generic [ref=e93]: "3"
- generic [ref=e94]:
- generic [ref=e102] [cursor=pointer]: GET /dashboard/awards
- generic [ref=e103] [cursor=pointer]:
- generic: 695ms
- generic [ref=e105] [cursor=pointer]:
- generic: 28MB
- generic [ref=e107] [cursor=pointer]:
- generic: 12.x
- generic [ref=e109]:
- generic [ref=e111]:
- generic:
- list
- generic [ref=e113]:
- list [ref=e114]
- textbox "Search" [ref=e117]
- generic [ref=e118]:
- list
- generic [ref=e120]:
- list
- list [ref=e125]
- generic [ref=e127]:
- generic:
- list
- generic [ref=e129]:
- list [ref=e130]
- textbox "Search" [ref=e133]
- generic [ref=e134]:
- list
- generic [ref=e136]:
- generic:
- list
```

View File

@@ -1,150 +0,0 @@
# Page snapshot
```yaml
- generic [active] [ref=e1]:
- banner [ref=e2]:
- generic [ref=e3]:
- link "Skinbase.org Skinbase.org" [ref=e4] [cursor=pointer]:
- /url: /
- img "Skinbase.org" [ref=e5]
- generic [ref=e6]: Skinbase.org
- navigation "Main navigation" [ref=e7]:
- button "Discover" [ref=e9] [cursor=pointer]:
- text: Discover
- img [ref=e10]
- button "Browse" [ref=e13] [cursor=pointer]:
- text: Browse
- img [ref=e14]
- button "Creators" [ref=e17] [cursor=pointer]:
- text: Creators
- img [ref=e18]
- button "Community" [ref=e21] [cursor=pointer]:
- text: Community
- img [ref=e22]
- generic [ref=e26]:
- button "Open search" [ref=e27] [cursor=pointer]:
- img [ref=e28]
- generic [ref=e30]: Search
- generic [ref=e31]: CtrlK
- search:
- generic:
- img
- searchbox "Search"
- generic:
- generic: Esc
- button "Close search":
- img
- generic [ref=e32]:
- link "Join" [ref=e33] [cursor=pointer]:
- /url: /register
- link "Sign in" [ref=e34] [cursor=pointer]:
- /url: /login
- text:                 
- main [ref=e35]:
- generic [ref=e36]:
- generic [ref=e37]:
- generic [ref=e38]: "500"
- generic [ref=e39]: Server Error
- heading "Something Went Wrong in the Nova" [level=1] [ref=e40]
- paragraph [ref=e41]: An unexpected error occurred. Our team has been notified and is on it.
- button "Try Again" [ref=e42] [cursor=pointer]:
- generic [ref=e43]: 
- text: Try Again
- generic [ref=e44]:
- link "Return Home" [ref=e45] [cursor=pointer]:
- /url: /
- link "Report Issue" [ref=e46] [cursor=pointer]:
- /url: /contact
- generic [ref=e49]:
- generic [ref=e50]: 
- text: "Reference ID:"
- generic [ref=e51]: RMSI5LGF
- contentinfo [ref=e52]:
- generic [ref=e53]:
- generic [ref=e54]:
- img "Skinbase" [ref=e55]
- generic [ref=e56]: Skinbase
- generic [ref=e57]:
- link "Contact / Apply" [ref=e58] [cursor=pointer]:
- /url: /contact
- link "RSS Feeds" [ref=e59] [cursor=pointer]:
- /url: /rss-feeds
- link "FAQ" [ref=e60] [cursor=pointer]:
- /url: /faq
- link "Rules and Guidelines" [ref=e61] [cursor=pointer]:
- /url: /rules-and-guidelines
- link "Staff" [ref=e62] [cursor=pointer]:
- /url: /staff
- link "Privacy Policy" [ref=e63] [cursor=pointer]:
- /url: /privacy-policy
- link "Terms of Service" [ref=e64] [cursor=pointer]:
- /url: /terms-of-service
- button "Cookie Preferences" [ref=e65] [cursor=pointer]
- generic [ref=e66]: © 2026 Skinbase.org
- dialog "Cookie consent" [ref=e67]:
- generic [ref=e68]:
- generic [ref=e69]:
- generic [ref=e70]: 🍪
- paragraph [ref=e71]:
- text: We use
- strong [ref=e72]: essential cookies
- text: to keep you logged in and protect your session. With your permission we also load
- strong [ref=e73]: advertising cookies
- text: from third-party networks.
- link "Learn more ↗" [ref=e74] [cursor=pointer]:
- /url: /privacy-policy#cookies
- generic [ref=e75]:
- button "Essential only" [ref=e76] [cursor=pointer]
- button "Accept all" [ref=e77] [cursor=pointer]
- generic [ref=e78]:
- generic [ref=e80]:
- generic [ref=e82]:
- generic [ref=e83] [cursor=pointer]:
- generic: Request
- generic [ref=e84]: "500"
- generic [ref=e85] [cursor=pointer]:
- generic: Exceptions
- generic [ref=e86]: "1"
- generic [ref=e87] [cursor=pointer]:
- generic: Messages
- generic [ref=e88]: "1"
- generic [ref=e89] [cursor=pointer]:
- generic: Timeline
- generic [ref=e90] [cursor=pointer]:
- generic: Views
- generic [ref=e91]: "5"
- generic [ref=e92] [cursor=pointer]:
- generic: Queries
- generic [ref=e93]: "3"
- generic [ref=e94]:
- generic [ref=e102] [cursor=pointer]: GET /recieved-comments
- generic [ref=e103] [cursor=pointer]:
- generic: 949ms
- generic [ref=e105] [cursor=pointer]:
- generic: 28MB
- generic [ref=e107] [cursor=pointer]:
- generic: 12.x
- generic [ref=e109]:
- generic [ref=e111]:
- generic:
- list
- generic [ref=e113]:
- list [ref=e114]
- textbox "Search" [ref=e117]
- generic [ref=e118]:
- list
- generic [ref=e120]:
- list
- list [ref=e125]
- generic [ref=e127]:
- generic:
- list
- generic [ref=e129]:
- list [ref=e130]
- textbox "Search" [ref=e133]
- generic [ref=e134]:
- list
- generic [ref=e136]:
- generic:
- list
```

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

View File

@@ -0,0 +1,289 @@
# Page snapshot
```yaml
- generic [active] [ref=e1]:
- generic [ref=e2]:
- generic [ref=e4]:
- generic [ref=e5]:
- img [ref=e7]
- generic [ref=e10]: Internal Server Error
- button "Copy as Markdown" [ref=e11] [cursor=pointer]:
- img [ref=e12]
- generic [ref=e15]: Copy as Markdown
- generic [ref=e18]:
- generic [ref=e19]:
- heading "Symfony\\Component\\Routing\\Exception\\RouteNotFoundException" [level=1] [ref=e20]
- generic [ref=e22]: vendor\laravel\framework\src\Illuminate\Routing\UrlGenerator.php:528
- paragraph [ref=e23]: Route [artwork.show] not defined.
- generic [ref=e24]:
- generic [ref=e25]:
- generic [ref=e26]:
- generic [ref=e27]: LARAVEL
- generic [ref=e28]: 12.53.0
- generic [ref=e29]:
- generic [ref=e30]: PHP
- generic [ref=e31]: 8.4.12
- generic [ref=e32]:
- img [ref=e33]
- text: UNHANDLED
- generic [ref=e36]: CODE 0
- generic [ref=e38]:
- generic [ref=e39]:
- img [ref=e40]
- text: "500"
- generic [ref=e43]:
- img [ref=e44]
- text: GET
- generic [ref=e47]: http://skinbase26.test/explore
- button [ref=e48] [cursor=pointer]:
- img [ref=e49]
- generic [ref=e53]:
- generic [ref=e54]:
- generic [ref=e55]:
- img [ref=e57]
- heading "Exception trace" [level=3] [ref=e60]
- generic [ref=e61]:
- generic [ref=e63] [cursor=pointer]:
- img [ref=e64]
- generic [ref=e68]: 2 vendor frames
- button [ref=e69]:
- img [ref=e70]
- generic [ref=e74]:
- generic [ref=e75] [cursor=pointer]:
- generic [ref=e78]:
- code [ref=e82]:
- generic [ref=e83]: route(string, string)
- generic [ref=e85]: resources\views\web\explore\index.blade.php:18
- button [ref=e87]:
- img [ref=e88]
- code [ref=e96]:
- generic [ref=e97]: 13 <span class="text-xs font-semibold uppercase tracking-widest text-amber-400">✦ Featured Today</span>
- generic [ref=e98]: 14 <span class="flex-1 border-t border-white/10"></span>
- generic [ref=e99]: 15 </div>
- generic [ref=e100]: 16 <div class="flex gap-4 overflow-x-auto nb-scrollbar-none pb-2">
- generic [ref=e101]: 17 @foreach($spotlight as $item)
- generic [ref=e102]: "18 <a href=\"{{ $item->slug ? route('artwork.show', $item->slug) : '#' }}\""
- generic [ref=e103]: 19 class="group relative flex-none w-44 md:w-52 rounded-xl overflow-hidden
- generic [ref=e104]: 20 bg-neutral-800 border border-white/10 hover:border-amber-400/40
- generic [ref=e105]: 21 hover:shadow-lg hover:shadow-amber-500/10 transition-all duration-200"
- generic [ref=e106]: "22 title=\"{{ $item->name ?? '' }}\">"
- generic [ref=e107]: "23"
- generic [ref=e108]: "24 {{-- Thumbnail --}}"
- generic [ref=e109]: 25 <div class="aspect-[4/3] overflow-hidden bg-neutral-900">
- generic [ref=e110]: 26 <img
- generic [ref=e111]: "27 src=\"{{ $item->thumb_url ?? '' }}\""
- generic [ref=e112]: "28 @if(!empty($item->thumb_srcset)) srcset=\"{{ $item->thumb_srcset }}\" @endif"
- generic [ref=e113]: "29 alt=\"{{ $item->name ?? 'Featured artwork' }}\""
- generic [ref=e114]: "30"
- generic [ref=e116] [cursor=pointer]:
- img [ref=e117]
- generic [ref=e121]: 15 vendor frames
- button [ref=e122]:
- img [ref=e123]
- generic [ref=e128] [cursor=pointer]:
- generic [ref=e131]:
- code [ref=e135]:
- generic [ref=e136]: "Illuminate\\Pipeline\\Pipeline->{closure:{closure:Illuminate\\Pipeline\\Pipeline::carry():194}:195}(object(Illuminate\\Http\\Request))"
- generic [ref=e138]: app\Http\Middleware\EnsureOnboardingComplete.php:27
- button [ref=e140]:
- img [ref=e141]
- generic [ref=e146] [cursor=pointer]:
- img [ref=e147]
- generic [ref=e151]: 45 vendor frames
- button [ref=e152]:
- img [ref=e153]
- generic [ref=e158] [cursor=pointer]:
- generic [ref=e161]:
- code [ref=e165]:
- generic [ref=e166]: Illuminate\Foundation\Application->handleRequest(object(Illuminate\Http\Request))
- generic [ref=e168]: public\index.php:20
- button [ref=e170]:
- img [ref=e171]
- generic [ref=e175]:
- generic [ref=e176]:
- generic [ref=e177]:
- img [ref=e179]
- heading "Queries" [level=3] [ref=e181]
- generic [ref=e183]: 1-10 of 90
- generic [ref=e184]:
- generic [ref=e185]:
- generic [ref=e186]:
- generic [ref=e187]:
- img [ref=e188]
- generic [ref=e190]: mysql
- code [ref=e194]:
- generic [ref=e195]: "select exists (select 1 from information_schema.tables where table_schema = schema() and table_name = 'cpad' and table_type in ('BASE TABLE', 'SYSTEM VERSIONED')) as `exists`"
- generic [ref=e196]: 13.96ms
- generic [ref=e197]:
- generic [ref=e198]:
- generic [ref=e199]:
- img [ref=e200]
- generic [ref=e202]: mysql
- code [ref=e206]:
- generic [ref=e207]: "select exists (select 1 from information_schema.tables where table_schema = schema() and table_name = 'cpad' and table_type in ('BASE TABLE', 'SYSTEM VERSIONED')) as `exists`"
- generic [ref=e208]: 1.1ms
- generic [ref=e209]:
- generic [ref=e210]:
- generic [ref=e211]:
- img [ref=e212]
- generic [ref=e214]: mysql
- code [ref=e218]:
- generic [ref=e219]: "select * from `cache` where `key` in ('skinbasenova-cache-service:ConfigService:config:plugins')"
- generic [ref=e220]: 0.53ms
- generic [ref=e221]:
- generic [ref=e222]:
- generic [ref=e223]:
- img [ref=e224]
- generic [ref=e226]: mysql
- code [ref=e230]:
- generic [ref=e231]: "select * from `cpad` where `keycode` = 'plugins' limit 1"
- generic [ref=e232]: 0.69ms
- generic [ref=e233]:
- generic [ref=e234]:
- generic [ref=e235]:
- img [ref=e236]
- generic [ref=e238]: mysql
- code [ref=e242]:
- generic [ref=e243]: "select * from `cache` where `key` in ('skinbasenova-cache-cpad_config_tabs_registry')"
- generic [ref=e244]: 0.51ms
- generic [ref=e245]:
- generic [ref=e246]:
- generic [ref=e247]:
- img [ref=e248]
- generic [ref=e250]: mysql
- code [ref=e254]:
- generic [ref=e255]: "insert into `cache` (`expiration`, `key`, `value`) values (1773088441, 'skinbasenova-cache-cpad_config_tabs_registry', 'a:1:{s:14:\"config.plugins\";O:50:\"Klevze\\ControlPanel\\Configuration\\PluginsConfigTab\":2:{s:14:\"*serviceName\";s:16:\"PluginsConfigTab\";s:11:\"*cacheTtl\";i:3600;}}') on duplicate key update `expiration` = values(`expiration`), `key` = values(`key`), `value` = values(`value`)"
- generic [ref=e256]: 3.31ms
- generic [ref=e257]:
- generic [ref=e258]:
- generic [ref=e259]:
- img [ref=e260]
- generic [ref=e262]: mysql
- code [ref=e266]:
- generic [ref=e267]: "delete from `cache` where `key` in ('skinbasenova-cache-cpad_config_tabs_registry', 'skinbasenova-cache-illuminate:cache:flexible:created:cpad_config_tabs_registry')"
- generic [ref=e268]: 2.8ms
- generic [ref=e269]:
- generic [ref=e270]:
- generic [ref=e271]:
- img [ref=e272]
- generic [ref=e274]: mysql
- code [ref=e278]:
- generic [ref=e279]: "select * from `sessions` where `id` = '9JQSo5DrgARJAXNMelWZeiOWRA88DskBb5LukhVI' limit 1"
- generic [ref=e280]: 1.28ms
- generic [ref=e281]:
- generic [ref=e282]:
- generic [ref=e283]:
- img [ref=e284]
- generic [ref=e286]: mysql
- code [ref=e290]:
- generic [ref=e291]: "select * from `cache` where `key` in ('skinbasenova-cache-explore.all.trending.1')"
- generic [ref=e292]: 0.58ms
- generic [ref=e293]:
- generic [ref=e294]:
- generic [ref=e295]:
- img [ref=e296]
- generic [ref=e298]: mysql
- code [ref=e302]:
- generic [ref=e303]: "select * from `artworks` where `artworks`.`id` in (69610, 69611, 69606, 69597, 69599, 69601, 69417, 9517, 9518, 9523, 9524, 9494, 9496, 9497, 9500, 9501, 9502, 9504, 9505, 9506, 9507, 9508, 9509, 9511)"
- generic [ref=e304]: 3.13ms
- generic [ref=e305]:
- button [disabled] [ref=e306]:
- img [ref=e307]
- button [disabled] [ref=e310]:
- img [ref=e311]
- button "1" [ref=e314] [cursor=pointer]
- button "2" [ref=e316] [cursor=pointer]
- button "3" [ref=e318] [cursor=pointer]
- button "4" [ref=e320] [cursor=pointer]
- button "5" [ref=e322] [cursor=pointer]
- generic [ref=e324]: ...
- button "9" [ref=e326] [cursor=pointer]
- button [ref=e327] [cursor=pointer]:
- img [ref=e328]
- button [ref=e330] [cursor=pointer]:
- img [ref=e331]
- generic [ref=e335]:
- generic [ref=e336]:
- heading "Headers" [level=2] [ref=e337]
- generic [ref=e338]:
- generic [ref=e339]:
- generic [ref=e340]: cookie
- generic [ref=e342]: XSRF-TOKEN=eyJpdiI6IjB5YWlxRFhOOXMzMFZKNVo2anlvV0E9PSIsInZhbHVlIjoibnlXOStINjhmdmhTRUF2VTlFdHpXL3V4cDNwaFdpNnRYU0NhTUVNa0tublNvUVM0UUtlQ010UGFMOG1FRFpSVDNBZGhOUmR5c1VlQnJTbjJ2cGRJZzZYWEI2M2ZkMTh3M0hKNkhLKzJGR1VCUEJoUFpJUEd5YkhTMTJjdXcvQS8iLCJtYWMiOiIzN2M0NTViYTEyMWIxNTA3MTM3YmU4MjgyZjY3NTQxN2QyMTljNzY3Mzg3ZTk4OGVmMjA4MWQ5Zjg2ZGMyNDUxIiwidGFnIjoiIn0%3D; skinbasenova-session=eyJpdiI6IjZ6OHJOSTF1YlFhUG5DaEZmK0R5UGc9PSIsInZhbHVlIjoiSXBwOEFWT25RRlBpaXVKdzZNWWRySE96NUJwOHF6SUc1RVdsR2pEblhYQ1c4N0lTNHFSY1ZtRDY2MmxzVjFXT2RwSkVWSG9SUWNweDNLdkxHM1NmcXhJNllUNEpxeGZVN3JxQmZJM1plb3BZQ3BTTVd4Z05YV0VYb0g0UnBIKzMiLCJtYWMiOiJkNDQ3MDlhNmQ1OTdkNjI1MDliZTBlZTkzNTdkZmQ0ZDQwNTU1ZjcwNmRiZjIxMThjNmVjMjNhMGE1YTI2Nzk1IiwidGFnIjoiIn0%3D; PHPDEBUGBAR_STACK_DATA=%7B%2201KKA1F4SZKZ192GVC1Q09NG2K%22%3Anull%7D
- generic [ref=e343]:
- generic [ref=e344]: accept-encoding
- generic [ref=e346]: gzip, deflate
- generic [ref=e347]:
- generic [ref=e348]: accept
- generic [ref=e350]: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
- generic [ref=e351]:
- generic [ref=e352]: accept-language
- generic [ref=e354]: en-US
- generic [ref=e355]:
- generic [ref=e356]: user-agent
- generic [ref=e358]: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.7632.6 Safari/537.36
- generic [ref=e359]:
- generic [ref=e360]: upgrade-insecure-requests
- generic [ref=e362]: "1"
- generic [ref=e363]:
- generic [ref=e364]: connection
- generic [ref=e366]: close
- generic [ref=e367]:
- generic [ref=e368]: host
- generic [ref=e370]: skinbase26.test
- generic [ref=e371]:
- heading "Body" [level=2] [ref=e372]
- generic [ref=e373]: // No request body
- generic [ref=e374]:
- heading "Routing" [level=2] [ref=e375]
- generic [ref=e376]:
- generic [ref=e377]:
- generic [ref=e378]: controller
- generic [ref=e380]: App\Http\Controllers\Web\ExploreController@index
- generic [ref=e381]:
- generic [ref=e382]: route name
- generic [ref=e384]: explore.index
- generic [ref=e385]:
- generic [ref=e386]: middleware
- generic [ref=e388]: web
- generic [ref=e389]:
- heading "Routing parameters" [level=2] [ref=e390]
- generic [ref=e391]: // No routing parameters
- generic [ref=e394]:
- img [ref=e396]
- img [ref=e3434]
- generic [ref=e6474]:
- generic [ref=e6476]:
- generic [ref=e6477] [cursor=pointer]:
- generic: Request
- generic [ref=e6478]: "500"
- generic [ref=e6479] [cursor=pointer]:
- generic: Exceptions
- generic [ref=e6480]: "2"
- generic [ref=e6481] [cursor=pointer]:
- generic: Messages
- generic [ref=e6482]: "5"
- generic [ref=e6483] [cursor=pointer]:
- generic: Timeline
- generic [ref=e6484] [cursor=pointer]:
- generic: Views
- generic [ref=e6485]: "513"
- generic [ref=e6486] [cursor=pointer]:
- generic: Queries
- generic [ref=e6487]: "91"
- generic [ref=e6488] [cursor=pointer]:
- generic: Models
- generic [ref=e6489]: "156"
- generic [ref=e6490] [cursor=pointer]:
- generic: Cache
- generic [ref=e6491]: "8"
- generic [ref=e6492]:
- generic [ref=e6499] [cursor=pointer]:
- generic [ref=e6500]: "2"
- generic [ref=e6501]: GET /explore
- generic [ref=e6502] [cursor=pointer]:
- generic: 4.91s
- generic [ref=e6504] [cursor=pointer]:
- generic: 51MB
- generic [ref=e6506] [cursor=pointer]:
- generic: 12.x
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

View File

@@ -1,150 +0,0 @@
# Page snapshot
```yaml
- generic [active] [ref=e1]:
- banner [ref=e2]:
- generic [ref=e3]:
- link "Skinbase.org Skinbase.org" [ref=e4] [cursor=pointer]:
- /url: /
- img "Skinbase.org" [ref=e5]
- generic [ref=e6]: Skinbase.org
- navigation "Main navigation" [ref=e7]:
- button "Discover" [ref=e9] [cursor=pointer]:
- text: Discover
- img [ref=e10]
- button "Browse" [ref=e13] [cursor=pointer]:
- text: Browse
- img [ref=e14]
- button "Creators" [ref=e17] [cursor=pointer]:
- text: Creators
- img [ref=e18]
- button "Community" [ref=e21] [cursor=pointer]:
- text: Community
- img [ref=e22]
- generic [ref=e26]:
- button "Open search" [ref=e27] [cursor=pointer]:
- img [ref=e28]
- generic [ref=e30]: Search
- generic [ref=e31]: CtrlK
- search:
- generic:
- img
- searchbox "Search"
- generic:
- generic: Esc
- button "Close search":
- img
- generic [ref=e32]:
- link "Join" [ref=e33] [cursor=pointer]:
- /url: /register
- link "Sign in" [ref=e34] [cursor=pointer]:
- /url: /login
- text:                 
- main [ref=e35]:
- generic [ref=e36]:
- generic [ref=e37]:
- generic [ref=e38]: "500"
- generic [ref=e39]: Server Error
- heading "Something Went Wrong in the Nova" [level=1] [ref=e40]
- paragraph [ref=e41]: An unexpected error occurred. Our team has been notified and is on it.
- button "Try Again" [ref=e42] [cursor=pointer]:
- generic [ref=e43]: 
- text: Try Again
- generic [ref=e44]:
- link "Return Home" [ref=e45] [cursor=pointer]:
- /url: /
- link "Report Issue" [ref=e46] [cursor=pointer]:
- /url: /contact
- generic [ref=e49]:
- generic [ref=e50]: 
- text: "Reference ID:"
- generic [ref=e51]: LRZGKP1C
- contentinfo [ref=e52]:
- generic [ref=e53]:
- generic [ref=e54]:
- img "Skinbase" [ref=e55]
- generic [ref=e56]: Skinbase
- generic [ref=e57]:
- link "Contact / Apply" [ref=e58] [cursor=pointer]:
- /url: /contact
- link "RSS Feeds" [ref=e59] [cursor=pointer]:
- /url: /rss-feeds
- link "FAQ" [ref=e60] [cursor=pointer]:
- /url: /faq
- link "Rules and Guidelines" [ref=e61] [cursor=pointer]:
- /url: /rules-and-guidelines
- link "Staff" [ref=e62] [cursor=pointer]:
- /url: /staff
- link "Privacy Policy" [ref=e63] [cursor=pointer]:
- /url: /privacy-policy
- link "Terms of Service" [ref=e64] [cursor=pointer]:
- /url: /terms-of-service
- button "Cookie Preferences" [ref=e65] [cursor=pointer]
- generic [ref=e66]: © 2026 Skinbase.org
- dialog "Cookie consent" [ref=e67]:
- generic [ref=e68]:
- generic [ref=e69]:
- generic [ref=e70]: 🍪
- paragraph [ref=e71]:
- text: We use
- strong [ref=e72]: essential cookies
- text: to keep you logged in and protect your session. With your permission we also load
- strong [ref=e73]: advertising cookies
- text: from third-party networks.
- link "Learn more ↗" [ref=e74] [cursor=pointer]:
- /url: /privacy-policy#cookies
- generic [ref=e75]:
- button "Essential only" [ref=e76] [cursor=pointer]
- button "Accept all" [ref=e77] [cursor=pointer]
- generic [ref=e78]:
- generic [ref=e80]:
- generic [ref=e82]:
- generic [ref=e83] [cursor=pointer]:
- generic: Request
- generic [ref=e84]: "500"
- generic [ref=e85] [cursor=pointer]:
- generic: Exceptions
- generic [ref=e86]: "1"
- generic [ref=e87] [cursor=pointer]:
- generic: Messages
- generic [ref=e88]: "1"
- generic [ref=e89] [cursor=pointer]:
- generic: Timeline
- generic [ref=e90] [cursor=pointer]:
- generic: Views
- generic [ref=e91]: "5"
- generic [ref=e92] [cursor=pointer]:
- generic: Queries
- generic [ref=e93]: "3"
- generic [ref=e94]:
- generic [ref=e102] [cursor=pointer]: GET /dashboard/gallery
- generic [ref=e103] [cursor=pointer]:
- generic: 965ms
- generic [ref=e105] [cursor=pointer]:
- generic: 28MB
- generic [ref=e107] [cursor=pointer]:
- generic: 12.x
- generic [ref=e109]:
- generic [ref=e111]:
- generic:
- list
- generic [ref=e113]:
- list [ref=e114]
- textbox "Search" [ref=e117]
- generic [ref=e118]:
- list
- generic [ref=e120]:
- list
- list [ref=e125]
- generic [ref=e127]:
- generic:
- list
- generic [ref=e129]:
- list [ref=e130]
- textbox "Search" [ref=e133]
- generic [ref=e134]:
- list
- generic [ref=e136]:
- generic:
- list
```

View File

@@ -1,150 +0,0 @@
# Page snapshot
```yaml
- generic [active] [ref=e1]:
- banner [ref=e2]:
- generic [ref=e3]:
- link "Skinbase.org Skinbase.org" [ref=e4] [cursor=pointer]:
- /url: /
- img "Skinbase.org" [ref=e5]
- generic [ref=e6]: Skinbase.org
- navigation "Main navigation" [ref=e7]:
- button "Discover" [ref=e9] [cursor=pointer]:
- text: Discover
- img [ref=e10]
- button "Browse" [ref=e13] [cursor=pointer]:
- text: Browse
- img [ref=e14]
- button "Creators" [ref=e17] [cursor=pointer]:
- text: Creators
- img [ref=e18]
- button "Community" [ref=e21] [cursor=pointer]:
- text: Community
- img [ref=e22]
- generic [ref=e26]:
- button "Open search" [ref=e27] [cursor=pointer]:
- img [ref=e28]
- generic [ref=e30]: Search
- generic [ref=e31]: CtrlK
- search:
- generic:
- img
- searchbox "Search"
- generic:
- generic: Esc
- button "Close search":
- img
- generic [ref=e32]:
- link "Join" [ref=e33] [cursor=pointer]:
- /url: /register
- link "Sign in" [ref=e34] [cursor=pointer]:
- /url: /login
- text:                 
- main [ref=e35]:
- generic [ref=e36]:
- generic [ref=e37]:
- generic [ref=e38]: "500"
- generic [ref=e39]: Server Error
- heading "Something Went Wrong in the Nova" [level=1] [ref=e40]
- paragraph [ref=e41]: An unexpected error occurred. Our team has been notified and is on it.
- button "Try Again" [ref=e42] [cursor=pointer]:
- generic [ref=e43]: 
- text: Try Again
- generic [ref=e44]:
- link "Return Home" [ref=e45] [cursor=pointer]:
- /url: /
- link "Report Issue" [ref=e46] [cursor=pointer]:
- /url: /contact
- generic [ref=e49]:
- generic [ref=e50]: 
- text: "Reference ID:"
- generic [ref=e51]: MQBPWNJE
- contentinfo [ref=e52]:
- generic [ref=e53]:
- generic [ref=e54]:
- img "Skinbase" [ref=e55]
- generic [ref=e56]: Skinbase
- generic [ref=e57]:
- link "Contact / Apply" [ref=e58] [cursor=pointer]:
- /url: /contact
- link "RSS Feeds" [ref=e59] [cursor=pointer]:
- /url: /rss-feeds
- link "FAQ" [ref=e60] [cursor=pointer]:
- /url: /faq
- link "Rules and Guidelines" [ref=e61] [cursor=pointer]:
- /url: /rules-and-guidelines
- link "Staff" [ref=e62] [cursor=pointer]:
- /url: /staff
- link "Privacy Policy" [ref=e63] [cursor=pointer]:
- /url: /privacy-policy
- link "Terms of Service" [ref=e64] [cursor=pointer]:
- /url: /terms-of-service
- button "Cookie Preferences" [ref=e65] [cursor=pointer]
- generic [ref=e66]: © 2026 Skinbase.org
- dialog "Cookie consent" [ref=e67]:
- generic [ref=e68]:
- generic [ref=e69]:
- generic [ref=e70]: 🍪
- paragraph [ref=e71]:
- text: We use
- strong [ref=e72]: essential cookies
- text: to keep you logged in and protect your session. With your permission we also load
- strong [ref=e73]: advertising cookies
- text: from third-party networks.
- link "Learn more ↗" [ref=e74] [cursor=pointer]:
- /url: /privacy-policy#cookies
- generic [ref=e75]:
- button "Essential only" [ref=e76] [cursor=pointer]
- button "Accept all" [ref=e77] [cursor=pointer]
- generic [ref=e78]:
- generic [ref=e80]:
- generic [ref=e82]:
- generic [ref=e83] [cursor=pointer]:
- generic: Request
- generic [ref=e84]: "500"
- generic [ref=e85] [cursor=pointer]:
- generic: Exceptions
- generic [ref=e86]: "1"
- generic [ref=e87] [cursor=pointer]:
- generic: Messages
- generic [ref=e88]: "1"
- generic [ref=e89] [cursor=pointer]:
- generic: Timeline
- generic [ref=e90] [cursor=pointer]:
- generic: Views
- generic [ref=e91]: "5"
- generic [ref=e92] [cursor=pointer]:
- generic: Queries
- generic [ref=e93]: "3"
- generic [ref=e94]:
- generic [ref=e102] [cursor=pointer]: GET /dashboard/favorites
- generic [ref=e103] [cursor=pointer]:
- generic: 983ms
- generic [ref=e105] [cursor=pointer]:
- generic: 28MB
- generic [ref=e107] [cursor=pointer]:
- generic: 12.x
- generic [ref=e109]:
- generic [ref=e111]:
- generic:
- list
- generic [ref=e113]:
- list [ref=e114]
- textbox "Search" [ref=e117]
- generic [ref=e118]:
- list
- generic [ref=e120]:
- list
- list [ref=e125]
- generic [ref=e127]:
- generic:
- list
- generic [ref=e129]:
- list [ref=e130]
- textbox "Search" [ref=e133]
- generic [ref=e134]:
- list
- generic [ref=e136]:
- generic:
- list
```

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

View File

@@ -1,150 +0,0 @@
# Page snapshot
```yaml
- generic [active] [ref=e1]:
- banner [ref=e2]:
- generic [ref=e3]:
- link "Skinbase.org Skinbase.org" [ref=e4] [cursor=pointer]:
- /url: /
- img "Skinbase.org" [ref=e5]
- generic [ref=e6]: Skinbase.org
- navigation "Main navigation" [ref=e7]:
- button "Discover" [ref=e9] [cursor=pointer]:
- text: Discover
- img [ref=e10]
- button "Browse" [ref=e13] [cursor=pointer]:
- text: Browse
- img [ref=e14]
- button "Creators" [ref=e17] [cursor=pointer]:
- text: Creators
- img [ref=e18]
- button "Community" [ref=e21] [cursor=pointer]:
- text: Community
- img [ref=e22]
- generic [ref=e26]:
- button "Open search" [ref=e27] [cursor=pointer]:
- img [ref=e28]
- generic [ref=e30]: Search
- generic [ref=e31]: CtrlK
- search:
- generic:
- img
- searchbox "Search"
- generic:
- generic: Esc
- button "Close search":
- img
- generic [ref=e32]:
- link "Join" [ref=e33] [cursor=pointer]:
- /url: /register
- link "Sign in" [ref=e34] [cursor=pointer]:
- /url: /login
- text:                 
- main [ref=e35]:
- generic [ref=e36]:
- generic [ref=e37]:
- generic [ref=e38]: "500"
- generic [ref=e39]: Server Error
- heading "Something Went Wrong in the Nova" [level=1] [ref=e40]
- paragraph [ref=e41]: An unexpected error occurred. Our team has been notified and is on it.
- button "Try Again" [ref=e42] [cursor=pointer]:
- generic [ref=e43]: 
- text: Try Again
- generic [ref=e44]:
- link "Return Home" [ref=e45] [cursor=pointer]:
- /url: /
- link "Report Issue" [ref=e46] [cursor=pointer]:
- /url: /contact
- generic [ref=e49]:
- generic [ref=e50]: 
- text: "Reference ID:"
- generic [ref=e51]: 0LKAPYMJ
- contentinfo [ref=e52]:
- generic [ref=e53]:
- generic [ref=e54]:
- img "Skinbase" [ref=e55]
- generic [ref=e56]: Skinbase
- generic [ref=e57]:
- link "Contact / Apply" [ref=e58] [cursor=pointer]:
- /url: /contact
- link "RSS Feeds" [ref=e59] [cursor=pointer]:
- /url: /rss-feeds
- link "FAQ" [ref=e60] [cursor=pointer]:
- /url: /faq
- link "Rules and Guidelines" [ref=e61] [cursor=pointer]:
- /url: /rules-and-guidelines
- link "Staff" [ref=e62] [cursor=pointer]:
- /url: /staff
- link "Privacy Policy" [ref=e63] [cursor=pointer]:
- /url: /privacy-policy
- link "Terms of Service" [ref=e64] [cursor=pointer]:
- /url: /terms-of-service
- button "Cookie Preferences" [ref=e65] [cursor=pointer]
- generic [ref=e66]: © 2026 Skinbase.org
- dialog "Cookie consent" [ref=e67]:
- generic [ref=e68]:
- generic [ref=e69]:
- generic [ref=e70]: 🍪
- paragraph [ref=e71]:
- text: We use
- strong [ref=e72]: essential cookies
- text: to keep you logged in and protect your session. With your permission we also load
- strong [ref=e73]: advertising cookies
- text: from third-party networks.
- link "Learn more ↗" [ref=e74] [cursor=pointer]:
- /url: /privacy-policy#cookies
- generic [ref=e75]:
- button "Essential only" [ref=e76] [cursor=pointer]
- button "Accept all" [ref=e77] [cursor=pointer]
- generic [ref=e78]:
- generic [ref=e80]:
- generic [ref=e82]:
- generic [ref=e83] [cursor=pointer]:
- generic: Request
- generic [ref=e84]: "500"
- generic [ref=e85] [cursor=pointer]:
- generic: Exceptions
- generic [ref=e86]: "1"
- generic [ref=e87] [cursor=pointer]:
- generic: Messages
- generic [ref=e88]: "1"
- generic [ref=e89] [cursor=pointer]:
- generic: Timeline
- generic [ref=e90] [cursor=pointer]:
- generic: Views
- generic [ref=e91]: "5"
- generic [ref=e92] [cursor=pointer]:
- generic: Queries
- generic [ref=e93]: "3"
- generic [ref=e94]:
- generic [ref=e102] [cursor=pointer]: GET /manage
- generic [ref=e103] [cursor=pointer]:
- generic: 699ms
- generic [ref=e105] [cursor=pointer]:
- generic: 28MB
- generic [ref=e107] [cursor=pointer]:
- generic: 12.x
- generic [ref=e109]:
- generic [ref=e111]:
- generic:
- list
- generic [ref=e113]:
- list [ref=e114]
- textbox "Search" [ref=e117]
- generic [ref=e118]:
- list
- generic [ref=e120]:
- list
- list [ref=e125]
- generic [ref=e127]:
- generic:
- list
- generic [ref=e129]:
- list [ref=e130]
- textbox "Search" [ref=e133]
- generic [ref=e134]:
- list
- generic [ref=e136]:
- generic:
- list
```

View File

@@ -1,150 +0,0 @@
# Page snapshot
```yaml
- generic [active] [ref=e1]:
- banner [ref=e2]:
- generic [ref=e3]:
- link "Skinbase.org Skinbase.org" [ref=e4] [cursor=pointer]:
- /url: /
- img "Skinbase.org" [ref=e5]
- generic [ref=e6]: Skinbase.org
- navigation "Main navigation" [ref=e7]:
- button "Discover" [ref=e9] [cursor=pointer]:
- text: Discover
- img [ref=e10]
- button "Browse" [ref=e13] [cursor=pointer]:
- text: Browse
- img [ref=e14]
- button "Creators" [ref=e17] [cursor=pointer]:
- text: Creators
- img [ref=e18]
- button "Community" [ref=e21] [cursor=pointer]:
- text: Community
- img [ref=e22]
- generic [ref=e26]:
- button "Open search" [ref=e27] [cursor=pointer]:
- img [ref=e28]
- generic [ref=e30]: Search
- generic [ref=e31]: CtrlK
- search:
- generic:
- img
- searchbox "Search"
- generic:
- generic: Esc
- button "Close search":
- img
- generic [ref=e32]:
- link "Join" [ref=e33] [cursor=pointer]:
- /url: /register
- link "Sign in" [ref=e34] [cursor=pointer]:
- /url: /login
- text:                 
- main [ref=e35]:
- generic [ref=e36]:
- generic [ref=e37]:
- generic [ref=e38]: "500"
- generic [ref=e39]: Server Error
- heading "Something Went Wrong in the Nova" [level=1] [ref=e40]
- paragraph [ref=e41]: An unexpected error occurred. Our team has been notified and is on it.
- button "Try Again" [ref=e42] [cursor=pointer]:
- generic [ref=e43]: 
- text: Try Again
- generic [ref=e44]:
- link "Return Home" [ref=e45] [cursor=pointer]:
- /url: /
- link "Report Issue" [ref=e46] [cursor=pointer]:
- /url: /contact
- generic [ref=e49]:
- generic [ref=e50]: 
- text: "Reference ID:"
- generic [ref=e51]: ETJV8WIA
- contentinfo [ref=e52]:
- generic [ref=e53]:
- generic [ref=e54]:
- img "Skinbase" [ref=e55]
- generic [ref=e56]: Skinbase
- generic [ref=e57]:
- link "Contact / Apply" [ref=e58] [cursor=pointer]:
- /url: /contact
- link "RSS Feeds" [ref=e59] [cursor=pointer]:
- /url: /rss-feeds
- link "FAQ" [ref=e60] [cursor=pointer]:
- /url: /faq
- link "Rules and Guidelines" [ref=e61] [cursor=pointer]:
- /url: /rules-and-guidelines
- link "Staff" [ref=e62] [cursor=pointer]:
- /url: /staff
- link "Privacy Policy" [ref=e63] [cursor=pointer]:
- /url: /privacy-policy
- link "Terms of Service" [ref=e64] [cursor=pointer]:
- /url: /terms-of-service
- button "Cookie Preferences" [ref=e65] [cursor=pointer]
- generic [ref=e66]: © 2026 Skinbase.org
- dialog "Cookie consent" [ref=e67]:
- generic [ref=e68]:
- generic [ref=e69]:
- generic [ref=e70]: 🍪
- paragraph [ref=e71]:
- text: We use
- strong [ref=e72]: essential cookies
- text: to keep you logged in and protect your session. With your permission we also load
- strong [ref=e73]: advertising cookies
- text: from third-party networks.
- link "Learn more ↗" [ref=e74] [cursor=pointer]:
- /url: /privacy-policy#cookies
- generic [ref=e75]:
- button "Essential only" [ref=e76] [cursor=pointer]
- button "Accept all" [ref=e77] [cursor=pointer]
- generic [ref=e78]:
- generic [ref=e80]:
- generic [ref=e82]:
- generic [ref=e83] [cursor=pointer]:
- generic: Request
- generic [ref=e84]: "500"
- generic [ref=e85] [cursor=pointer]:
- generic: Exceptions
- generic [ref=e86]: "1"
- generic [ref=e87] [cursor=pointer]:
- generic: Messages
- generic [ref=e88]: "1"
- generic [ref=e89] [cursor=pointer]:
- generic: Timeline
- generic [ref=e90] [cursor=pointer]:
- generic: Views
- generic [ref=e91]: "5"
- generic [ref=e92] [cursor=pointer]:
- generic: Queries
- generic [ref=e93]: "3"
- generic [ref=e94]:
- generic [ref=e102] [cursor=pointer]: GET /buddies
- generic [ref=e103] [cursor=pointer]:
- generic: 634ms
- generic [ref=e105] [cursor=pointer]:
- generic: 28MB
- generic [ref=e107] [cursor=pointer]:
- generic: 12.x
- generic [ref=e109]:
- generic [ref=e111]:
- generic:
- list
- generic [ref=e113]:
- list [ref=e114]
- textbox "Search" [ref=e117]
- generic [ref=e118]:
- list
- generic [ref=e120]:
- list
- list [ref=e125]
- generic [ref=e127]:
- generic:
- list
- generic [ref=e129]:
- list [ref=e130]
- textbox "Search" [ref=e133]
- generic [ref=e134]:
- list
- generic [ref=e136]:
- generic:
- list
```

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

View File

@@ -1,212 +0,0 @@
# Page snapshot
```yaml
- generic [active] [ref=e1]:
- banner [ref=e2]:
- generic [ref=e3]:
- link "Skinbase.org Skinbase.org" [ref=e4] [cursor=pointer]:
- /url: /
- img "Skinbase.org" [ref=e5]
- generic [ref=e6]: Skinbase.org
- navigation "Main navigation" [ref=e7]:
- button "Discover" [ref=e9] [cursor=pointer]:
- text: Discover
- img [ref=e10]
- button "Browse" [ref=e13] [cursor=pointer]:
- text: Browse
- img [ref=e14]
- button "Creators" [ref=e17] [cursor=pointer]:
- text: Creators
- img [ref=e18]
- button "Community" [ref=e21] [cursor=pointer]:
- text: Community
- img [ref=e22]
- generic [ref=e26]:
- button "Open search" [ref=e27] [cursor=pointer]:
- img [ref=e28]
- generic [ref=e30]: Search
- generic [ref=e31]: CtrlK
- search:
- generic:
- img
- searchbox "Search"
- generic:
- generic: Esc
- button "Close search":
- img
- generic [ref=e32]:
- link "Join" [ref=e33] [cursor=pointer]:
- /url: /register
- link "Sign in" [ref=e34] [cursor=pointer]:
- /url: /login
- text:                 
- main [ref=e35]:
- main [ref=e40]:
- generic [ref=e44]:
- navigation "Breadcrumb" [ref=e45]:
- link "Home" [ref=e46] [cursor=pointer]:
- /url: /
- generic [ref=e47]:
- generic [ref=e48]: Explore
- generic [ref=e49]:
- heading "Explore" [level=1] [ref=e50]
- paragraph [ref=e51]: Browse the full Skinbase catalog — wallpapers, skins, photography and more.
- generic [ref=e52]:
- img [ref=e53]
- generic [ref=e55]: 1,000 artworks
- generic [ref=e56]:
- link "All Artworks" [ref=e57] [cursor=pointer]:
- /url: /explore/artworks
- link "Skins" [ref=e58] [cursor=pointer]:
- /url: /explore/skins
- link "Wallpapers" [ref=e59] [cursor=pointer]:
- /url: /explore/wallpapers
- link "Photography" [ref=e60] [cursor=pointer]:
- /url: /explore/photography
- link "Other" [ref=e61] [cursor=pointer]:
- /url: /explore/other
- link "Members" [ref=e62] [cursor=pointer]:
- /url: /explore/members
- generic [ref=e65]:
- tablist [ref=e66]:
- tab "🔥 Trending" [selected] [ref=e67] [cursor=pointer]: 🔥 Trending
- tab "🚀 New & Hot" [ref=e69] [cursor=pointer]: 🚀 New & Hot
- tab "⭐ Best" [ref=e70] [cursor=pointer]: ⭐ Best
- tab "🕐 Latest" [ref=e71] [cursor=pointer]: 🕐 Latest
- button "Filters" [ref=e72] [cursor=pointer]:
- img [ref=e73]
- text: Filters
- navigation "Pagination Navigation" [ref=e77]:
- generic [ref=e78]:
- paragraph [ref=e80]: Showing 1 to 22 of 1000 results
- generic [ref=e82]:
- generic "&laquo; Previous" [ref=e83]:
- img [ref=e85]
- generic [ref=e88]: "1"
- link "Go to page 2" [ref=e89] [cursor=pointer]:
- /url: http://skinbase26.test/explore?query=&page=2
- text: "2"
- link "Go to page 3" [ref=e90] [cursor=pointer]:
- /url: http://skinbase26.test/explore?query=&page=3
- text: "3"
- link "Go to page 4" [ref=e91] [cursor=pointer]:
- /url: http://skinbase26.test/explore?query=&page=4
- text: "4"
- link "Go to page 5" [ref=e92] [cursor=pointer]:
- /url: http://skinbase26.test/explore?query=&page=5
- text: "5"
- link "Go to page 6" [ref=e93] [cursor=pointer]:
- /url: http://skinbase26.test/explore?query=&page=6
- text: "6"
- link "Go to page 7" [ref=e94] [cursor=pointer]:
- /url: http://skinbase26.test/explore?query=&page=7
- text: "7"
- link "Go to page 8" [ref=e95] [cursor=pointer]:
- /url: http://skinbase26.test/explore?query=&page=8
- text: "8"
- link "Go to page 9" [ref=e96] [cursor=pointer]:
- /url: http://skinbase26.test/explore?query=&page=9
- text: "9"
- link "Go to page 10" [ref=e97] [cursor=pointer]:
- /url: http://skinbase26.test/explore?query=&page=10
- text: "10"
- generic [ref=e99]: ...
- link "Go to page 41" [ref=e100] [cursor=pointer]:
- /url: http://skinbase26.test/explore?query=&page=41
- text: "41"
- link "Go to page 42" [ref=e101] [cursor=pointer]:
- /url: http://skinbase26.test/explore?query=&page=42
- text: "42"
- link "Next &raquo;" [ref=e102] [cursor=pointer]:
- /url: http://skinbase26.test/explore?query=&page=2
- img [ref=e103]
- contentinfo [ref=e105]:
- generic [ref=e106]:
- generic [ref=e107]:
- img "Skinbase" [ref=e108]
- generic [ref=e109]: Skinbase
- generic [ref=e110]:
- link "Contact / Apply" [ref=e111] [cursor=pointer]:
- /url: /contact
- link "RSS Feeds" [ref=e112] [cursor=pointer]:
- /url: /rss-feeds
- link "FAQ" [ref=e113] [cursor=pointer]:
- /url: /faq
- link "Rules and Guidelines" [ref=e114] [cursor=pointer]:
- /url: /rules-and-guidelines
- link "Staff" [ref=e115] [cursor=pointer]:
- /url: /staff
- link "Privacy Policy" [ref=e116] [cursor=pointer]:
- /url: /privacy-policy
- link "Terms of Service" [ref=e117] [cursor=pointer]:
- /url: /terms-of-service
- button "Cookie Preferences" [ref=e118] [cursor=pointer]
- generic [ref=e119]: © 2026 Skinbase.org
- dialog "Cookie consent" [ref=e120]:
- generic [ref=e121]:
- generic [ref=e122]:
- generic [ref=e123]: 🍪
- paragraph [ref=e124]:
- text: We use
- strong [ref=e125]: essential cookies
- text: to keep you logged in and protect your session. With your permission we also load
- strong [ref=e126]: advertising cookies
- text: from third-party networks.
- link "Learn more ↗" [ref=e127] [cursor=pointer]:
- /url: /privacy-policy#cookies
- generic [ref=e128]:
- button "Essential only" [ref=e129] [cursor=pointer]
- button "Accept all" [ref=e130] [cursor=pointer]
- generic [ref=e131]:
- generic [ref=e133]:
- generic [ref=e135]:
- generic [ref=e136] [cursor=pointer]:
- generic: Request
- generic [ref=e137] [cursor=pointer]:
- generic: Timeline
- generic [ref=e138] [cursor=pointer]:
- generic: Views
- generic [ref=e139]: "7"
- generic [ref=e140] [cursor=pointer]:
- generic: Queries
- generic [ref=e141]: "74"
- generic [ref=e142] [cursor=pointer]:
- generic: Models
- generic [ref=e143]: "91"
- generic [ref=e144] [cursor=pointer]:
- generic: Cache
- generic [ref=e145]: "2"
- generic [ref=e146]:
- generic [ref=e153] [cursor=pointer]:
- generic [ref=e154]: "2"
- generic [ref=e155]: GET /explore
- generic [ref=e156] [cursor=pointer]:
- generic: 1.73s
- generic [ref=e158] [cursor=pointer]:
- generic: 34MB
- generic [ref=e160] [cursor=pointer]:
- generic: 12.x
- generic [ref=e162]:
- generic [ref=e164]:
- generic:
- list
- generic [ref=e166]:
- list [ref=e167]
- textbox "Search" [ref=e170]
- generic [ref=e171]:
- list
- generic [ref=e173]:
- list
- list [ref=e178]
- generic [ref=e180]:
- generic:
- list
- generic [ref=e182]:
- list [ref=e183]
- textbox "Search" [ref=e186]
- generic [ref=e187]:
- list
- generic [ref=e189]:
- generic:
- list
```

View File

@@ -1,150 +0,0 @@
# Page snapshot
```yaml
- generic [active] [ref=e1]:
- banner [ref=e2]:
- generic [ref=e3]:
- link "Skinbase.org Skinbase.org" [ref=e4] [cursor=pointer]:
- /url: /
- img "Skinbase.org" [ref=e5]
- generic [ref=e6]: Skinbase.org
- navigation "Main navigation" [ref=e7]:
- button "Discover" [ref=e9] [cursor=pointer]:
- text: Discover
- img [ref=e10]
- button "Browse" [ref=e13] [cursor=pointer]:
- text: Browse
- img [ref=e14]
- button "Creators" [ref=e17] [cursor=pointer]:
- text: Creators
- img [ref=e18]
- button "Community" [ref=e21] [cursor=pointer]:
- text: Community
- img [ref=e22]
- generic [ref=e26]:
- button "Open search" [ref=e27] [cursor=pointer]:
- img [ref=e28]
- generic [ref=e30]: Search
- generic [ref=e31]: CtrlK
- search:
- generic:
- img
- searchbox "Search"
- generic:
- generic: Esc
- button "Close search":
- img
- generic [ref=e32]:
- link "Join" [ref=e33] [cursor=pointer]:
- /url: /register
- link "Sign in" [ref=e34] [cursor=pointer]:
- /url: /login
- text:                 
- main [ref=e35]:
- generic [ref=e36]:
- generic [ref=e37]:
- generic [ref=e38]: "500"
- generic [ref=e39]: Server Error
- heading "Something Went Wrong in the Nova" [level=1] [ref=e40]
- paragraph [ref=e41]: An unexpected error occurred. Our team has been notified and is on it.
- button "Try Again" [ref=e42] [cursor=pointer]:
- generic [ref=e43]: 
- text: Try Again
- generic [ref=e44]:
- link "Return Home" [ref=e45] [cursor=pointer]:
- /url: /
- link "Report Issue" [ref=e46] [cursor=pointer]:
- /url: /contact
- generic [ref=e49]:
- generic [ref=e50]: 
- text: "Reference ID:"
- generic [ref=e51]: 4ZXOYUFP
- contentinfo [ref=e52]:
- generic [ref=e53]:
- generic [ref=e54]:
- img "Skinbase" [ref=e55]
- generic [ref=e56]: Skinbase
- generic [ref=e57]:
- link "Contact / Apply" [ref=e58] [cursor=pointer]:
- /url: /contact
- link "RSS Feeds" [ref=e59] [cursor=pointer]:
- /url: /rss-feeds
- link "FAQ" [ref=e60] [cursor=pointer]:
- /url: /faq
- link "Rules and Guidelines" [ref=e61] [cursor=pointer]:
- /url: /rules-and-guidelines
- link "Staff" [ref=e62] [cursor=pointer]:
- /url: /staff
- link "Privacy Policy" [ref=e63] [cursor=pointer]:
- /url: /privacy-policy
- link "Terms of Service" [ref=e64] [cursor=pointer]:
- /url: /terms-of-service
- button "Cookie Preferences" [ref=e65] [cursor=pointer]
- generic [ref=e66]: © 2026 Skinbase.org
- dialog "Cookie consent" [ref=e67]:
- generic [ref=e68]:
- generic [ref=e69]:
- generic [ref=e70]: 🍪
- paragraph [ref=e71]:
- text: We use
- strong [ref=e72]: essential cookies
- text: to keep you logged in and protect your session. With your permission we also load
- strong [ref=e73]: advertising cookies
- text: from third-party networks.
- link "Learn more ↗" [ref=e74] [cursor=pointer]:
- /url: /privacy-policy#cookies
- generic [ref=e75]:
- button "Essential only" [ref=e76] [cursor=pointer]
- button "Accept all" [ref=e77] [cursor=pointer]
- generic [ref=e78]:
- generic [ref=e80]:
- generic [ref=e82]:
- generic [ref=e83] [cursor=pointer]:
- generic: Request
- generic [ref=e84]: "500"
- generic [ref=e85] [cursor=pointer]:
- generic: Exceptions
- generic [ref=e86]: "1"
- generic [ref=e87] [cursor=pointer]:
- generic: Messages
- generic [ref=e88]: "1"
- generic [ref=e89] [cursor=pointer]:
- generic: Timeline
- generic [ref=e90] [cursor=pointer]:
- generic: Views
- generic [ref=e91]: "5"
- generic [ref=e92] [cursor=pointer]:
- generic: Queries
- generic [ref=e93]: "3"
- generic [ref=e94]:
- generic [ref=e102] [cursor=pointer]: GET /upload
- generic [ref=e103] [cursor=pointer]:
- generic: 953ms
- generic [ref=e105] [cursor=pointer]:
- generic: 28MB
- generic [ref=e107] [cursor=pointer]:
- generic: 12.x
- generic [ref=e109]:
- generic [ref=e111]:
- generic:
- list
- generic [ref=e113]:
- list [ref=e114]
- textbox "Search" [ref=e117]
- generic [ref=e118]:
- list
- generic [ref=e120]:
- list
- list [ref=e125]
- generic [ref=e127]:
- generic:
- list
- generic [ref=e129]:
- list [ref=e130]
- textbox "Search" [ref=e133]
- generic [ref=e134]:
- list
- generic [ref=e136]:
- generic:
- list
```

Some files were not shown because too many files have changed in this diff Show More