Save workspace changes

This commit is contained in:
2026-04-18 17:02:56 +02:00
parent f02ea9a711
commit 87d60af5a9
4220 changed files with 1388603 additions and 1554 deletions

View File

@@ -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);
}
}
}

View File

@@ -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,
]);
}
}

View File

@@ -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();
}
}

View File

@@ -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'));
}
}

View File

@@ -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'));
}
}

View File

@@ -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

View File

@@ -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.');
}
}
}

View File

@@ -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,
]);
}
}

View File

@@ -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;
}
}

View File

@@ -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',
]);
}
}

View File

@@ -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]);
}
}

View File

@@ -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'),
]);
}
}

View File

@@ -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'));
}
}

View File

@@ -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]);
}
}

View File

@@ -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,
]);
}
}