Save workspace changes
This commit is contained in:
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\User;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\AvatarUploadRequest;
|
||||
use App\Services\AvatarService;
|
||||
use App\Support\AvatarUrl;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use RuntimeException;
|
||||
|
||||
class AvatarController extends Controller
|
||||
{
|
||||
protected $service;
|
||||
|
||||
public function __construct(AvatarService $service)
|
||||
{
|
||||
$this->service = $service;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle avatar upload request.
|
||||
*/
|
||||
public function upload(AvatarUploadRequest $request): JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
if (!$user) {
|
||||
return response()->json(['error' => 'Unauthorized'], 401);
|
||||
}
|
||||
|
||||
$file = $request->file('avatar');
|
||||
|
||||
try {
|
||||
$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, 256),
|
||||
], 200);
|
||||
} catch (RuntimeException $e) {
|
||||
logger()->warning('Avatar upload validation failed', [
|
||||
'user_id' => (int) $user->id,
|
||||
'message' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'error' => 'Validation failed',
|
||||
'message' => $e->getMessage(),
|
||||
], 422);
|
||||
} catch (\Throwable $e) {
|
||||
logger()->error('Avatar upload failed', [
|
||||
'user_id' => (int) $user->id,
|
||||
'message' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return response()->json(['error' => 'Processing failed'], 500);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\User;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Collections\ReorderSavedCollectionListItemsRequest;
|
||||
use App\Models\Collection;
|
||||
use App\Models\CollectionSavedList;
|
||||
use App\Services\CollectionSavedLibraryService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class CollectionSavedLibraryController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly CollectionSavedLibraryService $savedLibrary,
|
||||
) {
|
||||
}
|
||||
|
||||
public function storeList(Request $request): JsonResponse
|
||||
{
|
||||
$list = $this->savedLibrary->createList(
|
||||
$request->user(),
|
||||
(string) $request->validate([
|
||||
'title' => ['required', 'string', 'min:2', 'max:120'],
|
||||
])['title'],
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'list' => [
|
||||
'id' => (int) $list->id,
|
||||
'title' => $list->title,
|
||||
'slug' => $list->slug,
|
||||
'items_count' => 0,
|
||||
'url' => route('me.saved.collections.lists.show', ['listSlug' => $list->slug]),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function storeItem(Request $request, Collection $collection): JsonResponse
|
||||
{
|
||||
$payload = $request->validate([
|
||||
'saved_list_id' => ['required', 'integer', 'exists:collection_saved_lists,id'],
|
||||
]);
|
||||
|
||||
$list = CollectionSavedList::query()->findOrFail((int) $payload['saved_list_id']);
|
||||
$item = $this->savedLibrary->addToList($request->user(), $list, $collection);
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'added' => $item->wasRecentlyCreated,
|
||||
'item' => [
|
||||
'id' => (int) $item->id,
|
||||
'saved_list_id' => (int) $item->saved_list_id,
|
||||
'collection_id' => (int) $item->collection_id,
|
||||
'order_num' => (int) $item->order_num,
|
||||
],
|
||||
'list' => [
|
||||
'id' => (int) $list->id,
|
||||
'items_count' => $this->savedLibrary->itemsCount($list),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function destroyItem(Request $request, CollectionSavedList $list, Collection $collection): JsonResponse
|
||||
{
|
||||
$removed = $this->savedLibrary->removeFromList($request->user(), $list, $collection);
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'removed' => $removed,
|
||||
'list' => [
|
||||
'id' => (int) $list->id,
|
||||
'items_count' => $this->savedLibrary->itemsCount($list),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function reorderItems(ReorderSavedCollectionListItemsRequest $request, CollectionSavedList $list): JsonResponse
|
||||
{
|
||||
$orderedCollectionIds = $request->validated('collection_ids');
|
||||
|
||||
$this->savedLibrary->reorderList($request->user(), $list, $orderedCollectionIds);
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'list' => [
|
||||
'id' => (int) $list->id,
|
||||
'items_count' => $this->savedLibrary->itemsCount($list),
|
||||
],
|
||||
'ordered_collection_ids' => collect($orderedCollectionIds)
|
||||
->map(static fn ($id) => (int) $id)
|
||||
->values()
|
||||
->all(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function updateNote(Request $request, Collection $collection): JsonResponse
|
||||
{
|
||||
$payload = $request->validate([
|
||||
'note' => ['nullable', 'string', 'max:1000'],
|
||||
]);
|
||||
|
||||
$note = $this->savedLibrary->upsertNote($request->user(), $collection, $payload['note'] ?? null);
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'note' => $note ? [
|
||||
'collection_id' => (int) $note->collection_id,
|
||||
'note' => (string) $note->note,
|
||||
] : null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\User;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use App\Services\UserStatsService;
|
||||
use App\Support\UsernamePolicy;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class FavouritesController extends Controller
|
||||
{
|
||||
public function index(Request $request, $userId = null, $username = null)
|
||||
{
|
||||
$user = $this->resolveLegacyFavouritesUser($request, $userId, $username);
|
||||
if (! $user) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
return redirect()->route('profile.show', [
|
||||
'username' => strtolower((string) $user->username),
|
||||
'tab' => 'favourites',
|
||||
], 301);
|
||||
}
|
||||
|
||||
public function destroy(Request $request, $userId, $artworkId)
|
||||
{
|
||||
$auth = $request->user();
|
||||
if (! $auth || $auth->id != (int)$userId) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$creatorId = (int) DB::table('artworks')->where('id', (int) $artworkId)->value('user_id');
|
||||
|
||||
DB::table('artwork_favourites')->where('user_id', (int) $userId)->where('artwork_id', (int) $artworkId)->delete();
|
||||
|
||||
if ($creatorId) {
|
||||
app(UserStatsService::class)->decrementFavoritesReceived($creatorId);
|
||||
}
|
||||
|
||||
$username = strtolower((string) ($auth->username ?? DB::table('users')->where('id', (int) $userId)->value('username') ?? ''));
|
||||
|
||||
return redirect()->route('profile.show', [
|
||||
'username' => $username,
|
||||
'tab' => 'favourites',
|
||||
])->with('status', 'Removed from favourites');
|
||||
}
|
||||
|
||||
private function resolveLegacyFavouritesUser(Request $request, mixed $userId, mixed $username): ?User
|
||||
{
|
||||
if (is_string($userId) && ! is_numeric($userId) && $username === null) {
|
||||
$username = $userId;
|
||||
$userId = null;
|
||||
}
|
||||
|
||||
if (is_numeric($userId)) {
|
||||
return User::query()->find((int) $userId);
|
||||
}
|
||||
|
||||
if (is_string($username) && $username !== '') {
|
||||
$normalized = UsernamePolicy::normalize($username);
|
||||
|
||||
return User::query()->whereRaw('LOWER(username) = ?', [$normalized])->first();
|
||||
}
|
||||
|
||||
return $request->user();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\User;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Artwork;
|
||||
use App\Services\ArtworkService;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class MembersController extends Controller
|
||||
{
|
||||
protected ArtworkService $artworks;
|
||||
|
||||
public function __construct(ArtworkService $artworks)
|
||||
{
|
||||
$this->artworks = $artworks;
|
||||
}
|
||||
|
||||
public function photos(Request $request, $id = null)
|
||||
{
|
||||
$artworks = $this->artworks->getArtworksByContentType('photography', 40);
|
||||
|
||||
$artworks->getCollection()->load([
|
||||
'user:id,name,username',
|
||||
'user.profile:user_id,avatar_hash',
|
||||
]);
|
||||
|
||||
$artworks->getCollection()->transform(function (Artwork $artwork) {
|
||||
$primaryCategory = $artwork->categories->sortBy('sort_order')->first();
|
||||
$present = \App\Services\ThumbnailPresenter::present($artwork, 'md');
|
||||
|
||||
return (object) [
|
||||
'id' => $artwork->id,
|
||||
'name' => $artwork->title,
|
||||
'slug' => $artwork->slug,
|
||||
'url' => '/art/' . $artwork->id . '/' . $artwork->slug,
|
||||
'thumb' => $present['url'],
|
||||
'thumb_url' => $present['url'],
|
||||
'thumb_srcset' => $present['srcset'] ?? $present['url'],
|
||||
'uname' => $artwork->user->name ?? 'Skinbase',
|
||||
'username' => $artwork->user->username ?? '',
|
||||
'avatar_url' => \App\Support\AvatarUrl::forUser((int) ($artwork->user->id ?? 0), $artwork->user->profile->avatar_hash ?? null, 64),
|
||||
'content_type_name' => $primaryCategory?->contentType?->name ?? 'Photography',
|
||||
'content_type_slug' => $primaryCategory?->contentType?->slug ?? 'photography',
|
||||
'category_name' => $primaryCategory?->name ?? '',
|
||||
'category_slug' => $primaryCategory?->slug ?? '',
|
||||
'width' => $artwork->width,
|
||||
'height' => $artwork->height,
|
||||
'published_at' => $artwork->published_at,
|
||||
];
|
||||
});
|
||||
|
||||
$page_title = 'Member Photos';
|
||||
|
||||
return view('web.members.photos', compact('page_title', 'artworks'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\User;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class MonthlyCommentatorsController extends Controller
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
$hits = 30;
|
||||
$page = max(1, (int) $request->query('page', 1));
|
||||
|
||||
$query = DB::table('artwork_comments as t1')
|
||||
->leftJoin('users as t2', 't1.user_id', '=', 't2.id')
|
||||
->where('t1.user_id', '>', 0)
|
||||
->whereRaw('t1.created_at >= DATE_SUB(NOW(), INTERVAL 30 DAY)')
|
||||
->select(
|
||||
't2.id as user_id',
|
||||
't2.username as user_username',
|
||||
DB::raw('COALESCE(t2.username, t2.name, "User") as uname'),
|
||||
DB::raw('COUNT(*) as num_comments')
|
||||
)
|
||||
->groupBy('t2.id')
|
||||
->orderByDesc('num_comments');
|
||||
|
||||
$rows = $query->paginate($hits)->withQueryString();
|
||||
|
||||
$page_title = 'Monthly Top Commentators';
|
||||
|
||||
return view('web.comments.monthly', compact('page_title', 'rows'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\User;
|
||||
|
||||
use App\Events\Collections\CollectionViewed;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Collection;
|
||||
use App\Models\User;
|
||||
use App\Services\CollectionCollaborationService;
|
||||
use App\Services\CollectionCommentService;
|
||||
use App\Services\CollectionDiscoveryService;
|
||||
use App\Services\CollectionFollowService;
|
||||
use App\Services\CollectionLinkService;
|
||||
use App\Services\CollectionLikeService;
|
||||
use App\Services\CollectionLinkedCollectionsService;
|
||||
use App\Services\CollectionRecommendationService;
|
||||
use App\Services\CollectionSaveService;
|
||||
use App\Services\CollectionSeriesService;
|
||||
use App\Services\CollectionSubmissionService;
|
||||
use App\Services\CollectionService;
|
||||
use App\Support\Seo\SeoFactory;
|
||||
use App\Support\UsernamePolicy;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Inertia\Inertia;
|
||||
|
||||
class ProfileCollectionController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly CollectionService $collections,
|
||||
private readonly CollectionLikeService $likes,
|
||||
private readonly CollectionFollowService $follows,
|
||||
private readonly CollectionSaveService $saves,
|
||||
private readonly CollectionCollaborationService $collaborators,
|
||||
private readonly CollectionSubmissionService $submissions,
|
||||
private readonly CollectionCommentService $comments,
|
||||
private readonly CollectionDiscoveryService $discovery,
|
||||
private readonly CollectionRecommendationService $recommendations,
|
||||
private readonly CollectionLinkService $entityLinks,
|
||||
private readonly CollectionLinkedCollectionsService $linkedCollections,
|
||||
private readonly CollectionSeriesService $series,
|
||||
) {
|
||||
}
|
||||
|
||||
public function show(Request $request, string $username, string $slug)
|
||||
{
|
||||
$normalized = UsernamePolicy::normalize($username);
|
||||
$user = User::query()->whereRaw('LOWER(username) = ?', [$normalized])->first();
|
||||
|
||||
if (! $user) {
|
||||
$redirect = DB::table('username_redirects')
|
||||
->whereRaw('LOWER(old_username) = ?', [$normalized])
|
||||
->value('new_username');
|
||||
|
||||
if ($redirect) {
|
||||
return redirect()->route('profile.collections.show', [
|
||||
'username' => strtolower((string) $redirect),
|
||||
'slug' => $slug,
|
||||
], 301);
|
||||
}
|
||||
|
||||
abort(404);
|
||||
}
|
||||
|
||||
if ($username !== strtolower((string) $user->username)) {
|
||||
return redirect()->route('profile.collections.show', [
|
||||
'username' => strtolower((string) $user->username),
|
||||
'slug' => $slug,
|
||||
], 301);
|
||||
}
|
||||
|
||||
$collection = Collection::query()
|
||||
->with([
|
||||
'user.profile',
|
||||
'coverArtwork:id,user_id,title,slug,hash,thumb_ext,published_at,is_public,is_approved,deleted_at',
|
||||
'canonicalCollection.user.profile',
|
||||
])
|
||||
->where('user_id', $user->id)
|
||||
->where('slug', $slug)
|
||||
->firstOrFail();
|
||||
|
||||
$viewer = $request->user();
|
||||
$ownerView = $viewer && (int) $viewer->id === (int) $user->id;
|
||||
|
||||
if ($collection->canonical_collection_id) {
|
||||
$canonicalTarget = $collection->canonicalCollection;
|
||||
|
||||
if ($canonicalTarget instanceof Collection
|
||||
&& (int) $canonicalTarget->id !== (int) $collection->id
|
||||
&& $canonicalTarget->user instanceof User
|
||||
&& $canonicalTarget->canBeViewedBy($viewer)) {
|
||||
return redirect()->route('profile.collections.show', [
|
||||
'username' => strtolower((string) $canonicalTarget->user->username),
|
||||
'slug' => $canonicalTarget->slug,
|
||||
], 301);
|
||||
}
|
||||
}
|
||||
|
||||
if (! $collection->canBeViewedBy($viewer)) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$collection = $this->collections->recordView($collection);
|
||||
$this->saves->touchSavedCollectionView($viewer, $collection);
|
||||
$artworks = $this->collections->getCollectionDetailArtworks($collection, $ownerView, 24);
|
||||
$collectionPayload = $this->collections->mapCollectionDetailPayload($collection, $ownerView);
|
||||
$manualRelatedCollections = $this->linkedCollections->publicLinkedCollections($collection, 6);
|
||||
$recommendedCollections = $this->recommendations->relatedPublicCollections($collection, 6);
|
||||
$seriesContext = $collection->inSeries()
|
||||
? $this->series->seriesContext($collection)
|
||||
: ['previous' => null, 'next' => null, 'items' => [], 'title' => null, 'description' => null];
|
||||
|
||||
event(new CollectionViewed($collection, $viewer?->id));
|
||||
|
||||
$seo = app(SeoFactory::class)->collectionPage(
|
||||
$collection->is_featured
|
||||
? sprintf('Featured: %s by %s — Skinbase Nova', $collection->title, $collection->displayOwnerName())
|
||||
: sprintf('%s by %s — Skinbase Nova', $collection->title, $collection->displayOwnerName()),
|
||||
$collection->summary ?: $collection->description ?: sprintf('Explore the %s collection by %s on Skinbase Nova.', $collection->title, $collection->displayOwnerName()),
|
||||
$collectionPayload['public_url'],
|
||||
$collectionPayload['cover_image'],
|
||||
$collection->visibility === Collection::VISIBILITY_PUBLIC,
|
||||
)->toArray();
|
||||
|
||||
return Inertia::render('Collection/CollectionShow', [
|
||||
'collection' => $collectionPayload,
|
||||
'artworks' => $this->collections->mapArtworkPaginator($artworks),
|
||||
'owner' => $collectionPayload['owner'],
|
||||
'isOwner' => $ownerView,
|
||||
'manageUrl' => $ownerView ? route('settings.collections.show', ['collection' => $collection->id]) : null,
|
||||
'editUrl' => $ownerView ? route('settings.collections.edit', ['collection' => $collection->id]) : null,
|
||||
'analyticsUrl' => $ownerView && $collection->supportsAnalytics() ? route('settings.collections.analytics', ['collection' => $collection->id]) : null,
|
||||
'historyUrl' => $ownerView ? route('settings.collections.history', ['collection' => $collection->id]) : null,
|
||||
'engagement' => [
|
||||
'liked' => $this->likes->isLiked($viewer, $collection),
|
||||
'following' => $this->follows->isFollowing($viewer, $collection),
|
||||
'saved' => $this->saves->isSaved($viewer, $collection),
|
||||
'can_interact' => ! $ownerView && $collection->isPubliclyEngageable(),
|
||||
'like_url' => route('collections.like', ['collection' => $collection->id]),
|
||||
'unlike_url' => route('collections.unlike', ['collection' => $collection->id]),
|
||||
'follow_url' => route('collections.follow', ['collection' => $collection->id]),
|
||||
'unfollow_url' => route('collections.unfollow', ['collection' => $collection->id]),
|
||||
'save_url' => route('collections.save', ['collection' => $collection->id]),
|
||||
'unsave_url' => route('collections.unsave', ['collection' => $collection->id]),
|
||||
'share_url' => route('collections.share', ['collection' => $collection->id]),
|
||||
'login_url' => route('login'),
|
||||
],
|
||||
'members' => $this->collaborators->mapMembers($collection, $viewer),
|
||||
'submissions' => $this->submissions->mapSubmissions($collection, $viewer),
|
||||
'comments' => $this->comments->mapComments($collection, $viewer),
|
||||
'entityLinks' => $this->entityLinks->links($collection, true),
|
||||
'relatedCollections' => $this->collections->mapCollectionCardPayloads(
|
||||
$manualRelatedCollections
|
||||
->concat($recommendedCollections)
|
||||
->unique('id')
|
||||
->take(6)
|
||||
->values(),
|
||||
false
|
||||
),
|
||||
'seriesContext' => [
|
||||
'key' => $seriesContext['key'] ?? $collection->series_key,
|
||||
'title' => $seriesContext['title'] ?? $collection->series_title,
|
||||
'description' => $seriesContext['description'] ?? $collection->series_description,
|
||||
'url' => ! empty($seriesContext['key']) ? route('collections.series.show', ['seriesKey' => $seriesContext['key']]) : null,
|
||||
'previous' => $seriesContext['previous'] ? ($this->collections->mapCollectionCardPayloads(collect([$seriesContext['previous']]), false)[0] ?? null) : null,
|
||||
'next' => $seriesContext['next'] ? ($this->collections->mapCollectionCardPayloads(collect([$seriesContext['next']]), false)[0] ?? null) : null,
|
||||
'siblings' => $this->collections->mapCollectionCardPayloads(collect($seriesContext['items'] ?? [])->filter(fn (Collection $item) => (int) $item->id !== (int) $collection->id), false),
|
||||
],
|
||||
'submitEndpoint' => route('collections.submissions.store', ['collection' => $collection->id]),
|
||||
'commentsEndpoint' => route('collections.comments.store', ['collection' => $collection->id]),
|
||||
'submissionArtworkOptions' => $viewer ? $this->collections->getSubmissionArtworkOptions($viewer) : [],
|
||||
'canSubmit' => $collection->canReceiveSubmissionsFrom($viewer),
|
||||
'canComment' => $collection->canReceiveCommentsFrom($viewer),
|
||||
'profileCollectionsUrl' => route('profile.tab', [
|
||||
'username' => strtolower((string) $user->username),
|
||||
'tab' => 'collections',
|
||||
]),
|
||||
'featuredCollectionsUrl' => route('collections.featured'),
|
||||
'reportEndpoint' => $viewer ? route('api.reports.store') : null,
|
||||
'seo' => $seo,
|
||||
])->rootView('collections');
|
||||
}
|
||||
|
||||
public function showSeries(Request $request, string $seriesKey)
|
||||
{
|
||||
$seriesCollections = $this->series->publicSeriesItems($seriesKey);
|
||||
|
||||
abort_if($seriesCollections->isEmpty(), 404);
|
||||
|
||||
$mappedCollections = $this->collections->mapCollectionCardPayloads($seriesCollections, false);
|
||||
$leadCollection = $mappedCollections[0] ?? null;
|
||||
$ownersCount = $seriesCollections->pluck('user_id')->unique()->count();
|
||||
$artworksCount = $seriesCollections->sum(fn (Collection $collection) => (int) $collection->artworks_count);
|
||||
$latestActivityAt = $seriesCollections->max('last_activity_at');
|
||||
$seriesMeta = $this->series->metadataFor($seriesCollections);
|
||||
$seriesTitle = $seriesMeta['title'] ?: collect($mappedCollections)
|
||||
->pluck('campaign_label')
|
||||
->filter()
|
||||
->first();
|
||||
$seriesDescription = $seriesMeta['description'];
|
||||
|
||||
$seo = app(SeoFactory::class)->collectionListing(
|
||||
sprintf('Series: %s — Skinbase Nova', $seriesKey),
|
||||
sprintf('Explore the %s collection series on Skinbase Nova.', $seriesKey),
|
||||
route('collections.series.show', ['seriesKey' => $seriesKey])
|
||||
)->toArray();
|
||||
|
||||
return Inertia::render('Collection/CollectionSeriesShow', [
|
||||
'seriesKey' => $seriesKey,
|
||||
'title' => $seriesTitle ?: sprintf('Collection Series: %s', str_replace(['-', '_'], ' ', $seriesKey)),
|
||||
'description' => $seriesDescription ?: sprintf('Browse the %s collection series in sequence, with public entries ordered for smooth navigation across related curations.', $seriesKey),
|
||||
'collections' => $mappedCollections,
|
||||
'leadCollection' => $leadCollection,
|
||||
'stats' => [
|
||||
'collections' => $seriesCollections->count(),
|
||||
'owners' => $ownersCount,
|
||||
'artworks' => $artworksCount,
|
||||
'latest_activity_at' => optional($latestActivityAt)?->toISOString(),
|
||||
],
|
||||
'seo' => $seo,
|
||||
])->rootView('collections');
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,263 @@
|
||||
<?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\Storage;
|
||||
use Intervention\Image\Drivers\Gd\Driver as GdDriver;
|
||||
use Intervention\Image\Drivers\Imagick\Driver as ImagickDriver;
|
||||
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 coverDiskName(): string
|
||||
{
|
||||
return (string) config('covers.disk', 's3');
|
||||
}
|
||||
|
||||
private function coverDirectory(string $hash): string
|
||||
{
|
||||
$p1 = substr($hash, 0, 2);
|
||||
$p2 = substr($hash, 2, 2);
|
||||
|
||||
return 'covers/' . $p1 . '/' . $p2;
|
||||
}
|
||||
|
||||
private function coverPath(string $hash, string $ext): string
|
||||
{
|
||||
return $this->coverDirectory($hash) . '/' . $hash . '.' . $ext;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{hash: string, ext: string}
|
||||
*/
|
||||
private function storeCoverFile(UploadedFile $file): array
|
||||
{
|
||||
$this->assertImageManager();
|
||||
$this->assertStorageIsAllowed();
|
||||
|
||||
$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,
|
||||
));
|
||||
}
|
||||
|
||||
$image = $this->manager->read($raw);
|
||||
$processed = $image->cover(self::TARGET_WIDTH, self::TARGET_HEIGHT, 'center');
|
||||
$ext = 'webp';
|
||||
$encoded = $this->encodeByExtension($processed, $ext);
|
||||
|
||||
$hash = hash('sha256', $encoded);
|
||||
$disk = Storage::disk($this->coverDiskName());
|
||||
$written = $disk->put($this->coverPath($hash, $ext), $encoded, [
|
||||
'visibility' => 'public',
|
||||
'CacheControl' => 'public, max-age=31536000, immutable',
|
||||
'ContentType' => match ($ext) {
|
||||
'jpg' => 'image/jpeg',
|
||||
'png' => 'image/png',
|
||||
default => 'image/webp',
|
||||
},
|
||||
]);
|
||||
|
||||
if ($written !== true) {
|
||||
throw new RuntimeException('Unable to store cover image in object storage.');
|
||||
}
|
||||
|
||||
return ['hash' => $hash, 'ext' => $ext];
|
||||
}
|
||||
|
||||
private function encodeByExtension($image, string $ext): string
|
||||
{
|
||||
return match ($ext) {
|
||||
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;
|
||||
}
|
||||
|
||||
Storage::disk($this->coverDiskName())->delete($this->coverPath($trimHash, $trimExt));
|
||||
}
|
||||
|
||||
private function assertImageManager(): void
|
||||
{
|
||||
if ($this->manager !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
throw new RuntimeException('Image processing is not available on this environment.');
|
||||
}
|
||||
|
||||
private function assertStorageIsAllowed(): void
|
||||
{
|
||||
if (! app()->environment('production')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$diskName = $this->coverDiskName();
|
||||
if (in_array($diskName, ['local', 'public'], true)) {
|
||||
throw new RuntimeException('Production cover storage must use object storage, not local/public disks.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\User;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class ReceivedCommentsController extends Controller
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
$user = $request->user();
|
||||
if (! $user) {
|
||||
return redirect()->route('login');
|
||||
}
|
||||
|
||||
try {
|
||||
$comments = app(\App\Services\LegacyService::class)->receivedComments($user->id);
|
||||
} catch (\Throwable $e) {
|
||||
$comments = collect();
|
||||
}
|
||||
|
||||
return view('user.received-comments', [
|
||||
'comments' => $comments,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\User;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Collections\CollectionSavedLibraryRequest;
|
||||
use App\Models\Collection;
|
||||
use App\Models\CollectionSavedList;
|
||||
use App\Services\CollectionRecommendationService;
|
||||
use App\Services\CollectionSavedLibraryService;
|
||||
use App\Services\CollectionService;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class SavedCollectionController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly CollectionService $collections,
|
||||
private readonly CollectionSavedLibraryService $savedLibrary,
|
||||
private readonly CollectionRecommendationService $recommendations,
|
||||
) {
|
||||
}
|
||||
|
||||
public function index(CollectionSavedLibraryRequest $request): Response
|
||||
{
|
||||
return $this->renderSavedLibrary($request);
|
||||
}
|
||||
|
||||
public function showList(CollectionSavedLibraryRequest $request, string $listSlug): Response
|
||||
{
|
||||
$list = $this->savedLibrary->findListBySlugForUser($request->user(), $listSlug);
|
||||
|
||||
return $this->renderSavedLibrary($request, $list);
|
||||
}
|
||||
|
||||
private function renderSavedLibrary(CollectionSavedLibraryRequest $request, ?CollectionSavedList $activeList = null): Response
|
||||
{
|
||||
$savedCollections = $this->collections->getSavedCollectionsForUser($request->user(), 120);
|
||||
$filter = (string) ($request->validated('filter') ?? 'all');
|
||||
$sort = (string) ($request->validated('sort') ?? 'saved_desc');
|
||||
$query = trim((string) ($request->validated('q') ?? ''));
|
||||
$listId = $activeList ? (int) $activeList->id : ($request->filled('list') ? (int) $request->query('list') : null);
|
||||
$preserveListOrder = false;
|
||||
$listOrder = null;
|
||||
|
||||
if ($activeList) {
|
||||
$preserveListOrder = true;
|
||||
$allowedCollectionIds = $this->savedLibrary->collectionIdsForList($request->user(), $activeList);
|
||||
$listOrder = array_flip($allowedCollectionIds);
|
||||
$savedCollections = $savedCollections
|
||||
->filter(fn ($collection) => in_array((int) $collection->id, $allowedCollectionIds, true))
|
||||
->sortBy(fn ($collection) => $listOrder[(int) $collection->id] ?? PHP_INT_MAX)
|
||||
->values();
|
||||
} elseif ($listId) {
|
||||
$preserveListOrder = true;
|
||||
$activeList = $request->user()->savedCollectionLists()->withCount('items')->findOrFail($listId);
|
||||
$allowedCollectionIds = $this->savedLibrary->collectionIdsForList($request->user(), $activeList);
|
||||
$listOrder = array_flip($allowedCollectionIds);
|
||||
$savedCollections = $savedCollections
|
||||
->filter(fn ($collection) => in_array((int) $collection->id, $allowedCollectionIds, true))
|
||||
->sortBy(fn ($collection) => $listOrder[(int) $collection->id] ?? PHP_INT_MAX)
|
||||
->values();
|
||||
}
|
||||
|
||||
$savedCollectionIds = $savedCollections->pluck('id')->map(static fn ($id): int => (int) $id)->all();
|
||||
$notes = $this->savedLibrary->notesFor($request->user(), $savedCollectionIds);
|
||||
$saveMetadata = $this->savedLibrary->saveMetadataFor($request->user(), $savedCollectionIds);
|
||||
$filterCounts = $this->filterCounts($savedCollections, $notes);
|
||||
|
||||
$savedCollections = $savedCollections
|
||||
->filter(fn (Collection $collection): bool => $this->matchesSearch($collection, $query))
|
||||
->filter(fn (Collection $collection): bool => $this->matchesFilter($collection, $filter, $notes))
|
||||
->values();
|
||||
|
||||
if (! ($preserveListOrder && $sort === 'saved_desc')) {
|
||||
$savedCollections = $this->sortCollections($savedCollections, $sort)->values();
|
||||
}
|
||||
|
||||
$collectionPayloads = $this->collections->mapCollectionCardPayloads($savedCollections, false);
|
||||
$collectionIds = collect($collectionPayloads)->pluck('id')->map(static fn ($id) => (int) $id)->all();
|
||||
$memberships = $this->savedLibrary->membershipsFor($request->user(), $collectionIds);
|
||||
$savedLists = collect($this->savedLibrary->listsFor($request->user()))
|
||||
->map(function (array $list) use ($filter, $sort, $query): array {
|
||||
return [
|
||||
...$list,
|
||||
'url' => route('me.saved.collections.lists.show', ['listSlug' => $list['slug']]) . ($filter !== 'all' || $sort !== 'saved_desc'
|
||||
? ('?' . http_build_query(array_filter([
|
||||
'filter' => $filter !== 'all' ? $filter : null,
|
||||
'sort' => $sort !== 'saved_desc' ? $sort : null,
|
||||
'q' => $query !== '' ? $query : null,
|
||||
])))
|
||||
: ''),
|
||||
];
|
||||
})
|
||||
->values()
|
||||
->all();
|
||||
|
||||
$filterOptions = [
|
||||
['key' => 'all', 'label' => 'All', 'count' => $filterCounts['all'] ?? 0],
|
||||
['key' => 'editorial', 'label' => 'Editorial', 'count' => $filterCounts['editorial'] ?? 0],
|
||||
['key' => 'community', 'label' => 'Community', 'count' => $filterCounts['community'] ?? 0],
|
||||
['key' => 'personal', 'label' => 'Personal', 'count' => $filterCounts['personal'] ?? 0],
|
||||
['key' => 'seasonal', 'label' => 'Seasonal or campaign', 'count' => $filterCounts['seasonal'] ?? 0],
|
||||
['key' => 'noted', 'label' => 'With notes', 'count' => $filterCounts['noted'] ?? 0],
|
||||
['key' => 'revisited', 'label' => 'Revisited', 'count' => $filterCounts['revisited'] ?? 0],
|
||||
];
|
||||
|
||||
$sortOptions = [
|
||||
['key' => 'saved_desc', 'label' => 'Recently saved'],
|
||||
['key' => 'saved_asc', 'label' => 'Oldest saved'],
|
||||
['key' => 'updated_desc', 'label' => 'Recently updated'],
|
||||
['key' => 'revisited_desc', 'label' => 'Recently revisited'],
|
||||
['key' => 'ranking_desc', 'label' => 'Highest ranking'],
|
||||
['key' => 'title_asc', 'label' => 'Title A-Z'],
|
||||
];
|
||||
|
||||
return Inertia::render('Collection/SavedCollections', [
|
||||
'collections' => collect($collectionPayloads)->map(function (array $collection) use ($memberships, $notes, $saveMetadata): array {
|
||||
return [
|
||||
...$collection,
|
||||
'saved_list_ids' => $memberships[(int) $collection['id']] ?? [],
|
||||
'saved_note' => $notes[(int) $collection['id']] ?? null,
|
||||
'saved_because' => $saveMetadata[(int) $collection['id']]['saved_because'] ?? null,
|
||||
'last_viewed_at' => $saveMetadata[(int) $collection['id']]['last_viewed_at'] ?? null,
|
||||
];
|
||||
})->all(),
|
||||
'recentlyRevisited' => $this->collections->mapCollectionCardPayloads($this->savedLibrary->recentlyRevisited($request->user(), 6), false),
|
||||
'recommendedCollections' => $this->collections->mapCollectionCardPayloads($this->recommendations->recommendedForUser($request->user(), 6), false),
|
||||
'savedLists' => $savedLists,
|
||||
'activeList' => $activeList ? [
|
||||
'id' => (int) $activeList->id,
|
||||
'title' => (string) $activeList->title,
|
||||
'slug' => (string) $activeList->slug,
|
||||
'items_count' => (int) $activeList->items_count,
|
||||
'url' => route('me.saved.collections.lists.show', ['listSlug' => $activeList->slug]),
|
||||
] : null,
|
||||
'activeFilters' => [
|
||||
'q' => $query,
|
||||
'filter' => $filter,
|
||||
'sort' => $sort,
|
||||
'list' => $listId,
|
||||
],
|
||||
'filterOptions' => $filterOptions,
|
||||
'sortOptions' => $sortOptions,
|
||||
'endpoints' => [
|
||||
'createList' => route('me.saved.collections.lists.store'),
|
||||
'addToListPattern' => route('me.saved.collections.lists.items.store', ['collection' => '__COLLECTION__']),
|
||||
'removeFromListPattern' => route('me.saved.collections.lists.items.destroy', ['list' => '__LIST__', 'collection' => '__COLLECTION__']),
|
||||
'reorderItemsPattern' => route('me.saved.collections.lists.items.reorder', ['list' => '__LIST__']),
|
||||
'updateNotePattern' => route('me.saved.collections.notes.update', ['collection' => '__COLLECTION__']),
|
||||
'unsavePattern' => route('collections.unsave', ['collection' => '__COLLECTION__']),
|
||||
],
|
||||
'libraryUrl' => route('me.saved.collections'),
|
||||
'browseUrl' => route('collections.featured'),
|
||||
'seo' => [
|
||||
'title' => $activeList ? sprintf('%s — Saved Collections — Skinbase Nova', $activeList->title) : 'Saved Collections — Skinbase Nova',
|
||||
'description' => $activeList ? sprintf('Saved collections in the %s list on Skinbase Nova.', $activeList->title) : 'Your saved collections on Skinbase Nova.',
|
||||
'canonical' => $activeList ? route('me.saved.collections.lists.show', ['listSlug' => $activeList->slug]) : route('me.saved.collections'),
|
||||
'robots' => 'noindex,follow',
|
||||
],
|
||||
])->rootView('collections');
|
||||
}
|
||||
|
||||
private function matchesSearch(Collection $collection, string $query): bool
|
||||
{
|
||||
if ($query === '') {
|
||||
return true;
|
||||
}
|
||||
|
||||
$haystacks = [
|
||||
$collection->title,
|
||||
$collection->subtitle,
|
||||
$collection->summary,
|
||||
$collection->description,
|
||||
$collection->campaign_label,
|
||||
$collection->season_key,
|
||||
$collection->event_label,
|
||||
$collection->series_title,
|
||||
optional($collection->user)->username,
|
||||
optional($collection->user)->name,
|
||||
];
|
||||
|
||||
$needle = mb_strtolower($query);
|
||||
|
||||
return collect($haystacks)
|
||||
->filter(fn ($value): bool => is_string($value) && $value !== '')
|
||||
->contains(fn (string $value): bool => str_contains(mb_strtolower($value), $needle));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $notes
|
||||
*/
|
||||
private function matchesFilter(Collection $collection, string $filter, array $notes): bool
|
||||
{
|
||||
return match ($filter) {
|
||||
'editorial' => $collection->type === Collection::TYPE_EDITORIAL,
|
||||
'community' => $collection->type === Collection::TYPE_COMMUNITY,
|
||||
'personal' => $collection->type === Collection::TYPE_PERSONAL,
|
||||
'seasonal' => filled($collection->season_key) || filled($collection->event_key) || filled($collection->campaign_key),
|
||||
'noted' => filled($notes[(int) $collection->id] ?? null),
|
||||
'revisited' => $this->timestamp($collection->saved_last_viewed_at) !== $this->timestamp($collection->saved_at),
|
||||
default => true,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $notes
|
||||
* @return array<string, int>
|
||||
*/
|
||||
private function filterCounts($collections, array $notes): array
|
||||
{
|
||||
return [
|
||||
'all' => $collections->count(),
|
||||
'editorial' => $collections->where('type', Collection::TYPE_EDITORIAL)->count(),
|
||||
'community' => $collections->where('type', Collection::TYPE_COMMUNITY)->count(),
|
||||
'personal' => $collections->where('type', Collection::TYPE_PERSONAL)->count(),
|
||||
'seasonal' => $collections->filter(fn (Collection $collection): bool => filled($collection->season_key) || filled($collection->event_key) || filled($collection->campaign_key))->count(),
|
||||
'noted' => $collections->filter(fn (Collection $collection): bool => filled($notes[(int) $collection->id] ?? null))->count(),
|
||||
'revisited' => $collections->filter(fn (Collection $collection): bool => $this->timestamp($collection->saved_last_viewed_at) !== $this->timestamp($collection->saved_at))->count(),
|
||||
];
|
||||
}
|
||||
|
||||
private function sortCollections($collections, string $sort)
|
||||
{
|
||||
return match ($sort) {
|
||||
'saved_asc' => $collections->sortBy(fn (Collection $collection): int => $this->timestamp($collection->saved_at)),
|
||||
'updated_desc' => $collections->sortByDesc(fn (Collection $collection): int => $this->timestamp($collection->updated_at)),
|
||||
'revisited_desc' => $collections->sortByDesc(fn (Collection $collection): int => $this->timestamp($collection->saved_last_viewed_at)),
|
||||
'ranking_desc' => $collections->sortByDesc(fn (Collection $collection): float => (float) ($collection->ranking_score ?? 0)),
|
||||
'title_asc' => $collections->sortBy(fn (Collection $collection): string => mb_strtolower((string) $collection->title)),
|
||||
default => $collections->sortByDesc(fn (Collection $collection): int => $this->timestamp($collection->saved_at)),
|
||||
};
|
||||
}
|
||||
|
||||
private function timestamp(mixed $value): int
|
||||
{
|
||||
if ($value instanceof \DateTimeInterface) {
|
||||
return $value->getTimestamp();
|
||||
}
|
||||
|
||||
if (is_numeric($value)) {
|
||||
return (int) $value;
|
||||
}
|
||||
|
||||
if (is_string($value) && $value !== '') {
|
||||
$timestamp = strtotime($value);
|
||||
|
||||
return $timestamp !== false ? $timestamp : 0;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\User;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\ThumbnailPresenter;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class StatisticsController extends Controller
|
||||
{
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$userId = $request->user()->id;
|
||||
|
||||
$sort = (string) $request->query('sort', 'date');
|
||||
$allowed = ['date', 'name', 'dls', 'category', 'comments'];
|
||||
if (! in_array($sort, $allowed, true)) {
|
||||
$sort = 'date';
|
||||
}
|
||||
|
||||
$categorySub = DB::table('artwork_category as ac')
|
||||
->join('categories as c', 'ac.category_id', '=', 'c.id')
|
||||
->select('ac.artwork_id', DB::raw('MIN(c.name) as category_name'))
|
||||
->groupBy('ac.artwork_id');
|
||||
|
||||
$query = DB::table('artworks as a')
|
||||
->leftJoinSub($categorySub, 'cat', function ($join) {
|
||||
$join->on('a.id', '=', 'cat.artwork_id');
|
||||
})
|
||||
->where('a.user_id', $userId)
|
||||
->select([
|
||||
'a.*',
|
||||
DB::raw('cat.category_name as category_name'),
|
||||
])
|
||||
->selectRaw('(SELECT COUNT(*) FROM artwork_comments WHERE artwork_id = a.id) AS num_comments');
|
||||
|
||||
if ($sort === 'name') {
|
||||
$query->orderBy('a.name', 'asc');
|
||||
} elseif ($sort === 'dls') {
|
||||
$query->orderByDesc('a.dls');
|
||||
} elseif ($sort === 'category') {
|
||||
$query->orderBy('cat.category_name', 'asc');
|
||||
} elseif ($sort === 'comments') {
|
||||
$query->orderByDesc('num_comments');
|
||||
} else {
|
||||
$query->orderByDesc('a.published_at')->orderByDesc('a.id');
|
||||
}
|
||||
|
||||
$artworks = $query->paginate(20)->appends(['sort' => $sort]);
|
||||
|
||||
$artworks->getCollection()->transform(function ($row) {
|
||||
$thumb = ThumbnailPresenter::present($row, 'sm');
|
||||
$row->thumb_url = $thumb['url'] ?? '';
|
||||
$row->thumb_srcset = $thumb['srcset'] ?? null;
|
||||
return $row;
|
||||
});
|
||||
|
||||
return view('user.statistics', [
|
||||
'artworks' => $artworks,
|
||||
'sort' => $sort,
|
||||
'page_title' => 'Artwork Statistics',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\User;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use App\Models\ArtworkDownload;
|
||||
use Illuminate\Support\Str;
|
||||
use Carbon\Carbon;
|
||||
|
||||
class TodayDownloadsController extends Controller
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
$hits = 30;
|
||||
|
||||
$today = Carbon::now()->toDateString();
|
||||
|
||||
$query = ArtworkDownload::with([
|
||||
'artwork.user:id,name,username',
|
||||
'artwork.user.profile:user_id,avatar_hash',
|
||||
'artwork.categories:id,name,slug',
|
||||
])
|
||||
->whereDate('created_at', $today)
|
||||
->whereHas('artwork', function ($q) {
|
||||
$q->public()->published()->whereNull('deleted_at');
|
||||
})
|
||||
->selectRaw('artwork_id, COUNT(*) as num_downloads')
|
||||
->groupBy('artwork_id')
|
||||
->orderByDesc('num_downloads');
|
||||
|
||||
$paginator = $query->paginate($hits)->withQueryString();
|
||||
|
||||
$paginator->getCollection()->transform(function ($row) {
|
||||
$art = $row->artwork ?? null;
|
||||
if (! $art && isset($row->artwork_id)) {
|
||||
$art = \App\Models\Artwork::find($row->artwork_id);
|
||||
}
|
||||
|
||||
if (! $art) {
|
||||
return (object) [
|
||||
'id' => null,
|
||||
'name' => 'Artwork',
|
||||
'slug' => 'artwork',
|
||||
'thumb' => 'https://files.skinbase.org/default/missing_md.webp',
|
||||
'thumb_url' => 'https://files.skinbase.org/default/missing_md.webp',
|
||||
'thumb_srcset' => 'https://files.skinbase.org/default/missing_md.webp',
|
||||
'category_name' => '',
|
||||
'category_slug' => '',
|
||||
'num_downloads' => $row->num_downloads ?? 0,
|
||||
];
|
||||
}
|
||||
|
||||
$name = $art->title ?? null;
|
||||
$picture = $art->file_name ?? null;
|
||||
$ext = pathinfo($picture ?? '', PATHINFO_EXTENSION) ?: 'jpg';
|
||||
$encoded = null;
|
||||
$present = $art ? \App\Services\ThumbnailPresenter::present($art, 'md') : null;
|
||||
$thumb = $present ? $present['url'] : 'https://files.skinbase.org/default/missing_md.webp';
|
||||
$primaryCategory = $art->categories->first();
|
||||
$categoryId = $primaryCategory->id ?? null;
|
||||
$categoryName = $primaryCategory->name ?? '';
|
||||
$categorySlug = $primaryCategory->slug ?? '';
|
||||
$avatarHash = $art->user->profile->avatar_hash ?? null;
|
||||
|
||||
return (object) [
|
||||
'id' => $art->id ?? null,
|
||||
'name' => $name,
|
||||
'picture' => $picture,
|
||||
'slug' => $art->slug ?? Str::slug($name ?? ''),
|
||||
'ext' => $ext,
|
||||
'encoded' => $encoded,
|
||||
'thumb' => $thumb,
|
||||
'thumb_url' => $thumb,
|
||||
'thumb_srcset' => $thumb,
|
||||
'category' => $categoryId,
|
||||
'category_name' => $categoryName,
|
||||
'category_slug' => $categorySlug,
|
||||
'uname' => $art->user->name ?? 'Skinbase',
|
||||
'username' => $art->user->username ?? '',
|
||||
'avatar_url' => \App\Support\AvatarUrl::forUser((int) ($art->user->id ?? 0), $avatarHash, 64),
|
||||
'width' => $art->width,
|
||||
'height' => $art->height,
|
||||
'published_at' => $art->published_at,
|
||||
'num_downloads' => $row->num_downloads ?? 0,
|
||||
'gid_num' => $categoryId ? ((int) $categoryId % 5) * 5 : 0,
|
||||
];
|
||||
});
|
||||
|
||||
$page_title = 'Today Downloaded Artworks';
|
||||
|
||||
return view('web.downloads.today', ['page_title' => $page_title, 'artworks' => $paginator]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\User;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Artwork;
|
||||
use App\Support\AvatarUrl;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class TodayInHistoryController extends Controller
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
$perPage = 36;
|
||||
$artworks = null;
|
||||
$today = now();
|
||||
|
||||
// ── Strategy 1: legacy featured_works table (historical data from old site) ─
|
||||
$hasFeaturedWorks = false;
|
||||
try { $hasFeaturedWorks = Schema::hasTable('featured_works'); } catch (\Throwable) {}
|
||||
|
||||
if ($hasFeaturedWorks) {
|
||||
try {
|
||||
$artworks = DB::table('featured_works as f')
|
||||
->join('artworks as a', 'f.artwork_id', '=', 'a.id')
|
||||
->where('a.is_approved', true)
|
||||
->where('a.is_public', true)
|
||||
->whereNull('a.deleted_at')
|
||||
->whereRaw('MONTH(f.post_date) = ?', [$today->month])
|
||||
->whereRaw('DAY(f.post_date) = ?', [$today->day])
|
||||
->select('a.id', 'a.title as name', 'a.slug', 'a.hash', 'a.thumb_ext',
|
||||
DB::raw('f.post_date as featured_date'))
|
||||
->orderBy('f.post_date', 'desc')
|
||||
->paginate($perPage);
|
||||
} catch (\Throwable $e) {
|
||||
$artworks = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Strategy 2: new artwork_features table ───────────────────────────────
|
||||
if (!$artworks || $artworks->total() === 0) {
|
||||
try {
|
||||
$artworks = DB::table('artwork_features as f')
|
||||
->join('artworks as a', 'f.artwork_id', '=', 'a.id')
|
||||
->where('f.is_active', true)
|
||||
->where('a.is_approved', true)
|
||||
->where('a.is_public', true)
|
||||
->whereNull('a.deleted_at')
|
||||
->whereNotNull('a.published_at')
|
||||
->whereRaw('MONTH(f.featured_at) = ?', [$today->month])
|
||||
->whereRaw('DAY(f.featured_at) = ?', [$today->day])
|
||||
->select('a.id', 'a.title as name', 'a.slug', 'a.hash', 'a.thumb_ext',
|
||||
DB::raw('f.featured_at as featured_date'))
|
||||
->orderBy('f.featured_at', 'desc')
|
||||
->paginate($perPage);
|
||||
} catch (\Throwable $e) {
|
||||
$artworks = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Enrich with CDN thumbnails (batch load to avoid N+1) ─────────────────
|
||||
if ($artworks && method_exists($artworks, 'getCollection') && $artworks->count() > 0) {
|
||||
$ids = $artworks->getCollection()->pluck('id')->filter()->map(fn ($id) => (int) $id)->all();
|
||||
$modelsById = Artwork::query()
|
||||
->with([
|
||||
'user:id,name,username',
|
||||
'categories' => function ($query) {
|
||||
$query->select('categories.id', 'categories.name', 'categories.slug', 'categories.sort_order');
|
||||
},
|
||||
])
|
||||
->whereIn('id', $ids)
|
||||
->get()
|
||||
->keyBy('id');
|
||||
|
||||
$artworks->getCollection()->transform(function ($row) use ($modelsById) {
|
||||
/** @var ?Artwork $art */
|
||||
$art = $modelsById->get($row->id);
|
||||
$row->slug = $row->slug ?? Str::slug($row->name ?? '');
|
||||
|
||||
if ($art) {
|
||||
$primaryCategory = $art->categories?->sortBy('sort_order')->first();
|
||||
$author = $art->user;
|
||||
|
||||
try {
|
||||
$present = \App\Services\ThumbnailPresenter::present($art, 'md');
|
||||
$row->thumb_url = $present['url'];
|
||||
$row->thumb_srcset = $present['srcset'] ?? $present['url'];
|
||||
} catch (\Throwable $e) {
|
||||
$row->thumb_url = $art->thumbUrl('md') ?? 'https://files.skinbase.org/default/missing_md.webp';
|
||||
$row->thumb_srcset = $row->thumb_url;
|
||||
}
|
||||
|
||||
$row->url = url('/art/' . $art->id . '/' . ($art->slug ?: Str::slug($art->title ?: ($row->name ?? 'artwork'))));
|
||||
$row->art_url = $row->url;
|
||||
$row->name = $art->title ?: ($row->name ?? 'Untitled');
|
||||
$row->slug = $art->slug ?: $row->slug;
|
||||
$row->width = $art->width;
|
||||
$row->height = $art->height;
|
||||
$row->content_type_name = $primaryCategory?->contentType?->name ?? '';
|
||||
$row->content_type_slug = $primaryCategory?->contentType?->slug ?? '';
|
||||
$row->category_name = $primaryCategory->name ?? '';
|
||||
$row->category_slug = $primaryCategory->slug ?? '';
|
||||
$row->uname = $author->name ?? 'Skinbase';
|
||||
$row->username = $author->username ?? $author->name ?? '';
|
||||
$row->avatar_url = $author
|
||||
? AvatarUrl::forUser((int) $author->getKey(), null, 64)
|
||||
: AvatarUrl::default();
|
||||
} else {
|
||||
$row->thumb_url = 'https://files.skinbase.org/default/missing_md.webp';
|
||||
$row->thumb_srcset = $row->thumb_url;
|
||||
$row->url = url('/art/' . $row->id . '/' . ($row->slug ?: Str::slug($row->name ?? 'artwork')));
|
||||
$row->art_url = $row->url;
|
||||
$row->name = $row->name ?? 'Untitled';
|
||||
$row->content_type_name = $row->content_type_name ?? '';
|
||||
$row->content_type_slug = $row->content_type_slug ?? '';
|
||||
$row->category_name = $row->category_name ?? '';
|
||||
$row->category_slug = $row->category_slug ?? '';
|
||||
$row->uname = $row->uname ?? 'Skinbase';
|
||||
$row->username = $row->username ?? '';
|
||||
$row->avatar_url = $row->avatar_url ?? AvatarUrl::default();
|
||||
$row->width = $row->width ?? null;
|
||||
$row->height = $row->height ?? null;
|
||||
}
|
||||
|
||||
return $row;
|
||||
});
|
||||
}
|
||||
|
||||
return view('legacy::today-in-history', [
|
||||
'artworks' => $artworks,
|
||||
'page_title' => 'Popular on this day in history',
|
||||
'todayLabel' => $today->format('F j'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\User;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use App\Models\Artwork;
|
||||
|
||||
class TopAuthorsController extends Controller
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
$perPage = 20;
|
||||
$metric = strtolower($request->query('metric', 'views'));
|
||||
|
||||
if (! in_array($metric, ['views', 'downloads'])) {
|
||||
$metric = 'views';
|
||||
}
|
||||
|
||||
$sub = Artwork::query()
|
||||
->join('artwork_stats', 'artwork_stats.artwork_id', '=', 'artworks.id')
|
||||
->where('artworks.is_public', true)
|
||||
->where('artworks.is_approved', true)
|
||||
->whereNotNull('artworks.published_at')
|
||||
->where('artworks.published_at', '<=', now())
|
||||
->whereNull('artworks.deleted_at')
|
||||
->selectRaw('artworks.user_id, SUM(artwork_stats.' . $metric . ') as total_metric, MAX(artworks.published_at) as latest_published')
|
||||
->groupBy('artworks.user_id');
|
||||
|
||||
$query = DB::table(DB::raw('(' . $sub->toSql() . ') as t'))
|
||||
->mergeBindings($sub->getQuery())
|
||||
->join('users as u', 'u.id', '=', 't.user_id')
|
||||
->leftJoin('user_profiles as up', 'up.user_id', '=', 'u.id')
|
||||
->select('u.id as user_id', 'u.name as uname', 'u.username', 'up.avatar_hash', 't.total_metric', 't.latest_published')
|
||||
->orderByDesc('t.total_metric')
|
||||
->orderByDesc('t.latest_published');
|
||||
|
||||
$authors = $query->paginate($perPage)->withQueryString();
|
||||
|
||||
$authors->getCollection()->transform(function ($row) use ($metric) {
|
||||
return (object) [
|
||||
'user_id' => $row->user_id,
|
||||
'uname' => $row->uname,
|
||||
'username' => $row->username,
|
||||
'avatar_hash' => $row->avatar_hash,
|
||||
'total' => (int) $row->total_metric,
|
||||
'metric' => $metric,
|
||||
];
|
||||
});
|
||||
|
||||
$page_title = 'Top Creators';
|
||||
|
||||
return view('web.authors.top', compact('page_title', 'authors', 'metric'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\User;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Artwork;
|
||||
use App\Support\AvatarUrl;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class TopFavouritesController extends Controller
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
$hits = 21;
|
||||
$page = max(1, (int) $request->query('page', 1));
|
||||
|
||||
$base = DB::table('artwork_favourites as t1')
|
||||
->join('artworks as t2', 't1.artwork_id', '=', 't2.id')
|
||||
->whereNotNull('t2.published_at')
|
||||
->select('t2.id', 't2.title as name', 't2.slug', DB::raw('NULL as picture'), DB::raw('NULL as category'), DB::raw('COUNT(*) as num'))
|
||||
->groupBy('t2.id', 't2.title', 't2.slug');
|
||||
|
||||
try {
|
||||
$paginator = (clone $base)->orderBy('num', 'desc')->paginate($hits)->withQueryString();
|
||||
} catch (\Throwable $e) {
|
||||
$paginator = collect();
|
||||
}
|
||||
|
||||
if ($paginator && method_exists($paginator, 'getCollection')) {
|
||||
$artworkLookup = Artwork::query()
|
||||
->with([
|
||||
'user:id,name,username',
|
||||
'categories' => function ($query) {
|
||||
$query->select('categories.id', 'categories.name', 'categories.slug', 'categories.sort_order');
|
||||
},
|
||||
])
|
||||
->whereIn('id', $paginator->getCollection()->pluck('id')->filter()->map(fn ($id) => (int) $id)->all())
|
||||
->get()
|
||||
->keyBy('id');
|
||||
|
||||
$paginator->getCollection()->transform(function ($row) use ($artworkLookup) {
|
||||
$row->slug = $row->slug ?? Str::slug($row->name ?? '');
|
||||
$ext = pathinfo($row->picture ?? '', PATHINFO_EXTENSION) ?: 'jpg';
|
||||
$encoded = \App\Helpers\Thumb::encodeId((int) $row->id);
|
||||
$row->encoded = $encoded;
|
||||
$row->ext = $ext;
|
||||
|
||||
/** @var \App\Models\Artwork|null $art */
|
||||
$art = $artworkLookup->get((int) $row->id);
|
||||
$primaryCategory = $art?->categories?->sortBy('sort_order')->first();
|
||||
$author = $art?->user;
|
||||
|
||||
try {
|
||||
$present = \App\Services\ThumbnailPresenter::present($art ?: (array) $row, 'md');
|
||||
$row->thumb = $row->thumb ?? $present['url'];
|
||||
$row->thumb_srcset = $row->thumb_srcset ?? ($present['srcset'] ?? $present['url']);
|
||||
} catch (\Throwable $e) {
|
||||
$present = \App\Services\ThumbnailPresenter::present((array) $row, 'md');
|
||||
$row->thumb = $row->thumb ?? $present['url'];
|
||||
$row->thumb_srcset = $row->thumb_srcset ?? ($present['srcset'] ?? $present['url']);
|
||||
}
|
||||
|
||||
$row->thumb_url = $row->thumb ?? null;
|
||||
$row->gid_num = ((int)($row->category ?? 0) % 5) * 5;
|
||||
$row->url = url('/art/' . (int) $row->id . '/' . ($row->slug ?: Str::slug($row->name ?? 'artwork')));
|
||||
$row->width = $art?->width;
|
||||
$row->height = $art?->height;
|
||||
$row->content_type_name = $primaryCategory?->contentType?->name ?? '';
|
||||
$row->content_type_slug = $primaryCategory?->contentType?->slug ?? '';
|
||||
$row->category_name = $primaryCategory->name ?? '';
|
||||
$row->category_slug = $primaryCategory->slug ?? '';
|
||||
$row->uname = $author->name ?? 'Skinbase';
|
||||
$row->username = $author->username ?? $author->name ?? '';
|
||||
$row->avatar_url = $author
|
||||
? AvatarUrl::forUser((int) $author->getKey(), null, 64)
|
||||
: AvatarUrl::default();
|
||||
$row->favourites = (int) ($row->num ?? 0);
|
||||
|
||||
return $row;
|
||||
});
|
||||
}
|
||||
|
||||
$page_title = 'Top Favourites';
|
||||
|
||||
return view('legacy::top-favourites', ['page_title' => $page_title, 'artworks' => $paginator]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\User;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Artwork;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class UserController extends Controller
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
$user = $request->user();
|
||||
if (! $user) {
|
||||
return redirect()->route('login');
|
||||
}
|
||||
|
||||
try {
|
||||
$profile = app(\App\Services\LegacyService::class)->userAccount($user->id);
|
||||
} catch (\Throwable $e) {
|
||||
$profile = null;
|
||||
}
|
||||
|
||||
// Hero background: prefer featured wallpapers or photography
|
||||
$heroBgUrl = Artwork::public()
|
||||
->published()
|
||||
->whereNotNull('hash')
|
||||
->whereNotNull('thumb_ext')
|
||||
->whereHas('features', function ($q) {
|
||||
$q->where('is_active', true)
|
||||
->where(function ($q2) {
|
||||
$q2->whereNull('expires_at')->orWhere('expires_at', '>', now());
|
||||
});
|
||||
})
|
||||
->whereHas('categories', function ($q) {
|
||||
// content_type_id 2 = Wallpapers, 3 = Photography
|
||||
$q->whereIn('content_type_id', [2, 3]);
|
||||
})
|
||||
->inRandomOrder()
|
||||
->limit(1)
|
||||
->first()?->thumbUrl('lg');
|
||||
|
||||
return view('legacy::user', [
|
||||
'profile' => $profile,
|
||||
'heroBgUrl' => $heroBgUrl,
|
||||
]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user