update
This commit is contained in:
553
app/Services/LeaderboardService.php
Normal file
553
app/Services/LeaderboardService.php
Normal file
@@ -0,0 +1,553 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Models\ArtworkMetricSnapshotHourly;
|
||||
use App\Models\Leaderboard;
|
||||
use App\Models\Story;
|
||||
use App\Models\StoryLike;
|
||||
use App\Models\StoryView;
|
||||
use App\Models\User;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class LeaderboardService
|
||||
{
|
||||
private const CACHE_TTL_SECONDS = 3600;
|
||||
private const CREATOR_STORE_LIMIT = 10000;
|
||||
private const ENTITY_STORE_LIMIT = 500;
|
||||
|
||||
public function calculateCreatorLeaderboard(string $period): int
|
||||
{
|
||||
$normalizedPeriod = $this->normalizePeriod($period);
|
||||
$rows = $normalizedPeriod === Leaderboard::PERIOD_ALL_TIME
|
||||
? $this->allTimeCreatorRows()
|
||||
: $this->windowedCreatorRows($this->periodStart($normalizedPeriod));
|
||||
|
||||
return $this->persistRows(Leaderboard::TYPE_CREATOR, $normalizedPeriod, $rows, self::CREATOR_STORE_LIMIT);
|
||||
}
|
||||
|
||||
public function calculateArtworkLeaderboard(string $period): int
|
||||
{
|
||||
$normalizedPeriod = $this->normalizePeriod($period);
|
||||
$rows = $normalizedPeriod === Leaderboard::PERIOD_ALL_TIME
|
||||
? $this->allTimeArtworkRows()
|
||||
: $this->windowedArtworkRows($this->periodStart($normalizedPeriod));
|
||||
|
||||
return $this->persistRows(Leaderboard::TYPE_ARTWORK, $normalizedPeriod, $rows, self::ENTITY_STORE_LIMIT);
|
||||
}
|
||||
|
||||
public function calculateStoryLeaderboard(string $period): int
|
||||
{
|
||||
$normalizedPeriod = $this->normalizePeriod($period);
|
||||
$rows = $normalizedPeriod === Leaderboard::PERIOD_ALL_TIME
|
||||
? $this->allTimeStoryRows()
|
||||
: $this->windowedStoryRows($this->periodStart($normalizedPeriod));
|
||||
|
||||
return $this->persistRows(Leaderboard::TYPE_STORY, $normalizedPeriod, $rows, self::ENTITY_STORE_LIMIT);
|
||||
}
|
||||
|
||||
public function refreshAll(): array
|
||||
{
|
||||
$results = [];
|
||||
|
||||
foreach ([
|
||||
Leaderboard::TYPE_CREATOR,
|
||||
Leaderboard::TYPE_ARTWORK,
|
||||
Leaderboard::TYPE_STORY,
|
||||
] as $type) {
|
||||
foreach ($this->periods() as $period) {
|
||||
$results[$type][$period] = match ($type) {
|
||||
Leaderboard::TYPE_CREATOR => $this->calculateCreatorLeaderboard($period),
|
||||
Leaderboard::TYPE_ARTWORK => $this->calculateArtworkLeaderboard($period),
|
||||
Leaderboard::TYPE_STORY => $this->calculateStoryLeaderboard($period),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
public function getLeaderboard(string $type, string $period, int $limit = 50): array
|
||||
{
|
||||
$normalizedType = $this->normalizeType($type);
|
||||
$normalizedPeriod = $this->normalizePeriod($period);
|
||||
$limit = max(1, min($limit, 100));
|
||||
|
||||
return Cache::remember(
|
||||
$this->cacheKey($normalizedType, $normalizedPeriod, $limit),
|
||||
self::CACHE_TTL_SECONDS,
|
||||
function () use ($normalizedType, $normalizedPeriod, $limit): array {
|
||||
$items = Leaderboard::query()
|
||||
->where('type', $normalizedType)
|
||||
->where('period', $normalizedPeriod)
|
||||
->orderByDesc('score')
|
||||
->orderBy('entity_id')
|
||||
->limit($limit)
|
||||
->get(['entity_id', 'score'])
|
||||
->values();
|
||||
|
||||
if ($items->isEmpty()) {
|
||||
return [
|
||||
'type' => $normalizedType,
|
||||
'period' => $normalizedPeriod,
|
||||
'items' => [],
|
||||
];
|
||||
}
|
||||
|
||||
$entities = match ($normalizedType) {
|
||||
Leaderboard::TYPE_CREATOR => $this->creatorEntities($items->pluck('entity_id')->all()),
|
||||
Leaderboard::TYPE_ARTWORK => $this->artworkEntities($items->pluck('entity_id')->all()),
|
||||
Leaderboard::TYPE_STORY => $this->storyEntities($items->pluck('entity_id')->all()),
|
||||
};
|
||||
|
||||
return [
|
||||
'type' => $normalizedType,
|
||||
'period' => $normalizedPeriod,
|
||||
'items' => $items->values()->map(function (Leaderboard $row, int $index) use ($entities): array {
|
||||
return [
|
||||
'rank' => $index + 1,
|
||||
'score' => round((float) $row->score, 1),
|
||||
'entity' => $entities[(int) $row->entity_id] ?? null,
|
||||
];
|
||||
})->filter(fn (array $item): bool => $item['entity'] !== null)->values()->all(),
|
||||
];
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
public function creatorRankSummary(int $userId, string $period = Leaderboard::PERIOD_WEEKLY): ?array
|
||||
{
|
||||
$normalizedPeriod = $this->normalizePeriod($period);
|
||||
|
||||
return Cache::remember(
|
||||
sprintf('leaderboard:creator-rank:%d:%s', $userId, $normalizedPeriod),
|
||||
self::CACHE_TTL_SECONDS,
|
||||
function () use ($userId, $normalizedPeriod): ?array {
|
||||
$row = Leaderboard::query()
|
||||
->where('type', Leaderboard::TYPE_CREATOR)
|
||||
->where('period', $normalizedPeriod)
|
||||
->where('entity_id', $userId)
|
||||
->first(['entity_id', 'score']);
|
||||
|
||||
if (! $row) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$higherScores = Leaderboard::query()
|
||||
->where('type', Leaderboard::TYPE_CREATOR)
|
||||
->where('period', $normalizedPeriod)
|
||||
->where(function ($query) use ($row): void {
|
||||
$query->where('score', '>', $row->score)
|
||||
->orWhere(function ($ties) use ($row): void {
|
||||
$ties->where('score', '=', $row->score)
|
||||
->where('entity_id', '<', $row->entity_id);
|
||||
});
|
||||
})
|
||||
->count();
|
||||
|
||||
return [
|
||||
'period' => $normalizedPeriod,
|
||||
'rank' => $higherScores + 1,
|
||||
'score' => round((float) $row->score, 1),
|
||||
];
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
public function periods(): array
|
||||
{
|
||||
return [
|
||||
Leaderboard::PERIOD_DAILY,
|
||||
Leaderboard::PERIOD_WEEKLY,
|
||||
Leaderboard::PERIOD_MONTHLY,
|
||||
Leaderboard::PERIOD_ALL_TIME,
|
||||
];
|
||||
}
|
||||
|
||||
public function normalizePeriod(string $period): string
|
||||
{
|
||||
return match (strtolower(trim($period))) {
|
||||
'daily' => Leaderboard::PERIOD_DAILY,
|
||||
'weekly' => Leaderboard::PERIOD_WEEKLY,
|
||||
'monthly' => Leaderboard::PERIOD_MONTHLY,
|
||||
'all', 'all_time', 'all-time' => Leaderboard::PERIOD_ALL_TIME,
|
||||
default => Leaderboard::PERIOD_WEEKLY,
|
||||
};
|
||||
}
|
||||
|
||||
private function normalizeType(string $type): string
|
||||
{
|
||||
return match (strtolower(trim($type))) {
|
||||
'creator', 'creators' => Leaderboard::TYPE_CREATOR,
|
||||
'artwork', 'artworks' => Leaderboard::TYPE_ARTWORK,
|
||||
'story', 'stories' => Leaderboard::TYPE_STORY,
|
||||
default => Leaderboard::TYPE_CREATOR,
|
||||
};
|
||||
}
|
||||
|
||||
private function periodStart(string $period): CarbonImmutable
|
||||
{
|
||||
$now = CarbonImmutable::now();
|
||||
|
||||
return match ($period) {
|
||||
Leaderboard::PERIOD_DAILY => $now->subDay(),
|
||||
Leaderboard::PERIOD_WEEKLY => $now->subWeek(),
|
||||
Leaderboard::PERIOD_MONTHLY => $now->subMonth(),
|
||||
default => $now->subWeek(),
|
||||
};
|
||||
}
|
||||
|
||||
private function persistRows(string $type, string $period, Collection $rows, int $limit): int
|
||||
{
|
||||
$trimmed = $rows
|
||||
->sortByDesc('score')
|
||||
->take($limit)
|
||||
->values();
|
||||
|
||||
DB::transaction(function () use ($type, $period, $trimmed): void {
|
||||
Leaderboard::query()
|
||||
->where('type', $type)
|
||||
->where('period', $period)
|
||||
->delete();
|
||||
|
||||
if ($trimmed->isNotEmpty()) {
|
||||
$timestamp = now();
|
||||
Leaderboard::query()->insert(
|
||||
$trimmed->map(fn (array $row): array => [
|
||||
'type' => $type,
|
||||
'period' => $period,
|
||||
'entity_id' => (int) $row['entity_id'],
|
||||
'score' => round((float) $row['score'], 2),
|
||||
'created_at' => $timestamp,
|
||||
'updated_at' => $timestamp,
|
||||
])->all()
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
$this->flushCache($type, $period);
|
||||
|
||||
return $trimmed->count();
|
||||
}
|
||||
|
||||
private function flushCache(string $type, string $period): void
|
||||
{
|
||||
foreach ([10, 25, 50, 100] as $limit) {
|
||||
Cache::forget($this->cacheKey($type, $period, $limit));
|
||||
}
|
||||
|
||||
if ($type === Leaderboard::TYPE_CREATOR) {
|
||||
Cache::forget('leaderboard:top-creators-widget:' . $period);
|
||||
}
|
||||
}
|
||||
|
||||
private function cacheKey(string $type, string $period, int $limit): string
|
||||
{
|
||||
return sprintf('leaderboard:%s:%s:%d', $type, $period, $limit);
|
||||
}
|
||||
|
||||
private function allTimeCreatorRows(): Collection
|
||||
{
|
||||
return User::query()
|
||||
->from('users')
|
||||
->leftJoin('user_statistics as us', 'us.user_id', '=', 'users.id')
|
||||
->whereNull('users.deleted_at')
|
||||
->where('users.is_active', true)
|
||||
->select([
|
||||
'users.id',
|
||||
DB::raw('COALESCE(users.xp, 0) as xp'),
|
||||
DB::raw('COALESCE(us.followers_count, 0) as followers_count'),
|
||||
DB::raw('COALESCE(us.favorites_received_count, 0) as likes_received'),
|
||||
DB::raw('COALESCE(us.artwork_views_received_count, 0) as artwork_views'),
|
||||
])
|
||||
->get()
|
||||
->map(function ($row): array {
|
||||
$score = ((int) $row->xp * 1)
|
||||
+ ((int) $row->followers_count * 10)
|
||||
+ ((int) $row->likes_received * 2)
|
||||
+ ((int) $row->artwork_views * 0.1);
|
||||
|
||||
return [
|
||||
'entity_id' => (int) $row->id,
|
||||
'score' => $score,
|
||||
];
|
||||
})
|
||||
->filter(fn (array $row): bool => $row['score'] > 0)
|
||||
->values();
|
||||
}
|
||||
|
||||
private function windowedCreatorRows(CarbonImmutable $start): Collection
|
||||
{
|
||||
$xp = DB::table('user_xp_logs')
|
||||
->select('user_id', DB::raw('SUM(xp) as xp'))
|
||||
->where('created_at', '>=', $start)
|
||||
->groupBy('user_id');
|
||||
|
||||
$followers = DB::table('user_followers')
|
||||
->select('user_id', DB::raw('COUNT(*) as followers_count'))
|
||||
->where('created_at', '>=', $start)
|
||||
->groupBy('user_id');
|
||||
|
||||
$likes = DB::table('artwork_likes as likes')
|
||||
->join('artworks as artworks', 'artworks.id', '=', 'likes.artwork_id')
|
||||
->select('artworks.user_id', DB::raw('COUNT(*) as likes_received'))
|
||||
->where('likes.created_at', '>=', $start)
|
||||
->groupBy('artworks.user_id');
|
||||
|
||||
$views = DB::query()
|
||||
->fromSub($this->artworkSnapshotDeltas($start), 'deltas')
|
||||
->join('artworks as artworks', 'artworks.id', '=', 'deltas.artwork_id')
|
||||
->select('artworks.user_id', DB::raw('SUM(deltas.views_delta) as artwork_views'))
|
||||
->groupBy('artworks.user_id');
|
||||
|
||||
return User::query()
|
||||
->from('users')
|
||||
->leftJoinSub($xp, 'xp', 'xp.user_id', '=', 'users.id')
|
||||
->leftJoinSub($followers, 'followers', 'followers.user_id', '=', 'users.id')
|
||||
->leftJoinSub($likes, 'likes', 'likes.user_id', '=', 'users.id')
|
||||
->leftJoinSub($views, 'views', 'views.user_id', '=', 'users.id')
|
||||
->whereNull('users.deleted_at')
|
||||
->where('users.is_active', true)
|
||||
->select([
|
||||
'users.id',
|
||||
DB::raw('COALESCE(xp.xp, 0) as xp'),
|
||||
DB::raw('COALESCE(followers.followers_count, 0) as followers_count'),
|
||||
DB::raw('COALESCE(likes.likes_received, 0) as likes_received'),
|
||||
DB::raw('COALESCE(views.artwork_views, 0) as artwork_views'),
|
||||
])
|
||||
->get()
|
||||
->map(function ($row): array {
|
||||
$score = ((int) $row->xp * 1)
|
||||
+ ((int) $row->followers_count * 10)
|
||||
+ ((int) $row->likes_received * 2)
|
||||
+ ((float) $row->artwork_views * 0.1);
|
||||
|
||||
return [
|
||||
'entity_id' => (int) $row->id,
|
||||
'score' => $score,
|
||||
];
|
||||
})
|
||||
->filter(fn (array $row): bool => $row['score'] > 0)
|
||||
->values();
|
||||
}
|
||||
|
||||
private function allTimeArtworkRows(): Collection
|
||||
{
|
||||
return Artwork::query()
|
||||
->from('artworks')
|
||||
->join('artwork_stats as stats', 'stats.artwork_id', '=', 'artworks.id')
|
||||
->public()
|
||||
->select([
|
||||
'artworks.id',
|
||||
DB::raw('COALESCE(stats.favorites, 0) as likes_count'),
|
||||
DB::raw('COALESCE(stats.views, 0) as views_count'),
|
||||
DB::raw('COALESCE(stats.downloads, 0) as downloads_count'),
|
||||
DB::raw('COALESCE(stats.comments_count, 0) as comments_count'),
|
||||
])
|
||||
->get()
|
||||
->map(fn ($row): array => [
|
||||
'entity_id' => (int) $row->id,
|
||||
'score' => ((int) $row->likes_count * 3)
|
||||
+ ((int) $row->views_count * 1)
|
||||
+ ((int) $row->downloads_count * 5)
|
||||
+ ((int) $row->comments_count * 4),
|
||||
])
|
||||
->filter(fn (array $row): bool => $row['score'] > 0)
|
||||
->values();
|
||||
}
|
||||
|
||||
private function windowedArtworkRows(CarbonImmutable $start): Collection
|
||||
{
|
||||
$views = $this->artworkSnapshotDeltas($start);
|
||||
|
||||
$likes = DB::table('artwork_likes')
|
||||
->select('artwork_id', DB::raw('COUNT(*) as favourites_delta'))
|
||||
->where('created_at', '>=', $start)
|
||||
->groupBy('artwork_id');
|
||||
|
||||
$downloads = DB::table('artwork_downloads')
|
||||
->select('artwork_id', DB::raw('COUNT(*) as downloads_delta'))
|
||||
->where('created_at', '>=', $start)
|
||||
->groupBy('artwork_id');
|
||||
|
||||
$comments = DB::table('artwork_comments')
|
||||
->select('artwork_id', DB::raw('COUNT(*) as comments_delta'))
|
||||
->where('created_at', '>=', $start)
|
||||
->where('is_approved', true)
|
||||
->whereNull('deleted_at')
|
||||
->groupBy('artwork_id');
|
||||
|
||||
return Artwork::query()
|
||||
->from('artworks')
|
||||
->leftJoinSub($views, 'views', 'views.artwork_id', '=', 'artworks.id')
|
||||
->leftJoinSub($likes, 'likes', 'likes.artwork_id', '=', 'artworks.id')
|
||||
->leftJoinSub($downloads, 'downloads', 'downloads.artwork_id', '=', 'artworks.id')
|
||||
->leftJoinSub($comments, 'comments', 'comments.artwork_id', '=', 'artworks.id')
|
||||
->where('artworks.is_public', true)
|
||||
->where('artworks.is_approved', true)
|
||||
->whereNull('artworks.deleted_at')
|
||||
->whereNotNull('artworks.published_at')
|
||||
->select([
|
||||
'artworks.id',
|
||||
DB::raw('COALESCE(likes.favourites_delta, 0) as favourites_delta'),
|
||||
DB::raw('COALESCE(views.views_delta, 0) as views_delta'),
|
||||
DB::raw('COALESCE(downloads.downloads_delta, 0) as downloads_delta'),
|
||||
DB::raw('COALESCE(comments.comments_delta, 0) as comments_delta'),
|
||||
])
|
||||
->get()
|
||||
->map(fn ($row): array => [
|
||||
'entity_id' => (int) $row->id,
|
||||
'score' => ((int) $row->favourites_delta * 3)
|
||||
+ ((int) $row->views_delta * 1)
|
||||
+ ((int) $row->downloads_delta * 5)
|
||||
+ ((int) $row->comments_delta * 4),
|
||||
])
|
||||
->filter(fn (array $row): bool => $row['score'] > 0)
|
||||
->values();
|
||||
}
|
||||
|
||||
private function allTimeStoryRows(): Collection
|
||||
{
|
||||
return Story::query()
|
||||
->published()
|
||||
->select(['id', 'views', 'likes_count', 'comments_count', 'reading_time'])
|
||||
->get()
|
||||
->map(fn (Story $story): array => [
|
||||
'entity_id' => (int) $story->id,
|
||||
'score' => ((int) $story->views * 1)
|
||||
+ ((int) $story->likes_count * 3)
|
||||
+ ((int) $story->comments_count * 4)
|
||||
+ ((int) $story->reading_time * 0.5),
|
||||
])
|
||||
->filter(fn (array $row): bool => $row['score'] > 0)
|
||||
->values();
|
||||
}
|
||||
|
||||
private function windowedStoryRows(CarbonImmutable $start): Collection
|
||||
{
|
||||
$views = StoryView::query()
|
||||
->select('story_id', DB::raw('COUNT(*) as views_count'))
|
||||
->where('created_at', '>=', $start)
|
||||
->groupBy('story_id');
|
||||
|
||||
$likes = StoryLike::query()
|
||||
->select('story_id', DB::raw('COUNT(*) as likes_count'))
|
||||
->where('created_at', '>=', $start)
|
||||
->groupBy('story_id');
|
||||
|
||||
return Story::query()
|
||||
->from('stories')
|
||||
->leftJoinSub($views, 'views', 'views.story_id', '=', 'stories.id')
|
||||
->leftJoinSub($likes, 'likes', 'likes.story_id', '=', 'stories.id')
|
||||
->published()
|
||||
->select([
|
||||
'stories.id',
|
||||
'stories.comments_count',
|
||||
'stories.reading_time',
|
||||
DB::raw('COALESCE(views.views_count, 0) as views_count'),
|
||||
DB::raw('COALESCE(likes.likes_count, 0) as likes_count'),
|
||||
])
|
||||
->get()
|
||||
->map(fn ($row): array => [
|
||||
'entity_id' => (int) $row->id,
|
||||
'score' => ((int) $row->views_count * 1)
|
||||
+ ((int) $row->likes_count * 3)
|
||||
+ ((int) $row->comments_count * 4)
|
||||
+ ((int) $row->reading_time * 0.5),
|
||||
])
|
||||
->filter(fn (array $row): bool => $row['score'] > 0)
|
||||
->values();
|
||||
}
|
||||
|
||||
private function artworkSnapshotDeltas(CarbonImmutable $start): \Illuminate\Database\Query\Builder
|
||||
{
|
||||
return ArtworkMetricSnapshotHourly::query()
|
||||
->from('artwork_metric_snapshots_hourly as snapshots')
|
||||
->where('snapshots.bucket_hour', '>=', $start)
|
||||
->select([
|
||||
'snapshots.artwork_id',
|
||||
DB::raw('GREATEST(MAX(snapshots.views_count) - MIN(snapshots.views_count), 0) as views_delta'),
|
||||
DB::raw('GREATEST(MAX(snapshots.downloads_count) - MIN(snapshots.downloads_count), 0) as downloads_delta'),
|
||||
DB::raw('GREATEST(MAX(snapshots.favourites_count) - MIN(snapshots.favourites_count), 0) as favourites_delta'),
|
||||
DB::raw('GREATEST(MAX(snapshots.comments_count) - MIN(snapshots.comments_count), 0) as comments_delta'),
|
||||
])
|
||||
->groupBy('snapshots.artwork_id')
|
||||
->toBase();
|
||||
}
|
||||
|
||||
private function creatorEntities(array $ids): array
|
||||
{
|
||||
return User::query()
|
||||
->from('users')
|
||||
->leftJoin('user_profiles as profiles', 'profiles.user_id', '=', 'users.id')
|
||||
->whereIn('users.id', $ids)
|
||||
->select([
|
||||
'users.id',
|
||||
'users.username',
|
||||
'users.name',
|
||||
'users.level',
|
||||
'users.rank',
|
||||
'profiles.avatar_hash',
|
||||
])
|
||||
->get()
|
||||
->mapWithKeys(fn ($row): array => [
|
||||
(int) $row->id => [
|
||||
'id' => (int) $row->id,
|
||||
'type' => Leaderboard::TYPE_CREATOR,
|
||||
'name' => (string) ($row->username ?: $row->name ?: 'Creator'),
|
||||
'username' => $row->username,
|
||||
'url' => $row->username ? '/@' . $row->username : null,
|
||||
'avatar' => \App\Support\AvatarUrl::forUser((int) $row->id, $row->avatar_hash, 128),
|
||||
'level' => (int) ($row->level ?? 1),
|
||||
'rank' => (string) ($row->rank ?? 'Newbie'),
|
||||
],
|
||||
])
|
||||
->all();
|
||||
}
|
||||
|
||||
private function artworkEntities(array $ids): array
|
||||
{
|
||||
return Artwork::query()
|
||||
->with(['user.profile'])
|
||||
->whereIn('id', $ids)
|
||||
->get()
|
||||
->mapWithKeys(fn (Artwork $artwork): array => [
|
||||
(int) $artwork->id => [
|
||||
'id' => (int) $artwork->id,
|
||||
'type' => Leaderboard::TYPE_ARTWORK,
|
||||
'name' => $artwork->title,
|
||||
'url' => '/art/' . $artwork->id . '/' . $artwork->slug,
|
||||
'image' => $artwork->thumbUrl('md') ?? $artwork->thumbnail_url,
|
||||
'creator_name' => (string) ($artwork->user?->username ?: $artwork->user?->name ?: 'Creator'),
|
||||
'creator_url' => $artwork->user?->username ? '/@' . $artwork->user->username : null,
|
||||
],
|
||||
])
|
||||
->all();
|
||||
}
|
||||
|
||||
private function storyEntities(array $ids): array
|
||||
{
|
||||
return Story::query()
|
||||
->with('creator.profile')
|
||||
->whereIn('id', $ids)
|
||||
->get()
|
||||
->mapWithKeys(fn (Story $story): array => [
|
||||
(int) $story->id => [
|
||||
'id' => (int) $story->id,
|
||||
'type' => Leaderboard::TYPE_STORY,
|
||||
'name' => $story->title,
|
||||
'url' => '/stories/' . $story->slug,
|
||||
'image' => $story->cover_url,
|
||||
'creator_name' => (string) ($story->creator?->username ?: $story->creator?->name ?: 'Creator'),
|
||||
'creator_url' => $story->creator?->username ? '/@' . $story->creator->username : null,
|
||||
],
|
||||
])
|
||||
->all();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user