more fixes
8
.env.cpad
Normal 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
@@ -27,3 +27,4 @@ Thumbs.db
|
||||
oldSite
|
||||
packages
|
||||
/packages/*
|
||||
/public/admin/*
|
||||
108
app/Console/Commands/ExportMissingTranslations.php
Normal 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;
|
||||
}
|
||||
}
|
||||
206
app/Http/Controllers/Admin/StoryAdminController.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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 !== '') {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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.']);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
133
app/Http/Controllers/ArtworkDownloadController.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -28,7 +28,7 @@ class AuthenticatedSessionController extends Controller
|
||||
|
||||
$request->session()->regenerate();
|
||||
|
||||
return redirect()->intended(route('dashboard', absolute: false));
|
||||
return redirect()->intended('/');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -105,6 +105,7 @@ class RegisteredUserController extends Controller
|
||||
'is_active' => false,
|
||||
'onboarding_step' => 'email',
|
||||
'username_changed_at' => now(),
|
||||
'last_username_change_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
284
app/Http/Controllers/DashboardController.php
Normal 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';
|
||||
}
|
||||
}
|
||||
157
app/Http/Controllers/News/NewsController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
74
app/Http/Controllers/News/NewsRssController.php
Normal 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;
|
||||
}
|
||||
}
|
||||
1350
app/Http/Controllers/StoryController.php
Normal 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,
|
||||
|
||||
@@ -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', [
|
||||
|
||||
@@ -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,
|
||||
|
||||
252
app/Http/Controllers/User/ProfileCoverController.php
Normal 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.');
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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',
|
||||
|
||||
34
app/Http/Middleware/ConditionalCors.php
Normal 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);
|
||||
}
|
||||
}
|
||||
29
app/Http/Middleware/EnsureCreatorAccess.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
45
app/Http/Requests/Settings/RequestEmailChangeRequest.php
Normal 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.');
|
||||
}
|
||||
},
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
32
app/Http/Requests/Settings/UpdateAccountSectionRequest.php
Normal 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)],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
24
app/Http/Requests/Settings/UpdatePersonalSectionRequest.php
Normal 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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
29
app/Http/Requests/Settings/UpdateProfileSectionRequest.php
Normal 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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
24
app/Http/Requests/Settings/UpdateSecurityPasswordRequest.php
Normal 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)],
|
||||
];
|
||||
}
|
||||
}
|
||||
31
app/Http/Requests/Settings/VerifyEmailChangeRequest.php
Normal 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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
49
app/Mail/EmailChangeVerificationCodeMail.php
Normal 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 [];
|
||||
}
|
||||
}
|
||||
47
app/Mail/EmailChangedSecurityAlertMail.php
Normal 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 [];
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -87,7 +87,7 @@ class ForumCategory extends Model
|
||||
}
|
||||
|
||||
if ($slug !== '') {
|
||||
return '/images/forum/defaults/' . $slug . '.jpg';
|
||||
return '/images/forum/' . $slug . '.webp';
|
||||
}
|
||||
|
||||
return $default;
|
||||
|
||||
@@ -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
|
||||
|
||||
42
app/Models/Notification.php
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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 ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
|
||||
47
app/Notifications/StoryStatusNotification.php
Normal 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]),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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'],
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 © klevze.net',
|
||||
'logo' => '/admin/images/cp/logo.png',
|
||||
],
|
||||
|
||||
'tinymce' => [
|
||||
'apikey' => 'xbqp7qz3idlwqmbessgwzlptb87ffxwphdgadio4dyp72sbw',
|
||||
],
|
||||
];
|
||||
14
config/cpad.php
Normal 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
@@ -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,
|
||||
];
|
||||
@@ -67,7 +67,7 @@ return [
|
||||
'client_secret' => env('DISCORD_CLIENT_SECRET'),
|
||||
'redirect' => env('DISCORD_REDIRECT_URI', '/auth/discord/callback'),
|
||||
],
|
||||
|
||||
|
||||
|
||||
/*
|
||||
* Google AdSense
|
||||
|
||||
22
config/skinbase.php
Normal 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',
|
||||
],
|
||||
];
|
||||
@@ -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',
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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'");
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -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'");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
Before Width: | Height: | Size: 35 KiB |
@@ -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
|
||||
```
|
||||
@@ -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
|
||||
```
|
||||
@@ -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
|
||||
```
|
||||
|
Before Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 35 KiB |
@@ -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
|
||||
```
|
||||
@@ -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
|
||||
```
|
||||
|
Before Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 35 KiB |
@@ -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
|
||||
```
|
||||
|
After Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 35 KiB |
@@ -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
|
||||
```
|
||||
@@ -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
|
||||
```
|
||||
|
Before Width: | Height: | Size: 35 KiB |
@@ -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
|
||||
```
|
||||
@@ -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
|
||||
```
|
||||
|
Before Width: | Height: | Size: 34 KiB |
@@ -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 "« 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 »" [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
|
||||
```
|
||||