227 lines
7.7 KiB
PHP
227 lines
7.7 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Http\Controllers\Api;
|
|
|
|
use App\Http\Controllers\Controller;
|
|
use App\Models\Artwork;
|
|
use App\Models\User;
|
|
use Carbon\CarbonInterface;
|
|
use App\Services\ThumbnailPresenter;
|
|
use App\Support\UsernamePolicy;
|
|
use Illuminate\Http\JsonResponse;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Support\Facades\Auth;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Schema;
|
|
|
|
/**
|
|
* ProfileApiController
|
|
* JSON API endpoints for Profile page v2 tabs.
|
|
*/
|
|
final class ProfileApiController extends Controller
|
|
{
|
|
/**
|
|
* GET /api/profile/{username}/artworks
|
|
* Returns cursor-paginated artworks for the profile page tabs.
|
|
* Supports: sort=latest|trending|rising|views|favs, cursor=...
|
|
*/
|
|
public function artworks(Request $request, string $username): JsonResponse
|
|
{
|
|
$user = $this->resolveUser($username);
|
|
if (! $user) {
|
|
return response()->json(['error' => 'User not found'], 404);
|
|
}
|
|
|
|
$isOwner = Auth::check() && Auth::id() === $user->id;
|
|
$sort = $request->input('sort', 'latest');
|
|
|
|
$query = Artwork::with([
|
|
'user:id,name,username,level,rank',
|
|
'stats:artwork_id,views,downloads,favorites',
|
|
'categories' => function ($query) {
|
|
$query->select('categories.id', 'categories.content_type_id', 'categories.parent_id', 'categories.name', 'categories.slug', 'categories.sort_order')
|
|
->with(['contentType:id,slug,name']);
|
|
},
|
|
])
|
|
->where('user_id', $user->id)
|
|
->whereNull('deleted_at');
|
|
|
|
if (! $isOwner) {
|
|
$query->where('is_public', true)->where('is_approved', true)->whereNotNull('published_at');
|
|
}
|
|
|
|
$query = match ($sort) {
|
|
'trending' => $query->orderByDesc('ranking_score'),
|
|
'rising' => $query->orderByDesc('heat_score'),
|
|
'views' => $query->orderByDesc('view_count'),
|
|
'favs' => $query->orderByDesc('favourite_count'),
|
|
default => $query->orderByDesc('published_at'),
|
|
};
|
|
|
|
$perPage = 24;
|
|
$paginator = $query->cursorPaginate($perPage);
|
|
|
|
$data = collect($paginator->items())
|
|
->map(fn (Artwork $art) => $this->mapArtworkCardPayload($art))
|
|
->values();
|
|
|
|
return response()->json([
|
|
'data' => $data,
|
|
'next_cursor' => $paginator->nextCursor()?->encode(),
|
|
'has_more' => $paginator->hasMorePages(),
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* GET /api/profile/{username}/favourites
|
|
* Returns cursor-paginated favourites for the profile.
|
|
*/
|
|
public function favourites(Request $request, string $username): JsonResponse
|
|
{
|
|
$favouriteTable = $this->resolveFavouriteTable();
|
|
if ($favouriteTable === null) {
|
|
return response()->json(['data' => [], 'next_cursor' => null, 'has_more' => false]);
|
|
}
|
|
|
|
$user = $this->resolveUser($username);
|
|
if (! $user) {
|
|
return response()->json(['error' => 'User not found'], 404);
|
|
}
|
|
|
|
$perPage = 24;
|
|
$offset = max(0, (int) base64_decode((string) $request->input('cursor', ''), true));
|
|
|
|
$favIds = DB::table($favouriteTable . ' as af')
|
|
->join('artworks as a', 'a.id', '=', 'af.artwork_id')
|
|
->where('af.user_id', $user->id)
|
|
->whereNull('a.deleted_at')
|
|
->where('a.is_public', true)
|
|
->where('a.is_approved', true)
|
|
->whereNotNull('a.published_at')
|
|
->orderByDesc('af.created_at')
|
|
->orderByDesc('af.artwork_id')
|
|
->offset($offset)
|
|
->limit($perPage + 1)
|
|
->pluck('a.id');
|
|
|
|
$hasMore = $favIds->count() > $perPage;
|
|
$favIds = $favIds->take($perPage);
|
|
|
|
if ($favIds->isEmpty()) {
|
|
return response()->json(['data' => [], 'next_cursor' => null, 'has_more' => false]);
|
|
}
|
|
|
|
$indexed = Artwork::with([
|
|
'user:id,name,username,level,rank',
|
|
'stats:artwork_id,views,downloads,favorites',
|
|
'categories' => function ($query) {
|
|
$query->select('categories.id', 'categories.content_type_id', 'categories.parent_id', 'categories.name', 'categories.slug', 'categories.sort_order')
|
|
->with(['contentType:id,slug,name']);
|
|
},
|
|
])
|
|
->whereIn('id', $favIds)
|
|
->get()
|
|
->keyBy('id');
|
|
|
|
$data = $favIds
|
|
->filter(fn ($id) => $indexed->has($id))
|
|
->map(fn ($id) => $this->mapArtworkCardPayload($indexed[$id]))
|
|
->values();
|
|
|
|
return response()->json([
|
|
'data' => $data,
|
|
'next_cursor' => $hasMore ? base64_encode((string) ($offset + $perPage)) : null,
|
|
'has_more' => $hasMore,
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* GET /api/profile/{username}/stats
|
|
* Returns profile statistics.
|
|
*/
|
|
public function stats(Request $request, string $username): JsonResponse
|
|
{
|
|
$user = $this->resolveUser($username);
|
|
if (! $user) {
|
|
return response()->json(['error' => 'User not found'], 404);
|
|
}
|
|
|
|
$stats = null;
|
|
if (Schema::hasTable('user_statistics')) {
|
|
$stats = DB::table('user_statistics')->where('user_id', $user->id)->first();
|
|
}
|
|
|
|
$followerCount = 0;
|
|
if (Schema::hasTable('user_followers')) {
|
|
$followerCount = DB::table('user_followers')->where('user_id', $user->id)->count();
|
|
}
|
|
|
|
return response()->json([
|
|
'stats' => $stats,
|
|
'follower_count' => $followerCount,
|
|
]);
|
|
}
|
|
|
|
private function resolveUser(string $username): ?User
|
|
{
|
|
$normalized = UsernamePolicy::normalize($username);
|
|
return User::query()->whereRaw('LOWER(username) = ?', [$normalized])->first();
|
|
}
|
|
|
|
private function resolveFavouriteTable(): ?string
|
|
{
|
|
foreach (['artwork_favourites', 'user_favorites', 'artworks_favourites', 'favourites'] as $table) {
|
|
if (Schema::hasTable($table)) {
|
|
return $table;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function mapArtworkCardPayload(Artwork $art): array
|
|
{
|
|
$present = ThumbnailPresenter::present($art, 'md');
|
|
$category = $art->categories->first();
|
|
$contentType = $category?->contentType;
|
|
$stats = $art->stats;
|
|
|
|
return [
|
|
'id' => $art->id,
|
|
'name' => $art->title,
|
|
'thumb' => $present['url'],
|
|
'thumb_srcset' => $present['srcset'] ?? $present['url'],
|
|
'width' => $art->width,
|
|
'height' => $art->height,
|
|
'username' => $art->user->username ?? null,
|
|
'uname' => $art->user->username ?? $art->user->name ?? 'Skinbase',
|
|
'content_type' => $contentType?->name,
|
|
'content_type_slug' => $contentType?->slug,
|
|
'category' => $category?->name,
|
|
'category_slug' => $category?->slug,
|
|
'views' => (int) ($stats?->views ?? $art->view_count ?? 0),
|
|
'downloads' => (int) ($stats?->downloads ?? 0),
|
|
'likes' => (int) ($stats?->favorites ?? $art->favourite_count ?? 0),
|
|
'published_at' => $this->formatIsoDate($art->published_at),
|
|
];
|
|
}
|
|
|
|
private function formatIsoDate(mixed $value): ?string
|
|
{
|
|
if ($value instanceof CarbonInterface) {
|
|
return $value->toISOString();
|
|
}
|
|
|
|
if ($value instanceof \DateTimeInterface) {
|
|
return $value->format(DATE_ATOM);
|
|
}
|
|
|
|
return is_string($value) ? $value : null;
|
|
}
|
|
}
|