Save workspace changes
This commit is contained in:
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Studio\Contracts;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
interface CreatorStudioProvider
|
||||
{
|
||||
public function key(): string;
|
||||
|
||||
public function label(): string;
|
||||
|
||||
public function icon(): string;
|
||||
|
||||
public function createUrl(): string;
|
||||
|
||||
public function indexUrl(): string;
|
||||
|
||||
public function summary(User $user): array;
|
||||
|
||||
public function items(User $user, string $bucket = 'all', int $limit = 200): Collection;
|
||||
|
||||
public function topItems(User $user, int $limit = 5): Collection;
|
||||
|
||||
public function analytics(User $user): array;
|
||||
|
||||
public function scheduledItems(User $user, int $limit = 50): Collection;
|
||||
}
|
||||
@@ -0,0 +1,354 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Studio;
|
||||
|
||||
use App\Models\CollectionComment;
|
||||
use App\Models\NovaCardComment;
|
||||
use App\Models\StoryComment;
|
||||
use App\Models\User;
|
||||
use App\Services\NotificationService;
|
||||
use App\Support\AvatarUrl;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
final class CreatorStudioActivityService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly NotificationService $notifications,
|
||||
private readonly CreatorStudioPreferenceService $preferences,
|
||||
) {
|
||||
}
|
||||
|
||||
public function recent(User $user, int $limit = 12): array
|
||||
{
|
||||
return $this->feed($user)
|
||||
->take($limit)
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
public function feed(User $user)
|
||||
{
|
||||
return $this->mergedFeed($user)
|
||||
->sortByDesc(fn (array $item): int => $this->timestamp($item['created_at'] ?? null))
|
||||
->values();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $filters
|
||||
*/
|
||||
public function list(User $user, array $filters = []): array
|
||||
{
|
||||
$type = $this->normalizeType((string) ($filters['type'] ?? 'all'));
|
||||
$module = $this->normalizeModule((string) ($filters['module'] ?? 'all'));
|
||||
$q = trim((string) ($filters['q'] ?? ''));
|
||||
$page = max(1, (int) ($filters['page'] ?? 1));
|
||||
$perPage = min(max((int) ($filters['per_page'] ?? 24), 12), 48);
|
||||
$preferences = $this->preferences->forUser($user);
|
||||
|
||||
$items = $this->feed($user);
|
||||
|
||||
if ($type !== 'all') {
|
||||
$items = $items->where('type', $type)->values();
|
||||
}
|
||||
|
||||
if ($module !== 'all') {
|
||||
$items = $items->where('module', $module)->values();
|
||||
}
|
||||
|
||||
if ($q !== '') {
|
||||
$needle = mb_strtolower($q);
|
||||
$items = $items->filter(function (array $item) use ($needle): bool {
|
||||
return collect([
|
||||
$item['title'] ?? '',
|
||||
$item['body'] ?? '',
|
||||
$item['actor']['name'] ?? '',
|
||||
$item['module_label'] ?? '',
|
||||
])->contains(fn ($value): bool => is_string($value) && str_contains(mb_strtolower($value), $needle));
|
||||
})->values();
|
||||
}
|
||||
|
||||
$items = $items->sortByDesc(fn (array $item): int => $this->timestamp($item['created_at'] ?? null))->values();
|
||||
$total = $items->count();
|
||||
$lastPage = max(1, (int) ceil($total / $perPage));
|
||||
$page = min($page, $lastPage);
|
||||
$lastReadAt = $preferences['activity_last_read_at'] ?? null;
|
||||
$lastReadTimestamp = $this->timestamp($lastReadAt);
|
||||
|
||||
return [
|
||||
'items' => $items->forPage($page, $perPage)->map(function (array $item) use ($lastReadTimestamp): array {
|
||||
$item['is_new'] = $this->timestamp($item['created_at'] ?? null) > $lastReadTimestamp;
|
||||
|
||||
return $item;
|
||||
})->values()->all(),
|
||||
'meta' => [
|
||||
'current_page' => $page,
|
||||
'last_page' => $lastPage,
|
||||
'per_page' => $perPage,
|
||||
'total' => $total,
|
||||
],
|
||||
'filters' => [
|
||||
'type' => $type,
|
||||
'module' => $module,
|
||||
'q' => $q,
|
||||
],
|
||||
'type_options' => [
|
||||
['value' => 'all', 'label' => 'Everything'],
|
||||
['value' => 'notification', 'label' => 'Notifications'],
|
||||
['value' => 'comment', 'label' => 'Comments'],
|
||||
['value' => 'follower', 'label' => 'Followers'],
|
||||
],
|
||||
'module_options' => [
|
||||
['value' => 'all', 'label' => 'All content types'],
|
||||
['value' => 'artworks', 'label' => 'Artworks'],
|
||||
['value' => 'cards', 'label' => 'Cards'],
|
||||
['value' => 'collections', 'label' => 'Collections'],
|
||||
['value' => 'stories', 'label' => 'Stories'],
|
||||
['value' => 'followers', 'label' => 'Followers'],
|
||||
['value' => 'system', 'label' => 'System'],
|
||||
],
|
||||
'summary' => [
|
||||
'unread_notifications' => (int) $user->unreadNotifications()->count(),
|
||||
'last_read_at' => $lastReadAt,
|
||||
'new_items' => $items->filter(fn (array $item): bool => $this->timestamp($item['created_at'] ?? null) > $lastReadTimestamp)->count(),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public function markAllRead(User $user): array
|
||||
{
|
||||
$this->notifications->markAllRead($user);
|
||||
$updated = $this->preferences->update($user, [
|
||||
'activity_last_read_at' => now()->toIso8601String(),
|
||||
]);
|
||||
|
||||
return [
|
||||
'ok' => true,
|
||||
'activity_last_read_at' => $updated['activity_last_read_at'],
|
||||
];
|
||||
}
|
||||
|
||||
private function mergedFeed(User $user)
|
||||
{
|
||||
return collect($this->notificationItems($user))
|
||||
->concat($this->commentItems($user))
|
||||
->concat($this->followerItems($user));
|
||||
}
|
||||
|
||||
private function notificationItems(User $user): array
|
||||
{
|
||||
return collect($this->notifications->listForUser($user, 1, 30)['data'] ?? [])
|
||||
->map(fn (array $item): array => [
|
||||
'id' => 'notification:' . $item['id'],
|
||||
'type' => 'notification',
|
||||
'module' => 'system',
|
||||
'module_label' => 'Notification',
|
||||
'title' => $item['message'],
|
||||
'body' => $item['message'],
|
||||
'created_at' => $item['created_at'],
|
||||
'time_ago' => $item['time_ago'] ?? null,
|
||||
'url' => $item['url'] ?? route('studio.activity'),
|
||||
'actor' => $item['actor'] ?? null,
|
||||
'read' => (bool) ($item['read'] ?? false),
|
||||
])
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
private function commentItems(User $user): array
|
||||
{
|
||||
$artworkComments = DB::table('artwork_comments')
|
||||
->join('artworks', 'artworks.id', '=', 'artwork_comments.artwork_id')
|
||||
->join('users', 'users.id', '=', 'artwork_comments.user_id')
|
||||
->leftJoin('user_profiles', 'user_profiles.user_id', '=', 'users.id')
|
||||
->where('artworks.user_id', $user->id)
|
||||
->whereNull('artwork_comments.deleted_at')
|
||||
->orderByDesc('artwork_comments.created_at')
|
||||
->limit(20)
|
||||
->get([
|
||||
'artwork_comments.id',
|
||||
'artwork_comments.content as body',
|
||||
'artwork_comments.created_at',
|
||||
'users.id as actor_id',
|
||||
'users.name as actor_name',
|
||||
'users.username as actor_username',
|
||||
'user_profiles.avatar_hash',
|
||||
'artworks.title as item_title',
|
||||
'artworks.slug as item_slug',
|
||||
'artworks.id as item_id',
|
||||
])
|
||||
->map(fn ($row): array => [
|
||||
'id' => 'comment:artworks:' . $row->id,
|
||||
'type' => 'comment',
|
||||
'module' => 'artworks',
|
||||
'module_label' => 'Artwork comment',
|
||||
'title' => 'New comment on ' . $row->item_title,
|
||||
'body' => (string) $row->body,
|
||||
'created_at' => $this->normalizeDate($row->created_at),
|
||||
'time_ago' => null,
|
||||
'url' => route('art.show', ['id' => $row->item_id, 'slug' => $row->item_slug]) . '#comment-' . $row->id,
|
||||
'actor' => [
|
||||
'id' => (int) $row->actor_id,
|
||||
'name' => $row->actor_name ?: $row->actor_username ?: 'Creator',
|
||||
'username' => $row->actor_username,
|
||||
'avatar_url' => AvatarUrl::forUser((int) $row->actor_id, $row->avatar_hash, 64),
|
||||
],
|
||||
]);
|
||||
|
||||
$cardComments = NovaCardComment::query()
|
||||
->with(['user.profile', 'card'])
|
||||
->whereNull('deleted_at')
|
||||
->whereHas('card', fn ($query) => $query->where('user_id', $user->id))
|
||||
->latest('created_at')
|
||||
->limit(20)
|
||||
->get()
|
||||
->map(fn (NovaCardComment $comment): array => [
|
||||
'id' => 'comment:cards:' . $comment->id,
|
||||
'type' => 'comment',
|
||||
'module' => 'cards',
|
||||
'module_label' => 'Card comment',
|
||||
'title' => 'New comment on ' . ($comment->card?->title ?? 'card'),
|
||||
'body' => (string) $comment->body,
|
||||
'created_at' => $comment->created_at?->toIso8601String(),
|
||||
'time_ago' => $comment->created_at?->diffForHumans(),
|
||||
'url' => $comment->card ? $comment->card->publicUrl() . '#comment-' . $comment->id : route('studio.activity'),
|
||||
'actor' => $comment->user ? [
|
||||
'id' => (int) $comment->user->id,
|
||||
'name' => $comment->user->name ?: $comment->user->username ?: 'Creator',
|
||||
'username' => $comment->user->username,
|
||||
'avatar_url' => AvatarUrl::forUser((int) $comment->user->id, $comment->user->profile?->avatar_hash, 64),
|
||||
] : null,
|
||||
]);
|
||||
|
||||
$collectionComments = CollectionComment::query()
|
||||
->with(['user.profile', 'collection'])
|
||||
->whereNull('deleted_at')
|
||||
->whereHas('collection', fn ($query) => $query->where('user_id', $user->id))
|
||||
->latest('created_at')
|
||||
->limit(20)
|
||||
->get()
|
||||
->map(fn (CollectionComment $comment): array => [
|
||||
'id' => 'comment:collections:' . $comment->id,
|
||||
'type' => 'comment',
|
||||
'module' => 'collections',
|
||||
'module_label' => 'Collection comment',
|
||||
'title' => 'New comment on ' . ($comment->collection?->title ?? 'collection'),
|
||||
'body' => (string) $comment->body,
|
||||
'created_at' => $comment->created_at?->toIso8601String(),
|
||||
'time_ago' => $comment->created_at?->diffForHumans(),
|
||||
'url' => $comment->collection ? route('settings.collections.show', ['collection' => $comment->collection->id]) : route('studio.activity'),
|
||||
'actor' => $comment->user ? [
|
||||
'id' => (int) $comment->user->id,
|
||||
'name' => $comment->user->name ?: $comment->user->username ?: 'Creator',
|
||||
'username' => $comment->user->username,
|
||||
'avatar_url' => AvatarUrl::forUser((int) $comment->user->id, $comment->user->profile?->avatar_hash, 64),
|
||||
] : null,
|
||||
]);
|
||||
|
||||
$storyComments = StoryComment::query()
|
||||
->with(['user.profile', 'story'])
|
||||
->whereNull('deleted_at')
|
||||
->whereHas('story', fn ($query) => $query->where('creator_id', $user->id))
|
||||
->latest('created_at')
|
||||
->limit(20)
|
||||
->get()
|
||||
->map(fn (StoryComment $comment): array => [
|
||||
'id' => 'comment:stories:' . $comment->id,
|
||||
'type' => 'comment',
|
||||
'module' => 'stories',
|
||||
'module_label' => 'Story comment',
|
||||
'title' => 'New comment on ' . ($comment->story?->title ?? 'story'),
|
||||
'body' => (string) ($comment->raw_content ?: $comment->content),
|
||||
'created_at' => $comment->created_at?->toIso8601String(),
|
||||
'time_ago' => $comment->created_at?->diffForHumans(),
|
||||
'url' => $comment->story ? route('stories.show', ['slug' => $comment->story->slug]) . '#comment-' . $comment->id : route('studio.activity'),
|
||||
'actor' => $comment->user ? [
|
||||
'id' => (int) $comment->user->id,
|
||||
'name' => $comment->user->name ?: $comment->user->username ?: 'Creator',
|
||||
'username' => $comment->user->username,
|
||||
'avatar_url' => AvatarUrl::forUser((int) $comment->user->id, $comment->user->profile?->avatar_hash, 64),
|
||||
] : null,
|
||||
]);
|
||||
|
||||
return $artworkComments
|
||||
->concat($cardComments)
|
||||
->concat($collectionComments)
|
||||
->concat($storyComments)
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
private function followerItems(User $user): array
|
||||
{
|
||||
return DB::table('user_followers as uf')
|
||||
->join('users as follower', 'follower.id', '=', 'uf.follower_id')
|
||||
->leftJoin('user_profiles as profile', 'profile.user_id', '=', 'follower.id')
|
||||
->where('uf.user_id', $user->id)
|
||||
->whereNull('follower.deleted_at')
|
||||
->orderByDesc('uf.created_at')
|
||||
->limit(20)
|
||||
->get([
|
||||
'uf.created_at',
|
||||
'follower.id',
|
||||
'follower.username',
|
||||
'follower.name',
|
||||
'profile.avatar_hash',
|
||||
])
|
||||
->map(fn ($row): array => [
|
||||
'id' => 'follower:' . $row->id . ':' . strtotime((string) $row->created_at),
|
||||
'type' => 'follower',
|
||||
'module' => 'followers',
|
||||
'module_label' => 'Follower',
|
||||
'title' => ($row->name ?: $row->username ?: 'Someone') . ' followed you',
|
||||
'body' => 'New audience activity in Creator Studio.',
|
||||
'created_at' => $this->normalizeDate($row->created_at),
|
||||
'time_ago' => null,
|
||||
'url' => '/@' . strtolower((string) $row->username),
|
||||
'actor' => [
|
||||
'id' => (int) $row->id,
|
||||
'name' => $row->name ?: $row->username ?: 'Creator',
|
||||
'username' => $row->username,
|
||||
'avatar_url' => AvatarUrl::forUser((int) $row->id, $row->avatar_hash, 64),
|
||||
],
|
||||
])
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
private function normalizeType(string $type): string
|
||||
{
|
||||
return in_array($type, ['all', 'notification', 'comment', 'follower'], true)
|
||||
? $type
|
||||
: 'all';
|
||||
}
|
||||
|
||||
private function normalizeModule(string $module): string
|
||||
{
|
||||
return in_array($module, ['all', 'artworks', 'cards', 'collections', 'stories', 'followers', 'system'], true)
|
||||
? $module
|
||||
: 'all';
|
||||
}
|
||||
|
||||
private function timestamp(mixed $value): int
|
||||
{
|
||||
if (! is_string($value) || $value === '') {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return strtotime($value) ?: 0;
|
||||
}
|
||||
|
||||
private function normalizeDate(mixed $value): ?string
|
||||
{
|
||||
if ($value instanceof \DateTimeInterface) {
|
||||
return $value->format(DATE_ATOM);
|
||||
}
|
||||
|
||||
if (is_string($value) && $value !== '') {
|
||||
return $value;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Studio;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Services\Studio\Contracts\CreatorStudioProvider;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use function collect;
|
||||
use function now;
|
||||
|
||||
final class CreatorStudioAnalyticsService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly CreatorStudioContentService $content,
|
||||
) {
|
||||
}
|
||||
|
||||
public function overview(User $user, int $days = 30): array
|
||||
{
|
||||
$providers = collect($this->content->providers());
|
||||
$moduleBreakdown = $providers->map(function (CreatorStudioProvider $provider) use ($user): array {
|
||||
$summary = $provider->summary($user);
|
||||
$analytics = $provider->analytics($user);
|
||||
|
||||
return [
|
||||
'key' => $provider->key(),
|
||||
'label' => $provider->label(),
|
||||
'icon' => $provider->icon(),
|
||||
'count' => $summary['count'],
|
||||
'draft_count' => $summary['draft_count'],
|
||||
'published_count' => $summary['published_count'],
|
||||
'archived_count' => $summary['archived_count'],
|
||||
'views' => $analytics['views'],
|
||||
'appreciation' => $analytics['appreciation'],
|
||||
'shares' => $analytics['shares'],
|
||||
'comments' => $analytics['comments'],
|
||||
'saves' => $analytics['saves'],
|
||||
'index_url' => $provider->indexUrl(),
|
||||
];
|
||||
})->values();
|
||||
|
||||
$followers = (int) DB::table('user_followers')->where('user_id', $user->id)->count();
|
||||
|
||||
$totals = [
|
||||
'views' => (int) $moduleBreakdown->sum('views'),
|
||||
'appreciation' => (int) $moduleBreakdown->sum('appreciation'),
|
||||
'shares' => (int) $moduleBreakdown->sum('shares'),
|
||||
'comments' => (int) $moduleBreakdown->sum('comments'),
|
||||
'saves' => (int) $moduleBreakdown->sum('saves'),
|
||||
'followers' => $followers,
|
||||
'content_count' => (int) $moduleBreakdown->sum('count'),
|
||||
];
|
||||
|
||||
$topContent = $providers
|
||||
->flatMap(fn (CreatorStudioProvider $provider) => $provider->topItems($user, 4))
|
||||
->sortByDesc(fn (array $item): int => (int) ($item['engagement_score'] ?? 0))
|
||||
->take(8)
|
||||
->values()
|
||||
->all();
|
||||
|
||||
return [
|
||||
'totals' => $totals,
|
||||
'module_breakdown' => $moduleBreakdown->all(),
|
||||
'top_content' => $topContent,
|
||||
'views_trend' => $this->trendSeries($user, $days, 'views'),
|
||||
'engagement_trend' => $this->trendSeries($user, $days, 'engagement'),
|
||||
'publishing_timeline' => $this->publishingTimeline($user, $days),
|
||||
'comparison' => $this->comparison($user, $days),
|
||||
'insight_blocks' => $this->insightBlocks($moduleBreakdown, $totals, $days),
|
||||
'range_days' => $days,
|
||||
];
|
||||
}
|
||||
|
||||
private function insightBlocks($moduleBreakdown, array $totals, int $days): array
|
||||
{
|
||||
$strongest = $moduleBreakdown->sortByDesc('appreciation')->first();
|
||||
$busiest = $moduleBreakdown->sortByDesc('count')->first();
|
||||
$conversation = $moduleBreakdown->sortByDesc('comments')->first();
|
||||
|
||||
$insights = [];
|
||||
|
||||
if ($strongest) {
|
||||
$insights[] = [
|
||||
'key' => 'strongest_module',
|
||||
'title' => $strongest['label'] . ' is driving the strongest reaction',
|
||||
'body' => sprintf(
|
||||
'%s generated %s reactions in the last %d days, making it the strongest appreciation surface in Studio right now.',
|
||||
$strongest['label'],
|
||||
number_format((int) $strongest['appreciation']),
|
||||
$days,
|
||||
),
|
||||
'tone' => 'positive',
|
||||
'icon' => 'fa-solid fa-sparkles',
|
||||
'href' => $strongest['index_url'],
|
||||
'cta' => 'Open module',
|
||||
];
|
||||
}
|
||||
|
||||
if ($conversation && (int) ($conversation['comments'] ?? 0) > 0) {
|
||||
$insights[] = [
|
||||
'key' => 'conversation_module',
|
||||
'title' => 'Conversation is concentrating in ' . $conversation['label'],
|
||||
'body' => sprintf(
|
||||
'%s collected %s comments in this window. That is the clearest place to check for follow-up and community signals.',
|
||||
$conversation['label'],
|
||||
number_format((int) $conversation['comments']),
|
||||
),
|
||||
'tone' => 'action',
|
||||
'icon' => 'fa-solid fa-comments',
|
||||
'href' => route('studio.inbox', ['module' => $conversation['key'], 'type' => 'comment']),
|
||||
'cta' => 'Open inbox',
|
||||
];
|
||||
}
|
||||
|
||||
if ($busiest && (int) ($busiest['draft_count'] ?? 0) > 0) {
|
||||
$insights[] = [
|
||||
'key' => 'draft_pressure',
|
||||
'title' => $busiest['label'] . ' has the heaviest backlog',
|
||||
'body' => sprintf(
|
||||
'%s currently has %s drafts. That is the best candidate for your next cleanup, publish, or scheduling pass.',
|
||||
$busiest['label'],
|
||||
number_format((int) $busiest['draft_count']),
|
||||
),
|
||||
'tone' => 'warning',
|
||||
'icon' => 'fa-solid fa-layer-group',
|
||||
'href' => route('studio.drafts', ['module' => $busiest['key']]),
|
||||
'cta' => 'Review drafts',
|
||||
];
|
||||
}
|
||||
|
||||
if ((int) ($totals['followers'] ?? 0) > 0) {
|
||||
$insights[] = [
|
||||
'key' => 'audience_presence',
|
||||
'title' => 'Audience footprint is active across the workspace',
|
||||
'body' => sprintf(
|
||||
'You now have %s followers connected to this creator profile. Keep featured content and your publishing cadence aligned with that audience.',
|
||||
number_format((int) $totals['followers']),
|
||||
),
|
||||
'tone' => 'neutral',
|
||||
'icon' => 'fa-solid fa-user-group',
|
||||
'href' => route('studio.featured'),
|
||||
'cta' => 'Manage featured',
|
||||
];
|
||||
}
|
||||
|
||||
return collect($insights)->take(4)->values()->all();
|
||||
}
|
||||
|
||||
private function publishingTimeline(User $user, int $days): array
|
||||
{
|
||||
$timeline = collect(range($days - 1, 0))->map(function (int $offset): array {
|
||||
$date = now()->subDays($offset)->startOfDay();
|
||||
|
||||
return [
|
||||
'date' => $date->toDateString(),
|
||||
'count' => 0,
|
||||
];
|
||||
})->keyBy('date');
|
||||
|
||||
collect($this->content->recentPublished($user, 120))
|
||||
->each(function (array $item) use ($timeline): void {
|
||||
$publishedAt = $item['published_at'] ?? $item['updated_at'] ?? null;
|
||||
if (! $publishedAt) {
|
||||
return;
|
||||
}
|
||||
|
||||
$date = date('Y-m-d', strtotime((string) $publishedAt));
|
||||
if (! $timeline->has($date)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$row = $timeline->get($date);
|
||||
$row['count']++;
|
||||
$timeline->put($date, $row);
|
||||
});
|
||||
|
||||
return $timeline->values()->all();
|
||||
}
|
||||
|
||||
private function trendSeries(User $user, int $days, string $metric): array
|
||||
{
|
||||
$series = collect(range($days - 1, 0))->map(function (int $offset): array {
|
||||
$date = now()->subDays($offset)->toDateString();
|
||||
|
||||
return [
|
||||
'date' => $date,
|
||||
'value' => 0,
|
||||
];
|
||||
})->keyBy('date');
|
||||
|
||||
collect($this->content->providers())
|
||||
->flatMap(fn (CreatorStudioProvider $provider) => $provider->items($user, 'all', 240))
|
||||
->each(function (array $item) use ($series, $metric): void {
|
||||
$dateSource = $item['published_at'] ?? $item['updated_at'] ?? $item['created_at'] ?? null;
|
||||
if (! $dateSource) {
|
||||
return;
|
||||
}
|
||||
|
||||
$date = date('Y-m-d', strtotime((string) $dateSource));
|
||||
if (! $series->has($date)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$row = $series->get($date);
|
||||
$increment = $metric === 'views'
|
||||
? (int) ($item['metrics']['views'] ?? 0)
|
||||
: (int) ($item['engagement_score'] ?? 0);
|
||||
$row['value'] += $increment;
|
||||
$series->put($date, $row);
|
||||
});
|
||||
|
||||
return $series->values()->all();
|
||||
}
|
||||
|
||||
private function comparison(User $user, int $days): array
|
||||
{
|
||||
$windowStart = now()->subDays($days)->getTimestamp();
|
||||
|
||||
return collect($this->content->providers())
|
||||
->map(function (CreatorStudioProvider $provider) use ($user, $windowStart): array {
|
||||
$items = $provider->items($user, 'all', 240)
|
||||
->filter(function (array $item) use ($windowStart): bool {
|
||||
$date = $item['published_at'] ?? $item['updated_at'] ?? $item['created_at'] ?? null;
|
||||
|
||||
return $date !== null && strtotime((string) $date) >= $windowStart;
|
||||
});
|
||||
|
||||
return [
|
||||
'key' => $provider->key(),
|
||||
'label' => $provider->label(),
|
||||
'icon' => $provider->icon(),
|
||||
'views' => (int) $items->sum(fn (array $item): int => (int) ($item['metrics']['views'] ?? 0)),
|
||||
'engagement' => (int) $items->sum(fn (array $item): int => (int) ($item['engagement_score'] ?? 0)),
|
||||
'published_count' => (int) $items->filter(fn (array $item): bool => ($item['status'] ?? null) === 'published')->count(),
|
||||
];
|
||||
})
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Studio;
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Models\Collection;
|
||||
use App\Models\NovaCardBackground;
|
||||
use App\Models\Story;
|
||||
use App\Models\User;
|
||||
use App\Support\CoverUrl;
|
||||
use Illuminate\Support\Collection as SupportCollection;
|
||||
|
||||
final class CreatorStudioAssetService
|
||||
{
|
||||
/**
|
||||
* @param array<string, mixed> $filters
|
||||
*/
|
||||
public function library(User $user, array $filters = []): array
|
||||
{
|
||||
$type = $this->normalizeType((string) ($filters['type'] ?? 'all'));
|
||||
$source = $this->normalizeSource((string) ($filters['source'] ?? 'all'));
|
||||
$sort = $this->normalizeSort((string) ($filters['sort'] ?? 'recent'));
|
||||
$query = trim((string) ($filters['q'] ?? ''));
|
||||
$page = max(1, (int) ($filters['page'] ?? 1));
|
||||
$perPage = min(max((int) ($filters['per_page'] ?? 18), 12), 36);
|
||||
|
||||
$items = $this->allAssets($user);
|
||||
|
||||
if ($type !== 'all') {
|
||||
$items = $items->where('type', $type)->values();
|
||||
}
|
||||
|
||||
if ($source !== 'all') {
|
||||
$items = $items->where('source_key', $source)->values();
|
||||
}
|
||||
|
||||
if ($query !== '') {
|
||||
$needle = mb_strtolower($query);
|
||||
$items = $items->filter(function (array $item) use ($needle): bool {
|
||||
return collect([
|
||||
$item['title'] ?? '',
|
||||
$item['type_label'] ?? '',
|
||||
$item['source_label'] ?? '',
|
||||
$item['description'] ?? '',
|
||||
])->contains(fn ($value): bool => is_string($value) && str_contains(mb_strtolower($value), $needle));
|
||||
})->values();
|
||||
}
|
||||
|
||||
$items = $this->sortAssets($items, $sort);
|
||||
|
||||
$total = $items->count();
|
||||
$lastPage = max(1, (int) ceil($total / $perPage));
|
||||
$page = min($page, $lastPage);
|
||||
|
||||
return [
|
||||
'items' => $items->forPage($page, $perPage)->values()->all(),
|
||||
'meta' => [
|
||||
'current_page' => $page,
|
||||
'last_page' => $lastPage,
|
||||
'per_page' => $perPage,
|
||||
'total' => $total,
|
||||
],
|
||||
'filters' => [
|
||||
'type' => $type,
|
||||
'source' => $source,
|
||||
'sort' => $sort,
|
||||
'q' => $query,
|
||||
],
|
||||
'type_options' => [
|
||||
['value' => 'all', 'label' => 'All assets'],
|
||||
['value' => 'card_backgrounds', 'label' => 'Card backgrounds'],
|
||||
['value' => 'story_covers', 'label' => 'Story covers'],
|
||||
['value' => 'collection_covers', 'label' => 'Collection covers'],
|
||||
['value' => 'artwork_previews', 'label' => 'Artwork previews'],
|
||||
['value' => 'profile_branding', 'label' => 'Profile branding'],
|
||||
],
|
||||
'source_options' => [
|
||||
['value' => 'all', 'label' => 'All sources'],
|
||||
['value' => 'cards', 'label' => 'Nova Cards'],
|
||||
['value' => 'stories', 'label' => 'Stories'],
|
||||
['value' => 'collections', 'label' => 'Collections'],
|
||||
['value' => 'artworks', 'label' => 'Artworks'],
|
||||
['value' => 'profile', 'label' => 'Profile'],
|
||||
],
|
||||
'sort_options' => [
|
||||
['value' => 'recent', 'label' => 'Recently updated'],
|
||||
['value' => 'usage', 'label' => 'Most reused'],
|
||||
['value' => 'title', 'label' => 'Title A-Z'],
|
||||
],
|
||||
'summary' => $this->summary($items),
|
||||
'highlights' => [
|
||||
'recent_uploads' => $items->take(5)->values()->all(),
|
||||
'most_used' => $items->sortByDesc(fn (array $item): int => (int) ($item['usage_count'] ?? 0))->take(5)->values()->all(),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
private function allAssets(User $user): SupportCollection
|
||||
{
|
||||
return $this->cardBackgrounds($user)
|
||||
->concat($this->storyCovers($user))
|
||||
->concat($this->collectionCovers($user))
|
||||
->concat($this->artworkPreviews($user))
|
||||
->concat($this->profileBranding($user))
|
||||
->sortByDesc(fn (array $item): int => strtotime((string) ($item['created_at'] ?? '')) ?: 0)
|
||||
->values();
|
||||
}
|
||||
|
||||
private function cardBackgrounds(User $user): SupportCollection
|
||||
{
|
||||
return NovaCardBackground::query()
|
||||
->withCount('cards')
|
||||
->where('user_id', $user->id)
|
||||
->latest('created_at')
|
||||
->limit(120)
|
||||
->get()
|
||||
->map(fn (NovaCardBackground $background): array => [
|
||||
'id' => 'card-background:' . $background->id,
|
||||
'numeric_id' => (int) $background->id,
|
||||
'type' => 'card_backgrounds',
|
||||
'type_label' => 'Card background',
|
||||
'source_key' => 'cards',
|
||||
'title' => 'Background #' . $background->id,
|
||||
'description' => 'Reusable upload for Nova Card drafts and remixes.',
|
||||
'image_url' => $background->processedUrl(),
|
||||
'source_label' => 'Nova Cards',
|
||||
'usage_count' => (int) ($background->cards_count ?? 0),
|
||||
'usage_references' => [
|
||||
['label' => 'Nova Cards editor', 'href' => route('studio.cards.create')],
|
||||
['label' => 'Cards library', 'href' => route('studio.cards.index')],
|
||||
],
|
||||
'created_at' => $background->created_at?->toIso8601String(),
|
||||
'manage_url' => route('studio.cards.index'),
|
||||
'view_url' => route('studio.cards.create'),
|
||||
]);
|
||||
}
|
||||
|
||||
private function storyCovers(User $user): SupportCollection
|
||||
{
|
||||
return Story::query()
|
||||
->where('creator_id', $user->id)
|
||||
->whereNotNull('cover_image')
|
||||
->latest('updated_at')
|
||||
->limit(120)
|
||||
->get()
|
||||
->map(fn (Story $story): array => [
|
||||
'id' => 'story-cover:' . $story->id,
|
||||
'numeric_id' => (int) $story->id,
|
||||
'type' => 'story_covers',
|
||||
'type_label' => 'Story cover',
|
||||
'source_key' => 'stories',
|
||||
'title' => $story->title,
|
||||
'description' => $story->excerpt ?: 'Cover art attached to a story draft or publication.',
|
||||
'image_url' => $story->cover_url,
|
||||
'source_label' => 'Stories',
|
||||
'usage_count' => 1,
|
||||
'usage_references' => [
|
||||
['label' => 'Story editor', 'href' => route('creator.stories.edit', ['story' => $story->id])],
|
||||
],
|
||||
'created_at' => $story->updated_at?->toIso8601String(),
|
||||
'manage_url' => route('creator.stories.edit', ['story' => $story->id]),
|
||||
'view_url' => in_array($story->status, ['published', 'scheduled'], true)
|
||||
? route('stories.show', ['slug' => $story->slug])
|
||||
: route('creator.stories.preview', ['story' => $story->id]),
|
||||
]);
|
||||
}
|
||||
|
||||
private function collectionCovers(User $user): SupportCollection
|
||||
{
|
||||
return Collection::query()
|
||||
->with('coverArtwork')
|
||||
->where('user_id', $user->id)
|
||||
->latest('updated_at')
|
||||
->limit(120)
|
||||
->get()
|
||||
->filter(fn (Collection $collection): bool => $collection->coverArtwork !== null)
|
||||
->map(fn (Collection $collection): array => [
|
||||
'id' => 'collection-cover:' . $collection->id,
|
||||
'numeric_id' => (int) $collection->id,
|
||||
'type' => 'collection_covers',
|
||||
'type_label' => 'Collection cover',
|
||||
'source_key' => 'collections',
|
||||
'title' => $collection->title,
|
||||
'description' => $collection->summary ?: $collection->description ?: 'Cover artwork used for a collection shelf.',
|
||||
'image_url' => $collection->coverArtwork?->thumbUrl('md'),
|
||||
'source_label' => 'Collections',
|
||||
'usage_count' => 1,
|
||||
'usage_references' => [
|
||||
['label' => 'Collection dashboard', 'href' => route('settings.collections.show', ['collection' => $collection->id])],
|
||||
],
|
||||
'created_at' => $collection->updated_at?->toIso8601String(),
|
||||
'manage_url' => route('settings.collections.show', ['collection' => $collection->id]),
|
||||
'view_url' => route('profile.collections.show', [
|
||||
'username' => strtolower((string) $user->username),
|
||||
'slug' => $collection->slug,
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
private function artworkPreviews(User $user): SupportCollection
|
||||
{
|
||||
return Artwork::query()
|
||||
->where('user_id', $user->id)
|
||||
->whereNull('deleted_at')
|
||||
->latest('updated_at')
|
||||
->limit(120)
|
||||
->get()
|
||||
->map(fn (Artwork $artwork): array => [
|
||||
'id' => 'artwork-preview:' . $artwork->id,
|
||||
'numeric_id' => (int) $artwork->id,
|
||||
'type' => 'artwork_previews',
|
||||
'type_label' => 'Artwork preview',
|
||||
'source_key' => 'artworks',
|
||||
'title' => $artwork->title ?: 'Untitled artwork',
|
||||
'description' => $artwork->description ?: 'Reusable preview image from your artwork library.',
|
||||
'image_url' => $artwork->thumbUrl('md'),
|
||||
'source_label' => 'Artworks',
|
||||
'usage_count' => 1,
|
||||
'usage_references' => [
|
||||
['label' => 'Artwork editor', 'href' => route('studio.artworks.edit', ['id' => $artwork->id])],
|
||||
['label' => 'Artwork page', 'href' => $artwork->published_at ? route('art.show', ['id' => $artwork->id, 'slug' => $artwork->slug]) : route('studio.artworks.edit', ['id' => $artwork->id])],
|
||||
],
|
||||
'created_at' => $artwork->updated_at?->toIso8601String(),
|
||||
'manage_url' => route('studio.artworks.edit', ['id' => $artwork->id]),
|
||||
'view_url' => $artwork->published_at
|
||||
? route('art.show', ['id' => $artwork->id, 'slug' => $artwork->slug])
|
||||
: route('studio.artworks.edit', ['id' => $artwork->id]),
|
||||
]);
|
||||
}
|
||||
|
||||
private function profileBranding(User $user): SupportCollection
|
||||
{
|
||||
$items = [];
|
||||
|
||||
if ($user->cover_hash && $user->cover_ext) {
|
||||
$items[] = [
|
||||
'id' => 'profile-cover:' . $user->id,
|
||||
'numeric_id' => (int) $user->id,
|
||||
'type' => 'profile_branding',
|
||||
'type_label' => 'Profile banner',
|
||||
'source_key' => 'profile',
|
||||
'title' => 'Profile banner',
|
||||
'description' => 'Banner image used on your public creator profile.',
|
||||
'image_url' => CoverUrl::forUser($user->cover_hash, $user->cover_ext, time()),
|
||||
'source_label' => 'Profile',
|
||||
'usage_count' => 1,
|
||||
'usage_references' => [
|
||||
['label' => 'Studio profile', 'href' => route('studio.profile')],
|
||||
['label' => 'Public profile', 'href' => '/@' . strtolower((string) $user->username)],
|
||||
],
|
||||
'created_at' => now()->toIso8601String(),
|
||||
'manage_url' => route('studio.profile'),
|
||||
'view_url' => '/@' . strtolower((string) $user->username),
|
||||
];
|
||||
}
|
||||
|
||||
return collect($items);
|
||||
}
|
||||
|
||||
private function summary(SupportCollection $items): array
|
||||
{
|
||||
return [
|
||||
['key' => 'card_backgrounds', 'label' => 'Card backgrounds', 'count' => $items->where('type', 'card_backgrounds')->count(), 'icon' => 'fa-solid fa-panorama'],
|
||||
['key' => 'story_covers', 'label' => 'Story covers', 'count' => $items->where('type', 'story_covers')->count(), 'icon' => 'fa-solid fa-book-open-cover'],
|
||||
['key' => 'collection_covers', 'label' => 'Collection covers', 'count' => $items->where('type', 'collection_covers')->count(), 'icon' => 'fa-solid fa-layer-group'],
|
||||
['key' => 'artwork_previews', 'label' => 'Artwork previews', 'count' => $items->where('type', 'artwork_previews')->count(), 'icon' => 'fa-solid fa-image'],
|
||||
['key' => 'profile_branding', 'label' => 'Profile branding', 'count' => $items->where('type', 'profile_branding')->count(), 'icon' => 'fa-solid fa-id-badge'],
|
||||
];
|
||||
}
|
||||
|
||||
private function normalizeType(string $type): string
|
||||
{
|
||||
$allowed = ['all', 'card_backgrounds', 'story_covers', 'collection_covers', 'artwork_previews', 'profile_branding'];
|
||||
|
||||
return in_array($type, $allowed, true) ? $type : 'all';
|
||||
}
|
||||
|
||||
private function normalizeSource(string $source): string
|
||||
{
|
||||
$allowed = ['all', 'cards', 'stories', 'collections', 'artworks', 'profile'];
|
||||
|
||||
return in_array($source, $allowed, true) ? $source : 'all';
|
||||
}
|
||||
|
||||
private function normalizeSort(string $sort): string
|
||||
{
|
||||
return in_array($sort, ['recent', 'usage', 'title'], true) ? $sort : 'recent';
|
||||
}
|
||||
|
||||
private function sortAssets(SupportCollection $items, string $sort): SupportCollection
|
||||
{
|
||||
return match ($sort) {
|
||||
'usage' => $items->sortByDesc(fn (array $item): int => (int) ($item['usage_count'] ?? 0))->values(),
|
||||
'title' => $items->sortBy(fn (array $item): string => mb_strtolower((string) ($item['title'] ?? '')))->values(),
|
||||
default => $items->sortByDesc(fn (array $item): int => strtotime((string) ($item['created_at'] ?? '')) ?: 0)->values(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Studio;
|
||||
|
||||
use App\Models\User;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
final class CreatorStudioCalendarService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly CreatorStudioContentService $content,
|
||||
private readonly CreatorStudioScheduledService $scheduled,
|
||||
) {
|
||||
}
|
||||
|
||||
public function build(User $user, array $filters = []): array
|
||||
{
|
||||
$view = $this->normalizeView((string) ($filters['view'] ?? 'month'));
|
||||
$module = $this->normalizeModule((string) ($filters['module'] ?? 'all'));
|
||||
$status = $this->normalizeStatus((string) ($filters['status'] ?? 'scheduled'));
|
||||
$query = trim((string) ($filters['q'] ?? ''));
|
||||
$focusDate = $this->normalizeFocusDate((string) ($filters['focus_date'] ?? ''));
|
||||
|
||||
$scheduledItems = $this->scheduledItems($user, $module, $query);
|
||||
$unscheduledItems = $this->unscheduledItems($user, $module, $query);
|
||||
|
||||
return [
|
||||
'filters' => [
|
||||
'view' => $view,
|
||||
'module' => $module,
|
||||
'status' => $status,
|
||||
'q' => $query,
|
||||
'focus_date' => $focusDate->toDateString(),
|
||||
],
|
||||
'view_options' => [
|
||||
['value' => 'month', 'label' => 'Month'],
|
||||
['value' => 'week', 'label' => 'Week'],
|
||||
['value' => 'agenda', 'label' => 'Agenda'],
|
||||
],
|
||||
'status_options' => [
|
||||
['value' => 'scheduled', 'label' => 'Scheduled only'],
|
||||
['value' => 'unscheduled', 'label' => 'Unscheduled queue'],
|
||||
['value' => 'all', 'label' => 'Everything'],
|
||||
],
|
||||
'module_options' => array_merge([
|
||||
['value' => 'all', 'label' => 'All content'],
|
||||
], collect($this->content->moduleSummaries($user))->map(fn (array $summary): array => [
|
||||
'value' => $summary['key'],
|
||||
'label' => $summary['label'],
|
||||
])->all()),
|
||||
'summary' => $this->summary($scheduledItems, $unscheduledItems),
|
||||
'month' => $this->monthGrid($scheduledItems, $focusDate),
|
||||
'week' => $this->weekGrid($scheduledItems, $focusDate),
|
||||
'agenda' => $this->agenda($scheduledItems),
|
||||
'scheduled_items' => $status === 'unscheduled' ? [] : $scheduledItems->take(18)->values()->all(),
|
||||
'unscheduled_items' => $status === 'scheduled' ? [] : $unscheduledItems->take(12)->values()->all(),
|
||||
'gaps' => $this->gaps($scheduledItems, $focusDate),
|
||||
];
|
||||
}
|
||||
|
||||
private function scheduledItems(User $user, string $module, string $query): Collection
|
||||
{
|
||||
$items = $module === 'all'
|
||||
? collect($this->content->providers())->flatMap(fn ($provider) => $provider->scheduledItems($user, 320))
|
||||
: ($this->content->provider($module)?->scheduledItems($user, 320) ?? collect());
|
||||
|
||||
if ($query !== '') {
|
||||
$needle = mb_strtolower($query);
|
||||
$items = $items->filter(fn (array $item): bool => str_contains(mb_strtolower((string) ($item['title'] ?? '')), $needle));
|
||||
}
|
||||
|
||||
return $items
|
||||
->sortBy(fn (array $item): int => strtotime((string) ($item['scheduled_at'] ?? $item['published_at'] ?? '')) ?: PHP_INT_MAX)
|
||||
->values();
|
||||
}
|
||||
|
||||
private function unscheduledItems(User $user, string $module, string $query): Collection
|
||||
{
|
||||
$items = $module === 'all'
|
||||
? collect($this->content->providers())->flatMap(fn ($provider) => $provider->items($user, 'all', 240))
|
||||
: ($this->content->provider($module)?->items($user, 'all', 240) ?? collect());
|
||||
|
||||
return $items
|
||||
->filter(function (array $item) use ($query): bool {
|
||||
if (filled($item['scheduled_at'] ?? null)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (in_array((string) ($item['status'] ?? ''), ['archived', 'hidden', 'rejected'], true)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($query === '') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return str_contains(mb_strtolower((string) ($item['title'] ?? '')), mb_strtolower($query));
|
||||
})
|
||||
->sortByDesc(fn (array $item): int => strtotime((string) ($item['updated_at'] ?? $item['created_at'] ?? '')) ?: 0)
|
||||
->values();
|
||||
}
|
||||
|
||||
private function summary(Collection $scheduledItems, Collection $unscheduledItems): array
|
||||
{
|
||||
$days = $scheduledItems
|
||||
->groupBy(fn (array $item): string => date('Y-m-d', strtotime((string) ($item['scheduled_at'] ?? $item['published_at'] ?? now()->toIso8601String()))))
|
||||
->map(fn (Collection $items): int => $items->count());
|
||||
|
||||
return [
|
||||
'scheduled_total' => $scheduledItems->count(),
|
||||
'unscheduled_total' => $unscheduledItems->count(),
|
||||
'overloaded_days' => $days->filter(fn (int $count): bool => $count >= 3)->count(),
|
||||
'next_publish_at' => $scheduledItems->first()['scheduled_at'] ?? null,
|
||||
];
|
||||
}
|
||||
|
||||
private function monthGrid(Collection $scheduledItems, Carbon $focusDate): array
|
||||
{
|
||||
$start = $focusDate->copy()->startOfMonth()->startOfWeek();
|
||||
$end = $focusDate->copy()->endOfMonth()->endOfWeek();
|
||||
$days = [];
|
||||
|
||||
for ($date = $start->copy(); $date->lte($end); $date->addDay()) {
|
||||
$key = $date->toDateString();
|
||||
$items = $scheduledItems->filter(fn (array $item): bool => str_starts_with((string) ($item['scheduled_at'] ?? ''), $key))->values();
|
||||
$days[] = [
|
||||
'date' => $key,
|
||||
'day' => $date->day,
|
||||
'is_current_month' => $date->month === $focusDate->month,
|
||||
'count' => $items->count(),
|
||||
'items' => $items->take(3)->all(),
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'label' => $focusDate->format('F Y'),
|
||||
'days' => $days,
|
||||
];
|
||||
}
|
||||
|
||||
private function weekGrid(Collection $scheduledItems, Carbon $focusDate): array
|
||||
{
|
||||
$start = $focusDate->copy()->startOfWeek();
|
||||
|
||||
return [
|
||||
'label' => $start->format('M j') . ' - ' . $start->copy()->endOfWeek()->format('M j'),
|
||||
'days' => collect(range(0, 6))->map(function (int $offset) use ($start, $scheduledItems): array {
|
||||
$date = $start->copy()->addDays($offset);
|
||||
$key = $date->toDateString();
|
||||
$items = $scheduledItems->filter(fn (array $item): bool => str_starts_with((string) ($item['scheduled_at'] ?? ''), $key))->values();
|
||||
|
||||
return [
|
||||
'date' => $key,
|
||||
'label' => $date->format('D j'),
|
||||
'items' => $items->all(),
|
||||
];
|
||||
})->all(),
|
||||
];
|
||||
}
|
||||
|
||||
private function agenda(Collection $scheduledItems): array
|
||||
{
|
||||
return $scheduledItems
|
||||
->groupBy(fn (array $item): string => date('Y-m-d', strtotime((string) ($item['scheduled_at'] ?? $item['published_at'] ?? now()->toIso8601String()))))
|
||||
->map(fn (Collection $items, string $date): array => [
|
||||
'date' => $date,
|
||||
'label' => Carbon::parse($date)->format('M j'),
|
||||
'count' => $items->count(),
|
||||
'items' => $items->values()->all(),
|
||||
])
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
private function gaps(Collection $scheduledItems, Carbon $focusDate): array
|
||||
{
|
||||
return collect(range(0, 13))
|
||||
->map(function (int $offset) use ($focusDate, $scheduledItems): ?array {
|
||||
$date = $focusDate->copy()->startOfDay()->addDays($offset);
|
||||
$key = $date->toDateString();
|
||||
$count = $scheduledItems->filter(fn (array $item): bool => str_starts_with((string) ($item['scheduled_at'] ?? ''), $key))->count();
|
||||
|
||||
if ($count > 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'date' => $key,
|
||||
'label' => $date->format('D, M j'),
|
||||
];
|
||||
})
|
||||
->filter()
|
||||
->take(6)
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
private function normalizeView(string $view): string
|
||||
{
|
||||
return in_array($view, ['month', 'week', 'agenda'], true) ? $view : 'month';
|
||||
}
|
||||
|
||||
private function normalizeStatus(string $status): string
|
||||
{
|
||||
return in_array($status, ['scheduled', 'unscheduled', 'all'], true) ? $status : 'scheduled';
|
||||
}
|
||||
|
||||
private function normalizeModule(string $module): string
|
||||
{
|
||||
return in_array($module, ['all', 'artworks', 'cards', 'collections', 'stories'], true) ? $module : 'all';
|
||||
}
|
||||
|
||||
private function normalizeFocusDate(string $value): Carbon
|
||||
{
|
||||
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $value) === 1) {
|
||||
return Carbon::parse($value);
|
||||
}
|
||||
|
||||
return now();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Studio;
|
||||
|
||||
use App\Models\NovaCard;
|
||||
use App\Models\NovaCardChallenge;
|
||||
use App\Models\NovaCardChallengeEntry;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
final class CreatorStudioChallengeService
|
||||
{
|
||||
public function build(User $user): array
|
||||
{
|
||||
$entryCounts = NovaCardChallengeEntry::query()
|
||||
->where('user_id', $user->id)
|
||||
->whereNotIn('status', [NovaCardChallengeEntry::STATUS_HIDDEN, NovaCardChallengeEntry::STATUS_REJECTED])
|
||||
->selectRaw('challenge_id, COUNT(*) as aggregate')
|
||||
->groupBy('challenge_id')
|
||||
->pluck('aggregate', 'challenge_id');
|
||||
|
||||
$recentEntriesQuery = NovaCardChallengeEntry::query()
|
||||
->with(['challenge', 'card'])
|
||||
->where('user_id', $user->id)
|
||||
->whereNotIn('status', [NovaCardChallengeEntry::STATUS_HIDDEN, NovaCardChallengeEntry::STATUS_REJECTED])
|
||||
->whereHas('challenge')
|
||||
->whereHas('card');
|
||||
|
||||
$recentEntries = $recentEntriesQuery
|
||||
->latest('created_at')
|
||||
->limit(8)
|
||||
->get()
|
||||
->map(fn (NovaCardChallengeEntry $entry): array => $this->mapEntry($entry))
|
||||
->values()
|
||||
->all();
|
||||
|
||||
$activeChallenges = NovaCardChallenge::query()
|
||||
->whereIn('status', [NovaCardChallenge::STATUS_ACTIVE, NovaCardChallenge::STATUS_COMPLETED])
|
||||
->orderByRaw('CASE WHEN featured = 1 THEN 0 ELSE 1 END')
|
||||
->orderByRaw("CASE WHEN status = 'active' THEN 0 ELSE 1 END")
|
||||
->orderBy('ends_at')
|
||||
->orderByDesc('starts_at')
|
||||
->limit(10)
|
||||
->get()
|
||||
->map(fn (NovaCardChallenge $challenge): array => $this->mapChallenge($challenge, $entryCounts))
|
||||
->values();
|
||||
|
||||
$spotlight = $activeChallenges->first();
|
||||
|
||||
$cardLeaders = NovaCard::query()
|
||||
->where('user_id', $user->id)
|
||||
->whereNull('deleted_at')
|
||||
->where('challenge_entries_count', '>', 0)
|
||||
->orderByDesc('challenge_entries_count')
|
||||
->orderByDesc('published_at')
|
||||
->limit(6)
|
||||
->get()
|
||||
->map(fn (NovaCard $card): array => [
|
||||
'id' => (int) $card->id,
|
||||
'title' => (string) $card->title,
|
||||
'status' => (string) $card->status,
|
||||
'challenge_entries_count' => (int) $card->challenge_entries_count,
|
||||
'views_count' => (int) $card->views_count,
|
||||
'comments_count' => (int) $card->comments_count,
|
||||
'preview_url' => route('studio.cards.preview', ['id' => $card->id]),
|
||||
'edit_url' => route('studio.cards.edit', ['id' => $card->id]),
|
||||
'analytics_url' => route('studio.cards.analytics', ['id' => $card->id]),
|
||||
])
|
||||
->values()
|
||||
->all();
|
||||
|
||||
$cardsAvailable = (int) NovaCard::query()
|
||||
->where('user_id', $user->id)
|
||||
->whereNull('deleted_at')
|
||||
->whereNotIn('status', [NovaCard::STATUS_HIDDEN, NovaCard::STATUS_REJECTED])
|
||||
->count();
|
||||
|
||||
$entryCount = (int) $entryCounts->sum();
|
||||
$featuredEntries = (int) (clone $recentEntriesQuery)
|
||||
->whereIn('status', [NovaCardChallengeEntry::STATUS_FEATURED, NovaCardChallengeEntry::STATUS_WINNER])
|
||||
->count();
|
||||
$winnerEntries = (int) (clone $recentEntriesQuery)
|
||||
->where('status', NovaCardChallengeEntry::STATUS_WINNER)
|
||||
->count();
|
||||
|
||||
return [
|
||||
'summary' => [
|
||||
'active_challenges' => (int) $activeChallenges->where('status', NovaCardChallenge::STATUS_ACTIVE)->count(),
|
||||
'joined_challenges' => (int) $entryCounts->keys()->count(),
|
||||
'entries_submitted' => $entryCount,
|
||||
'featured_entries' => $featuredEntries,
|
||||
'winner_entries' => $winnerEntries,
|
||||
'cards_available' => $cardsAvailable,
|
||||
],
|
||||
'spotlight' => $spotlight,
|
||||
'active_challenges' => $activeChallenges->all(),
|
||||
'recent_entries' => $recentEntries,
|
||||
'card_leaders' => $cardLeaders,
|
||||
'reminders' => $this->reminders($cardsAvailable, $entryCount, $activeChallenges, $featuredEntries, $winnerEntries),
|
||||
];
|
||||
}
|
||||
|
||||
private function mapChallenge(NovaCardChallenge $challenge, Collection $entryCounts): array
|
||||
{
|
||||
return [
|
||||
'id' => (int) $challenge->id,
|
||||
'slug' => (string) $challenge->slug,
|
||||
'title' => (string) $challenge->title,
|
||||
'description' => $challenge->description,
|
||||
'prompt' => $challenge->prompt,
|
||||
'status' => (string) $challenge->status,
|
||||
'official' => (bool) $challenge->official,
|
||||
'featured' => (bool) $challenge->featured,
|
||||
'entries_count' => (int) $challenge->entries_count,
|
||||
'starts_at' => $challenge->starts_at?->toIso8601String(),
|
||||
'ends_at' => $challenge->ends_at?->toIso8601String(),
|
||||
'is_joined' => $entryCounts->has($challenge->id),
|
||||
'submission_count' => (int) ($entryCounts->get($challenge->id) ?? 0),
|
||||
'url' => route('cards.challenges.show', ['slug' => $challenge->slug]),
|
||||
];
|
||||
}
|
||||
|
||||
private function mapEntry(NovaCardChallengeEntry $entry): array
|
||||
{
|
||||
return [
|
||||
'id' => (int) $entry->id,
|
||||
'status' => (string) $entry->status,
|
||||
'submitted_at' => $entry->created_at?->toIso8601String(),
|
||||
'note' => $entry->note,
|
||||
'challenge' => [
|
||||
'id' => (int) $entry->challenge_id,
|
||||
'title' => (string) ($entry->challenge?->title ?? 'Challenge'),
|
||||
'status' => (string) ($entry->challenge?->status ?? ''),
|
||||
'official' => (bool) ($entry->challenge?->official ?? false),
|
||||
'url' => $entry->challenge ? route('cards.challenges.show', ['slug' => $entry->challenge->slug]) : route('cards.challenges'),
|
||||
],
|
||||
'card' => [
|
||||
'id' => (int) $entry->card_id,
|
||||
'title' => (string) ($entry->card?->title ?? 'Card'),
|
||||
'preview_url' => $entry->card ? route('studio.cards.preview', ['id' => $entry->card->id]) : route('studio.cards.index'),
|
||||
'edit_url' => $entry->card ? route('studio.cards.edit', ['id' => $entry->card->id]) : route('studio.cards.create'),
|
||||
'analytics_url' => $entry->card ? route('studio.cards.analytics', ['id' => $entry->card->id]) : route('studio.cards.index'),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
private function reminders(int $cardsAvailable, int $entryCount, Collection $activeChallenges, int $featuredEntries, int $winnerEntries): array
|
||||
{
|
||||
$items = [];
|
||||
|
||||
if ($cardsAvailable === 0) {
|
||||
$items[] = [
|
||||
'title' => 'Create a card to join challenges',
|
||||
'body' => 'Challenge participation starts from published or ready-to-share cards inside Studio.',
|
||||
'href' => route('studio.cards.create'),
|
||||
'cta' => 'Create card',
|
||||
];
|
||||
}
|
||||
|
||||
if ($cardsAvailable > 0 && $entryCount === 0 && $activeChallenges->where('status', NovaCardChallenge::STATUS_ACTIVE)->isNotEmpty()) {
|
||||
$items[] = [
|
||||
'title' => 'You have active challenge windows open',
|
||||
'body' => 'Submit an existing card to the current prompt lineup before the next window closes.',
|
||||
'href' => route('studio.cards.index'),
|
||||
'cta' => 'Open cards',
|
||||
];
|
||||
}
|
||||
|
||||
if ($featuredEntries > 0) {
|
||||
$items[] = [
|
||||
'title' => 'Featured challenge entries are live',
|
||||
'body' => 'Review promoted submissions and keep those cards ready for profile, editorial, or follow-up pushes.',
|
||||
'href' => route('studio.featured'),
|
||||
'cta' => 'Manage featured',
|
||||
];
|
||||
}
|
||||
|
||||
if ($winnerEntries > 0) {
|
||||
$items[] = [
|
||||
'title' => 'Winning challenge work deserves a spotlight',
|
||||
'body' => 'Use featured content and profile curation to extend the reach of cards that already placed well.',
|
||||
'href' => route('studio.profile'),
|
||||
'cta' => 'Open profile',
|
||||
];
|
||||
}
|
||||
|
||||
if ($activeChallenges->isNotEmpty()) {
|
||||
$items[] = [
|
||||
'title' => 'Public challenge archive stays one click away',
|
||||
'body' => 'Use the public challenge directory to review prompts, reference past winners, and see how new runs are framed.',
|
||||
'href' => route('cards.challenges'),
|
||||
'cta' => 'Browse challenges',
|
||||
];
|
||||
}
|
||||
|
||||
return collect($items)->take(4)->values()->all();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,379 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Studio;
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Models\ArtworkComment;
|
||||
use App\Models\Collection;
|
||||
use App\Models\CollectionComment;
|
||||
use App\Models\NovaCard;
|
||||
use App\Models\NovaCardComment;
|
||||
use App\Models\Report;
|
||||
use App\Models\Story;
|
||||
use App\Models\StoryComment;
|
||||
use App\Models\User;
|
||||
use App\Services\CollectionCommentService;
|
||||
use App\Services\NovaCards\NovaCardCommentService;
|
||||
use App\Services\SocialService;
|
||||
use App\Support\AvatarUrl;
|
||||
use App\Support\ContentSanitizer;
|
||||
use App\Support\Moderation\ReportTargetResolver;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
final class CreatorStudioCommentService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly NovaCardCommentService $cardComments,
|
||||
private readonly CollectionCommentService $collectionComments,
|
||||
private readonly SocialService $social,
|
||||
private readonly ReportTargetResolver $reports,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $filters
|
||||
*/
|
||||
public function list(User $user, array $filters = []): array
|
||||
{
|
||||
$module = $this->normalizeModule((string) ($filters['module'] ?? 'all'));
|
||||
$query = trim((string) ($filters['q'] ?? ''));
|
||||
$page = max(1, (int) ($filters['page'] ?? 1));
|
||||
$perPage = min(max((int) ($filters['per_page'] ?? 18), 12), 36);
|
||||
|
||||
$items = $this->artworkComments($user)
|
||||
->concat($this->cardComments($user))
|
||||
->concat($this->collectionComments($user))
|
||||
->concat($this->storyComments($user))
|
||||
->sortByDesc(fn (array $item): int => strtotime((string) ($item['created_at'] ?? '')) ?: 0)
|
||||
->values();
|
||||
|
||||
if ($module !== 'all') {
|
||||
$items = $items->where('module', $module)->values();
|
||||
}
|
||||
|
||||
if ($query !== '') {
|
||||
$needle = mb_strtolower($query);
|
||||
$items = $items->filter(function (array $item) use ($needle): bool {
|
||||
return collect([
|
||||
$item['author_name'] ?? '',
|
||||
$item['item_title'] ?? '',
|
||||
$item['body'] ?? '',
|
||||
$item['module_label'] ?? '',
|
||||
])->contains(fn ($value): bool => is_string($value) && str_contains(mb_strtolower($value), $needle));
|
||||
})->values();
|
||||
}
|
||||
|
||||
$total = $items->count();
|
||||
$lastPage = max(1, (int) ceil($total / $perPage));
|
||||
$page = min($page, $lastPage);
|
||||
|
||||
return [
|
||||
'items' => $items->forPage($page, $perPage)->values()->all(),
|
||||
'meta' => [
|
||||
'current_page' => $page,
|
||||
'last_page' => $lastPage,
|
||||
'per_page' => $perPage,
|
||||
'total' => $total,
|
||||
],
|
||||
'filters' => [
|
||||
'module' => $module,
|
||||
'q' => $query,
|
||||
],
|
||||
'module_options' => [
|
||||
['value' => 'all', 'label' => 'All comments'],
|
||||
['value' => 'artworks', 'label' => 'Artworks'],
|
||||
['value' => 'cards', 'label' => 'Cards'],
|
||||
['value' => 'collections', 'label' => 'Collections'],
|
||||
['value' => 'stories', 'label' => 'Stories'],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public function reply(User $user, string $module, int $commentId, string $content): void
|
||||
{
|
||||
$trimmed = trim($content);
|
||||
abort_if($trimmed === '' || mb_strlen($trimmed) > 10000, 422, 'Reply content is invalid.');
|
||||
|
||||
match ($this->normalizeModule($module)) {
|
||||
'artworks' => $this->replyToArtworkComment($user, $commentId, $trimmed),
|
||||
'cards' => $this->replyToCardComment($user, $commentId, $trimmed),
|
||||
'collections' => $this->replyToCollectionComment($user, $commentId, $trimmed),
|
||||
'stories' => $this->replyToStoryComment($user, $commentId, $trimmed),
|
||||
default => abort(404),
|
||||
};
|
||||
}
|
||||
|
||||
public function moderate(User $user, string $module, int $commentId): void
|
||||
{
|
||||
match ($this->normalizeModule($module)) {
|
||||
'artworks' => $this->deleteArtworkComment($user, $commentId),
|
||||
'cards' => $this->deleteCardComment($user, $commentId),
|
||||
'collections' => $this->deleteCollectionComment($user, $commentId),
|
||||
'stories' => $this->deleteStoryComment($user, $commentId),
|
||||
default => abort(404),
|
||||
};
|
||||
}
|
||||
|
||||
public function report(User $user, string $module, int $commentId, string $reason, ?string $details = null): array
|
||||
{
|
||||
$targetType = match ($this->normalizeModule($module)) {
|
||||
'artworks' => 'artwork_comment',
|
||||
'cards' => 'nova_card_comment',
|
||||
'collections' => 'collection_comment',
|
||||
'stories' => 'story_comment',
|
||||
default => abort(404),
|
||||
};
|
||||
|
||||
$this->reports->validateForReporter($user, $targetType, $commentId);
|
||||
|
||||
$report = Report::query()->create([
|
||||
'reporter_id' => $user->id,
|
||||
'target_type' => $targetType,
|
||||
'target_id' => $commentId,
|
||||
'reason' => trim($reason),
|
||||
'details' => $details ? trim($details) : null,
|
||||
'status' => 'open',
|
||||
]);
|
||||
|
||||
return [
|
||||
'id' => (int) $report->id,
|
||||
'status' => (string) $report->status,
|
||||
];
|
||||
}
|
||||
|
||||
private function artworkComments(User $user)
|
||||
{
|
||||
return ArtworkComment::query()
|
||||
->with(['user.profile', 'artwork'])
|
||||
->whereNull('deleted_at')
|
||||
->whereHas('artwork', fn ($query) => $query->where('user_id', $user->id))
|
||||
->latest('created_at')
|
||||
->limit(120)
|
||||
->get()
|
||||
->map(fn (ArtworkComment $comment): array => [
|
||||
'id' => 'artworks:' . $comment->id,
|
||||
'comment_id' => (int) $comment->id,
|
||||
'module' => 'artworks',
|
||||
'module_label' => 'Artworks',
|
||||
'target_type' => 'artwork_comment',
|
||||
'author_name' => $comment->user?->name ?: $comment->user?->username ?: 'Creator',
|
||||
'author_username' => $comment->user?->username,
|
||||
'author_avatar_url' => AvatarUrl::forUser((int) ($comment->user?->id ?? 0), $comment->user?->profile?->avatar_hash, 64),
|
||||
'item_title' => $comment->artwork?->title,
|
||||
'item_id' => (int) ($comment->artwork?->id ?? 0),
|
||||
'body' => (string) ($comment->raw_content ?: $comment->content),
|
||||
'created_at' => $comment->created_at?->toIso8601String(),
|
||||
'time_ago' => $comment->created_at?->diffForHumans(),
|
||||
'context_url' => $comment->artwork
|
||||
? route('art.show', ['id' => $comment->artwork->id, 'slug' => $comment->artwork->slug]) . '#comment-' . $comment->id
|
||||
: route('studio.comments'),
|
||||
'preview_url' => $comment->artwork
|
||||
? route('art.show', ['id' => $comment->artwork->id, 'slug' => $comment->artwork->slug])
|
||||
: null,
|
||||
'reply_supported' => true,
|
||||
'moderate_supported' => true,
|
||||
'report_supported' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
private function cardComments(User $user)
|
||||
{
|
||||
return NovaCardComment::query()
|
||||
->with(['user.profile', 'card'])
|
||||
->whereNull('deleted_at')
|
||||
->whereHas('card', fn ($query) => $query->where('user_id', $user->id))
|
||||
->latest('created_at')
|
||||
->limit(120)
|
||||
->get()
|
||||
->map(fn (NovaCardComment $comment): array => [
|
||||
'id' => 'cards:' . $comment->id,
|
||||
'comment_id' => (int) $comment->id,
|
||||
'module' => 'cards',
|
||||
'module_label' => 'Cards',
|
||||
'target_type' => 'nova_card_comment',
|
||||
'author_name' => $comment->user?->name ?: $comment->user?->username ?: 'Creator',
|
||||
'author_username' => $comment->user?->username,
|
||||
'author_avatar_url' => AvatarUrl::forUser((int) ($comment->user?->id ?? 0), $comment->user?->profile?->avatar_hash, 64),
|
||||
'item_title' => $comment->card?->title,
|
||||
'item_id' => (int) ($comment->card?->id ?? 0),
|
||||
'body' => (string) $comment->body,
|
||||
'created_at' => $comment->created_at?->toIso8601String(),
|
||||
'time_ago' => $comment->created_at?->diffForHumans(),
|
||||
'context_url' => $comment->card ? $comment->card->publicUrl() . '#comment-' . $comment->id : route('studio.comments'),
|
||||
'preview_url' => $comment->card ? route('studio.cards.preview', ['id' => $comment->card->id]) : null,
|
||||
'reply_supported' => true,
|
||||
'moderate_supported' => true,
|
||||
'report_supported' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
private function collectionComments(User $user)
|
||||
{
|
||||
return CollectionComment::query()
|
||||
->with(['user.profile', 'collection'])
|
||||
->whereNull('deleted_at')
|
||||
->whereHas('collection', fn ($query) => $query->where('user_id', $user->id))
|
||||
->latest('created_at')
|
||||
->limit(120)
|
||||
->get()
|
||||
->map(fn (CollectionComment $comment): array => [
|
||||
'id' => 'collections:' . $comment->id,
|
||||
'comment_id' => (int) $comment->id,
|
||||
'module' => 'collections',
|
||||
'module_label' => 'Collections',
|
||||
'target_type' => 'collection_comment',
|
||||
'author_name' => $comment->user?->name ?: $comment->user?->username ?: 'Creator',
|
||||
'author_username' => $comment->user?->username,
|
||||
'author_avatar_url' => AvatarUrl::forUser((int) ($comment->user?->id ?? 0), $comment->user?->profile?->avatar_hash, 64),
|
||||
'item_title' => $comment->collection?->title,
|
||||
'item_id' => (int) ($comment->collection?->id ?? 0),
|
||||
'body' => (string) $comment->body,
|
||||
'created_at' => $comment->created_at?->toIso8601String(),
|
||||
'time_ago' => $comment->created_at?->diffForHumans(),
|
||||
'context_url' => $comment->collection
|
||||
? route('profile.collections.show', ['username' => strtolower((string) $user->username), 'slug' => $comment->collection->slug]) . '#comment-' . $comment->id
|
||||
: route('studio.comments'),
|
||||
'preview_url' => $comment->collection
|
||||
? route('profile.collections.show', ['username' => strtolower((string) $user->username), 'slug' => $comment->collection->slug])
|
||||
: null,
|
||||
'reply_supported' => true,
|
||||
'moderate_supported' => true,
|
||||
'report_supported' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
private function storyComments(User $user)
|
||||
{
|
||||
return StoryComment::query()
|
||||
->with(['user.profile', 'story'])
|
||||
->whereNull('deleted_at')
|
||||
->whereHas('story', fn ($query) => $query->where('creator_id', $user->id))
|
||||
->latest('created_at')
|
||||
->limit(120)
|
||||
->get()
|
||||
->map(fn (StoryComment $comment): array => [
|
||||
'id' => 'stories:' . $comment->id,
|
||||
'comment_id' => (int) $comment->id,
|
||||
'module' => 'stories',
|
||||
'module_label' => 'Stories',
|
||||
'target_type' => 'story_comment',
|
||||
'author_name' => $comment->user?->name ?: $comment->user?->username ?: 'Creator',
|
||||
'author_username' => $comment->user?->username,
|
||||
'author_avatar_url' => AvatarUrl::forUser((int) ($comment->user?->id ?? 0), $comment->user?->profile?->avatar_hash, 64),
|
||||
'item_title' => $comment->story?->title,
|
||||
'item_id' => (int) ($comment->story?->id ?? 0),
|
||||
'body' => (string) ($comment->raw_content ?: $comment->content),
|
||||
'created_at' => $comment->created_at?->toIso8601String(),
|
||||
'time_ago' => $comment->created_at?->diffForHumans(),
|
||||
'context_url' => $comment->story ? route('stories.show', ['slug' => $comment->story->slug]) . '#comment-' . $comment->id : route('studio.comments'),
|
||||
'preview_url' => $comment->story ? route('creator.stories.preview', ['story' => $comment->story->id]) : null,
|
||||
'reply_supported' => true,
|
||||
'moderate_supported' => true,
|
||||
'report_supported' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
private function replyToArtworkComment(User $user, int $commentId, string $content): void
|
||||
{
|
||||
$comment = ArtworkComment::query()
|
||||
->with('artwork')
|
||||
->findOrFail($commentId);
|
||||
|
||||
abort_unless((int) ($comment->artwork?->user_id ?? 0) === (int) $user->id, 403);
|
||||
|
||||
$errors = ContentSanitizer::validate($content);
|
||||
if ($errors !== []) {
|
||||
abort(422, implode(' ', $errors));
|
||||
}
|
||||
|
||||
ArtworkComment::query()->create([
|
||||
'artwork_id' => $comment->artwork_id,
|
||||
'user_id' => $user->id,
|
||||
'parent_id' => $comment->id,
|
||||
'content' => $content,
|
||||
'raw_content' => $content,
|
||||
'rendered_content' => ContentSanitizer::render($content),
|
||||
'is_approved' => true,
|
||||
]);
|
||||
|
||||
$this->syncArtworkCommentCount((int) $comment->artwork_id);
|
||||
}
|
||||
|
||||
private function replyToCardComment(User $user, int $commentId, string $content): void
|
||||
{
|
||||
$comment = NovaCardComment::query()->with(['card.user'])->findOrFail($commentId);
|
||||
abort_unless((int) ($comment->card?->user_id ?? 0) === (int) $user->id, 403);
|
||||
$this->cardComments->create($comment->card->loadMissing('user'), $user, $content, $comment);
|
||||
}
|
||||
|
||||
private function replyToCollectionComment(User $user, int $commentId, string $content): void
|
||||
{
|
||||
$comment = CollectionComment::query()->with(['collection.user'])->findOrFail($commentId);
|
||||
abort_unless((int) ($comment->collection?->user_id ?? 0) === (int) $user->id, 403);
|
||||
$this->collectionComments->create($comment->collection->loadMissing('user'), $user, $content, $comment);
|
||||
}
|
||||
|
||||
private function replyToStoryComment(User $user, int $commentId, string $content): void
|
||||
{
|
||||
$comment = StoryComment::query()->with('story')->findOrFail($commentId);
|
||||
abort_unless((int) ($comment->story?->creator_id ?? 0) === (int) $user->id, 403);
|
||||
$this->social->addStoryComment($user, $comment->story, $content, $comment->id);
|
||||
}
|
||||
|
||||
private function deleteArtworkComment(User $user, int $commentId): void
|
||||
{
|
||||
$comment = ArtworkComment::query()->with('artwork')->findOrFail($commentId);
|
||||
abort_unless((int) ($comment->artwork?->user_id ?? 0) === (int) $user->id, 403);
|
||||
|
||||
if (! $comment->trashed()) {
|
||||
$comment->delete();
|
||||
}
|
||||
|
||||
$this->syncArtworkCommentCount((int) $comment->artwork_id);
|
||||
}
|
||||
|
||||
private function deleteCardComment(User $user, int $commentId): void
|
||||
{
|
||||
$comment = NovaCardComment::query()->with('card')->findOrFail($commentId);
|
||||
abort_unless((int) ($comment->card?->user_id ?? 0) === (int) $user->id, 403);
|
||||
$this->cardComments->delete($comment, $user);
|
||||
}
|
||||
|
||||
private function deleteCollectionComment(User $user, int $commentId): void
|
||||
{
|
||||
$comment = CollectionComment::query()->with('collection')->findOrFail($commentId);
|
||||
abort_unless((int) ($comment->collection?->user_id ?? 0) === (int) $user->id, 403);
|
||||
$this->collectionComments->delete($comment, $user);
|
||||
}
|
||||
|
||||
private function deleteStoryComment(User $user, int $commentId): void
|
||||
{
|
||||
$comment = StoryComment::query()->with('story')->findOrFail($commentId);
|
||||
abort_unless((int) ($comment->story?->creator_id ?? 0) === (int) $user->id, 403);
|
||||
$this->social->deleteStoryComment($user, $comment);
|
||||
}
|
||||
|
||||
private function syncArtworkCommentCount(int $artworkId): void
|
||||
{
|
||||
$count = ArtworkComment::query()
|
||||
->where('artwork_id', $artworkId)
|
||||
->whereNull('deleted_at')
|
||||
->count();
|
||||
|
||||
if (DB::table('artwork_stats')->where('artwork_id', $artworkId)->exists()) {
|
||||
DB::table('artwork_stats')
|
||||
->where('artwork_id', $artworkId)
|
||||
->update(['comments_count' => $count, 'updated_at' => now()]);
|
||||
}
|
||||
}
|
||||
|
||||
private function normalizeModule(string $module): string
|
||||
{
|
||||
return in_array($module, ['all', 'artworks', 'cards', 'collections', 'stories'], true)
|
||||
? $module
|
||||
: 'all';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,491 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Studio;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Services\Studio\Contracts\CreatorStudioProvider;
|
||||
use App\Services\Studio\Providers\ArtworkStudioProvider;
|
||||
use App\Services\Studio\Providers\CardStudioProvider;
|
||||
use App\Services\Studio\Providers\CollectionStudioProvider;
|
||||
use App\Services\Studio\Providers\StoryStudioProvider;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Collection as SupportCollection;
|
||||
|
||||
final class CreatorStudioContentService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ArtworkStudioProvider $artworks,
|
||||
private readonly CardStudioProvider $cards,
|
||||
private readonly CollectionStudioProvider $collections,
|
||||
private readonly StoryStudioProvider $stories,
|
||||
) {
|
||||
}
|
||||
|
||||
public function moduleSummaries(User $user): array
|
||||
{
|
||||
return SupportCollection::make($this->providers())
|
||||
->map(fn (CreatorStudioProvider $provider): array => $provider->summary($user))
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
public function quickCreate(): array
|
||||
{
|
||||
$preferredOrder = ['artworks', 'cards', 'stories', 'collections'];
|
||||
|
||||
return SupportCollection::make($this->providers())
|
||||
->sortBy(fn (CreatorStudioProvider $provider): int => array_search($provider->key(), $preferredOrder, true))
|
||||
->map(fn (CreatorStudioProvider $provider): array => [
|
||||
'key' => $provider->key(),
|
||||
'label' => rtrim($provider->label(), 's'),
|
||||
'icon' => $provider->icon(),
|
||||
'url' => $provider->createUrl(),
|
||||
])
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
public function list(User $user, array $filters = [], ?string $fixedBucket = null, ?string $fixedModule = null): array
|
||||
{
|
||||
$module = $fixedModule ?: $this->normalizeModule((string) ($filters['module'] ?? 'all'));
|
||||
$bucket = $fixedBucket ?: $this->normalizeBucket((string) ($filters['bucket'] ?? 'all'));
|
||||
$search = trim((string) ($filters['q'] ?? ''));
|
||||
$sort = $this->normalizeSort((string) ($filters['sort'] ?? 'updated_desc'));
|
||||
$category = (string) ($filters['category'] ?? 'all');
|
||||
$tag = trim((string) ($filters['tag'] ?? ''));
|
||||
$visibility = (string) ($filters['visibility'] ?? 'all');
|
||||
$activityState = (string) ($filters['activity_state'] ?? 'all');
|
||||
$stale = (string) ($filters['stale'] ?? 'all');
|
||||
$page = max(1, (int) ($filters['page'] ?? 1));
|
||||
$perPage = min(max((int) ($filters['per_page'] ?? 24), 12), 48);
|
||||
|
||||
$items = $module === 'all'
|
||||
? SupportCollection::make($this->providers())->flatMap(fn (CreatorStudioProvider $provider) => $provider->items($user, $this->providerBucket($bucket), 200))
|
||||
: $this->provider($module)?->items($user, $this->providerBucket($bucket), 240) ?? SupportCollection::make();
|
||||
|
||||
if ($bucket === 'featured') {
|
||||
$items = $items->filter(fn (array $item): bool => (bool) ($item['featured'] ?? false));
|
||||
} elseif ($bucket === 'recent') {
|
||||
$items = $items->filter(function (array $item): bool {
|
||||
$date = $item['published_at'] ?? $item['updated_at'] ?? $item['created_at'] ?? null;
|
||||
|
||||
return $date !== null && strtotime((string) $date) >= Carbon::now()->subDays(30)->getTimestamp();
|
||||
});
|
||||
}
|
||||
|
||||
if ($search !== '') {
|
||||
$needle = mb_strtolower($search);
|
||||
$items = $items->filter(function (array $item) use ($needle): bool {
|
||||
$haystacks = [
|
||||
$item['title'] ?? '',
|
||||
$item['subtitle'] ?? '',
|
||||
$item['description'] ?? '',
|
||||
$item['module_label'] ?? '',
|
||||
];
|
||||
|
||||
return SupportCollection::make($haystacks)
|
||||
->filter(fn ($value): bool => is_string($value) && $value !== '')
|
||||
->contains(fn (string $value): bool => str_contains(mb_strtolower($value), $needle));
|
||||
});
|
||||
}
|
||||
|
||||
if ($module === 'artworks' && $category !== 'all') {
|
||||
$items = $items->filter(function (array $item) use ($category): bool {
|
||||
return SupportCollection::make($item['taxonomies']['categories'] ?? [])
|
||||
->contains(fn (array $entry): bool => (string) ($entry['slug'] ?? '') === $category);
|
||||
});
|
||||
}
|
||||
|
||||
if ($module === 'artworks' && $tag !== '') {
|
||||
$needle = mb_strtolower($tag);
|
||||
$items = $items->filter(function (array $item) use ($needle): bool {
|
||||
return SupportCollection::make($item['taxonomies']['tags'] ?? [])
|
||||
->contains(fn (array $entry): bool => str_contains(mb_strtolower((string) ($entry['name'] ?? '')), $needle));
|
||||
});
|
||||
}
|
||||
|
||||
if ($module === 'collections' && $visibility !== 'all') {
|
||||
$items = $items->filter(fn (array $item): bool => (string) ($item['visibility'] ?? '') === $visibility);
|
||||
}
|
||||
|
||||
if ($module === 'stories' && $activityState !== 'all') {
|
||||
$items = $items->filter(fn (array $item): bool => (string) ($item['activity_state'] ?? 'all') === $activityState);
|
||||
}
|
||||
|
||||
if ($stale === 'only') {
|
||||
$threshold = Carbon::now()->subDays(3)->getTimestamp();
|
||||
$items = $items->filter(function (array $item) use ($threshold): bool {
|
||||
$updatedAt = strtotime((string) ($item['updated_at'] ?? $item['created_at'] ?? ''));
|
||||
|
||||
return $updatedAt > 0 && $updatedAt <= $threshold;
|
||||
});
|
||||
}
|
||||
|
||||
$items = $this->annotateItems($this->sortItems($items, $sort)->values());
|
||||
$total = $items->count();
|
||||
$lastPage = max(1, (int) ceil($total / $perPage));
|
||||
$page = min($page, $lastPage);
|
||||
|
||||
return [
|
||||
'items' => $items->forPage($page, $perPage)->values()->all(),
|
||||
'meta' => [
|
||||
'current_page' => $page,
|
||||
'last_page' => $lastPage,
|
||||
'per_page' => $perPage,
|
||||
'total' => $total,
|
||||
],
|
||||
'filters' => [
|
||||
'module' => $module,
|
||||
'bucket' => $bucket,
|
||||
'q' => $search,
|
||||
'sort' => $sort,
|
||||
'category' => $category,
|
||||
'tag' => $tag,
|
||||
'visibility' => $visibility,
|
||||
'activity_state' => $activityState,
|
||||
'stale' => $stale,
|
||||
],
|
||||
'module_options' => array_merge([
|
||||
['value' => 'all', 'label' => 'All content'],
|
||||
], SupportCollection::make($this->moduleSummaries($user))->map(fn (array $summary): array => [
|
||||
'value' => $summary['key'],
|
||||
'label' => $summary['label'],
|
||||
])->all()),
|
||||
'bucket_options' => [
|
||||
['value' => 'all', 'label' => 'All'],
|
||||
['value' => 'published', 'label' => 'Published'],
|
||||
['value' => 'drafts', 'label' => 'Drafts'],
|
||||
['value' => 'scheduled', 'label' => 'Scheduled'],
|
||||
['value' => 'archived', 'label' => 'Archived'],
|
||||
['value' => 'featured', 'label' => 'Featured'],
|
||||
['value' => 'recent', 'label' => 'Recent'],
|
||||
],
|
||||
'sort_options' => [
|
||||
['value' => 'updated_desc', 'label' => 'Recently updated'],
|
||||
['value' => 'updated_asc', 'label' => 'Oldest untouched'],
|
||||
['value' => 'created_desc', 'label' => 'Newest created'],
|
||||
['value' => 'published_desc', 'label' => 'Newest published'],
|
||||
['value' => 'views_desc', 'label' => 'Most viewed'],
|
||||
['value' => 'appreciation_desc', 'label' => 'Most liked'],
|
||||
['value' => 'comments_desc', 'label' => 'Most commented'],
|
||||
['value' => 'engagement_desc', 'label' => 'Best engagement'],
|
||||
['value' => 'title_asc', 'label' => 'Title A-Z'],
|
||||
],
|
||||
'advanced_filters' => $this->advancedFilters($module, $items, [
|
||||
'category' => $category,
|
||||
'tag' => $tag,
|
||||
'visibility' => $visibility,
|
||||
'activity_state' => $activityState,
|
||||
'stale' => $stale,
|
||||
]),
|
||||
];
|
||||
}
|
||||
|
||||
public function draftReminders(User $user, int $limit = 4): array
|
||||
{
|
||||
return $this->annotateItems(SupportCollection::make($this->providers())
|
||||
->flatMap(fn (CreatorStudioProvider $provider) => $provider->items($user, 'drafts', $limit))
|
||||
->sortByDesc(fn (array $item): int => strtotime((string) ($item['updated_at'] ?? $item['created_at'] ?? Carbon::now()->toIso8601String())))
|
||||
->take($limit)
|
||||
->values())
|
||||
->all();
|
||||
}
|
||||
|
||||
public function staleDrafts(User $user, int $limit = 4): array
|
||||
{
|
||||
return $this->annotateItems(SupportCollection::make($this->providers())
|
||||
->flatMap(fn (CreatorStudioProvider $provider) => $provider->items($user, 'drafts', $limit * 4))
|
||||
->filter(function (array $item): bool {
|
||||
$updatedAt = strtotime((string) ($item['updated_at'] ?? $item['created_at'] ?? Carbon::now()->toIso8601String()));
|
||||
|
||||
return $updatedAt <= Carbon::now()->subDays(3)->getTimestamp();
|
||||
})
|
||||
->sortBy(fn (array $item): int => strtotime((string) ($item['updated_at'] ?? $item['created_at'] ?? Carbon::now()->toIso8601String())))
|
||||
->take($limit)
|
||||
->values())
|
||||
->all();
|
||||
}
|
||||
|
||||
public function continueWorking(User $user, string $draftBehavior = 'resume-last', int $limit = 3): array
|
||||
{
|
||||
if ($draftBehavior === 'focus-published') {
|
||||
return $this->recentPublished($user, $limit);
|
||||
}
|
||||
|
||||
return $this->annotateItems(SupportCollection::make($this->providers())
|
||||
->flatMap(fn (CreatorStudioProvider $provider) => $provider->items($user, 'drafts', $limit * 4))
|
||||
->sortByDesc(fn (array $item): int => strtotime((string) ($item['updated_at'] ?? $item['created_at'] ?? Carbon::now()->toIso8601String())))
|
||||
->take($limit)
|
||||
->values())
|
||||
->all();
|
||||
}
|
||||
|
||||
public function recentPublished(User $user, int $limit = 6): array
|
||||
{
|
||||
return $this->annotateItems(SupportCollection::make($this->providers())
|
||||
->flatMap(fn (CreatorStudioProvider $provider) => $provider->items($user, 'published', $limit * 2))
|
||||
->sortByDesc(fn (array $item): int => strtotime((string) ($item['published_at'] ?? $item['updated_at'] ?? Carbon::now()->toIso8601String())))
|
||||
->take($limit)
|
||||
->values())
|
||||
->all();
|
||||
}
|
||||
|
||||
public function featuredCandidates(User $user, int $limit = 8): array
|
||||
{
|
||||
return $this->annotateItems(SupportCollection::make($this->providers())
|
||||
->flatMap(fn (CreatorStudioProvider $provider) => $provider->items($user, 'published', $limit * 2))
|
||||
->filter(fn (array $item): bool => ($item['status'] ?? null) === 'published')
|
||||
->sortByDesc(fn (array $item): int => (int) ($item['engagement_score'] ?? 0))
|
||||
->take($limit)
|
||||
->values())
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, int> $featuredContent
|
||||
* @return array<string, array<string, mixed>|null>
|
||||
*/
|
||||
public function selectedItems(User $user, array $featuredContent): array
|
||||
{
|
||||
return SupportCollection::make(['artworks', 'cards', 'collections', 'stories'])
|
||||
->mapWithKeys(function (string $module) use ($user, $featuredContent): array {
|
||||
$selectedId = (int) ($featuredContent[$module] ?? 0);
|
||||
if ($selectedId < 1) {
|
||||
return [$module => null];
|
||||
}
|
||||
|
||||
$item = $this->provider($module)?->items($user, 'all', 400)
|
||||
->first(fn (array $entry): bool => (int) ($entry['numeric_id'] ?? 0) === $selectedId);
|
||||
|
||||
return [$module => $item ?: null];
|
||||
})
|
||||
->all();
|
||||
}
|
||||
|
||||
public function provider(string $module): ?CreatorStudioProvider
|
||||
{
|
||||
return SupportCollection::make($this->providers())->first(fn (CreatorStudioProvider $provider): bool => $provider->key() === $module);
|
||||
}
|
||||
|
||||
public function providers(): array
|
||||
{
|
||||
return [
|
||||
$this->artworks,
|
||||
$this->cards,
|
||||
$this->collections,
|
||||
$this->stories,
|
||||
];
|
||||
}
|
||||
|
||||
private function normalizeModule(string $module): string
|
||||
{
|
||||
$allowed = ['all', 'artworks', 'cards', 'collections', 'stories'];
|
||||
|
||||
return in_array($module, $allowed, true) ? $module : 'all';
|
||||
}
|
||||
|
||||
private function normalizeBucket(string $bucket): string
|
||||
{
|
||||
$allowed = ['all', 'published', 'drafts', 'scheduled', 'archived', 'featured', 'recent'];
|
||||
|
||||
return in_array($bucket, $allowed, true) ? $bucket : 'all';
|
||||
}
|
||||
|
||||
private function normalizeSort(string $sort): string
|
||||
{
|
||||
$allowed = ['updated_desc', 'updated_asc', 'created_desc', 'published_desc', 'views_desc', 'appreciation_desc', 'comments_desc', 'engagement_desc', 'title_asc'];
|
||||
|
||||
return in_array($sort, $allowed, true) ? $sort : 'updated_desc';
|
||||
}
|
||||
|
||||
private function providerBucket(string $bucket): string
|
||||
{
|
||||
return $bucket === 'featured' ? 'published' : $bucket;
|
||||
}
|
||||
|
||||
private function sortItems(SupportCollection $items, string $sort): SupportCollection
|
||||
{
|
||||
return match ($sort) {
|
||||
'created_desc' => $items->sortByDesc(fn (array $item): int => strtotime((string) ($item['created_at'] ?? ''))),
|
||||
'updated_asc' => $items->sortBy(fn (array $item): int => strtotime((string) ($item['updated_at'] ?? $item['created_at'] ?? ''))),
|
||||
'published_desc' => $items->sortByDesc(fn (array $item): int => strtotime((string) ($item['published_at'] ?? $item['updated_at'] ?? ''))),
|
||||
'views_desc' => $items->sortByDesc(fn (array $item): int => (int) (($item['metrics']['views'] ?? 0))),
|
||||
'appreciation_desc' => $items->sortByDesc(fn (array $item): int => (int) (($item['metrics']['appreciation'] ?? 0))),
|
||||
'comments_desc' => $items->sortByDesc(fn (array $item): int => (int) (($item['metrics']['comments'] ?? 0))),
|
||||
'engagement_desc' => $items->sortByDesc(fn (array $item): int => (int) ($item['engagement_score'] ?? 0)),
|
||||
'title_asc' => $items->sortBy(fn (array $item): string => mb_strtolower((string) ($item['title'] ?? ''))),
|
||||
default => $items->sortByDesc(fn (array $item): int => strtotime((string) ($item['updated_at'] ?? $item['created_at'] ?? ''))),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string> $currentFilters
|
||||
*/
|
||||
private function advancedFilters(string $module, SupportCollection $items, array $currentFilters): array
|
||||
{
|
||||
return match ($module) {
|
||||
'artworks' => [
|
||||
[
|
||||
'key' => 'category',
|
||||
'label' => 'Category',
|
||||
'type' => 'select',
|
||||
'value' => $currentFilters['category'] ?? 'all',
|
||||
'options' => array_merge([
|
||||
['value' => 'all', 'label' => 'All categories'],
|
||||
], $items
|
||||
->flatMap(fn (array $item) => $item['taxonomies']['categories'] ?? [])
|
||||
->unique('slug')
|
||||
->sortBy('name')
|
||||
->map(fn (array $entry): array => [
|
||||
'value' => (string) ($entry['slug'] ?? ''),
|
||||
'label' => (string) ($entry['name'] ?? 'Category'),
|
||||
])->values()->all()),
|
||||
],
|
||||
[
|
||||
'key' => 'tag',
|
||||
'label' => 'Tag',
|
||||
'type' => 'search',
|
||||
'value' => $currentFilters['tag'] ?? '',
|
||||
'placeholder' => 'Filter by tag',
|
||||
],
|
||||
],
|
||||
'collections' => [[
|
||||
'key' => 'visibility',
|
||||
'label' => 'Visibility',
|
||||
'type' => 'select',
|
||||
'value' => $currentFilters['visibility'] ?? 'all',
|
||||
'options' => [
|
||||
['value' => 'all', 'label' => 'All visibility'],
|
||||
['value' => 'public', 'label' => 'Public'],
|
||||
['value' => 'unlisted', 'label' => 'Unlisted'],
|
||||
['value' => 'private', 'label' => 'Private'],
|
||||
],
|
||||
]],
|
||||
'stories' => [[
|
||||
'key' => 'activity_state',
|
||||
'label' => 'Activity',
|
||||
'type' => 'select',
|
||||
'value' => $currentFilters['activity_state'] ?? 'all',
|
||||
'options' => [
|
||||
['value' => 'all', 'label' => 'All states'],
|
||||
['value' => 'active', 'label' => 'Active'],
|
||||
['value' => 'inactive', 'label' => 'Inactive'],
|
||||
['value' => 'archived', 'label' => 'Archived'],
|
||||
],
|
||||
]],
|
||||
'all' => [[
|
||||
'key' => 'stale',
|
||||
'label' => 'Draft freshness',
|
||||
'type' => 'select',
|
||||
'value' => $currentFilters['stale'] ?? 'all',
|
||||
'options' => [
|
||||
['value' => 'all', 'label' => 'All drafts'],
|
||||
['value' => 'only', 'label' => 'Stale drafts'],
|
||||
],
|
||||
]],
|
||||
default => [[
|
||||
'key' => 'stale',
|
||||
'label' => 'Draft freshness',
|
||||
'type' => 'select',
|
||||
'value' => $currentFilters['stale'] ?? 'all',
|
||||
'options' => [
|
||||
['value' => 'all', 'label' => 'All drafts'],
|
||||
['value' => 'only', 'label' => 'Stale drafts'],
|
||||
],
|
||||
]],
|
||||
};
|
||||
}
|
||||
|
||||
private function annotateItems(SupportCollection $items): SupportCollection
|
||||
{
|
||||
return $items->map(fn (array $item): array => $this->annotateItem($item))->values();
|
||||
}
|
||||
|
||||
private function annotateItem(array $item): array
|
||||
{
|
||||
$now = Carbon::now();
|
||||
$updatedAt = Carbon::parse((string) ($item['updated_at'] ?? $item['created_at'] ?? $now->toIso8601String()));
|
||||
$status = (string) ($item['status'] ?? '');
|
||||
$isDraft = ($item['status'] ?? null) === 'draft';
|
||||
$missing = [];
|
||||
$score = 0;
|
||||
|
||||
if ($this->hasValue($item['title'] ?? null)) {
|
||||
$score++;
|
||||
} else {
|
||||
$missing[] = 'Add a title';
|
||||
}
|
||||
|
||||
if ($this->hasValue($item['description'] ?? null)) {
|
||||
$score++;
|
||||
} else {
|
||||
$missing[] = 'Add a description';
|
||||
}
|
||||
|
||||
if ($this->hasValue($item['image_url'] ?? null)) {
|
||||
$score++;
|
||||
} else {
|
||||
$missing[] = 'Add a preview image';
|
||||
}
|
||||
|
||||
if (! empty($item['taxonomies']['categories'] ?? []) || $this->hasValue($item['subtitle'] ?? null)) {
|
||||
$score++;
|
||||
} else {
|
||||
$missing[] = 'Choose a category or content context';
|
||||
}
|
||||
|
||||
$label = match (true) {
|
||||
$score >= 4 => 'Ready to publish',
|
||||
$score === 3 => 'Almost ready',
|
||||
default => 'Needs more work',
|
||||
};
|
||||
|
||||
$readiness = $status === 'published'
|
||||
? null
|
||||
: [
|
||||
'score' => $score,
|
||||
'max' => 4,
|
||||
'label' => $label,
|
||||
'can_publish' => $score >= 3,
|
||||
'missing' => $missing,
|
||||
];
|
||||
|
||||
$workflowActions = match ((string) ($item['module'] ?? '')) {
|
||||
'artworks' => [
|
||||
['label' => 'Create card', 'href' => route('studio.cards.create'), 'icon' => 'fa-solid fa-id-card'],
|
||||
['label' => 'Curate collection', 'href' => route('settings.collections.create'), 'icon' => 'fa-solid fa-layer-group'],
|
||||
['label' => 'Tell story', 'href' => route('creator.stories.create'), 'icon' => 'fa-solid fa-feather-pointed'],
|
||||
],
|
||||
'cards' => [
|
||||
['label' => 'Build collection', 'href' => route('settings.collections.create'), 'icon' => 'fa-solid fa-layer-group'],
|
||||
['label' => 'Open stories', 'href' => route('studio.stories'), 'icon' => 'fa-solid fa-feather-pointed'],
|
||||
],
|
||||
'stories' => [
|
||||
['label' => 'Create card', 'href' => route('studio.cards.create'), 'icon' => 'fa-solid fa-id-card'],
|
||||
['label' => 'Curate collection', 'href' => route('settings.collections.create'), 'icon' => 'fa-solid fa-layer-group'],
|
||||
],
|
||||
'collections' => [
|
||||
['label' => 'Review artworks', 'href' => route('studio.artworks'), 'icon' => 'fa-solid fa-images'],
|
||||
['label' => 'Review cards', 'href' => route('studio.cards.index'), 'icon' => 'fa-solid fa-id-card'],
|
||||
],
|
||||
default => [],
|
||||
};
|
||||
|
||||
$item['workflow'] = [
|
||||
'is_stale_draft' => $isDraft && $updatedAt->lte($now->copy()->subDays(3)),
|
||||
'last_touched_days' => max(0, $updatedAt->diffInDays($now)),
|
||||
'resume_label' => $isDraft ? 'Resume draft' : 'Open item',
|
||||
'readiness' => $readiness,
|
||||
'cross_module_actions' => $workflowActions,
|
||||
];
|
||||
|
||||
return $item;
|
||||
}
|
||||
|
||||
private function hasValue(mixed $value): bool
|
||||
{
|
||||
return is_string($value) ? trim($value) !== '' : ! empty($value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Studio;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
final class CreatorStudioEventService
|
||||
{
|
||||
private const ALLOWED_EVENTS = [
|
||||
'studio_opened',
|
||||
'studio_module_opened',
|
||||
'studio_quick_create_used',
|
||||
'studio_filter_used',
|
||||
'studio_item_edited',
|
||||
'studio_item_archived',
|
||||
'studio_item_restored',
|
||||
'studio_activity_opened',
|
||||
'studio_scheduled_opened',
|
||||
'studio_asset_opened',
|
||||
'studio_continue_working_used',
|
||||
'studio_schedule_created',
|
||||
'studio_schedule_updated',
|
||||
'studio_schedule_cleared',
|
||||
'studio_calendar_item_rescheduled',
|
||||
'studio_widget_customized',
|
||||
'studio_widget_reordered',
|
||||
'studio_asset_reused',
|
||||
'studio_comment_replied',
|
||||
'studio_comment_moderated',
|
||||
'studio_comment_reported',
|
||||
'studio_challenge_action_taken',
|
||||
'studio_insight_clicked',
|
||||
'studio_stale_draft_archived',
|
||||
];
|
||||
|
||||
public function allowedEvents(): array
|
||||
{
|
||||
return self::ALLOWED_EVENTS;
|
||||
}
|
||||
|
||||
public function record(User $user, array $payload): void
|
||||
{
|
||||
Log::info('creator_studio_event', [
|
||||
'user_id' => (int) $user->id,
|
||||
'event_type' => (string) $payload['event_type'],
|
||||
'module' => Arr::get($payload, 'module'),
|
||||
'surface' => Arr::get($payload, 'surface'),
|
||||
'item_module' => Arr::get($payload, 'item_module'),
|
||||
'item_id' => Arr::get($payload, 'item_id'),
|
||||
'meta' => Arr::get($payload, 'meta', []),
|
||||
'occurred_at' => now()->toIso8601String(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Studio;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Support\AvatarUrl;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
final class CreatorStudioFollowersService
|
||||
{
|
||||
public function list(User $user, array $filters = []): array
|
||||
{
|
||||
$perPage = 30;
|
||||
$search = trim((string) ($filters['q'] ?? ''));
|
||||
$sort = (string) ($filters['sort'] ?? 'recent');
|
||||
$relationship = (string) ($filters['relationship'] ?? 'all');
|
||||
$page = max(1, (int) ($filters['page'] ?? 1));
|
||||
|
||||
$allowedSorts = ['recent', 'oldest', 'name', 'uploads', 'followers'];
|
||||
$allowedRelationships = ['all', 'following-back', 'not-followed'];
|
||||
|
||||
if (! in_array($sort, $allowedSorts, true)) {
|
||||
$sort = 'recent';
|
||||
}
|
||||
|
||||
if (! in_array($relationship, $allowedRelationships, true)) {
|
||||
$relationship = 'all';
|
||||
}
|
||||
|
||||
$baseQuery = DB::table('user_followers as uf')
|
||||
->join('users as u', 'u.id', '=', 'uf.follower_id')
|
||||
->leftJoin('user_profiles as up', 'up.user_id', '=', 'u.id')
|
||||
->leftJoin('user_statistics as us', 'us.user_id', '=', 'u.id')
|
||||
->leftJoin('user_followers as mutual', function ($join) use ($user): void {
|
||||
$join->on('mutual.user_id', '=', 'uf.follower_id')
|
||||
->where('mutual.follower_id', '=', $user->id);
|
||||
})
|
||||
->where('uf.user_id', $user->id)
|
||||
->whereNull('u.deleted_at')
|
||||
->when($search !== '', function ($query) use ($search): void {
|
||||
$query->where(function ($inner) use ($search): void {
|
||||
$inner->where('u.username', 'like', '%' . $search . '%')
|
||||
->orWhere('u.name', 'like', '%' . $search . '%');
|
||||
});
|
||||
})
|
||||
->when($relationship === 'following-back', fn ($query) => $query->whereNotNull('mutual.created_at'))
|
||||
->when($relationship === 'not-followed', fn ($query) => $query->whereNull('mutual.created_at'));
|
||||
|
||||
$summaryBaseQuery = clone $baseQuery;
|
||||
|
||||
$followers = $baseQuery
|
||||
->when($sort === 'recent', fn ($query) => $query->orderByDesc('uf.created_at'))
|
||||
->when($sort === 'oldest', fn ($query) => $query->orderBy('uf.created_at'))
|
||||
->when($sort === 'name', fn ($query) => $query->orderByRaw('COALESCE(u.username, u.name) asc'))
|
||||
->when($sort === 'uploads', fn ($query) => $query->orderByDesc('us.uploads_count')->orderByRaw('COALESCE(u.username, u.name) asc'))
|
||||
->when($sort === 'followers', fn ($query) => $query->orderByDesc('us.followers_count')->orderByRaw('COALESCE(u.username, u.name) asc'))
|
||||
->select([
|
||||
'u.id',
|
||||
'u.username',
|
||||
'u.name',
|
||||
'up.avatar_hash',
|
||||
'us.uploads_count',
|
||||
'us.followers_count',
|
||||
'uf.created_at as followed_at',
|
||||
'mutual.created_at as followed_back_at',
|
||||
])
|
||||
->paginate($perPage, ['*'], 'page', $page)
|
||||
->withQueryString();
|
||||
|
||||
return [
|
||||
'items' => collect($followers->items())->map(fn ($row): array => [
|
||||
'id' => (int) $row->id,
|
||||
'name' => $row->name ?: '@' . $row->username,
|
||||
'username' => $row->username,
|
||||
'avatar_url' => AvatarUrl::forUser((int) $row->id, $row->avatar_hash, 64),
|
||||
'profile_url' => '/@' . strtolower((string) ($row->username ?? $row->id)),
|
||||
'uploads_count' => (int) ($row->uploads_count ?? 0),
|
||||
'followers_count' => (int) ($row->followers_count ?? 0),
|
||||
'is_following_back' => $row->followed_back_at !== null,
|
||||
'followed_back_at' => $row->followed_back_at,
|
||||
'followed_at' => $row->followed_at,
|
||||
])->values()->all(),
|
||||
'meta' => [
|
||||
'current_page' => $followers->currentPage(),
|
||||
'last_page' => $followers->lastPage(),
|
||||
'per_page' => $followers->perPage(),
|
||||
'total' => $followers->total(),
|
||||
],
|
||||
'filters' => [
|
||||
'q' => $search,
|
||||
'sort' => $sort,
|
||||
'relationship' => $relationship,
|
||||
],
|
||||
'summary' => [
|
||||
'total_followers' => (clone $summaryBaseQuery)->count(),
|
||||
'following_back' => (clone $summaryBaseQuery)->whereNotNull('mutual.created_at')->count(),
|
||||
'not_followed' => (clone $summaryBaseQuery)->whereNull('mutual.created_at')->count(),
|
||||
],
|
||||
'sort_options' => [
|
||||
['value' => 'recent', 'label' => 'Most recent'],
|
||||
['value' => 'oldest', 'label' => 'Oldest first'],
|
||||
['value' => 'name', 'label' => 'Name A-Z'],
|
||||
['value' => 'uploads', 'label' => 'Most uploads'],
|
||||
['value' => 'followers', 'label' => 'Most followers'],
|
||||
],
|
||||
'relationship_options' => [
|
||||
['value' => 'all', 'label' => 'All followers'],
|
||||
['value' => 'following-back', 'label' => 'Following back'],
|
||||
['value' => 'not-followed', 'label' => 'Not followed yet'],
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Studio;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use function collect;
|
||||
|
||||
final class CreatorStudioGrowthService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly CreatorStudioAnalyticsService $analytics,
|
||||
private readonly CreatorStudioContentService $content,
|
||||
private readonly CreatorStudioPreferenceService $preferences,
|
||||
) {
|
||||
}
|
||||
|
||||
public function build(User $user, int $days = 30): array
|
||||
{
|
||||
$analytics = $this->analytics->overview($user, $days);
|
||||
$preferences = $this->preferences->forUser($user);
|
||||
$user->loadMissing(['profile', 'statistics']);
|
||||
|
||||
$socialLinksCount = (int) DB::table('user_social_links')->where('user_id', $user->id)->count();
|
||||
$publishedInRange = (int) collect($analytics['comparison'])->sum('published_count');
|
||||
$challengeEntries = (int) DB::table('nova_cards')
|
||||
->where('user_id', $user->id)
|
||||
->whereNull('deleted_at')
|
||||
->sum('challenge_entries_count');
|
||||
$profileScore = $this->profileScore($user, $socialLinksCount);
|
||||
$cadenceScore = $this->ratioScore($publishedInRange, 6);
|
||||
$engagementScore = $this->ratioScore(
|
||||
(int) ($analytics['totals']['appreciation'] ?? 0)
|
||||
+ (int) ($analytics['totals']['comments'] ?? 0)
|
||||
+ (int) ($analytics['totals']['shares'] ?? 0)
|
||||
+ (int) ($analytics['totals']['saves'] ?? 0),
|
||||
max(3, (int) ($analytics['totals']['content_count'] ?? 0) * 12)
|
||||
);
|
||||
$curationScore = $this->ratioScore(count($preferences['featured_modules']), 4);
|
||||
|
||||
return [
|
||||
'summary' => [
|
||||
'followers' => (int) ($analytics['totals']['followers'] ?? 0),
|
||||
'published_in_range' => $publishedInRange,
|
||||
'engagement_actions' => (int) ($analytics['totals']['appreciation'] ?? 0)
|
||||
+ (int) ($analytics['totals']['comments'] ?? 0)
|
||||
+ (int) ($analytics['totals']['shares'] ?? 0)
|
||||
+ (int) ($analytics['totals']['saves'] ?? 0),
|
||||
'profile_completion' => $profileScore,
|
||||
'challenge_entries' => $challengeEntries,
|
||||
'featured_modules' => count($preferences['featured_modules']),
|
||||
],
|
||||
'module_focus' => $this->moduleFocus($analytics),
|
||||
'checkpoints' => [
|
||||
$this->checkpoint('profile', 'Profile presentation', $profileScore, 'Profile, cover, and social details shape how new visitors read your work.', route('studio.profile'), 'Update profile'),
|
||||
$this->checkpoint('cadence', 'Publishing cadence', $cadenceScore, sprintf('You published %d items in the last %d days.', $publishedInRange, $days), route('studio.calendar'), 'Open calendar'),
|
||||
$this->checkpoint('engagement', 'Audience response', $engagementScore, 'Comments, reactions, saves, and shares show whether your output is creating momentum.', route('studio.inbox'), 'Open inbox'),
|
||||
$this->checkpoint('curation', 'Featured curation', $curationScore, 'Featured modules make your strongest work easier to discover across the profile surface.', route('studio.featured'), 'Manage featured'),
|
||||
$this->checkpoint('challenges', 'Challenge participation', $this->ratioScore($challengeEntries, 5), 'Challenge submissions create discoverable surfaces beyond your core publishing flow.', route('studio.challenges'), 'Open challenges'),
|
||||
],
|
||||
'opportunities' => $this->opportunities($profileScore, $publishedInRange, $challengeEntries, $preferences),
|
||||
'milestones' => [
|
||||
$this->milestone('followers', 'Follower milestone', (int) ($analytics['totals']['followers'] ?? 0), $this->nextMilestone((int) ($analytics['totals']['followers'] ?? 0), [10, 25, 50, 100, 250, 500, 1000, 2500, 5000])),
|
||||
$this->milestone('publishing', sprintf('Published in %d days', $days), $publishedInRange, $this->nextMilestone($publishedInRange, [3, 5, 8, 12, 20, 30])),
|
||||
$this->milestone('challenges', 'Challenge submissions', $challengeEntries, $this->nextMilestone($challengeEntries, [1, 3, 5, 10, 25, 50])),
|
||||
],
|
||||
'momentum' => [
|
||||
'views_trend' => $analytics['views_trend'],
|
||||
'engagement_trend' => $analytics['engagement_trend'],
|
||||
'publishing_timeline' => $analytics['publishing_timeline'],
|
||||
],
|
||||
'top_content' => collect($analytics['top_content'])->take(5)->values()->all(),
|
||||
'range_days' => $days,
|
||||
];
|
||||
}
|
||||
|
||||
private function moduleFocus(array $analytics): array
|
||||
{
|
||||
$totalViews = max(1, (int) ($analytics['totals']['views'] ?? 0));
|
||||
$totalEngagement = max(1,
|
||||
(int) ($analytics['totals']['appreciation'] ?? 0)
|
||||
+ (int) ($analytics['totals']['comments'] ?? 0)
|
||||
+ (int) ($analytics['totals']['shares'] ?? 0)
|
||||
+ (int) ($analytics['totals']['saves'] ?? 0)
|
||||
);
|
||||
$comparison = collect($analytics['comparison'] ?? [])->keyBy('key');
|
||||
|
||||
return collect($analytics['module_breakdown'] ?? [])
|
||||
->map(function (array $item) use ($comparison, $totalViews, $totalEngagement): array {
|
||||
$engagementValue = (int) ($item['appreciation'] ?? 0)
|
||||
+ (int) ($item['comments'] ?? 0)
|
||||
+ (int) ($item['shares'] ?? 0)
|
||||
+ (int) ($item['saves'] ?? 0);
|
||||
$publishedCount = (int) ($comparison->get($item['key'])['published_count'] ?? 0);
|
||||
|
||||
return [
|
||||
'key' => $item['key'],
|
||||
'label' => $item['label'],
|
||||
'icon' => $item['icon'],
|
||||
'views' => (int) ($item['views'] ?? 0),
|
||||
'engagement' => $engagementValue,
|
||||
'published_count' => $publishedCount,
|
||||
'draft_count' => (int) ($item['draft_count'] ?? 0),
|
||||
'view_share' => (int) round(((int) ($item['views'] ?? 0) / $totalViews) * 100),
|
||||
'engagement_share' => (int) round(($engagementValue / $totalEngagement) * 100),
|
||||
'href' => $item['index_url'],
|
||||
];
|
||||
})
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
private function opportunities(int $profileScore, int $publishedInRange, int $challengeEntries, array $preferences): array
|
||||
{
|
||||
$items = [];
|
||||
|
||||
if ($profileScore < 80) {
|
||||
$items[] = [
|
||||
'title' => 'Tighten creator presentation',
|
||||
'body' => 'A more complete profile helps new followers understand the work behind the metrics.',
|
||||
'href' => route('studio.profile'),
|
||||
'cta' => 'Update profile',
|
||||
];
|
||||
}
|
||||
|
||||
if ($publishedInRange < 3) {
|
||||
$items[] = [
|
||||
'title' => 'Increase publishing cadence',
|
||||
'body' => 'The calendar is still the clearest place to turn draft backlog into visible output.',
|
||||
'href' => route('studio.calendar'),
|
||||
'cta' => 'Plan schedule',
|
||||
];
|
||||
}
|
||||
|
||||
if (count($preferences['featured_modules'] ?? []) < 3) {
|
||||
$items[] = [
|
||||
'title' => 'Expand featured module coverage',
|
||||
'body' => 'Featured content gives your strongest modules a cleaner discovery path from the public profile.',
|
||||
'href' => route('studio.featured'),
|
||||
'cta' => 'Manage featured',
|
||||
];
|
||||
}
|
||||
|
||||
if ($challengeEntries === 0) {
|
||||
$items[] = [
|
||||
'title' => 'Use challenges as a growth surface',
|
||||
'body' => 'Challenge runs create another discovery path for cards beyond your normal publishing feed.',
|
||||
'href' => route('studio.challenges'),
|
||||
'cta' => 'Review challenges',
|
||||
];
|
||||
}
|
||||
|
||||
$items[] = [
|
||||
'title' => 'Stay close to response signals',
|
||||
'body' => 'Inbox and comments are still the fastest route from passive reach to actual creator retention.',
|
||||
'href' => route('studio.inbox'),
|
||||
'cta' => 'Open inbox',
|
||||
];
|
||||
|
||||
return collect($items)->take(4)->values()->all();
|
||||
}
|
||||
|
||||
private function checkpoint(string $key, string $label, int $score, string $detail, string $href, string $cta): array
|
||||
{
|
||||
return [
|
||||
'key' => $key,
|
||||
'label' => $label,
|
||||
'score' => $score,
|
||||
'status' => $score >= 80 ? 'strong' : ($score >= 55 ? 'building' : 'needs_attention'),
|
||||
'detail' => $detail,
|
||||
'href' => $href,
|
||||
'cta' => $cta,
|
||||
];
|
||||
}
|
||||
|
||||
private function milestone(string $key, string $label, int $current, int $target): array
|
||||
{
|
||||
return [
|
||||
'key' => $key,
|
||||
'label' => $label,
|
||||
'current' => $current,
|
||||
'target' => $target,
|
||||
'progress' => $target > 0 ? min(100, (int) round(($current / $target) * 100)) : 100,
|
||||
];
|
||||
}
|
||||
|
||||
private function ratioScore(int $current, int $target): int
|
||||
{
|
||||
if ($target <= 0) {
|
||||
return 100;
|
||||
}
|
||||
|
||||
return max(0, min(100, (int) round(($current / $target) * 100)));
|
||||
}
|
||||
|
||||
private function nextMilestone(int $current, array $steps): int
|
||||
{
|
||||
foreach ($steps as $step) {
|
||||
if ($current < $step) {
|
||||
return $step;
|
||||
}
|
||||
}
|
||||
|
||||
return max($current, 1);
|
||||
}
|
||||
|
||||
private function profileScore(User $user, int $socialLinksCount): int
|
||||
{
|
||||
$score = 0;
|
||||
|
||||
if (filled($user->name)) {
|
||||
$score += 15;
|
||||
}
|
||||
|
||||
if (filled($user->profile?->description)) {
|
||||
$score += 20;
|
||||
}
|
||||
|
||||
if (filled($user->profile?->about)) {
|
||||
$score += 20;
|
||||
}
|
||||
|
||||
if (filled($user->profile?->website)) {
|
||||
$score += 15;
|
||||
}
|
||||
|
||||
if (filled($user->profile?->avatar_url)) {
|
||||
$score += 10;
|
||||
}
|
||||
|
||||
if (filled($user->cover_hash) && filled($user->cover_ext)) {
|
||||
$score += 10;
|
||||
}
|
||||
|
||||
if ($socialLinksCount > 0) {
|
||||
$score += 10;
|
||||
}
|
||||
|
||||
return min(100, $score);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Studio;
|
||||
|
||||
use App\Models\User;
|
||||
|
||||
final class CreatorStudioInboxService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly CreatorStudioActivityService $activity,
|
||||
private readonly CreatorStudioPreferenceService $preferences,
|
||||
) {
|
||||
}
|
||||
|
||||
public function build(User $user, array $filters = []): array
|
||||
{
|
||||
$type = $this->normalizeType((string) ($filters['type'] ?? 'all'));
|
||||
$module = $this->normalizeModule((string) ($filters['module'] ?? 'all'));
|
||||
$readState = $this->normalizeReadState((string) ($filters['read_state'] ?? 'all'));
|
||||
$priority = $this->normalizePriority((string) ($filters['priority'] ?? 'all'));
|
||||
$query = trim((string) ($filters['q'] ?? ''));
|
||||
$page = max(1, (int) ($filters['page'] ?? 1));
|
||||
$perPage = min(max((int) ($filters['per_page'] ?? 20), 12), 40);
|
||||
|
||||
$lastReadAt = $this->preferences->forUser($user)['activity_last_read_at'] ?? null;
|
||||
$lastReadTimestamp = strtotime((string) $lastReadAt) ?: 0;
|
||||
|
||||
$items = $this->activity->feed($user)->map(function (array $item) use ($lastReadTimestamp): array {
|
||||
$timestamp = strtotime((string) ($item['created_at'] ?? '')) ?: 0;
|
||||
$item['is_new'] = $timestamp > $lastReadTimestamp;
|
||||
$item['priority'] = $this->priorityFor($item);
|
||||
|
||||
return $item;
|
||||
});
|
||||
|
||||
if ($type !== 'all') {
|
||||
$items = $items->where('type', $type)->values();
|
||||
}
|
||||
|
||||
if ($module !== 'all') {
|
||||
$items = $items->where('module', $module)->values();
|
||||
}
|
||||
|
||||
if ($readState === 'unread') {
|
||||
$items = $items->filter(fn (array $item): bool => (bool) ($item['is_new'] ?? false))->values();
|
||||
} elseif ($readState === 'read') {
|
||||
$items = $items->filter(fn (array $item): bool => ! (bool) ($item['is_new'] ?? false))->values();
|
||||
}
|
||||
|
||||
if ($priority !== 'all') {
|
||||
$items = $items->where('priority', $priority)->values();
|
||||
}
|
||||
|
||||
if ($query !== '') {
|
||||
$needle = mb_strtolower($query);
|
||||
$items = $items->filter(function (array $item) use ($needle): bool {
|
||||
return collect([
|
||||
$item['title'] ?? '',
|
||||
$item['body'] ?? '',
|
||||
$item['actor']['name'] ?? '',
|
||||
$item['module_label'] ?? '',
|
||||
])->contains(fn ($value): bool => is_string($value) && str_contains(mb_strtolower($value), $needle));
|
||||
})->values();
|
||||
}
|
||||
|
||||
$total = $items->count();
|
||||
$lastPage = max(1, (int) ceil($total / $perPage));
|
||||
$page = min($page, $lastPage);
|
||||
|
||||
return [
|
||||
'items' => $items->forPage($page, $perPage)->values()->all(),
|
||||
'meta' => [
|
||||
'current_page' => $page,
|
||||
'last_page' => $lastPage,
|
||||
'per_page' => $perPage,
|
||||
'total' => $total,
|
||||
],
|
||||
'filters' => [
|
||||
'type' => $type,
|
||||
'module' => $module,
|
||||
'read_state' => $readState,
|
||||
'priority' => $priority,
|
||||
'q' => $query,
|
||||
],
|
||||
'type_options' => [
|
||||
['value' => 'all', 'label' => 'Everything'],
|
||||
['value' => 'notification', 'label' => 'Notifications'],
|
||||
['value' => 'comment', 'label' => 'Comments'],
|
||||
['value' => 'follower', 'label' => 'Followers'],
|
||||
],
|
||||
'module_options' => [
|
||||
['value' => 'all', 'label' => 'All modules'],
|
||||
['value' => 'artworks', 'label' => 'Artworks'],
|
||||
['value' => 'cards', 'label' => 'Cards'],
|
||||
['value' => 'collections', 'label' => 'Collections'],
|
||||
['value' => 'stories', 'label' => 'Stories'],
|
||||
['value' => 'followers', 'label' => 'Followers'],
|
||||
['value' => 'system', 'label' => 'System'],
|
||||
],
|
||||
'read_state_options' => [
|
||||
['value' => 'all', 'label' => 'All'],
|
||||
['value' => 'unread', 'label' => 'Unread'],
|
||||
['value' => 'read', 'label' => 'Read'],
|
||||
],
|
||||
'priority_options' => [
|
||||
['value' => 'all', 'label' => 'All priorities'],
|
||||
['value' => 'high', 'label' => 'High priority'],
|
||||
['value' => 'medium', 'label' => 'Medium priority'],
|
||||
['value' => 'low', 'label' => 'Low priority'],
|
||||
],
|
||||
'summary' => [
|
||||
'unread_count' => $items->filter(fn (array $item): bool => (bool) ($item['is_new'] ?? false))->count(),
|
||||
'high_priority_count' => $items->where('priority', 'high')->count(),
|
||||
'comment_count' => $items->where('type', 'comment')->count(),
|
||||
'follower_count' => $items->where('type', 'follower')->count(),
|
||||
'last_read_at' => $lastReadAt,
|
||||
],
|
||||
'panels' => [
|
||||
'attention_now' => $items->filter(fn (array $item): bool => ($item['priority'] ?? 'low') === 'high')->take(5)->values()->all(),
|
||||
'follow_up_queue' => $items->filter(fn (array $item): bool => (bool) ($item['is_new'] ?? false))->take(5)->values()->all(),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
private function priorityFor(array $item): string
|
||||
{
|
||||
return match ((string) ($item['type'] ?? '')) {
|
||||
'comment' => 'high',
|
||||
'notification' => (bool) ($item['read'] ?? false) ? 'medium' : 'high',
|
||||
'follower' => 'medium',
|
||||
default => 'low',
|
||||
};
|
||||
}
|
||||
|
||||
private function normalizeType(string $value): string
|
||||
{
|
||||
return in_array($value, ['all', 'notification', 'comment', 'follower'], true) ? $value : 'all';
|
||||
}
|
||||
|
||||
private function normalizeModule(string $value): string
|
||||
{
|
||||
return in_array($value, ['all', 'artworks', 'cards', 'collections', 'stories', 'followers', 'system'], true) ? $value : 'all';
|
||||
}
|
||||
|
||||
private function normalizeReadState(string $value): string
|
||||
{
|
||||
return in_array($value, ['all', 'read', 'unread'], true) ? $value : 'all';
|
||||
}
|
||||
|
||||
private function normalizePriority(string $value): string
|
||||
{
|
||||
return in_array($value, ['all', 'high', 'medium', 'low'], true) ? $value : 'all';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Studio;
|
||||
|
||||
use App\Models\CollectionComment;
|
||||
use App\Models\NovaCardComment;
|
||||
use App\Models\StoryComment;
|
||||
use App\Models\User;
|
||||
use App\Support\AvatarUrl;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
final class CreatorStudioOverviewService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly CreatorStudioContentService $content,
|
||||
private readonly CreatorStudioAnalyticsService $analytics,
|
||||
private readonly CreatorStudioScheduledService $scheduled,
|
||||
private readonly CreatorStudioActivityService $activity,
|
||||
private readonly CreatorStudioPreferenceService $preferences,
|
||||
private readonly CreatorStudioChallengeService $challenges,
|
||||
private readonly CreatorStudioGrowthService $growth,
|
||||
) {
|
||||
}
|
||||
|
||||
public function build(User $user): array
|
||||
{
|
||||
$analytics = $this->analytics->overview($user);
|
||||
$moduleSummaries = $this->content->moduleSummaries($user);
|
||||
$preferences = $this->preferences->forUser($user);
|
||||
$challengeData = $this->challenges->build($user);
|
||||
$growthData = $this->growth->build($user, $preferences['analytics_range_days']);
|
||||
$featuredContent = $this->content->selectedItems($user, $preferences['featured_content']);
|
||||
|
||||
return [
|
||||
'kpis' => [
|
||||
'total_content' => $analytics['totals']['content_count'],
|
||||
'views_30d' => $analytics['totals']['views'],
|
||||
'appreciation_30d' => $analytics['totals']['appreciation'],
|
||||
'shares_30d' => $analytics['totals']['shares'],
|
||||
'comments_30d' => $analytics['totals']['comments'],
|
||||
'followers' => $analytics['totals']['followers'],
|
||||
],
|
||||
'module_summaries' => $moduleSummaries,
|
||||
'quick_create' => $this->content->quickCreate(),
|
||||
'continue_working' => $this->content->continueWorking($user, $preferences['draft_behavior']),
|
||||
'scheduled_items' => $this->scheduled->upcoming($user, 5),
|
||||
'recent_activity' => $this->activity->recent($user, 6),
|
||||
'top_performers' => $analytics['top_content'],
|
||||
'recent_comments' => $this->recentComments($user),
|
||||
'recent_followers' => $this->recentFollowers($user),
|
||||
'draft_reminders' => $this->content->draftReminders($user),
|
||||
'stale_drafts' => $this->content->staleDrafts($user),
|
||||
'recent_publishes' => $this->content->recentPublished($user, 6),
|
||||
'growth_hints' => $this->growthHints($user, $moduleSummaries),
|
||||
'active_challenges' => [
|
||||
'summary' => $challengeData['summary'],
|
||||
'spotlight' => $challengeData['spotlight'],
|
||||
'items' => collect($challengeData['active_challenges'] ?? [])->take(3)->values()->all(),
|
||||
],
|
||||
'creator_health' => [
|
||||
'score' => (int) round(collect($growthData['checkpoints'] ?? [])->avg('score') ?? 0),
|
||||
'summary' => $growthData['summary'],
|
||||
'checkpoints' => collect($growthData['checkpoints'] ?? [])->take(3)->values()->all(),
|
||||
],
|
||||
'featured_status' => $this->featuredStatus($preferences, $featuredContent),
|
||||
'workflow_focus' => $this->workflowFocus($user),
|
||||
'command_center' => $this->commandCenter($user),
|
||||
'insight_blocks' => $analytics['insight_blocks'] ?? [],
|
||||
'preferences' => [
|
||||
'widget_visibility' => $preferences['widget_visibility'],
|
||||
'widget_order' => $preferences['widget_order'],
|
||||
'card_density' => $preferences['card_density'],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
private function featuredStatus(array $preferences, array $featuredContent): array
|
||||
{
|
||||
$modules = ['artworks', 'cards', 'collections', 'stories'];
|
||||
$selectedModules = array_values(array_filter($modules, fn (string $module): bool => isset($featuredContent[$module]) && is_array($featuredContent[$module])));
|
||||
$missingModules = array_values(array_diff($modules, $selectedModules));
|
||||
|
||||
return [
|
||||
'selected_count' => count($selectedModules),
|
||||
'target_count' => count($modules),
|
||||
'featured_modules' => $preferences['featured_modules'],
|
||||
'missing_modules' => $missingModules,
|
||||
'items' => collect($featuredContent)
|
||||
->filter(fn ($item): bool => is_array($item))
|
||||
->values()
|
||||
->take(4)
|
||||
->all(),
|
||||
];
|
||||
}
|
||||
|
||||
private function workflowFocus(User $user): array
|
||||
{
|
||||
$continue = collect($this->content->continueWorking($user, 'resume-last', 6));
|
||||
|
||||
return [
|
||||
'priority_drafts' => $continue
|
||||
->filter(fn (array $item): bool => (bool) ($item['workflow']['is_stale_draft'] ?? false) || ! (bool) ($item['workflow']['readiness']['can_publish'] ?? false))
|
||||
->take(3)
|
||||
->values()
|
||||
->all(),
|
||||
'ready_to_schedule' => $continue
|
||||
->filter(fn (array $item): bool => (bool) ($item['workflow']['readiness']['can_publish'] ?? false))
|
||||
->take(3)
|
||||
->values()
|
||||
->all(),
|
||||
];
|
||||
}
|
||||
|
||||
private function commandCenter(User $user): array
|
||||
{
|
||||
$scheduled = collect($this->scheduled->upcoming($user, 16));
|
||||
$inbox = collect($this->activity->recent($user, 16));
|
||||
$todayStart = now()->startOfDay();
|
||||
$todayEnd = now()->endOfDay();
|
||||
|
||||
return [
|
||||
'publishing_today' => $scheduled->filter(function (array $item) use ($todayStart, $todayEnd): bool {
|
||||
$timestamp = strtotime((string) ($item['scheduled_at'] ?? $item['published_at'] ?? '')) ?: 0;
|
||||
|
||||
return $timestamp >= $todayStart->getTimestamp() && $timestamp <= $todayEnd->getTimestamp();
|
||||
})->values()->all(),
|
||||
'attention_now' => $inbox->filter(fn (array $item): bool => in_array((string) ($item['type'] ?? ''), ['comment', 'notification'], true))->take(4)->values()->all(),
|
||||
];
|
||||
}
|
||||
|
||||
private function growthHints(User $user, array $moduleSummaries): array
|
||||
{
|
||||
$user->loadMissing('profile');
|
||||
$summaries = collect($moduleSummaries)->keyBy('key');
|
||||
$hints = [];
|
||||
|
||||
if (blank($user->profile?->bio) || blank($user->profile?->tagline)) {
|
||||
$hints[] = [
|
||||
'title' => 'Complete your creator profile',
|
||||
'body' => 'Add a tagline and bio so your public presence matches the work you are publishing.',
|
||||
'url' => route('studio.profile'),
|
||||
'label' => 'Update profile',
|
||||
];
|
||||
}
|
||||
|
||||
if (((int) ($summaries->get('cards')['count'] ?? 0)) === 0) {
|
||||
$hints[] = [
|
||||
'title' => 'Publish your first card',
|
||||
'body' => 'Cards now live inside Creator Studio, making short-form publishing a first-class workflow.',
|
||||
'url' => route('studio.cards.create'),
|
||||
'label' => 'Create card',
|
||||
];
|
||||
}
|
||||
|
||||
if (((int) ($summaries->get('collections')['count'] ?? 0)) === 0) {
|
||||
$hints[] = [
|
||||
'title' => 'Create a featured collection',
|
||||
'body' => 'Curated collections give your profile a stronger editorial shape and a better publishing shelf.',
|
||||
'url' => route('settings.collections.create'),
|
||||
'label' => 'Start collection',
|
||||
];
|
||||
}
|
||||
|
||||
if (((int) ($summaries->get('artworks')['count'] ?? 0)) === 0) {
|
||||
$hints[] = [
|
||||
'title' => 'Upload your first artwork',
|
||||
'body' => 'Seed the workspace with a first long-form piece so analytics, drafts, and collections have something to build on.',
|
||||
'url' => '/upload',
|
||||
'label' => 'Upload artwork',
|
||||
];
|
||||
}
|
||||
|
||||
return collect($hints)->take(3)->values()->all();
|
||||
}
|
||||
|
||||
public function recentComments(User $user, int $limit = 12): array
|
||||
{
|
||||
$artworkComments = DB::table('artwork_comments')
|
||||
->join('artworks', 'artworks.id', '=', 'artwork_comments.artwork_id')
|
||||
->join('users', 'users.id', '=', 'artwork_comments.user_id')
|
||||
->where('artworks.user_id', $user->id)
|
||||
->whereNull('artwork_comments.deleted_at')
|
||||
->select([
|
||||
'artwork_comments.id',
|
||||
'artwork_comments.content as body',
|
||||
'artwork_comments.created_at',
|
||||
'users.name as author_name',
|
||||
'artworks.title as item_title',
|
||||
'artworks.slug as item_slug',
|
||||
'artworks.id as item_id',
|
||||
])
|
||||
->orderByDesc('artwork_comments.created_at')
|
||||
->limit($limit)
|
||||
->get()
|
||||
->map(fn ($row): array => [
|
||||
'id' => sprintf('artworks:%d', (int) $row->id),
|
||||
'module' => 'artworks',
|
||||
'module_label' => 'Artworks',
|
||||
'author_name' => $row->author_name,
|
||||
'item_title' => $row->item_title,
|
||||
'body' => $row->body,
|
||||
'created_at' => $this->normalizeDate($row->created_at),
|
||||
'context_url' => route('art.show', ['id' => $row->item_id, 'slug' => $row->item_slug]),
|
||||
]);
|
||||
|
||||
$cardComments = NovaCardComment::query()
|
||||
->with(['user:id,name,username', 'card:id,title,slug'])
|
||||
->whereNull('deleted_at')
|
||||
->whereHas('card', fn ($query) => $query->where('user_id', $user->id))
|
||||
->latest('created_at')
|
||||
->limit($limit)
|
||||
->get()
|
||||
->map(fn (NovaCardComment $comment): array => [
|
||||
'id' => sprintf('cards:%d', (int) $comment->id),
|
||||
'module' => 'cards',
|
||||
'module_label' => 'Cards',
|
||||
'author_name' => $comment->user?->name ?: $comment->user?->username ?: 'Creator',
|
||||
'item_title' => $comment->card?->title,
|
||||
'body' => $comment->body,
|
||||
'created_at' => $comment->created_at?->toIso8601String(),
|
||||
'context_url' => $comment->card ? route('studio.cards.analytics', ['id' => $comment->card->id]) : route('studio.cards.index'),
|
||||
]);
|
||||
|
||||
$collectionComments = CollectionComment::query()
|
||||
->with(['user:id,name,username', 'collection:id,title'])
|
||||
->whereNull('deleted_at')
|
||||
->whereHas('collection', fn ($query) => $query->where('user_id', $user->id))
|
||||
->latest('created_at')
|
||||
->limit($limit)
|
||||
->get()
|
||||
->map(fn (CollectionComment $comment): array => [
|
||||
'id' => sprintf('collections:%d', (int) $comment->id),
|
||||
'module' => 'collections',
|
||||
'module_label' => 'Collections',
|
||||
'author_name' => $comment->user?->name ?: $comment->user?->username ?: 'Creator',
|
||||
'item_title' => $comment->collection?->title,
|
||||
'body' => $comment->body,
|
||||
'created_at' => $comment->created_at?->toIso8601String(),
|
||||
'context_url' => $comment->collection ? route('settings.collections.show', ['collection' => $comment->collection->id]) : route('studio.collections'),
|
||||
]);
|
||||
|
||||
$storyComments = StoryComment::query()
|
||||
->with(['user:id,name,username', 'story:id,title'])
|
||||
->whereNull('deleted_at')
|
||||
->where('is_approved', true)
|
||||
->whereHas('story', fn ($query) => $query->where('creator_id', $user->id))
|
||||
->latest('created_at')
|
||||
->limit($limit)
|
||||
->get()
|
||||
->map(fn (StoryComment $comment): array => [
|
||||
'id' => sprintf('stories:%d', (int) $comment->id),
|
||||
'module' => 'stories',
|
||||
'module_label' => 'Stories',
|
||||
'author_name' => $comment->user?->name ?: $comment->user?->username ?: 'Creator',
|
||||
'item_title' => $comment->story?->title,
|
||||
'body' => $comment->content,
|
||||
'created_at' => $comment->created_at?->toIso8601String(),
|
||||
'context_url' => $comment->story ? route('creator.stories.analytics', ['story' => $comment->story->id]) : route('studio.stories'),
|
||||
]);
|
||||
|
||||
return $artworkComments
|
||||
->concat($cardComments)
|
||||
->concat($collectionComments)
|
||||
->concat($storyComments)
|
||||
->sortByDesc(fn (array $comment): int => $this->timestamp($comment['created_at'] ?? null))
|
||||
->take($limit)
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
public function recentFollowers(User $user, int $limit = 8): array
|
||||
{
|
||||
return DB::table('user_followers as uf')
|
||||
->join('users as follower', 'follower.id', '=', 'uf.follower_id')
|
||||
->leftJoin('user_profiles as profile', 'profile.user_id', '=', 'follower.id')
|
||||
->where('uf.user_id', $user->id)
|
||||
->whereNull('follower.deleted_at')
|
||||
->orderByDesc('uf.created_at')
|
||||
->limit($limit)
|
||||
->get([
|
||||
'follower.id',
|
||||
'follower.username',
|
||||
'follower.name',
|
||||
'profile.avatar_hash',
|
||||
'uf.created_at',
|
||||
])
|
||||
->map(fn ($row): array => [
|
||||
'id' => (int) $row->id,
|
||||
'name' => $row->name ?: '@' . $row->username,
|
||||
'username' => $row->username,
|
||||
'profile_url' => '/@' . strtolower((string) $row->username),
|
||||
'avatar_url' => AvatarUrl::forUser((int) $row->id, $row->avatar_hash, 64),
|
||||
'created_at' => $this->normalizeDate($row->created_at),
|
||||
])
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
private function normalizeDate(mixed $value): ?string
|
||||
{
|
||||
if ($value instanceof \DateTimeInterface) {
|
||||
return $value->format(DATE_ATOM);
|
||||
}
|
||||
|
||||
if (is_string($value) && $value !== '') {
|
||||
return $value;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function timestamp(mixed $value): int
|
||||
{
|
||||
if (! is_string($value) || $value === '') {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return strtotime($value) ?: 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Studio;
|
||||
|
||||
use App\Models\DashboardPreference;
|
||||
use App\Models\User;
|
||||
|
||||
final class CreatorStudioPreferenceService
|
||||
{
|
||||
/**
|
||||
* @return array{
|
||||
* default_content_view: string,
|
||||
* analytics_range_days: int,
|
||||
* dashboard_shortcuts: array<int, string>,
|
||||
* featured_modules: array<int, string>,
|
||||
* featured_content: array<string, int>,
|
||||
* draft_behavior: string,
|
||||
* default_landing_page: string,
|
||||
* widget_visibility: array<string, bool>,
|
||||
* widget_order: array<int, string>,
|
||||
* card_density: string,
|
||||
* scheduling_timezone: string|null,
|
||||
* activity_last_read_at: string|null
|
||||
* }
|
||||
*/
|
||||
public function forUser(User $user): array
|
||||
{
|
||||
$record = DashboardPreference::query()->find($user->id);
|
||||
$stored = is_array($record?->studio_preferences) ? $record->studio_preferences : [];
|
||||
|
||||
return [
|
||||
'default_content_view' => $this->normalizeView((string) ($stored['default_content_view'] ?? 'grid')),
|
||||
'analytics_range_days' => $this->normalizeRange((int) ($stored['analytics_range_days'] ?? 30)),
|
||||
'dashboard_shortcuts' => DashboardPreference::pinnedSpacesForUser($user),
|
||||
'featured_modules' => $this->normalizeModules($stored['featured_modules'] ?? []),
|
||||
'featured_content' => $this->normalizeFeaturedContent($stored['featured_content'] ?? []),
|
||||
'draft_behavior' => $this->normalizeDraftBehavior((string) ($stored['draft_behavior'] ?? 'resume-last')),
|
||||
'default_landing_page' => $this->normalizeLandingPage((string) ($stored['default_landing_page'] ?? 'overview')),
|
||||
'widget_visibility' => $this->normalizeWidgetVisibility($stored['widget_visibility'] ?? []),
|
||||
'widget_order' => $this->normalizeWidgetOrder($stored['widget_order'] ?? []),
|
||||
'card_density' => $this->normalizeDensity((string) ($stored['card_density'] ?? 'comfortable')),
|
||||
'scheduling_timezone' => $this->normalizeTimezone($stored['scheduling_timezone'] ?? null),
|
||||
'activity_last_read_at' => $this->normalizeActivityLastReadAt($stored['activity_last_read_at'] ?? null),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $attributes
|
||||
* @return array{
|
||||
* default_content_view: string,
|
||||
* analytics_range_days: int,
|
||||
* dashboard_shortcuts: array<int, string>,
|
||||
* featured_modules: array<int, string>,
|
||||
* featured_content: array<string, int>,
|
||||
* draft_behavior: string,
|
||||
* default_landing_page: string,
|
||||
* widget_visibility: array<string, bool>,
|
||||
* widget_order: array<int, string>,
|
||||
* card_density: string,
|
||||
* scheduling_timezone: string|null,
|
||||
* activity_last_read_at: string|null
|
||||
* }
|
||||
*/
|
||||
public function update(User $user, array $attributes): array
|
||||
{
|
||||
$current = $this->forUser($user);
|
||||
$payload = [
|
||||
'default_content_view' => array_key_exists('default_content_view', $attributes)
|
||||
? $this->normalizeView((string) $attributes['default_content_view'])
|
||||
: $current['default_content_view'],
|
||||
'analytics_range_days' => array_key_exists('analytics_range_days', $attributes)
|
||||
? $this->normalizeRange((int) $attributes['analytics_range_days'])
|
||||
: $current['analytics_range_days'],
|
||||
'featured_modules' => array_key_exists('featured_modules', $attributes)
|
||||
? $this->normalizeModules($attributes['featured_modules'])
|
||||
: $current['featured_modules'],
|
||||
'featured_content' => array_key_exists('featured_content', $attributes)
|
||||
? $this->normalizeFeaturedContent($attributes['featured_content'])
|
||||
: $current['featured_content'],
|
||||
'draft_behavior' => array_key_exists('draft_behavior', $attributes)
|
||||
? $this->normalizeDraftBehavior((string) $attributes['draft_behavior'])
|
||||
: $current['draft_behavior'],
|
||||
'default_landing_page' => array_key_exists('default_landing_page', $attributes)
|
||||
? $this->normalizeLandingPage((string) $attributes['default_landing_page'])
|
||||
: $current['default_landing_page'],
|
||||
'widget_visibility' => array_key_exists('widget_visibility', $attributes)
|
||||
? $this->normalizeWidgetVisibility($attributes['widget_visibility'])
|
||||
: $current['widget_visibility'],
|
||||
'widget_order' => array_key_exists('widget_order', $attributes)
|
||||
? $this->normalizeWidgetOrder($attributes['widget_order'])
|
||||
: $current['widget_order'],
|
||||
'card_density' => array_key_exists('card_density', $attributes)
|
||||
? $this->normalizeDensity((string) $attributes['card_density'])
|
||||
: $current['card_density'],
|
||||
'scheduling_timezone' => array_key_exists('scheduling_timezone', $attributes)
|
||||
? $this->normalizeTimezone($attributes['scheduling_timezone'])
|
||||
: $current['scheduling_timezone'],
|
||||
'activity_last_read_at' => array_key_exists('activity_last_read_at', $attributes)
|
||||
? $this->normalizeActivityLastReadAt($attributes['activity_last_read_at'])
|
||||
: $current['activity_last_read_at'],
|
||||
];
|
||||
|
||||
$record = DashboardPreference::query()->firstOrNew(['user_id' => $user->id]);
|
||||
$record->pinned_spaces = array_key_exists('dashboard_shortcuts', $attributes)
|
||||
? DashboardPreference::sanitizePinnedSpaces(is_array($attributes['dashboard_shortcuts']) ? $attributes['dashboard_shortcuts'] : [])
|
||||
: $current['dashboard_shortcuts'];
|
||||
$record->studio_preferences = $payload;
|
||||
$record->save();
|
||||
|
||||
return $this->forUser($user);
|
||||
}
|
||||
|
||||
private function normalizeView(string $view): string
|
||||
{
|
||||
return in_array($view, ['grid', 'list'], true) ? $view : 'grid';
|
||||
}
|
||||
|
||||
private function normalizeRange(int $days): int
|
||||
{
|
||||
return in_array($days, [7, 14, 30, 60, 90], true) ? $days : 30;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed $modules
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function normalizeModules(mixed $modules): array
|
||||
{
|
||||
$allowed = ['artworks', 'cards', 'collections', 'stories'];
|
||||
|
||||
return collect(is_array($modules) ? $modules : [])
|
||||
->map(fn ($module): string => (string) $module)
|
||||
->filter(fn (string $module): bool => in_array($module, $allowed, true))
|
||||
->unique()
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed $featuredContent
|
||||
* @return array<string, int>
|
||||
*/
|
||||
private function normalizeFeaturedContent(mixed $featuredContent): array
|
||||
{
|
||||
$allowed = ['artworks', 'cards', 'collections', 'stories'];
|
||||
|
||||
return collect(is_array($featuredContent) ? $featuredContent : [])
|
||||
->mapWithKeys(function ($id, $module) use ($allowed): array {
|
||||
$moduleKey = (string) $module;
|
||||
$normalizedId = (int) $id;
|
||||
|
||||
if (! in_array($moduleKey, $allowed, true) || $normalizedId < 1) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [$moduleKey => $normalizedId];
|
||||
})
|
||||
->all();
|
||||
}
|
||||
|
||||
private function normalizeDraftBehavior(string $value): string
|
||||
{
|
||||
return in_array($value, ['resume-last', 'open-drafts', 'focus-published'], true)
|
||||
? $value
|
||||
: 'resume-last';
|
||||
}
|
||||
|
||||
private function normalizeLandingPage(string $value): string
|
||||
{
|
||||
return in_array($value, ['overview', 'content', 'drafts', 'scheduled', 'analytics', 'activity', 'calendar', 'inbox', 'search', 'growth', 'challenges', 'preferences'], true)
|
||||
? $value
|
||||
: 'overview';
|
||||
}
|
||||
|
||||
private function normalizeDensity(string $value): string
|
||||
{
|
||||
return in_array($value, ['compact', 'comfortable'], true)
|
||||
? $value
|
||||
: 'comfortable';
|
||||
}
|
||||
|
||||
private function normalizeTimezone(mixed $value): ?string
|
||||
{
|
||||
if (! is_string($value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$value = trim($value);
|
||||
|
||||
return $value !== '' ? $value : null;
|
||||
}
|
||||
|
||||
private function normalizeActivityLastReadAt(mixed $value): ?string
|
||||
{
|
||||
if (! is_string($value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$value = trim($value);
|
||||
|
||||
return $value !== '' ? $value : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed $value
|
||||
* @return array<string, bool>
|
||||
*/
|
||||
private function normalizeWidgetVisibility(mixed $value): array
|
||||
{
|
||||
$defaults = collect($this->allowedWidgets())->mapWithKeys(fn (string $widget): array => [$widget => true]);
|
||||
|
||||
return $defaults->merge(
|
||||
collect(is_array($value) ? $value : [])
|
||||
->filter(fn ($enabled, $widget): bool => in_array((string) $widget, $this->allowedWidgets(), true))
|
||||
->map(fn ($enabled): bool => (bool) $enabled)
|
||||
)->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed $value
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function normalizeWidgetOrder(mixed $value): array
|
||||
{
|
||||
$requested = collect(is_array($value) ? $value : [])
|
||||
->map(fn ($widget): string => (string) $widget)
|
||||
->filter(fn (string $widget): bool => in_array($widget, $this->allowedWidgets(), true))
|
||||
->unique()
|
||||
->values();
|
||||
|
||||
return $requested
|
||||
->concat(collect($this->allowedWidgets())->reject(fn (string $widget): bool => $requested->contains($widget)))
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function allowedWidgets(): array
|
||||
{
|
||||
return [
|
||||
'quick_stats',
|
||||
'continue_working',
|
||||
'scheduled_items',
|
||||
'recent_activity',
|
||||
'top_performers',
|
||||
'draft_reminders',
|
||||
'module_summaries',
|
||||
'growth_hints',
|
||||
'active_challenges',
|
||||
'creator_health',
|
||||
'featured_status',
|
||||
'comments_snapshot',
|
||||
'stale_drafts',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Studio;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Services\Studio\Contracts\CreatorStudioProvider;
|
||||
|
||||
final class CreatorStudioScheduledService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly CreatorStudioContentService $content,
|
||||
) {
|
||||
}
|
||||
|
||||
public function upcoming(User $user, int $limit = 20): array
|
||||
{
|
||||
return collect($this->content->providers())
|
||||
->flatMap(fn (CreatorStudioProvider $provider) => $provider->scheduledItems($user, $limit * 2))
|
||||
->sortBy(fn (array $item): int => $this->scheduledTimestamp($item))
|
||||
->take($limit)
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $filters
|
||||
*/
|
||||
public function list(User $user, array $filters = []): array
|
||||
{
|
||||
$module = $this->normalizeModule((string) ($filters['module'] ?? 'all'));
|
||||
$q = trim((string) ($filters['q'] ?? ''));
|
||||
$range = $this->normalizeRange((string) ($filters['range'] ?? 'upcoming'));
|
||||
$startDate = $this->normalizeDate((string) ($filters['start_date'] ?? ''));
|
||||
$endDate = $this->normalizeDate((string) ($filters['end_date'] ?? ''));
|
||||
$page = max(1, (int) ($filters['page'] ?? 1));
|
||||
$perPage = min(max((int) ($filters['per_page'] ?? 24), 12), 48);
|
||||
|
||||
$items = $module === 'all'
|
||||
? collect($this->content->providers())->flatMap(fn (CreatorStudioProvider $provider) => $provider->scheduledItems($user, 120))
|
||||
: ($this->content->provider($module)?->scheduledItems($user, 120) ?? collect());
|
||||
|
||||
if ($q !== '') {
|
||||
$needle = mb_strtolower($q);
|
||||
$items = $items->filter(function (array $item) use ($needle): bool {
|
||||
return collect([
|
||||
$item['title'] ?? '',
|
||||
$item['subtitle'] ?? '',
|
||||
$item['module_label'] ?? '',
|
||||
])->contains(fn ($value): bool => is_string($value) && str_contains(mb_strtolower($value), $needle));
|
||||
});
|
||||
}
|
||||
|
||||
$items = $items->filter(function (array $item) use ($range, $startDate, $endDate): bool {
|
||||
$timestamp = $this->scheduledTimestamp($item);
|
||||
if ($timestamp === PHP_INT_MAX) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($range === 'today') {
|
||||
return $timestamp >= now()->startOfDay()->getTimestamp() && $timestamp <= now()->endOfDay()->getTimestamp();
|
||||
}
|
||||
|
||||
if ($range === 'week') {
|
||||
return $timestamp >= now()->startOfDay()->getTimestamp() && $timestamp <= now()->addDays(7)->endOfDay()->getTimestamp();
|
||||
}
|
||||
|
||||
if ($range === 'month') {
|
||||
return $timestamp >= now()->startOfDay()->getTimestamp() && $timestamp <= now()->addDays(30)->endOfDay()->getTimestamp();
|
||||
}
|
||||
|
||||
if ($startDate !== null && $timestamp < strtotime($startDate . ' 00:00:00')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($endDate !== null && $timestamp > strtotime($endDate . ' 23:59:59')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
$items = $items
|
||||
->sortBy(fn (array $item): int => $this->scheduledTimestamp($item))
|
||||
->values();
|
||||
|
||||
$total = $items->count();
|
||||
$lastPage = max(1, (int) ceil($total / $perPage));
|
||||
$page = min($page, $lastPage);
|
||||
|
||||
return [
|
||||
'items' => $items->forPage($page, $perPage)->values()->all(),
|
||||
'meta' => [
|
||||
'current_page' => $page,
|
||||
'last_page' => $lastPage,
|
||||
'per_page' => $perPage,
|
||||
'total' => $total,
|
||||
],
|
||||
'filters' => [
|
||||
'module' => $module,
|
||||
'q' => $q,
|
||||
'range' => $range,
|
||||
'start_date' => $startDate,
|
||||
'end_date' => $endDate,
|
||||
],
|
||||
'module_options' => array_merge([
|
||||
['value' => 'all', 'label' => 'All scheduled content'],
|
||||
], collect($this->content->moduleSummaries($user))->map(fn (array $summary): array => [
|
||||
'value' => $summary['key'],
|
||||
'label' => $summary['label'],
|
||||
])->all()),
|
||||
'range_options' => [
|
||||
['value' => 'upcoming', 'label' => 'All upcoming'],
|
||||
['value' => 'today', 'label' => 'Today'],
|
||||
['value' => 'week', 'label' => 'Next 7 days'],
|
||||
['value' => 'month', 'label' => 'Next 30 days'],
|
||||
['value' => 'custom', 'label' => 'Custom range'],
|
||||
],
|
||||
'summary' => $this->summary($user),
|
||||
'agenda' => $this->agenda($user, 14),
|
||||
];
|
||||
}
|
||||
|
||||
public function summary(User $user): array
|
||||
{
|
||||
$items = collect($this->upcoming($user, 200));
|
||||
|
||||
return [
|
||||
'total' => $items->count(),
|
||||
'next_publish_at' => $items->first()['scheduled_at'] ?? null,
|
||||
'by_module' => collect($this->content->moduleSummaries($user))
|
||||
->map(fn (array $summary): array => [
|
||||
'key' => $summary['key'],
|
||||
'label' => $summary['label'],
|
||||
'count' => $items->where('module', $summary['key'])->count(),
|
||||
'icon' => $summary['icon'],
|
||||
])
|
||||
->values()
|
||||
->all(),
|
||||
];
|
||||
}
|
||||
|
||||
public function agenda(User $user, int $days = 14): array
|
||||
{
|
||||
return collect($this->upcoming($user, 200))
|
||||
->filter(fn (array $item): bool => $this->scheduledTimestamp($item) <= now()->addDays($days)->getTimestamp())
|
||||
->groupBy(function (array $item): string {
|
||||
$value = (string) ($item['scheduled_at'] ?? $item['published_at'] ?? now()->toIso8601String());
|
||||
|
||||
return date('Y-m-d', strtotime($value));
|
||||
})
|
||||
->map(fn ($group, string $date): array => [
|
||||
'date' => $date,
|
||||
'label' => date('M j', strtotime($date)),
|
||||
'count' => $group->count(),
|
||||
'items' => $group->values()->all(),
|
||||
])
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
private function normalizeModule(string $module): string
|
||||
{
|
||||
return in_array($module, ['all', 'artworks', 'cards', 'collections', 'stories'], true)
|
||||
? $module
|
||||
: 'all';
|
||||
}
|
||||
|
||||
private function normalizeRange(string $range): string
|
||||
{
|
||||
return in_array($range, ['upcoming', 'today', 'week', 'month', 'custom'], true)
|
||||
? $range
|
||||
: 'upcoming';
|
||||
}
|
||||
|
||||
private function normalizeDate(string $value): ?string
|
||||
{
|
||||
$value = trim($value);
|
||||
if ($value === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return preg_match('/^\d{4}-\d{2}-\d{2}$/', $value) === 1 ? $value : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $item
|
||||
*/
|
||||
private function scheduledTimestamp(array $item): int
|
||||
{
|
||||
return strtotime((string) ($item['scheduled_at'] ?? $item['published_at'] ?? $item['updated_at'] ?? now()->toIso8601String())) ?: PHP_INT_MAX;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Studio;
|
||||
|
||||
use App\Models\User;
|
||||
|
||||
final class CreatorStudioSearchService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly CreatorStudioContentService $content,
|
||||
private readonly CreatorStudioCommentService $comments,
|
||||
private readonly CreatorStudioActivityService $activity,
|
||||
private readonly CreatorStudioAssetService $assets,
|
||||
) {
|
||||
}
|
||||
|
||||
public function build(User $user, array $filters = []): array
|
||||
{
|
||||
$query = trim((string) ($filters['q'] ?? ''));
|
||||
$module = $this->normalizeModule((string) ($filters['module'] ?? 'all'));
|
||||
$type = $this->normalizeType((string) ($filters['type'] ?? 'all'));
|
||||
|
||||
if ($query === '') {
|
||||
return [
|
||||
'filters' => ['q' => '', 'module' => $module, 'type' => $type],
|
||||
'sections' => [],
|
||||
'summary' => [
|
||||
'total' => 0,
|
||||
'query' => '',
|
||||
],
|
||||
'empty_state' => [
|
||||
'continue_working' => $this->content->continueWorking($user, 'resume-last', 5),
|
||||
'stale_drafts' => $this->content->staleDrafts($user, 5),
|
||||
'scheduled' => $this->content->providers() ? collect($this->content->providers())->flatMap(fn ($provider) => $provider->scheduledItems($user, 3))->take(5)->values()->all() : [],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
$sections = collect();
|
||||
|
||||
if (in_array($type, ['all', 'content'], true)) {
|
||||
$content = $this->content->list($user, ['module' => $module, 'q' => $query, 'per_page' => 12]);
|
||||
$sections->push([
|
||||
'key' => 'content',
|
||||
'label' => 'Content',
|
||||
'count' => count($content['items']),
|
||||
'items' => collect($content['items'])->map(fn (array $item): array => [
|
||||
'id' => $item['id'],
|
||||
'title' => $item['title'],
|
||||
'subtitle' => $item['module_label'] . ' · ' . ($item['status'] ?? 'draft'),
|
||||
'description' => $item['description'],
|
||||
'href' => $item['edit_url'] ?? $item['manage_url'] ?? $item['view_url'],
|
||||
'icon' => $item['module_icon'] ?? 'fa-solid fa-table-cells-large',
|
||||
'module' => $item['module'],
|
||||
'kind' => 'content',
|
||||
])->all(),
|
||||
]);
|
||||
}
|
||||
|
||||
if (in_array($type, ['all', 'comments'], true)) {
|
||||
$comments = $this->comments->list($user, ['module' => $module, 'q' => $query, 'per_page' => 8]);
|
||||
$sections->push([
|
||||
'key' => 'comments',
|
||||
'label' => 'Comments',
|
||||
'count' => count($comments['items']),
|
||||
'items' => collect($comments['items'])->map(fn (array $item): array => [
|
||||
'id' => $item['id'],
|
||||
'title' => $item['author_name'] . ' on ' . ($item['item_title'] ?? 'Untitled'),
|
||||
'subtitle' => $item['module_label'],
|
||||
'description' => $item['body'],
|
||||
'href' => $item['context_url'],
|
||||
'icon' => 'fa-solid fa-comments',
|
||||
'module' => $item['module'],
|
||||
'kind' => 'comment',
|
||||
])->all(),
|
||||
]);
|
||||
}
|
||||
|
||||
if (in_array($type, ['all', 'inbox'], true)) {
|
||||
$activity = $this->activity->list($user, ['module' => $module, 'q' => $query, 'per_page' => 8]);
|
||||
$sections->push([
|
||||
'key' => 'inbox',
|
||||
'label' => 'Inbox',
|
||||
'count' => count($activity['items']),
|
||||
'items' => collect($activity['items'])->map(fn (array $item): array => [
|
||||
'id' => $item['id'],
|
||||
'title' => $item['title'],
|
||||
'subtitle' => $item['module_label'],
|
||||
'description' => $item['body'],
|
||||
'href' => $item['url'],
|
||||
'icon' => 'fa-solid fa-bell',
|
||||
'module' => $item['module'],
|
||||
'kind' => 'inbox',
|
||||
])->all(),
|
||||
]);
|
||||
}
|
||||
|
||||
if (in_array($type, ['all', 'assets'], true)) {
|
||||
$assets = $this->assets->library($user, ['q' => $query, 'per_page' => 8]);
|
||||
$sections->push([
|
||||
'key' => 'assets',
|
||||
'label' => 'Assets',
|
||||
'count' => count($assets['items']),
|
||||
'items' => collect($assets['items'])->map(fn (array $item): array => [
|
||||
'id' => $item['id'],
|
||||
'title' => $item['title'],
|
||||
'subtitle' => $item['type_label'],
|
||||
'description' => $item['description'],
|
||||
'href' => $item['manage_url'] ?? $item['view_url'],
|
||||
'icon' => 'fa-solid fa-photo-film',
|
||||
'module' => $item['source_key'] ?? 'assets',
|
||||
'kind' => 'asset',
|
||||
])->all(),
|
||||
]);
|
||||
}
|
||||
|
||||
$sections = $sections->filter(fn (array $section): bool => $section['count'] > 0)->values();
|
||||
|
||||
return [
|
||||
'filters' => ['q' => $query, 'module' => $module, 'type' => $type],
|
||||
'sections' => $sections->all(),
|
||||
'summary' => [
|
||||
'total' => $sections->sum('count'),
|
||||
'query' => $query,
|
||||
],
|
||||
'type_options' => [
|
||||
['value' => 'all', 'label' => 'Everywhere'],
|
||||
['value' => 'content', 'label' => 'Content'],
|
||||
['value' => 'comments', 'label' => 'Comments'],
|
||||
['value' => 'inbox', 'label' => 'Inbox'],
|
||||
['value' => 'assets', 'label' => 'Assets'],
|
||||
],
|
||||
'module_options' => [
|
||||
['value' => 'all', 'label' => 'All modules'],
|
||||
['value' => 'artworks', 'label' => 'Artworks'],
|
||||
['value' => 'cards', 'label' => 'Cards'],
|
||||
['value' => 'collections', 'label' => 'Collections'],
|
||||
['value' => 'stories', 'label' => 'Stories'],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
private function normalizeModule(string $value): string
|
||||
{
|
||||
return in_array($value, ['all', 'artworks', 'cards', 'collections', 'stories'], true) ? $value : 'all';
|
||||
}
|
||||
|
||||
private function normalizeType(string $value): string
|
||||
{
|
||||
return in_array($value, ['all', 'content', 'comments', 'inbox', 'assets'], true) ? $value : 'all';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Studio\Providers;
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Models\ArtworkStats;
|
||||
use App\Models\User;
|
||||
use App\Services\Studio\Contracts\CreatorStudioProvider;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
final class ArtworkStudioProvider implements CreatorStudioProvider
|
||||
{
|
||||
public function key(): string
|
||||
{
|
||||
return 'artworks';
|
||||
}
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return 'Artworks';
|
||||
}
|
||||
|
||||
public function icon(): string
|
||||
{
|
||||
return 'fa-solid fa-images';
|
||||
}
|
||||
|
||||
public function createUrl(): string
|
||||
{
|
||||
return '/upload';
|
||||
}
|
||||
|
||||
public function indexUrl(): string
|
||||
{
|
||||
return route('studio.artworks');
|
||||
}
|
||||
|
||||
public function summary(User $user): array
|
||||
{
|
||||
$baseQuery = Artwork::query()->withTrashed()->where('user_id', $user->id);
|
||||
|
||||
$count = (clone $baseQuery)
|
||||
->whereNull('deleted_at')
|
||||
->count();
|
||||
|
||||
$draftCount = (clone $baseQuery)
|
||||
->whereNull('deleted_at')
|
||||
->where(function (Builder $query): void {
|
||||
$query->whereNull('artwork_status')
|
||||
->orWhere('artwork_status', '!=', 'scheduled');
|
||||
})
|
||||
->where('is_public', false)
|
||||
->count();
|
||||
|
||||
$publishedCount = (clone $baseQuery)
|
||||
->whereNull('deleted_at')
|
||||
->where('is_public', true)
|
||||
->whereNotNull('published_at')
|
||||
->count();
|
||||
|
||||
$recentPublishedCount = (clone $baseQuery)
|
||||
->whereNull('deleted_at')
|
||||
->where('is_public', true)
|
||||
->where('published_at', '>=', now()->subDays(30))
|
||||
->count();
|
||||
|
||||
$archivedCount = Artwork::onlyTrashed()
|
||||
->where('user_id', $user->id)
|
||||
->count();
|
||||
|
||||
return [
|
||||
'key' => $this->key(),
|
||||
'label' => $this->label(),
|
||||
'icon' => $this->icon(),
|
||||
'count' => $count,
|
||||
'draft_count' => $draftCount,
|
||||
'published_count' => $publishedCount,
|
||||
'archived_count' => $archivedCount,
|
||||
'trend_value' => $recentPublishedCount,
|
||||
'trend_label' => 'published in 30d',
|
||||
'quick_action_label' => 'Upload artwork',
|
||||
'index_url' => $this->indexUrl(),
|
||||
'create_url' => $this->createUrl(),
|
||||
];
|
||||
}
|
||||
|
||||
public function items(User $user, string $bucket = 'all', int $limit = 200): Collection
|
||||
{
|
||||
$query = Artwork::query()
|
||||
->withTrashed()
|
||||
->where('user_id', $user->id)
|
||||
->with([
|
||||
'stats',
|
||||
'categories',
|
||||
'tags',
|
||||
'features' => function ($query): void {
|
||||
$query->where('is_active', true)
|
||||
->whereNull('deleted_at')
|
||||
->where(function (Builder $builder): void {
|
||||
$builder->whereNull('expires_at')
|
||||
->orWhere('expires_at', '>', now());
|
||||
});
|
||||
},
|
||||
])
|
||||
->orderByDesc('updated_at')
|
||||
->limit($limit);
|
||||
|
||||
if ($bucket === 'drafts') {
|
||||
$query->whereNull('deleted_at')
|
||||
->where(function (Builder $builder): void {
|
||||
$builder->whereNull('artwork_status')
|
||||
->orWhere('artwork_status', '!=', 'scheduled');
|
||||
})
|
||||
->where('is_public', false);
|
||||
} elseif ($bucket === 'scheduled') {
|
||||
$query->whereNull('deleted_at')
|
||||
->where('artwork_status', 'scheduled');
|
||||
} elseif ($bucket === 'archived') {
|
||||
$query->onlyTrashed();
|
||||
} elseif ($bucket === 'published') {
|
||||
$query->whereNull('deleted_at')
|
||||
->where('is_public', true)
|
||||
->whereNotNull('published_at');
|
||||
} else {
|
||||
$query->whereNull('deleted_at');
|
||||
}
|
||||
|
||||
return $query->get()->map(fn (Artwork $artwork): array => $this->mapItem($artwork));
|
||||
}
|
||||
|
||||
public function topItems(User $user, int $limit = 5): Collection
|
||||
{
|
||||
return Artwork::query()
|
||||
->where('user_id', $user->id)
|
||||
->whereNull('deleted_at')
|
||||
->where('is_public', true)
|
||||
->with(['stats', 'categories', 'tags'])
|
||||
->whereHas('stats')
|
||||
->orderByDesc(
|
||||
ArtworkStats::select('ranking_score')
|
||||
->whereColumn('artwork_stats.artwork_id', 'artworks.id')
|
||||
->limit(1)
|
||||
)
|
||||
->limit($limit)
|
||||
->get()
|
||||
->map(fn (Artwork $artwork): array => $this->mapItem($artwork));
|
||||
}
|
||||
|
||||
public function analytics(User $user): array
|
||||
{
|
||||
$totals = DB::table('artwork_stats')
|
||||
->join('artworks', 'artworks.id', '=', 'artwork_stats.artwork_id')
|
||||
->where('artworks.user_id', $user->id)
|
||||
->whereNull('artworks.deleted_at')
|
||||
->selectRaw('COALESCE(SUM(artwork_stats.views), 0) as views')
|
||||
->selectRaw('COALESCE(SUM(artwork_stats.favorites), 0) as appreciation')
|
||||
->selectRaw('COALESCE(SUM(artwork_stats.shares_count), 0) as shares')
|
||||
->selectRaw('COALESCE(SUM(artwork_stats.comments_count), 0) as comments')
|
||||
->selectRaw('COALESCE(SUM(artwork_stats.downloads), 0) as saves')
|
||||
->first();
|
||||
|
||||
return [
|
||||
'views' => (int) ($totals->views ?? 0),
|
||||
'appreciation' => (int) ($totals->appreciation ?? 0),
|
||||
'shares' => (int) ($totals->shares ?? 0),
|
||||
'comments' => (int) ($totals->comments ?? 0),
|
||||
'saves' => (int) ($totals->saves ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
public function scheduledItems(User $user, int $limit = 50): Collection
|
||||
{
|
||||
return $this->items($user, 'scheduled', $limit);
|
||||
}
|
||||
|
||||
private function mapItem(Artwork $artwork): array
|
||||
{
|
||||
$stats = $artwork->stats;
|
||||
$status = $artwork->deleted_at
|
||||
? 'archived'
|
||||
: ($artwork->artwork_status === 'scheduled'
|
||||
? 'scheduled'
|
||||
: ((bool) $artwork->is_public ? 'published' : 'draft'));
|
||||
|
||||
$category = $artwork->categories->first();
|
||||
$visibility = $artwork->visibility ?: ((bool) $artwork->is_public ? Artwork::VISIBILITY_PUBLIC : Artwork::VISIBILITY_PRIVATE);
|
||||
|
||||
return [
|
||||
'id' => sprintf('%s:%d', $this->key(), (int) $artwork->id),
|
||||
'numeric_id' => (int) $artwork->id,
|
||||
'module' => $this->key(),
|
||||
'module_label' => $this->label(),
|
||||
'module_icon' => $this->icon(),
|
||||
'title' => $artwork->title,
|
||||
'subtitle' => $category?->name,
|
||||
'description' => $artwork->description,
|
||||
'status' => $status,
|
||||
'visibility' => $visibility,
|
||||
'image_url' => $artwork->thumbUrl('md'),
|
||||
'preview_url' => $artwork->published_at ? route('art.show', ['id' => $artwork->id, 'slug' => $artwork->slug]) : route('studio.artworks.edit', ['id' => $artwork->id]),
|
||||
'view_url' => $artwork->published_at ? route('art.show', ['id' => $artwork->id, 'slug' => $artwork->slug]) : route('studio.artworks.edit', ['id' => $artwork->id]),
|
||||
'edit_url' => route('studio.artworks.edit', ['id' => $artwork->id]),
|
||||
'manage_url' => route('studio.artworks.edit', ['id' => $artwork->id]),
|
||||
'analytics_url' => route('studio.artworks.analytics', ['id' => $artwork->id]),
|
||||
'create_url' => $this->createUrl(),
|
||||
'actions' => $this->actionsFor($artwork, $status),
|
||||
'created_at' => $artwork->created_at?->toIso8601String(),
|
||||
'updated_at' => $artwork->updated_at?->toIso8601String(),
|
||||
'published_at' => $artwork->published_at?->toIso8601String(),
|
||||
'scheduled_at' => $artwork->publish_at?->toIso8601String(),
|
||||
'schedule_timezone' => $artwork->artwork_timezone,
|
||||
'featured' => $artwork->features->isNotEmpty(),
|
||||
'metrics' => [
|
||||
'views' => (int) ($stats?->views ?? 0),
|
||||
'appreciation' => (int) ($stats?->favorites ?? 0),
|
||||
'shares' => (int) ($stats?->shares_count ?? 0),
|
||||
'comments' => (int) ($stats?->comments_count ?? 0),
|
||||
'saves' => (int) ($stats?->downloads ?? 0),
|
||||
],
|
||||
'engagement_score' => (int) ($stats?->views ?? 0)
|
||||
+ ((int) ($stats?->favorites ?? 0) * 2)
|
||||
+ ((int) ($stats?->comments_count ?? 0) * 3)
|
||||
+ ((int) ($stats?->shares_count ?? 0) * 2),
|
||||
'taxonomies' => [
|
||||
'categories' => $artwork->categories->map(fn ($entry): array => [
|
||||
'id' => (int) $entry->id,
|
||||
'name' => (string) $entry->name,
|
||||
'slug' => (string) $entry->slug,
|
||||
])->values()->all(),
|
||||
'tags' => $artwork->tags->map(fn ($entry): array => [
|
||||
'id' => (int) $entry->id,
|
||||
'name' => (string) $entry->name,
|
||||
'slug' => (string) $entry->slug,
|
||||
])->values()->all(),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
private function actionsFor(Artwork $artwork, string $status): array
|
||||
{
|
||||
$actions = [];
|
||||
|
||||
if ($status === 'draft') {
|
||||
$actions[] = $this->requestAction('publish', 'Publish', 'fa-solid fa-rocket', route('api.studio.artworks.toggle', ['id' => $artwork->id]), ['action' => 'publish']);
|
||||
}
|
||||
|
||||
if ($status === 'scheduled') {
|
||||
$actions[] = $this->requestAction('publish_now', 'Publish now', 'fa-solid fa-bolt', route('api.studio.schedule.publishNow', ['module' => 'artworks', 'id' => $artwork->id]), []);
|
||||
$actions[] = $this->requestAction('unschedule', 'Unschedule', 'fa-solid fa-calendar-xmark', route('api.studio.schedule.unschedule', ['module' => 'artworks', 'id' => $artwork->id]), []);
|
||||
}
|
||||
|
||||
if ($status === 'published') {
|
||||
$actions[] = $this->requestAction('unpublish', 'Unpublish', 'fa-solid fa-eye-slash', route('api.studio.artworks.toggle', ['id' => $artwork->id]), ['action' => 'unpublish']);
|
||||
$actions[] = $this->requestAction('archive', 'Archive', 'fa-solid fa-box-archive', route('api.studio.artworks.toggle', ['id' => $artwork->id]), ['action' => 'archive']);
|
||||
}
|
||||
|
||||
if ($status === 'archived') {
|
||||
$actions[] = $this->requestAction('restore', 'Restore', 'fa-solid fa-rotate-left', route('api.studio.artworks.toggle', ['id' => $artwork->id]), ['action' => 'unarchive']);
|
||||
}
|
||||
|
||||
$actions[] = $this->requestAction(
|
||||
'delete',
|
||||
'Delete',
|
||||
'fa-solid fa-trash',
|
||||
route('api.studio.artworks.bulk'),
|
||||
[
|
||||
'action' => 'delete',
|
||||
'artwork_ids' => [$artwork->id],
|
||||
'confirm' => 'DELETE',
|
||||
],
|
||||
'Delete this artwork permanently?'
|
||||
);
|
||||
|
||||
return $actions;
|
||||
}
|
||||
|
||||
private function requestAction(string $key, string $label, string $icon, string $url, array $payload, ?string $confirm = null): array
|
||||
{
|
||||
return [
|
||||
'key' => $key,
|
||||
'label' => $label,
|
||||
'icon' => $icon,
|
||||
'type' => 'request',
|
||||
'method' => 'post',
|
||||
'url' => $url,
|
||||
'payload' => $payload,
|
||||
'confirm' => $confirm,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Studio\Providers;
|
||||
|
||||
use App\Models\NovaCard;
|
||||
use App\Models\User;
|
||||
use App\Services\Studio\Contracts\CreatorStudioProvider;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
final class CardStudioProvider implements CreatorStudioProvider
|
||||
{
|
||||
public function key(): string
|
||||
{
|
||||
return 'cards';
|
||||
}
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return 'Cards';
|
||||
}
|
||||
|
||||
public function icon(): string
|
||||
{
|
||||
return 'fa-solid fa-id-card';
|
||||
}
|
||||
|
||||
public function createUrl(): string
|
||||
{
|
||||
return route('studio.cards.create');
|
||||
}
|
||||
|
||||
public function indexUrl(): string
|
||||
{
|
||||
return route('studio.cards.index');
|
||||
}
|
||||
|
||||
public function summary(User $user): array
|
||||
{
|
||||
$baseQuery = NovaCard::query()->withTrashed()->where('user_id', $user->id);
|
||||
|
||||
$count = (clone $baseQuery)
|
||||
->whereNull('deleted_at')
|
||||
->whereNotIn('status', [NovaCard::STATUS_HIDDEN, NovaCard::STATUS_REJECTED])
|
||||
->count();
|
||||
|
||||
$draftCount = (clone $baseQuery)
|
||||
->whereNull('deleted_at')
|
||||
->where('status', NovaCard::STATUS_DRAFT)
|
||||
->count();
|
||||
|
||||
$publishedCount = (clone $baseQuery)
|
||||
->whereNull('deleted_at')
|
||||
->where('status', NovaCard::STATUS_PUBLISHED)
|
||||
->count();
|
||||
|
||||
$recentPublishedCount = (clone $baseQuery)
|
||||
->whereNull('deleted_at')
|
||||
->where('status', NovaCard::STATUS_PUBLISHED)
|
||||
->where('published_at', '>=', now()->subDays(30))
|
||||
->count();
|
||||
|
||||
$archivedCount = (clone $baseQuery)
|
||||
->where(function (Builder $query): void {
|
||||
$query->whereNotNull('deleted_at')
|
||||
->orWhereIn('status', [NovaCard::STATUS_HIDDEN, NovaCard::STATUS_REJECTED]);
|
||||
})
|
||||
->count();
|
||||
|
||||
return [
|
||||
'key' => $this->key(),
|
||||
'label' => $this->label(),
|
||||
'icon' => $this->icon(),
|
||||
'count' => $count,
|
||||
'draft_count' => $draftCount,
|
||||
'published_count' => $publishedCount,
|
||||
'archived_count' => $archivedCount,
|
||||
'trend_value' => $recentPublishedCount,
|
||||
'trend_label' => 'published in 30d',
|
||||
'quick_action_label' => 'Create card',
|
||||
'index_url' => $this->indexUrl(),
|
||||
'create_url' => $this->createUrl(),
|
||||
];
|
||||
}
|
||||
|
||||
public function items(User $user, string $bucket = 'all', int $limit = 200): Collection
|
||||
{
|
||||
$query = NovaCard::query()
|
||||
->withTrashed()
|
||||
->where('user_id', $user->id)
|
||||
->with(['category', 'tags'])
|
||||
->orderByDesc('updated_at')
|
||||
->limit($limit);
|
||||
|
||||
if ($bucket === 'drafts') {
|
||||
$query->whereNull('deleted_at')->where('status', NovaCard::STATUS_DRAFT);
|
||||
} elseif ($bucket === 'scheduled') {
|
||||
$query->whereNull('deleted_at')->where('status', NovaCard::STATUS_SCHEDULED);
|
||||
} elseif ($bucket === 'archived') {
|
||||
$query->where(function (Builder $builder): void {
|
||||
$builder->whereNotNull('deleted_at')
|
||||
->orWhereIn('status', [NovaCard::STATUS_HIDDEN, NovaCard::STATUS_REJECTED]);
|
||||
});
|
||||
} elseif ($bucket === 'published') {
|
||||
$query->whereNull('deleted_at')->where('status', NovaCard::STATUS_PUBLISHED);
|
||||
} else {
|
||||
$query->whereNull('deleted_at')->whereNotIn('status', [NovaCard::STATUS_HIDDEN, NovaCard::STATUS_REJECTED]);
|
||||
}
|
||||
|
||||
return $query->get()->map(fn (NovaCard $card): array => $this->mapItem($card));
|
||||
}
|
||||
|
||||
public function topItems(User $user, int $limit = 5): Collection
|
||||
{
|
||||
return NovaCard::query()
|
||||
->where('user_id', $user->id)
|
||||
->whereNull('deleted_at')
|
||||
->where('status', NovaCard::STATUS_PUBLISHED)
|
||||
->orderByDesc('trending_score')
|
||||
->orderByDesc('views_count')
|
||||
->limit($limit)
|
||||
->get()
|
||||
->map(fn (NovaCard $card): array => $this->mapItem($card));
|
||||
}
|
||||
|
||||
public function analytics(User $user): array
|
||||
{
|
||||
$totals = NovaCard::query()
|
||||
->where('user_id', $user->id)
|
||||
->whereNull('deleted_at')
|
||||
->selectRaw('COALESCE(SUM(views_count), 0) as views')
|
||||
->selectRaw('COALESCE(SUM(likes_count + favorites_count), 0) as appreciation')
|
||||
->selectRaw('COALESCE(SUM(shares_count), 0) as shares')
|
||||
->selectRaw('COALESCE(SUM(comments_count), 0) as comments')
|
||||
->selectRaw('COALESCE(SUM(saves_count), 0) as saves')
|
||||
->first();
|
||||
|
||||
return [
|
||||
'views' => (int) ($totals->views ?? 0),
|
||||
'appreciation' => (int) ($totals->appreciation ?? 0),
|
||||
'shares' => (int) ($totals->shares ?? 0),
|
||||
'comments' => (int) ($totals->comments ?? 0),
|
||||
'saves' => (int) ($totals->saves ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
public function scheduledItems(User $user, int $limit = 50): Collection
|
||||
{
|
||||
return $this->items($user, 'scheduled', $limit);
|
||||
}
|
||||
|
||||
private function mapItem(NovaCard $card): array
|
||||
{
|
||||
$status = $card->deleted_at || in_array($card->status, [NovaCard::STATUS_HIDDEN, NovaCard::STATUS_REJECTED], true)
|
||||
? 'archived'
|
||||
: $card->status;
|
||||
|
||||
return [
|
||||
'id' => sprintf('%s:%d', $this->key(), (int) $card->id),
|
||||
'numeric_id' => (int) $card->id,
|
||||
'module' => $this->key(),
|
||||
'module_label' => $this->label(),
|
||||
'module_icon' => $this->icon(),
|
||||
'title' => $card->title,
|
||||
'subtitle' => $card->category?->name ?: strtoupper((string) $card->format),
|
||||
'description' => $card->description,
|
||||
'status' => $status,
|
||||
'visibility' => $card->visibility,
|
||||
'image_url' => $card->previewUrl(),
|
||||
'preview_url' => route('studio.cards.preview', ['id' => $card->id]),
|
||||
'view_url' => $card->status === NovaCard::STATUS_PUBLISHED ? $card->publicUrl() : route('studio.cards.preview', ['id' => $card->id]),
|
||||
'edit_url' => route('studio.cards.edit', ['id' => $card->id]),
|
||||
'manage_url' => route('studio.cards.edit', ['id' => $card->id]),
|
||||
'analytics_url' => route('studio.cards.analytics', ['id' => $card->id]),
|
||||
'create_url' => $this->createUrl(),
|
||||
'actions' => $this->actionsFor($card, $status),
|
||||
'created_at' => $card->created_at?->toIso8601String(),
|
||||
'updated_at' => $card->updated_at?->toIso8601String(),
|
||||
'published_at' => $card->published_at?->toIso8601String(),
|
||||
'scheduled_at' => $card->scheduled_for?->toIso8601String(),
|
||||
'schedule_timezone' => $card->scheduling_timezone,
|
||||
'featured' => (bool) $card->featured,
|
||||
'metrics' => [
|
||||
'views' => (int) $card->views_count,
|
||||
'appreciation' => (int) ($card->likes_count + $card->favorites_count),
|
||||
'shares' => (int) $card->shares_count,
|
||||
'comments' => (int) $card->comments_count,
|
||||
'saves' => (int) $card->saves_count,
|
||||
],
|
||||
'engagement_score' => (int) $card->views_count
|
||||
+ ((int) $card->likes_count * 2)
|
||||
+ ((int) $card->favorites_count * 2)
|
||||
+ ((int) $card->comments_count * 3)
|
||||
+ ((int) $card->shares_count * 2)
|
||||
+ ((int) $card->saves_count * 2),
|
||||
'taxonomies' => [
|
||||
'categories' => $card->category ? [[
|
||||
'id' => (int) $card->category->id,
|
||||
'name' => (string) $card->category->name,
|
||||
'slug' => (string) $card->category->slug,
|
||||
]] : [],
|
||||
'tags' => $card->tags->map(fn ($entry): array => [
|
||||
'id' => (int) $entry->id,
|
||||
'name' => (string) $entry->name,
|
||||
'slug' => (string) $entry->slug,
|
||||
])->values()->all(),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
private function actionsFor(NovaCard $card, string $status): array
|
||||
{
|
||||
$actions = [
|
||||
[
|
||||
'key' => 'duplicate',
|
||||
'label' => 'Duplicate',
|
||||
'icon' => 'fa-solid fa-id-card',
|
||||
'type' => 'request',
|
||||
'method' => 'post',
|
||||
'url' => route('api.cards.duplicate', ['id' => $card->id]),
|
||||
'redirect_pattern' => route('studio.cards.edit', ['id' => '__ID__']),
|
||||
],
|
||||
];
|
||||
|
||||
if ($status === NovaCard::STATUS_DRAFT) {
|
||||
$actions[] = [
|
||||
'key' => 'delete',
|
||||
'label' => 'Delete draft',
|
||||
'icon' => 'fa-solid fa-trash',
|
||||
'type' => 'request',
|
||||
'method' => 'delete',
|
||||
'url' => route('api.cards.drafts.destroy', ['id' => $card->id]),
|
||||
'confirm' => 'Delete this card draft?',
|
||||
];
|
||||
}
|
||||
|
||||
if ($status === NovaCard::STATUS_SCHEDULED) {
|
||||
$actions[] = [
|
||||
'key' => 'publish_now',
|
||||
'label' => 'Publish now',
|
||||
'icon' => 'fa-solid fa-bolt',
|
||||
'type' => 'request',
|
||||
'method' => 'post',
|
||||
'url' => route('api.studio.schedule.publishNow', ['module' => 'cards', 'id' => $card->id]),
|
||||
];
|
||||
$actions[] = [
|
||||
'key' => 'unschedule',
|
||||
'label' => 'Unschedule',
|
||||
'icon' => 'fa-solid fa-calendar-xmark',
|
||||
'type' => 'request',
|
||||
'method' => 'post',
|
||||
'url' => route('api.studio.schedule.unschedule', ['module' => 'cards', 'id' => $card->id]),
|
||||
];
|
||||
}
|
||||
|
||||
return $actions;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Studio\Providers;
|
||||
|
||||
use App\Models\Collection;
|
||||
use App\Models\User;
|
||||
use App\Services\CollectionService;
|
||||
use App\Services\Studio\Contracts\CreatorStudioProvider;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Collection as SupportCollection;
|
||||
|
||||
final class CollectionStudioProvider implements CreatorStudioProvider
|
||||
{
|
||||
public function __construct(
|
||||
private readonly CollectionService $collections,
|
||||
) {
|
||||
}
|
||||
|
||||
public function key(): string
|
||||
{
|
||||
return 'collections';
|
||||
}
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return 'Collections';
|
||||
}
|
||||
|
||||
public function icon(): string
|
||||
{
|
||||
return 'fa-solid fa-layer-group';
|
||||
}
|
||||
|
||||
public function createUrl(): string
|
||||
{
|
||||
return route('settings.collections.create');
|
||||
}
|
||||
|
||||
public function indexUrl(): string
|
||||
{
|
||||
return route('studio.collections');
|
||||
}
|
||||
|
||||
public function summary(User $user): array
|
||||
{
|
||||
$baseQuery = Collection::query()->withTrashed()->where('user_id', $user->id);
|
||||
|
||||
$count = (clone $baseQuery)
|
||||
->whereNull('deleted_at')
|
||||
->where('lifecycle_state', '!=', Collection::LIFECYCLE_ARCHIVED)
|
||||
->count();
|
||||
|
||||
$draftCount = (clone $baseQuery)
|
||||
->whereNull('deleted_at')
|
||||
->where(function (Builder $query): void {
|
||||
$query->where('lifecycle_state', Collection::LIFECYCLE_DRAFT)
|
||||
->orWhere('workflow_state', Collection::WORKFLOW_DRAFT)
|
||||
->orWhere('workflow_state', Collection::WORKFLOW_IN_REVIEW);
|
||||
})
|
||||
->count();
|
||||
|
||||
$publishedCount = (clone $baseQuery)
|
||||
->whereNull('deleted_at')
|
||||
->whereIn('lifecycle_state', [Collection::LIFECYCLE_PUBLISHED, Collection::LIFECYCLE_FEATURED, Collection::LIFECYCLE_SCHEDULED])
|
||||
->count();
|
||||
|
||||
$recentPublishedCount = (clone $baseQuery)
|
||||
->whereNull('deleted_at')
|
||||
->whereIn('lifecycle_state', [Collection::LIFECYCLE_PUBLISHED, Collection::LIFECYCLE_FEATURED, Collection::LIFECYCLE_SCHEDULED])
|
||||
->where('published_at', '>=', now()->subDays(30))
|
||||
->count();
|
||||
|
||||
$archivedCount = (clone $baseQuery)
|
||||
->where(function (Builder $query): void {
|
||||
$query->whereNotNull('deleted_at')
|
||||
->orWhere('lifecycle_state', Collection::LIFECYCLE_ARCHIVED);
|
||||
})
|
||||
->count();
|
||||
|
||||
return [
|
||||
'key' => $this->key(),
|
||||
'label' => $this->label(),
|
||||
'icon' => $this->icon(),
|
||||
'count' => $count,
|
||||
'draft_count' => $draftCount,
|
||||
'published_count' => $publishedCount,
|
||||
'archived_count' => $archivedCount,
|
||||
'trend_value' => $recentPublishedCount,
|
||||
'trend_label' => 'published in 30d',
|
||||
'quick_action_label' => 'Create collection',
|
||||
'index_url' => $this->indexUrl(),
|
||||
'create_url' => $this->createUrl(),
|
||||
];
|
||||
}
|
||||
|
||||
public function items(User $user, string $bucket = 'all', int $limit = 200): SupportCollection
|
||||
{
|
||||
$query = Collection::query()
|
||||
->withTrashed()
|
||||
->where('user_id', $user->id)
|
||||
->with(['user.profile', 'coverArtwork'])
|
||||
->orderByDesc('updated_at')
|
||||
->limit($limit);
|
||||
|
||||
if ($bucket === 'drafts') {
|
||||
$query->whereNull('deleted_at')
|
||||
->where(function (Builder $builder): void {
|
||||
$builder->where('lifecycle_state', Collection::LIFECYCLE_DRAFT)
|
||||
->orWhere('workflow_state', Collection::WORKFLOW_DRAFT)
|
||||
->orWhere('workflow_state', Collection::WORKFLOW_IN_REVIEW);
|
||||
});
|
||||
} elseif ($bucket === 'scheduled') {
|
||||
$query->whereNull('deleted_at')
|
||||
->where('lifecycle_state', Collection::LIFECYCLE_SCHEDULED);
|
||||
} elseif ($bucket === 'archived') {
|
||||
$query->where(function (Builder $builder): void {
|
||||
$builder->whereNotNull('deleted_at')
|
||||
->orWhere('lifecycle_state', Collection::LIFECYCLE_ARCHIVED);
|
||||
});
|
||||
} elseif ($bucket === 'published') {
|
||||
$query->whereNull('deleted_at')
|
||||
->whereIn('lifecycle_state', [Collection::LIFECYCLE_PUBLISHED, Collection::LIFECYCLE_FEATURED, Collection::LIFECYCLE_SCHEDULED]);
|
||||
} else {
|
||||
$query->whereNull('deleted_at')->where('lifecycle_state', '!=', Collection::LIFECYCLE_ARCHIVED);
|
||||
}
|
||||
|
||||
return collect($this->collections->mapCollectionCardPayloads($query->get(), true, $user))
|
||||
->map(fn (array $item): array => $this->mapItem($item));
|
||||
}
|
||||
|
||||
public function topItems(User $user, int $limit = 5): SupportCollection
|
||||
{
|
||||
$collections = Collection::query()
|
||||
->where('user_id', $user->id)
|
||||
->whereNull('deleted_at')
|
||||
->whereIn('lifecycle_state', [Collection::LIFECYCLE_PUBLISHED, Collection::LIFECYCLE_FEATURED])
|
||||
->with(['user.profile', 'coverArtwork'])
|
||||
->orderByDesc('ranking_score')
|
||||
->orderByDesc('views_count')
|
||||
->limit($limit)
|
||||
->get();
|
||||
|
||||
return collect($this->collections->mapCollectionCardPayloads($collections, true, $user))
|
||||
->map(fn (array $item): array => $this->mapItem($item));
|
||||
}
|
||||
|
||||
public function analytics(User $user): array
|
||||
{
|
||||
$totals = Collection::query()
|
||||
->where('user_id', $user->id)
|
||||
->whereNull('deleted_at')
|
||||
->selectRaw('COALESCE(SUM(views_count), 0) as views')
|
||||
->selectRaw('COALESCE(SUM(likes_count + followers_count), 0) as appreciation')
|
||||
->selectRaw('COALESCE(SUM(shares_count), 0) as shares')
|
||||
->selectRaw('COALESCE(SUM(comments_count), 0) as comments')
|
||||
->selectRaw('COALESCE(SUM(saves_count), 0) as saves')
|
||||
->first();
|
||||
|
||||
return [
|
||||
'views' => (int) ($totals->views ?? 0),
|
||||
'appreciation' => (int) ($totals->appreciation ?? 0),
|
||||
'shares' => (int) ($totals->shares ?? 0),
|
||||
'comments' => (int) ($totals->comments ?? 0),
|
||||
'saves' => (int) ($totals->saves ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
public function scheduledItems(User $user, int $limit = 50): SupportCollection
|
||||
{
|
||||
return $this->items($user, 'scheduled', $limit);
|
||||
}
|
||||
|
||||
private function mapItem(array $item): array
|
||||
{
|
||||
$status = $item['lifecycle_state'] ?? 'draft';
|
||||
if ($status === Collection::LIFECYCLE_FEATURED) {
|
||||
$status = 'published';
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => sprintf('%s:%d', $this->key(), (int) $item['id']),
|
||||
'numeric_id' => (int) $item['id'],
|
||||
'module' => $this->key(),
|
||||
'module_label' => $this->label(),
|
||||
'module_icon' => $this->icon(),
|
||||
'title' => $item['title'],
|
||||
'subtitle' => $item['subtitle'] ?: ($item['type'] ? ucfirst((string) $item['type']) : null),
|
||||
'description' => $item['summary'] ?: $item['description'],
|
||||
'status' => $status,
|
||||
'visibility' => $item['visibility'],
|
||||
'image_url' => $item['cover_image'],
|
||||
'preview_url' => $item['url'],
|
||||
'view_url' => $item['url'],
|
||||
'edit_url' => $item['edit_url'] ?: $item['manage_url'],
|
||||
'manage_url' => $item['manage_url'],
|
||||
'analytics_url' => route('settings.collections.analytics', ['collection' => $item['id']]),
|
||||
'create_url' => $this->createUrl(),
|
||||
'actions' => $this->actionsFor($item, $status),
|
||||
'created_at' => $item['published_at'] ?? $item['updated_at'],
|
||||
'updated_at' => $item['updated_at'],
|
||||
'published_at' => $item['published_at'] ?? null,
|
||||
'scheduled_at' => $status === Collection::LIFECYCLE_SCHEDULED ? ($item['published_at'] ?? null) : null,
|
||||
'featured' => (bool) ($item['is_featured'] ?? false),
|
||||
'metrics' => [
|
||||
'views' => (int) ($item['views_count'] ?? 0),
|
||||
'appreciation' => (int) (($item['likes_count'] ?? 0) + ($item['followers_count'] ?? 0)),
|
||||
'shares' => (int) ($item['shares_count'] ?? 0),
|
||||
'comments' => (int) ($item['comments_count'] ?? 0),
|
||||
'saves' => (int) ($item['saves_count'] ?? 0),
|
||||
],
|
||||
'engagement_score' => (int) ($item['views_count'] ?? 0)
|
||||
+ ((int) ($item['likes_count'] ?? 0) * 2)
|
||||
+ ((int) ($item['followers_count'] ?? 0) * 2)
|
||||
+ ((int) ($item['comments_count'] ?? 0) * 3)
|
||||
+ ((int) ($item['shares_count'] ?? 0) * 2)
|
||||
+ ((int) ($item['saves_count'] ?? 0) * 2),
|
||||
'taxonomies' => [
|
||||
'categories' => [],
|
||||
'tags' => [],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
private function actionsFor(array $item, string $status): array
|
||||
{
|
||||
$collectionId = (int) $item['id'];
|
||||
$actions = [];
|
||||
$featured = (bool) ($item['is_featured'] ?? false);
|
||||
|
||||
if ($status === 'draft') {
|
||||
$actions[] = $this->requestAction(
|
||||
'publish',
|
||||
'Publish',
|
||||
'fa-solid fa-rocket',
|
||||
route('settings.collections.lifecycle', ['collection' => $collectionId]),
|
||||
[
|
||||
'lifecycle_state' => Collection::LIFECYCLE_PUBLISHED,
|
||||
'visibility' => Collection::VISIBILITY_PUBLIC,
|
||||
'published_at' => now()->toIso8601String(),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
if (in_array($status, ['published', 'scheduled'], true)) {
|
||||
$actions[] = $this->requestAction(
|
||||
'archive',
|
||||
'Archive',
|
||||
'fa-solid fa-box-archive',
|
||||
route('settings.collections.lifecycle', ['collection' => $collectionId]),
|
||||
[
|
||||
'lifecycle_state' => Collection::LIFECYCLE_ARCHIVED,
|
||||
'archived_at' => now()->toIso8601String(),
|
||||
]
|
||||
);
|
||||
|
||||
$actions[] = $featured
|
||||
? $this->requestAction('unfeature', 'Remove feature', 'fa-solid fa-star-half-stroke', route('settings.collections.unfeature', ['collection' => $collectionId]), [], null, 'delete')
|
||||
: $this->requestAction('feature', 'Feature', 'fa-solid fa-star', route('settings.collections.feature', ['collection' => $collectionId]), []);
|
||||
|
||||
if ($status === 'scheduled') {
|
||||
$actions[] = $this->requestAction('publish_now', 'Publish now', 'fa-solid fa-bolt', route('api.studio.schedule.publishNow', ['module' => 'collections', 'id' => $collectionId]), []);
|
||||
$actions[] = $this->requestAction('unschedule', 'Unschedule', 'fa-solid fa-calendar-xmark', route('api.studio.schedule.unschedule', ['module' => 'collections', 'id' => $collectionId]), []);
|
||||
}
|
||||
}
|
||||
|
||||
if ($status === 'archived') {
|
||||
$actions[] = $this->requestAction(
|
||||
'restore',
|
||||
'Restore',
|
||||
'fa-solid fa-rotate-left',
|
||||
route('settings.collections.lifecycle', ['collection' => $collectionId]),
|
||||
[
|
||||
'lifecycle_state' => Collection::LIFECYCLE_DRAFT,
|
||||
'visibility' => Collection::VISIBILITY_PRIVATE,
|
||||
'archived_at' => null,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
$actions[] = $this->requestAction(
|
||||
'delete',
|
||||
'Delete',
|
||||
'fa-solid fa-trash',
|
||||
route('settings.collections.destroy', ['collection' => $collectionId]),
|
||||
[],
|
||||
'Delete this collection permanently?',
|
||||
'delete'
|
||||
);
|
||||
|
||||
return $actions;
|
||||
}
|
||||
|
||||
private function requestAction(string $key, string $label, string $icon, string $url, array $payload = [], ?string $confirm = null, string $method = 'post'): array
|
||||
{
|
||||
return [
|
||||
'key' => $key,
|
||||
'label' => $label,
|
||||
'icon' => $icon,
|
||||
'type' => 'request',
|
||||
'method' => $method,
|
||||
'url' => $url,
|
||||
'payload' => $payload,
|
||||
'confirm' => $confirm,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Studio\Providers;
|
||||
|
||||
use App\Models\Story;
|
||||
use App\Models\User;
|
||||
use App\Services\Studio\Contracts\CreatorStudioProvider;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
final class StoryStudioProvider implements CreatorStudioProvider
|
||||
{
|
||||
public function key(): string
|
||||
{
|
||||
return 'stories';
|
||||
}
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return 'Stories';
|
||||
}
|
||||
|
||||
public function icon(): string
|
||||
{
|
||||
return 'fa-solid fa-feather-pointed';
|
||||
}
|
||||
|
||||
public function createUrl(): string
|
||||
{
|
||||
return route('creator.stories.create');
|
||||
}
|
||||
|
||||
public function indexUrl(): string
|
||||
{
|
||||
return route('studio.stories');
|
||||
}
|
||||
|
||||
public function summary(User $user): array
|
||||
{
|
||||
$baseQuery = Story::query()->where('creator_id', $user->id);
|
||||
|
||||
$count = (clone $baseQuery)
|
||||
->whereNotIn('status', ['archived'])
|
||||
->count();
|
||||
|
||||
$draftCount = (clone $baseQuery)
|
||||
->whereIn('status', ['draft', 'pending_review', 'rejected'])
|
||||
->count();
|
||||
|
||||
$publishedCount = (clone $baseQuery)
|
||||
->whereIn('status', ['published', 'scheduled'])
|
||||
->count();
|
||||
|
||||
$recentPublishedCount = (clone $baseQuery)
|
||||
->whereIn('status', ['published', 'scheduled'])
|
||||
->where('published_at', '>=', now()->subDays(30))
|
||||
->count();
|
||||
|
||||
$archivedCount = (clone $baseQuery)
|
||||
->where('status', 'archived')
|
||||
->count();
|
||||
|
||||
return [
|
||||
'key' => $this->key(),
|
||||
'label' => $this->label(),
|
||||
'icon' => $this->icon(),
|
||||
'count' => $count,
|
||||
'draft_count' => $draftCount,
|
||||
'published_count' => $publishedCount,
|
||||
'archived_count' => $archivedCount,
|
||||
'trend_value' => $recentPublishedCount,
|
||||
'trend_label' => 'published in 30d',
|
||||
'quick_action_label' => 'Create story',
|
||||
'index_url' => $this->indexUrl(),
|
||||
'create_url' => $this->createUrl(),
|
||||
];
|
||||
}
|
||||
|
||||
public function items(User $user, string $bucket = 'all', int $limit = 200): Collection
|
||||
{
|
||||
$query = Story::query()
|
||||
->where('creator_id', $user->id)
|
||||
->with(['tags'])
|
||||
->orderByDesc('updated_at')
|
||||
->limit($limit);
|
||||
|
||||
if ($bucket === 'drafts') {
|
||||
$query->whereIn('status', ['draft', 'pending_review', 'rejected']);
|
||||
} elseif ($bucket === 'scheduled') {
|
||||
$query->where('status', 'scheduled');
|
||||
} elseif ($bucket === 'archived') {
|
||||
$query->where('status', 'archived');
|
||||
} elseif ($bucket === 'published') {
|
||||
$query->whereIn('status', ['published', 'scheduled']);
|
||||
} else {
|
||||
$query->where('status', '!=', 'archived');
|
||||
}
|
||||
|
||||
return $query->get()->map(fn (Story $story): array => $this->mapItem($story));
|
||||
}
|
||||
|
||||
public function topItems(User $user, int $limit = 5): Collection
|
||||
{
|
||||
return Story::query()
|
||||
->where('creator_id', $user->id)
|
||||
->whereIn('status', ['published', 'scheduled'])
|
||||
->orderByDesc('views')
|
||||
->orderByDesc('likes_count')
|
||||
->limit($limit)
|
||||
->get()
|
||||
->map(fn (Story $story): array => $this->mapItem($story));
|
||||
}
|
||||
|
||||
public function analytics(User $user): array
|
||||
{
|
||||
$totals = Story::query()
|
||||
->where('creator_id', $user->id)
|
||||
->where('status', '!=', 'archived')
|
||||
->selectRaw('COALESCE(SUM(views), 0) as views')
|
||||
->selectRaw('COALESCE(SUM(likes_count), 0) as appreciation')
|
||||
->selectRaw('0 as shares')
|
||||
->selectRaw('COALESCE(SUM(comments_count), 0) as comments')
|
||||
->selectRaw('0 as saves')
|
||||
->first();
|
||||
|
||||
return [
|
||||
'views' => (int) ($totals->views ?? 0),
|
||||
'appreciation' => (int) ($totals->appreciation ?? 0),
|
||||
'shares' => 0,
|
||||
'comments' => (int) ($totals->comments ?? 0),
|
||||
'saves' => 0,
|
||||
];
|
||||
}
|
||||
|
||||
public function scheduledItems(User $user, int $limit = 50): Collection
|
||||
{
|
||||
return $this->items($user, 'scheduled', $limit);
|
||||
}
|
||||
|
||||
private function mapItem(Story $story): array
|
||||
{
|
||||
$subtitle = $story->story_type ? ucfirst(str_replace('_', ' ', (string) $story->story_type)) : null;
|
||||
$viewUrl = in_array($story->status, ['published', 'scheduled'], true)
|
||||
? route('stories.show', ['slug' => $story->slug])
|
||||
: route('creator.stories.preview', ['story' => $story->id]);
|
||||
|
||||
return [
|
||||
'id' => sprintf('%s:%d', $this->key(), (int) $story->id),
|
||||
'numeric_id' => (int) $story->id,
|
||||
'module' => $this->key(),
|
||||
'module_label' => $this->label(),
|
||||
'module_icon' => $this->icon(),
|
||||
'title' => $story->title,
|
||||
'subtitle' => $subtitle,
|
||||
'description' => $story->excerpt,
|
||||
'status' => $story->status,
|
||||
'visibility' => $story->status === 'published' ? 'public' : 'private',
|
||||
'image_url' => $story->cover_url,
|
||||
'preview_url' => route('creator.stories.preview', ['story' => $story->id]),
|
||||
'view_url' => $viewUrl,
|
||||
'edit_url' => route('creator.stories.edit', ['story' => $story->id]),
|
||||
'manage_url' => route('creator.stories.edit', ['story' => $story->id]),
|
||||
'analytics_url' => route('creator.stories.analytics', ['story' => $story->id]),
|
||||
'create_url' => $this->createUrl(),
|
||||
'actions' => $this->actionsFor($story, $story->status),
|
||||
'created_at' => $story->created_at?->toIso8601String(),
|
||||
'updated_at' => $story->updated_at?->toIso8601String(),
|
||||
'published_at' => $story->published_at?->toIso8601String(),
|
||||
'scheduled_at' => $story->scheduled_for?->toIso8601String(),
|
||||
'featured' => (bool) $story->featured,
|
||||
'activity_state' => in_array($story->status, ['published', 'scheduled'], true)
|
||||
? 'active'
|
||||
: ($story->status === 'archived' ? 'archived' : 'inactive'),
|
||||
'metrics' => [
|
||||
'views' => (int) $story->views,
|
||||
'appreciation' => (int) $story->likes_count,
|
||||
'shares' => 0,
|
||||
'comments' => (int) $story->comments_count,
|
||||
'saves' => 0,
|
||||
],
|
||||
'engagement_score' => (int) $story->views
|
||||
+ ((int) $story->likes_count * 2)
|
||||
+ ((int) $story->comments_count * 3),
|
||||
'taxonomies' => [
|
||||
'categories' => [],
|
||||
'tags' => $story->tags->map(fn ($entry): array => [
|
||||
'id' => (int) $entry->id,
|
||||
'name' => (string) $entry->name,
|
||||
'slug' => (string) $entry->slug,
|
||||
])->values()->all(),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
private function actionsFor(Story $story, string $status): array
|
||||
{
|
||||
$actions = [];
|
||||
|
||||
if (in_array($status, ['draft', 'pending_review', 'rejected'], true)) {
|
||||
$actions[] = $this->requestAction(
|
||||
'publish',
|
||||
'Publish',
|
||||
'fa-solid fa-rocket',
|
||||
route('api.stories.update'),
|
||||
[
|
||||
'story_id' => (int) $story->id,
|
||||
'status' => 'published',
|
||||
],
|
||||
null,
|
||||
'put'
|
||||
);
|
||||
}
|
||||
|
||||
if (in_array($status, ['draft', 'pending_review', 'rejected', 'published', 'scheduled'], true)) {
|
||||
$actions[] = $this->requestAction(
|
||||
'archive',
|
||||
'Archive',
|
||||
'fa-solid fa-box-archive',
|
||||
route('api.stories.update'),
|
||||
[
|
||||
'story_id' => (int) $story->id,
|
||||
'status' => 'archived',
|
||||
],
|
||||
null,
|
||||
'put'
|
||||
);
|
||||
}
|
||||
|
||||
if ($status === 'scheduled') {
|
||||
$actions[] = $this->requestAction('publish_now', 'Publish now', 'fa-solid fa-bolt', route('api.studio.schedule.publishNow', ['module' => 'stories', 'id' => $story->id]), []);
|
||||
$actions[] = $this->requestAction('unschedule', 'Unschedule', 'fa-solid fa-calendar-xmark', route('api.studio.schedule.unschedule', ['module' => 'stories', 'id' => $story->id]), []);
|
||||
}
|
||||
|
||||
if ($status === 'archived') {
|
||||
$actions[] = $this->requestAction(
|
||||
'restore',
|
||||
'Restore',
|
||||
'fa-solid fa-rotate-left',
|
||||
route('api.stories.update'),
|
||||
[
|
||||
'story_id' => (int) $story->id,
|
||||
'status' => 'draft',
|
||||
],
|
||||
null,
|
||||
'put'
|
||||
);
|
||||
}
|
||||
|
||||
$actions[] = $this->requestAction(
|
||||
'delete',
|
||||
'Delete',
|
||||
'fa-solid fa-trash',
|
||||
route('creator.stories.destroy', ['story' => $story->id]),
|
||||
[],
|
||||
'Delete this story permanently?',
|
||||
'delete'
|
||||
);
|
||||
|
||||
return $actions;
|
||||
}
|
||||
|
||||
private function requestAction(string $key, string $label, string $icon, string $url, array $payload = [], ?string $confirm = null, string $method = 'post'): array
|
||||
{
|
||||
return [
|
||||
'key' => $key,
|
||||
'label' => $label,
|
||||
'icon' => $icon,
|
||||
'type' => 'request',
|
||||
'method' => $method,
|
||||
'url' => $url,
|
||||
'payload' => $payload,
|
||||
'confirm' => $confirm,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Studio;
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Models\ArtworkAiAssist;
|
||||
use App\Models\ArtworkAiAssistEvent;
|
||||
|
||||
final class StudioAiAssistEventService
|
||||
{
|
||||
/**
|
||||
* @param array<string, mixed> $meta
|
||||
*/
|
||||
public function record(Artwork $artwork, string $eventType, array $meta = [], ?ArtworkAiAssist $assist = null): ArtworkAiAssistEvent
|
||||
{
|
||||
$assist ??= $artwork->artworkAiAssist;
|
||||
|
||||
return ArtworkAiAssistEvent::query()->create([
|
||||
'artwork_ai_assist_id' => $assist?->id,
|
||||
'artwork_id' => $artwork->id,
|
||||
'user_id' => $artwork->user_id,
|
||||
'event_type' => $eventType,
|
||||
'meta' => $meta,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,514 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Studio;
|
||||
|
||||
use App\Jobs\AnalyzeArtworkAiAssistJob;
|
||||
use App\Models\Artwork;
|
||||
use App\Models\ArtworkAiAssist;
|
||||
use App\Models\Category;
|
||||
use App\Models\ContentType;
|
||||
use App\Services\TagNormalizer;
|
||||
use App\Services\TagService;
|
||||
use App\Services\Vision\AiArtworkVectorSearchService;
|
||||
use App\Services\Vision\VisionService;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
final class StudioAiAssistService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly VisionService $vision,
|
||||
private readonly StudioAiSuggestionBuilder $builder,
|
||||
private readonly StudioAiCategoryMapper $categoryMapper,
|
||||
private readonly AiArtworkVectorSearchService $similarity,
|
||||
private readonly TagService $tagService,
|
||||
private readonly TagNormalizer $tagNormalizer,
|
||||
private readonly StudioAiAssistEventService $eventService,
|
||||
) {
|
||||
}
|
||||
|
||||
public function queueAnalysis(Artwork $artwork, bool $force = false, ?string $intent = null): ArtworkAiAssist
|
||||
{
|
||||
$assist = $this->assistRecord($artwork);
|
||||
$mode = $assist->mode ?: $this->builder->detectMode($artwork->loadMissing(['tags', 'categories.contentType']), []);
|
||||
|
||||
$assist->forceFill([
|
||||
'status' => ArtworkAiAssist::STATUS_QUEUED,
|
||||
'mode' => $mode,
|
||||
'error_message' => null,
|
||||
])->save();
|
||||
|
||||
$artwork->forceFill(['ai_status' => ArtworkAiAssist::STATUS_QUEUED])->saveQuietly();
|
||||
$meta = ['force' => $force, 'direct' => false, 'intent' => $intent];
|
||||
$this->appendAction($assist, 'analysis_requested', $meta);
|
||||
$this->eventService->record($artwork, 'analysis_requested', $meta, $assist);
|
||||
|
||||
AnalyzeArtworkAiAssistJob::dispatch($artwork->id, $force)->afterCommit();
|
||||
|
||||
return $assist->fresh();
|
||||
}
|
||||
|
||||
public function analyzeDirect(Artwork $artwork, bool $force = false, ?string $intent = null): ArtworkAiAssist
|
||||
{
|
||||
$assist = $this->assistRecord($artwork);
|
||||
$meta = ['force' => $force, 'direct' => true, 'intent' => $intent];
|
||||
$this->appendAction($assist, 'analysis_requested', $meta);
|
||||
$this->eventService->record($artwork, 'analysis_requested', $meta, $assist);
|
||||
|
||||
return $this->analyze($artwork, $force, $intent);
|
||||
}
|
||||
|
||||
public function analyze(Artwork $artwork, bool $force = false, ?string $intent = null): ArtworkAiAssist
|
||||
{
|
||||
$artwork->loadMissing(['tags', 'categories.contentType', 'user']);
|
||||
|
||||
$assist = $this->assistRecord($artwork);
|
||||
$assist->forceFill([
|
||||
'status' => ArtworkAiAssist::STATUS_PROCESSING,
|
||||
'error_message' => null,
|
||||
])->save();
|
||||
$artwork->forceFill(['ai_status' => ArtworkAiAssist::STATUS_PROCESSING])->saveQuietly();
|
||||
|
||||
$hash = strtolower((string) preg_replace('/[^a-z0-9]/', '', (string) ($artwork->hash ?? '')));
|
||||
if ($hash === '') {
|
||||
return $this->failAssist($assist, $artwork, 'Artwork hash is missing, so AI analysis could not start.');
|
||||
}
|
||||
|
||||
if (! $this->vision->isEnabled()) {
|
||||
return $this->failAssist($assist, $artwork, 'Vision analysis is disabled in the current environment.');
|
||||
}
|
||||
|
||||
try {
|
||||
$visionResult = $this->vision->analyzeArtworkDetailed($artwork, $hash);
|
||||
$analysis = (array) ($visionResult['analysis'] ?? []);
|
||||
$visionDebug = (array) ($visionResult['debug'] ?? []);
|
||||
$this->vision->persistVisionMetadata(
|
||||
$artwork,
|
||||
(array) ($analysis['clip_tags'] ?? []),
|
||||
isset($analysis['blip_caption']) ? (string) $analysis['blip_caption'] : null,
|
||||
(array) ($analysis['yolo_objects'] ?? [])
|
||||
);
|
||||
|
||||
$mode = $this->builder->detectMode($artwork, $analysis);
|
||||
$signals = $this->builder->buildSignals($artwork, $analysis);
|
||||
$primaryCategory = $artwork->categories->sortBy('sort_order')->first();
|
||||
$categorySuggestions = $this->categoryMapper->map($signals, $primaryCategory instanceof Category ? $primaryCategory : null);
|
||||
|
||||
$titleSuggestions = $this->builder->buildTitleSuggestions($artwork, $analysis, $mode);
|
||||
$descriptionSuggestions = $this->builder->buildDescriptionSuggestions($artwork, $analysis, $mode);
|
||||
$tagSuggestions = $this->builder->buildTagSuggestions($artwork, $analysis, $mode);
|
||||
$similarCandidates = $this->buildSimilarCandidates($artwork);
|
||||
|
||||
$assist->forceFill([
|
||||
'status' => ArtworkAiAssist::STATUS_READY,
|
||||
'mode' => $mode,
|
||||
'title_suggestions_json' => $titleSuggestions,
|
||||
'description_suggestions_json' => $descriptionSuggestions,
|
||||
'tag_suggestions_json' => $tagSuggestions,
|
||||
'category_suggestions_json' => $categorySuggestions,
|
||||
'similar_candidates_json' => $similarCandidates,
|
||||
'raw_response_json' => [
|
||||
'request' => [
|
||||
'artwork_id' => (int) $artwork->id,
|
||||
'hash' => $hash,
|
||||
'intent' => $intent,
|
||||
'force' => $force,
|
||||
'current_title' => (string) ($artwork->title ?? ''),
|
||||
'current_description' => (string) ($artwork->description ?? ''),
|
||||
'current_tags' => $artwork->tags->pluck('slug')->values()->all(),
|
||||
],
|
||||
'vision_debug' => $visionDebug,
|
||||
'analysis' => $analysis,
|
||||
'generated_at' => \now()->toIso8601String(),
|
||||
'force' => $force,
|
||||
],
|
||||
'error_message' => null,
|
||||
'processed_at' => \now(),
|
||||
])->save();
|
||||
|
||||
$artwork->forceFill(['ai_status' => ArtworkAiAssist::STATUS_READY])->saveQuietly();
|
||||
$meta = [
|
||||
'force' => $force,
|
||||
'mode' => $mode,
|
||||
'intent' => $intent,
|
||||
'title_suggestion_count' => count($titleSuggestions),
|
||||
'description_suggestion_count' => count($descriptionSuggestions),
|
||||
'tag_suggestion_count' => count($tagSuggestions),
|
||||
'similar_candidate_count' => count($similarCandidates),
|
||||
];
|
||||
$this->appendAction($assist, 'analysis_completed', $meta);
|
||||
$this->eventService->record($artwork, 'analysis_completed', $meta, $assist);
|
||||
|
||||
return $assist->fresh();
|
||||
} catch (\Throwable $exception) {
|
||||
return $this->failAssist($assist, $artwork, $exception->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function applySuggestions(Artwork $artwork, array $payload): array
|
||||
{
|
||||
$artwork->loadMissing(['tags', 'categories.contentType']);
|
||||
$assist = $this->assistRecord($artwork);
|
||||
$updated = false;
|
||||
$applied = [];
|
||||
|
||||
DB::transaction(function () use ($artwork, $payload, &$updated, &$applied): void {
|
||||
if (\filled($payload['title'] ?? null)) {
|
||||
$mode = (string) ($payload['title_mode'] ?? 'replace');
|
||||
$incoming = trim((string) $payload['title']);
|
||||
$artwork->title = $mode === 'insert' && $artwork->title
|
||||
? trim($artwork->title . ' ' . $incoming)
|
||||
: $incoming;
|
||||
$artwork->title_source = 'ai_applied';
|
||||
$updated = true;
|
||||
$applied[] = 'title';
|
||||
}
|
||||
|
||||
if (\filled($payload['description'] ?? null)) {
|
||||
$mode = (string) ($payload['description_mode'] ?? 'replace');
|
||||
$incoming = trim((string) $payload['description']);
|
||||
$artwork->description = $mode === 'append' && \filled($artwork->description)
|
||||
? trim((string) $artwork->description . "\n\n" . $incoming)
|
||||
: $incoming;
|
||||
$artwork->description_source = 'ai_applied';
|
||||
$updated = true;
|
||||
$applied[] = 'description';
|
||||
}
|
||||
|
||||
if (array_key_exists('tags', $payload) && is_array($payload['tags'])) {
|
||||
$tagMode = (string) ($payload['tag_mode'] ?? 'add');
|
||||
$tags = array_values(array_filter(array_map(fn (mixed $tag): string => $this->tagNormalizer->normalize((string) $tag), $payload['tags'])));
|
||||
|
||||
if ($tagMode === 'replace') {
|
||||
$currentTags = $artwork->tags->pluck('slug')->all();
|
||||
if ($currentTags !== []) {
|
||||
$this->tagService->detachTags($artwork, $currentTags);
|
||||
}
|
||||
$this->tagService->attachAiTags($artwork, array_map(fn (string $tag): array => ['tag' => $tag], $tags));
|
||||
} elseif ($tagMode === 'remove') {
|
||||
$this->tagService->detachTags($artwork, $tags);
|
||||
} else {
|
||||
$this->tagService->attachAiTags($artwork, array_map(fn (string $tag): array => ['tag' => $tag], $tags));
|
||||
}
|
||||
|
||||
$artwork->tags_source = 'ai_applied';
|
||||
$updated = true;
|
||||
$applied[] = 'tags';
|
||||
}
|
||||
|
||||
$categoryId = $this->resolveCategoryId($payload);
|
||||
|
||||
if ($categoryId !== null) {
|
||||
$artwork->categories()->sync([$categoryId]);
|
||||
$artwork->category_source = 'ai_applied';
|
||||
$updated = true;
|
||||
$applied[] = 'category';
|
||||
|
||||
if (isset($payload['content_type_id']) && $payload['content_type_id'] !== null) {
|
||||
$applied[] = 'content_type';
|
||||
}
|
||||
}
|
||||
|
||||
if ($updated) {
|
||||
$artwork->save();
|
||||
$artwork->load(['tags', 'categories.contentType']);
|
||||
}
|
||||
});
|
||||
|
||||
if (! empty($payload['similar_actions']) && is_array($payload['similar_actions'])) {
|
||||
$this->applySimilarActions($assist, $payload['similar_actions']);
|
||||
$applied[] = 'similar_candidates';
|
||||
$this->eventService->record($artwork, 'similar_candidates_updated', [
|
||||
'count' => count($payload['similar_actions']),
|
||||
'states' => array_values(array_unique(array_map(
|
||||
static fn (array $action): string => (string) ($action['state'] ?? 'unknown'),
|
||||
array_filter($payload['similar_actions'], 'is_array')
|
||||
))),
|
||||
], $assist);
|
||||
|
||||
foreach (array_filter($payload['similar_actions'], 'is_array') as $action) {
|
||||
$state = (string) ($action['state'] ?? 'unknown');
|
||||
$candidateId = (int) ($action['artwork_id'] ?? 0);
|
||||
if ($candidateId <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$eventType = match ($state) {
|
||||
'ignored' => 'duplicate_candidate_ignored',
|
||||
'reviewed' => 'duplicate_candidate_reviewed',
|
||||
default => 'duplicate_candidate_updated',
|
||||
};
|
||||
|
||||
$this->eventService->record($artwork, $eventType, [
|
||||
'candidate_artwork_id' => $candidateId,
|
||||
'state' => $state,
|
||||
], $assist);
|
||||
}
|
||||
}
|
||||
|
||||
if ($applied !== []) {
|
||||
$fields = array_values(array_unique($applied));
|
||||
$meta = ['fields' => $fields];
|
||||
$this->appendAction($assist, 'suggestions_applied', $meta);
|
||||
$this->eventService->record($artwork, 'suggestions_applied', $meta, $assist);
|
||||
|
||||
foreach ($fields as $field) {
|
||||
$eventType = match ($field) {
|
||||
'title' => 'title_suggestion_applied',
|
||||
'description' => 'description_suggestion_applied',
|
||||
'tags' => 'tags_suggestion_applied',
|
||||
'content_type' => 'content_type_suggestion_applied',
|
||||
'category' => 'category_suggestion_applied',
|
||||
default => null,
|
||||
};
|
||||
|
||||
if ($eventType === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->eventService->record($artwork, $eventType, [
|
||||
'fields' => $fields,
|
||||
], $assist);
|
||||
}
|
||||
}
|
||||
|
||||
return $this->payloadFor($artwork->fresh(['tags', 'categories.contentType', 'artworkAiAssist']));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function payloadFor(Artwork $artwork): array
|
||||
{
|
||||
$artwork->loadMissing(['artworkAiAssist', 'tags', 'categories.contentType']);
|
||||
$assist = $artwork->artworkAiAssist;
|
||||
|
||||
$primaryCategory = $artwork->categories->first();
|
||||
|
||||
if (! $assist) {
|
||||
return [
|
||||
'status' => 'not_analyzed',
|
||||
'mode' => null,
|
||||
'title_suggestions' => [],
|
||||
'description_suggestions' => [],
|
||||
'tag_suggestions' => [],
|
||||
'content_type' => null,
|
||||
'category' => null,
|
||||
'similar_candidates' => [],
|
||||
'processed_at' => null,
|
||||
'error_message' => null,
|
||||
'current' => $this->currentPayload($artwork, $primaryCategory),
|
||||
];
|
||||
}
|
||||
|
||||
$categorySuggestions = is_array($assist->category_suggestions_json) ? $assist->category_suggestions_json : [];
|
||||
|
||||
return [
|
||||
'status' => (string) $assist->status,
|
||||
'mode' => $assist->mode,
|
||||
'title_suggestions' => array_values((array) ($assist->title_suggestions_json ?? [])),
|
||||
'description_suggestions' => array_values((array) ($assist->description_suggestions_json ?? [])),
|
||||
'tag_suggestions' => array_values((array) ($assist->tag_suggestions_json ?? [])),
|
||||
'content_type' => $categorySuggestions['content_type'] ?? null,
|
||||
'category' => $categorySuggestions['category'] ?? null,
|
||||
'similar_candidates' => array_values((array) ($assist->similar_candidates_json ?? [])),
|
||||
'processed_at' => optional($assist->processed_at)?->toIso8601String(),
|
||||
'error_message' => $assist->error_message,
|
||||
'current' => $this->currentPayload($artwork, $primaryCategory),
|
||||
'debug' => is_array($assist->raw_response_json) ? [
|
||||
'request' => $assist->raw_response_json['request'] ?? null,
|
||||
'vision_debug' => $assist->raw_response_json['vision_debug'] ?? null,
|
||||
'analysis' => $assist->raw_response_json['analysis'] ?? null,
|
||||
'generated_at' => $assist->raw_response_json['generated_at'] ?? null,
|
||||
] : null,
|
||||
];
|
||||
}
|
||||
|
||||
private function assistRecord(Artwork $artwork): ArtworkAiAssist
|
||||
{
|
||||
return ArtworkAiAssist::query()->firstOrCreate(
|
||||
['artwork_id' => (int) $artwork->id],
|
||||
['status' => ArtworkAiAssist::STATUS_PENDING]
|
||||
);
|
||||
}
|
||||
|
||||
private function failAssist(ArtworkAiAssist $assist, Artwork $artwork, string $message): ArtworkAiAssist
|
||||
{
|
||||
$assist->forceFill([
|
||||
'status' => ArtworkAiAssist::STATUS_FAILED,
|
||||
'error_message' => Str::limit($message, 1500, ''),
|
||||
])->save();
|
||||
$artwork->forceFill(['ai_status' => ArtworkAiAssist::STATUS_FAILED])->saveQuietly();
|
||||
$meta = ['message' => Str::limit($message, 240, '')];
|
||||
$this->appendAction($assist, 'analysis_failed', $meta);
|
||||
$this->eventService->record($artwork, 'analysis_failed', $meta, $assist);
|
||||
|
||||
return $assist->fresh();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function buildSimilarCandidates(Artwork $artwork): array
|
||||
{
|
||||
$exactMatches = Artwork::query()
|
||||
->with('user:id,name')
|
||||
->where('id', '!=', $artwork->id)
|
||||
->whereNotNull('hash')
|
||||
->where('hash', $artwork->hash)
|
||||
->latest('id')
|
||||
->limit(5)
|
||||
->get()
|
||||
->map(fn (Artwork $candidate): array => [
|
||||
'artwork_id' => (int) $candidate->id,
|
||||
'title' => (string) $candidate->title,
|
||||
'thumbnail_url' => $candidate->thumbUrl('md'),
|
||||
'match_type' => 'exact_hash',
|
||||
'score' => 1.0,
|
||||
'owner' => $candidate->user?->name,
|
||||
'url' => '/art/' . $candidate->id . '/' . $candidate->slug,
|
||||
'review_state' => null,
|
||||
])
|
||||
->values()
|
||||
->all();
|
||||
|
||||
$vectorMatches = [];
|
||||
if ($this->similarity->isConfigured()) {
|
||||
try {
|
||||
foreach ($this->similarity->similarToArtwork($artwork, 5) as $candidate) {
|
||||
$vectorMatches[] = [
|
||||
'artwork_id' => (int) ($candidate['id'] ?? 0),
|
||||
'title' => (string) ($candidate['title'] ?? ''),
|
||||
'thumbnail_url' => $candidate['thumb'] ?? null,
|
||||
'match_type' => (string) ($candidate['source'] ?? 'vector_gateway'),
|
||||
'score' => (float) ($candidate['score'] ?? 0.0),
|
||||
'owner' => $candidate['author'] ?? null,
|
||||
'url' => $candidate['url'] ?? null,
|
||||
'review_state' => null,
|
||||
];
|
||||
}
|
||||
} catch (\Throwable $exception) {
|
||||
Log::warning('Studio AI assist similar lookup failed', [
|
||||
'artwork_id' => (int) $artwork->id,
|
||||
'error' => $exception->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return collect($exactMatches)
|
||||
->merge($vectorMatches)
|
||||
->unique('artwork_id')
|
||||
->take(5)
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array<string, mixed>> $similarActions
|
||||
*/
|
||||
private function applySimilarActions(ArtworkAiAssist $assist, array $similarActions): void
|
||||
{
|
||||
$current = collect((array) ($assist->similar_candidates_json ?? []));
|
||||
if ($current->isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$indexedActions = collect($similarActions)
|
||||
->filter(fn (mixed $item): bool => is_array($item) && isset($item['artwork_id'], $item['state']))
|
||||
->keyBy(fn (array $item): int => (int) $item['artwork_id']);
|
||||
|
||||
$updated = $current->map(function (array $candidate) use ($indexedActions): array {
|
||||
$action = $indexedActions->get((int) ($candidate['artwork_id'] ?? 0));
|
||||
if (! $action) {
|
||||
return $candidate;
|
||||
}
|
||||
|
||||
$candidate['review_state'] = (string) $action['state'];
|
||||
return $candidate;
|
||||
})->values()->all();
|
||||
|
||||
$assist->forceFill(['similar_candidates_json' => $updated])->save();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $meta
|
||||
*/
|
||||
private function appendAction(ArtworkAiAssist $assist, string $type, array $meta = []): void
|
||||
{
|
||||
$log = collect((array) ($assist->action_log_json ?? []))
|
||||
->take(-24)
|
||||
->push([
|
||||
'type' => $type,
|
||||
'meta' => $meta,
|
||||
'created_at' => \now()->toIso8601String(),
|
||||
])
|
||||
->values()
|
||||
->all();
|
||||
|
||||
$assist->forceFill(['action_log_json' => $log])->save();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function currentPayload(Artwork $artwork, mixed $primaryCategory): array
|
||||
{
|
||||
return [
|
||||
'title' => (string) $artwork->title,
|
||||
'description' => (string) ($artwork->description ?? ''),
|
||||
'tags' => $artwork->tags->pluck('slug')->values()->all(),
|
||||
'category_id' => $primaryCategory?->id,
|
||||
'content_type_id' => $primaryCategory?->contentType?->id,
|
||||
'sources' => [
|
||||
'title' => $artwork->title_source ?: 'manual',
|
||||
'description' => $artwork->description_source ?: 'manual',
|
||||
'tags' => $artwork->tags_source ?: 'manual',
|
||||
'category' => $artwork->category_source ?: 'manual',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
*/
|
||||
private function resolveCategoryId(array $payload): ?int
|
||||
{
|
||||
if (isset($payload['category_id']) && $payload['category_id'] !== null) {
|
||||
return (int) $payload['category_id'];
|
||||
}
|
||||
|
||||
if (! isset($payload['content_type_id']) || $payload['content_type_id'] === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$contentType = ContentType::query()->find((int) $payload['content_type_id']);
|
||||
if (! $contentType) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$category = $contentType->rootCategories()
|
||||
->where('is_active', true)
|
||||
->orderBy('sort_order')
|
||||
->orderBy('name')
|
||||
->first();
|
||||
|
||||
if (! $category) {
|
||||
$category = Category::query()
|
||||
->where('content_type_id', $contentType->id)
|
||||
->where('is_active', true)
|
||||
->orderByRaw('CASE WHEN parent_id IS NULL THEN 0 ELSE 1 END')
|
||||
->orderBy('sort_order')
|
||||
->orderBy('name')
|
||||
->first();
|
||||
}
|
||||
|
||||
return $category?->id;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Studio;
|
||||
|
||||
use App\Models\Category;
|
||||
use App\Models\ContentType;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
final class StudioAiCategoryMapper
|
||||
{
|
||||
/**
|
||||
* @param array<int, string> $signals
|
||||
* @return array{content_type: array<string, mixed>|null, category: array<string, mixed>|null}
|
||||
*/
|
||||
public function map(array $signals, ?Category $currentCategory = null): array
|
||||
{
|
||||
$tokens = $this->tokenize($signals);
|
||||
$haystack = ' ' . implode(' ', $tokens) . ' ';
|
||||
|
||||
$contentTypes = ContentType::query()->with(['rootCategories.children'])->ordered()->get();
|
||||
$contentTypeScores = $contentTypes
|
||||
->map(fn (ContentType $contentType): array => $this->scoreContentType($contentType, $tokens, $haystack))
|
||||
->filter(fn (array $row): bool => $row['score'] > 0)
|
||||
->sortByDesc('score')
|
||||
->values();
|
||||
|
||||
$selectedContentTypeRow = $contentTypeScores->first();
|
||||
$selectedContentType = is_array($selectedContentTypeRow) ? ($selectedContentTypeRow['model'] ?? null) : null;
|
||||
if (! $selectedContentType) {
|
||||
$selectedContentType = $currentCategory?->contentType;
|
||||
}
|
||||
|
||||
$categoryScores = $this->scoreCategories($contentTypes, $tokens, $haystack, $selectedContentType?->id);
|
||||
$selectedCategoryRow = $categoryScores->first();
|
||||
$selectedCategory = is_array($selectedCategoryRow) ? ($selectedCategoryRow['model'] ?? null) : null;
|
||||
if (! $selectedCategory) {
|
||||
$selectedCategory = $currentCategory;
|
||||
}
|
||||
|
||||
return [
|
||||
'content_type' => $selectedContentType ? $this->serializeContentType(
|
||||
$selectedContentType,
|
||||
$this->confidenceForModel($contentTypeScores, $selectedContentType->id)
|
||||
) : null,
|
||||
'category' => $selectedCategory ? $this->serializeCategory(
|
||||
$selectedCategory,
|
||||
$this->confidenceForModel($categoryScores, $selectedCategory->id),
|
||||
$categoryScores
|
||||
->reject(fn (array $row): bool => (int) $row['model']->id === (int) $selectedCategory->id)
|
||||
->take(3)
|
||||
->map(fn (array $row): array => $this->serializeCategory($row['model'], $row['confidence']))
|
||||
->all()
|
||||
) : null,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $tokens
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function scoreContentType(ContentType $contentType, array $tokens, string $haystack): array
|
||||
{
|
||||
$keywords = array_merge([$contentType->slug, $contentType->name], $this->keywordsForContentType($contentType->slug));
|
||||
$score = $this->keywordScore($keywords, $tokens, $haystack);
|
||||
|
||||
return [
|
||||
'model' => $contentType,
|
||||
'score' => $score,
|
||||
'confidence' => $this->normalizeConfidence($score),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, array{model: Category, score: int, confidence: float}>
|
||||
*/
|
||||
private function scoreCategories(Collection $contentTypes, array $tokens, string $haystack, ?int $contentTypeId = null): Collection
|
||||
{
|
||||
return $contentTypes
|
||||
->filter(fn (ContentType $contentType): bool => $contentTypeId === null || (int) $contentType->id === (int) $contentTypeId)
|
||||
->flatMap(function (ContentType $contentType) use ($tokens, $haystack): array {
|
||||
$categories = [];
|
||||
|
||||
foreach ($contentType->rootCategories as $rootCategory) {
|
||||
$categories[] = $rootCategory;
|
||||
foreach ($rootCategory->children as $childCategory) {
|
||||
$categories[] = $childCategory;
|
||||
}
|
||||
}
|
||||
|
||||
return array_map(function (Category $category) use ($tokens, $haystack): array {
|
||||
$keywords = array_filter([
|
||||
$category->slug,
|
||||
$category->name,
|
||||
$category->parent?->slug,
|
||||
$category->parent?->name,
|
||||
]);
|
||||
$score = $this->keywordScore($keywords, $tokens, $haystack);
|
||||
|
||||
return [
|
||||
'model' => $category,
|
||||
'score' => $score,
|
||||
'confidence' => $this->normalizeConfidence($score),
|
||||
];
|
||||
}, $categories);
|
||||
})
|
||||
->filter(fn (array $row): bool => $row['score'] > 0)
|
||||
->sortByDesc('score')
|
||||
->values();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $signals
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function tokenize(array $signals): array
|
||||
{
|
||||
return Collection::make($signals)
|
||||
->filter(fn (mixed $value): bool => is_string($value) && trim($value) !== '')
|
||||
->flatMap(function (string $value): array {
|
||||
$normalized = Str::of($value)
|
||||
->lower()
|
||||
->replaceMatches('/[^a-z0-9\s\-]+/', ' ')
|
||||
->replace('-', ' ')
|
||||
->squish()
|
||||
->value();
|
||||
|
||||
return $normalized === '' ? [] : explode(' ', $normalized);
|
||||
})
|
||||
->filter(fn (string $value): bool => $value !== '' && strlen($value) >= 3)
|
||||
->unique()
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $keywords
|
||||
* @param array<int, string> $tokens
|
||||
*/
|
||||
private function keywordScore(array $keywords, array $tokens, string $haystack): int
|
||||
{
|
||||
$score = 0;
|
||||
$tokenVariants = Collection::make($tokens)
|
||||
->flatMap(fn (string $token): array => array_unique([$token, $this->singularize($token), $this->pluralize($token)]))
|
||||
->filter(fn (string $token): bool => $token !== '')
|
||||
->values()
|
||||
->all();
|
||||
|
||||
foreach ($keywords as $keyword) {
|
||||
$normalized = Str::of((string) $keyword)
|
||||
->lower()
|
||||
->replaceMatches('/[^a-z0-9\s\-]+/', ' ')
|
||||
->replace('-', ' ')
|
||||
->squish()
|
||||
->value();
|
||||
|
||||
if ($normalized === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (str_contains($haystack, ' ' . $normalized . ' ')) {
|
||||
$score += str_contains($normalized, ' ') ? 4 : 3;
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (explode(' ', $normalized) as $part) {
|
||||
if ($part !== '' && in_array($part, $tokenVariants, true)) {
|
||||
$score += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $score;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function keywordsForContentType(string $slug): array
|
||||
{
|
||||
return match ($slug) {
|
||||
'skins' => ['skin', 'winamp', 'theme', 'interface skin'],
|
||||
'wallpapers' => ['wallpaper', 'background', 'desktop', 'lockscreen'],
|
||||
'photography' => ['photo', 'photograph', 'photography', 'portrait', 'macro', 'nature', 'camera'],
|
||||
'members' => ['profile', 'avatar', 'member'],
|
||||
default => ['artwork', 'illustration', 'digital art', 'painting', 'concept art', 'screenshot', 'ui', 'game'],
|
||||
};
|
||||
}
|
||||
|
||||
private function normalizeConfidence(int $score): float
|
||||
{
|
||||
if ($score <= 0) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
return min(0.99, round(0.45 + ($score * 0.08), 2));
|
||||
}
|
||||
|
||||
private function singularize(string $value): string
|
||||
{
|
||||
return str_ends_with($value, 's') ? rtrim($value, 's') : $value;
|
||||
}
|
||||
|
||||
private function pluralize(string $value): string
|
||||
{
|
||||
return str_ends_with($value, 's') ? $value : $value . 's';
|
||||
}
|
||||
|
||||
private function confidenceForModel(Collection $scores, int $modelId): float
|
||||
{
|
||||
$row = $scores->first(fn (array $item): bool => (int) $item['model']->id === $modelId);
|
||||
|
||||
return (float) ($row['confidence'] ?? 0.55);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function serializeContentType(ContentType $contentType, float $confidence): array
|
||||
{
|
||||
return [
|
||||
'id' => (int) $contentType->id,
|
||||
'value' => (string) $contentType->slug,
|
||||
'label' => (string) $contentType->name,
|
||||
'confidence' => $confidence,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array<string, mixed>> $alternatives
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function serializeCategory(Category $category, float $confidence, array $alternatives = []): array
|
||||
{
|
||||
$rootCategory = $category->parent ?: $category;
|
||||
|
||||
return [
|
||||
'id' => (int) $category->id,
|
||||
'value' => (string) $category->slug,
|
||||
'label' => (string) $category->name,
|
||||
'confidence' => $confidence,
|
||||
'content_type_id' => (int) $category->content_type_id,
|
||||
'root_category_id' => (int) $rootCategory->id,
|
||||
'sub_category_id' => $category->parent_id ? (int) $category->id : null,
|
||||
'alternatives' => array_values($alternatives),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Studio;
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Services\TagNormalizer;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
final class StudioAiSuggestionBuilder
|
||||
{
|
||||
private const GENERIC_TAGS = [
|
||||
'image', 'picture', 'artwork', 'art', 'design', 'visual', 'graphic', 'photo of', 'image of',
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
private readonly TagNormalizer $normalizer,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $analysis
|
||||
*/
|
||||
public function detectMode(Artwork $artwork, array $analysis): string
|
||||
{
|
||||
$signals = Collection::make([
|
||||
$artwork->title,
|
||||
$artwork->description,
|
||||
$artwork->file_name,
|
||||
$analysis['blip_caption'] ?? null,
|
||||
...Collection::make((array) ($analysis['clip_tags'] ?? []))->pluck('tag')->all(),
|
||||
...Collection::make((array) ($analysis['yolo_objects'] ?? []))->pluck('tag')->all(),
|
||||
])->filter()->implode(' ');
|
||||
|
||||
return preg_match('/\b(screenshot|screen|ui|interface|menu|hud|dashboard|settings|launcher|app|game)\b/i', $signals) === 1
|
||||
? 'screenshot'
|
||||
: 'artwork';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $analysis
|
||||
* @return array<int, array{text: string, confidence: float}>
|
||||
*/
|
||||
public function buildTitleSuggestions(Artwork $artwork, array $analysis, string $mode): array
|
||||
{
|
||||
$caption = $this->cleanCaption((string) ($analysis['blip_caption'] ?? ''));
|
||||
$topTerms = $this->topTerms($analysis, 4);
|
||||
$titleSeeds = Collection::make([
|
||||
$this->titleCase($caption),
|
||||
$this->titleCase($this->limitWords($caption, 6)),
|
||||
$mode === 'screenshot'
|
||||
? $this->titleCase(trim(implode(' ', array_slice($topTerms, 0, 2)) . ' Screen'))
|
||||
: $this->titleCase(trim(implode(' ', array_slice($topTerms, 0, 3)))),
|
||||
$mode === 'screenshot'
|
||||
? $this->titleCase(trim(implode(' ', array_slice($topTerms, 0, 2)) . ' Interface'))
|
||||
: $this->titleCase(trim(implode(' ', array_slice($topTerms, 0, 2)) . ' Study')),
|
||||
$mode === 'screenshot'
|
||||
? $this->titleCase(trim(($topTerms[0] ?? 'Interface') . ' View'))
|
||||
: $this->titleCase(trim(($topTerms[0] ?? 'Artwork') . ' Composition')),
|
||||
])
|
||||
->filter(fn (?string $value): bool => is_string($value) && trim($value) !== '')
|
||||
->map(fn (string $value): string => Str::limit(trim($value), 80, ''))
|
||||
->unique()
|
||||
->take(5)
|
||||
->values();
|
||||
|
||||
return $titleSeeds->map(fn (string $text, int $index): array => [
|
||||
'text' => $text,
|
||||
'confidence' => round(max(0.55, 0.92 - ($index * 0.07)), 2),
|
||||
])->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $analysis
|
||||
* @return array<int, array{variant: string, text: string, confidence: float}>
|
||||
*/
|
||||
public function buildDescriptionSuggestions(Artwork $artwork, array $analysis, string $mode): array
|
||||
{
|
||||
$caption = $this->cleanCaption((string) ($analysis['blip_caption'] ?? ''));
|
||||
$terms = $this->topTerms($analysis, 5);
|
||||
$termSentence = $terms !== [] ? implode(', ', array_slice($terms, 0, 3)) : null;
|
||||
|
||||
$short = $caption !== ''
|
||||
? Str::ucfirst(Str::finish($caption, '.'))
|
||||
: ($mode === 'screenshot'
|
||||
? 'A clear screenshot with interface-focused visual details.'
|
||||
: 'A visually focused artwork with clear subject and style cues.');
|
||||
|
||||
$normal = $short;
|
||||
if ($termSentence) {
|
||||
$normal .= ' It highlights ' . $termSentence . ' without overclaiming details.';
|
||||
}
|
||||
|
||||
$seo = $artwork->title !== ''
|
||||
? $artwork->title . ' is presented with ' . ($termSentence ?: ($mode === 'screenshot' ? 'useful interface context' : 'strong visual detail')) . ' for discovery on Skinbase.'
|
||||
: $normal;
|
||||
|
||||
return [
|
||||
['variant' => 'short', 'text' => Str::limit($short, 180, ''), 'confidence' => 0.89],
|
||||
['variant' => 'normal', 'text' => Str::limit($normal, 280, ''), 'confidence' => 0.85],
|
||||
['variant' => 'seo', 'text' => Str::limit($seo, 220, ''), 'confidence' => 0.8],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $analysis
|
||||
* @return array<int, array{tag: string, confidence: float|null}>
|
||||
*/
|
||||
public function buildTagSuggestions(Artwork $artwork, array $analysis, string $mode): array
|
||||
{
|
||||
$rawTags = Collection::make()
|
||||
->merge((array) ($analysis['clip_tags'] ?? []))
|
||||
->merge((array) ($analysis['yolo_objects'] ?? []))
|
||||
->map(function (mixed $item): array {
|
||||
if (is_string($item)) {
|
||||
return ['tag' => $item, 'confidence' => null];
|
||||
}
|
||||
|
||||
return [
|
||||
'tag' => (string) ($item['tag'] ?? ''),
|
||||
'confidence' => isset($item['confidence']) && is_numeric($item['confidence']) ? (float) $item['confidence'] : null,
|
||||
];
|
||||
});
|
||||
|
||||
foreach ($this->extractCaptionTags((string) ($analysis['blip_caption'] ?? '')) as $captionTag) {
|
||||
$rawTags->push(['tag' => $captionTag, 'confidence' => 0.62]);
|
||||
}
|
||||
|
||||
if ($mode === 'screenshot') {
|
||||
foreach (['screenshot', 'ui'] as $fallbackTag) {
|
||||
$rawTags->push(['tag' => $fallbackTag, 'confidence' => 0.58]);
|
||||
}
|
||||
}
|
||||
|
||||
$suggestions = $rawTags
|
||||
->map(function (array $row): ?array {
|
||||
$tag = $this->normalizer->normalize((string) ($row['tag'] ?? ''));
|
||||
if ($tag === '' || in_array($tag, self::GENERIC_TAGS, true)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'tag' => $tag,
|
||||
'confidence' => isset($row['confidence']) && is_numeric($row['confidence']) ? round((float) $row['confidence'], 2) : null,
|
||||
];
|
||||
})
|
||||
->filter()
|
||||
->unique('tag')
|
||||
->sortByDesc(fn (array $row): float => (float) ($row['confidence'] ?? 0.0))
|
||||
->take(15)
|
||||
->values();
|
||||
|
||||
return $suggestions->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $analysis
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public function buildSignals(Artwork $artwork, array $analysis): array
|
||||
{
|
||||
return Collection::make([
|
||||
$artwork->title,
|
||||
$artwork->description,
|
||||
$artwork->file_name,
|
||||
$analysis['blip_caption'] ?? null,
|
||||
...Collection::make((array) ($analysis['clip_tags'] ?? []))->pluck('tag')->all(),
|
||||
...Collection::make((array) ($analysis['yolo_objects'] ?? []))->pluck('tag')->all(),
|
||||
...$artwork->tags->pluck('slug')->all(),
|
||||
])
|
||||
->filter(fn (mixed $value): bool => is_string($value) && trim($value) !== '')
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $analysis
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function topTerms(array $analysis, int $limit): array
|
||||
{
|
||||
return Collection::make()
|
||||
->merge((array) ($analysis['clip_tags'] ?? []))
|
||||
->merge((array) ($analysis['yolo_objects'] ?? []))
|
||||
->map(fn (mixed $item): string => trim((string) (is_array($item) ? ($item['tag'] ?? '') : $item)))
|
||||
->filter()
|
||||
->flatMap(fn (string $term): array => preg_split('/\s+/', Str::of($term)->replace('-', ' ')->value()) ?: [])
|
||||
->filter(fn (string $term): bool => strlen($term) >= 3)
|
||||
->map(fn (string $term): string => Str::title($term))
|
||||
->unique()
|
||||
->take($limit)
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function extractCaptionTags(string $caption): array
|
||||
{
|
||||
$clean = Str::of($caption)
|
||||
->lower()
|
||||
->replaceMatches('/[^a-z0-9\s\-]+/', ' ')
|
||||
->replace('-', ' ')
|
||||
->squish()
|
||||
->value();
|
||||
|
||||
if ($clean === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
$tokens = Collection::make(explode(' ', $clean))
|
||||
->filter(fn (string $value): bool => strlen($value) >= 3)
|
||||
->reject(fn (string $value): bool => in_array($value, ['with', 'from', 'into', 'over', 'under', 'image', 'picture', 'artwork'], true))
|
||||
->values();
|
||||
|
||||
$bigrams = [];
|
||||
for ($index = 0; $index < $tokens->count() - 1; $index++) {
|
||||
$bigrams[] = $tokens[$index] . ' ' . $tokens[$index + 1];
|
||||
}
|
||||
|
||||
return $tokens->merge($bigrams)->unique()->take(10)->all();
|
||||
}
|
||||
|
||||
private function cleanCaption(string $caption): string
|
||||
{
|
||||
return Str::of($caption)
|
||||
->replaceMatches('/^(a|an|the)\s+/i', '')
|
||||
->replaceMatches('/^(image|photo|screenshot) of\s+/i', '')
|
||||
->squish()
|
||||
->value();
|
||||
}
|
||||
|
||||
private function titleCase(string $value): string
|
||||
{
|
||||
return Str::title(trim($value));
|
||||
}
|
||||
|
||||
private function limitWords(string $value, int $maxWords): string
|
||||
{
|
||||
$words = preg_split('/\s+/', trim($value)) ?: [];
|
||||
|
||||
return implode(' ', array_slice($words, 0, $maxWords));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Studio;
|
||||
|
||||
use App\Models\Artwork;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Handles artwork listing queries for Studio, using Meilisearch with DB fallback.
|
||||
*/
|
||||
final class StudioArtworkQueryService
|
||||
{
|
||||
/**
|
||||
* List artworks for a creator with search, filter, and sort via Meilisearch.
|
||||
*
|
||||
* Supported $filters keys:
|
||||
* q string — free-text search
|
||||
* status string — published|draft|archived
|
||||
* category string — category slug
|
||||
* tags array — tag slugs
|
||||
* date_from string — Y-m-d
|
||||
* date_to string — Y-m-d
|
||||
* performance string — rising|top|low
|
||||
* sort string — created_at:desc (default), ranking_score:desc, heat_score:desc, etc.
|
||||
*/
|
||||
public function list(int $userId, array $filters = [], int $perPage = 24): LengthAwarePaginator
|
||||
{
|
||||
// Studio is a management dashboard — DB is always authoritative.
|
||||
// Draft/unpublished artworks are never indexed in Meilisearch, so using
|
||||
// Meili as the primary source would silently hide them.
|
||||
//
|
||||
// Meilisearch is only used when the user submits a free-text query (`q`),
|
||||
// since it can provide relevance-ranked full-text search across many docs.
|
||||
// Even then, we fall back to DB on any Meili error.
|
||||
|
||||
$hasTextQuery = !empty($filters['q']);
|
||||
$driver = config('scout.driver');
|
||||
$useMeili = $hasTextQuery && !empty($driver) && $driver !== 'null';
|
||||
|
||||
if ($useMeili) {
|
||||
try {
|
||||
return $this->listViaMeilisearch($userId, $filters, $perPage);
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('Studio: Meilisearch unavailable during text search, falling back to DB', [
|
||||
'error' => $e->getMessage(),
|
||||
'user_id' => $userId,
|
||||
]);
|
||||
// fall through to DB
|
||||
}
|
||||
}
|
||||
|
||||
return $this->listViaDatabase($userId, $filters, $perPage);
|
||||
}
|
||||
|
||||
private function listViaMeilisearch(int $userId, array $filters, int $perPage): LengthAwarePaginator
|
||||
{
|
||||
$q = $filters['q'] ?? '';
|
||||
$filterParts = ["author_id = {$userId}"];
|
||||
$sort = [];
|
||||
|
||||
// Status filter
|
||||
$status = $filters['status'] ?? null;
|
||||
if ($status === 'published') {
|
||||
$filterParts[] = 'is_public = true AND is_approved = true';
|
||||
} elseif ($status === 'draft') {
|
||||
$filterParts[] = 'is_public = false';
|
||||
}
|
||||
// archived handled at DB level since Meili doesn't see soft-deleted
|
||||
|
||||
// Category filter
|
||||
if (!empty($filters['category'])) {
|
||||
$filterParts[] = 'category = "' . addslashes((string) $filters['category']) . '"';
|
||||
}
|
||||
|
||||
// Tag filter
|
||||
if (!empty($filters['tags'])) {
|
||||
foreach ((array) $filters['tags'] as $tag) {
|
||||
$filterParts[] = 'tags = "' . addslashes((string) $tag) . '"';
|
||||
}
|
||||
}
|
||||
|
||||
// Date range
|
||||
if (!empty($filters['date_from'])) {
|
||||
$filterParts[] = 'created_at >= "' . $filters['date_from'] . '"';
|
||||
}
|
||||
if (!empty($filters['date_to'])) {
|
||||
$filterParts[] = 'created_at <= "' . $filters['date_to'] . '"';
|
||||
}
|
||||
|
||||
// Performance quick filters
|
||||
if (!empty($filters['performance'])) {
|
||||
match ($filters['performance']) {
|
||||
'rising' => $filterParts[] = 'heat_score > 5',
|
||||
'top' => $filterParts[] = 'ranking_score > 50',
|
||||
'low' => $filterParts[] = 'views < 10',
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
// Sort
|
||||
$sortParam = $filters['sort'] ?? 'created_at:desc';
|
||||
$validSortFields = [
|
||||
'created_at', 'ranking_score', 'heat_score',
|
||||
'views', 'likes', 'shares_count',
|
||||
'downloads', 'comments_count', 'favorites_count',
|
||||
];
|
||||
$parts = explode(':', $sortParam);
|
||||
if (count($parts) === 2 && in_array($parts[0], $validSortFields, true)) {
|
||||
$sort[] = $parts[0] . ':' . ($parts[1] === 'asc' ? 'asc' : 'desc');
|
||||
}
|
||||
|
||||
$options = ['filter' => implode(' AND ', $filterParts)];
|
||||
if ($sort !== []) {
|
||||
$options['sort'] = $sort;
|
||||
}
|
||||
|
||||
return Artwork::search($q ?: '')
|
||||
->options($options)
|
||||
->query(fn (Builder $query) => $query
|
||||
->with(['stats', 'categories', 'tags'])
|
||||
->withCount(['comments', 'downloads'])
|
||||
)
|
||||
->paginate($perPage);
|
||||
}
|
||||
|
||||
private function listViaDatabase(int $userId, array $filters, int $perPage): LengthAwarePaginator
|
||||
{
|
||||
$query = Artwork::where('user_id', $userId)
|
||||
->with(['stats', 'categories', 'tags'])
|
||||
->withCount(['comments', 'downloads']);
|
||||
|
||||
$status = $filters['status'] ?? null;
|
||||
if ($status === 'published') {
|
||||
$query->where('is_public', true)->where('is_approved', true);
|
||||
} elseif ($status === 'draft') {
|
||||
$query->where('is_public', false);
|
||||
} elseif ($status === 'archived') {
|
||||
$query->onlyTrashed();
|
||||
} else {
|
||||
// Show all except archived by default
|
||||
$query->whereNull('deleted_at');
|
||||
}
|
||||
|
||||
// Free-text search
|
||||
if (!empty($filters['q'])) {
|
||||
$q = $filters['q'];
|
||||
$query->where(function (Builder $w) use ($q) {
|
||||
$w->where('title', 'LIKE', "%{$q}%")
|
||||
->orWhereHas('tags', fn (Builder $t) => $t->where('slug', 'LIKE', "%{$q}%"));
|
||||
});
|
||||
}
|
||||
|
||||
// Category
|
||||
if (!empty($filters['category'])) {
|
||||
$query->whereHas('categories', fn (Builder $c) => $c->where('slug', $filters['category']));
|
||||
}
|
||||
|
||||
// Tags
|
||||
if (!empty($filters['tags'])) {
|
||||
foreach ((array) $filters['tags'] as $tag) {
|
||||
$query->whereHas('tags', fn (Builder $t) => $t->where('slug', $tag));
|
||||
}
|
||||
}
|
||||
|
||||
// Date range
|
||||
if (!empty($filters['date_from'])) {
|
||||
$query->where('created_at', '>=', $filters['date_from']);
|
||||
}
|
||||
if (!empty($filters['date_to'])) {
|
||||
$query->where('created_at', '<=', $filters['date_to']);
|
||||
}
|
||||
|
||||
// Performance
|
||||
if (!empty($filters['performance'])) {
|
||||
$query->whereHas('stats', function (Builder $s) use ($filters) {
|
||||
match ($filters['performance']) {
|
||||
'rising' => $s->where('heat_score', '>', 5),
|
||||
'top' => $s->where('ranking_score', '>', 50),
|
||||
'low' => $s->where('views', '<', 10),
|
||||
default => null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// Sort
|
||||
$sortParam = $filters['sort'] ?? 'created_at:desc';
|
||||
$parts = explode(':', $sortParam);
|
||||
$sortField = $parts[0] ?? 'created_at';
|
||||
$sortDir = ($parts[1] ?? 'desc') === 'asc' ? 'asc' : 'desc';
|
||||
|
||||
$dbSortMap = [
|
||||
'created_at' => 'artworks.created_at',
|
||||
'ranking_score' => 'ranking_score',
|
||||
'heat_score' => 'heat_score',
|
||||
'views' => 'views',
|
||||
'likes' => 'favorites',
|
||||
'shares_count' => 'shares_count',
|
||||
'downloads' => 'downloads',
|
||||
'comments_count' => 'comments_count',
|
||||
'favorites_count' => 'favorites',
|
||||
];
|
||||
|
||||
$statsSortFields = ['ranking_score', 'heat_score', 'views', 'likes', 'shares_count', 'downloads', 'comments_count', 'favorites_count'];
|
||||
|
||||
if (in_array($sortField, $statsSortFields, true)) {
|
||||
$dbCol = $dbSortMap[$sortField] ?? $sortField;
|
||||
$query->leftJoin('artwork_stats', 'artworks.id', '=', 'artwork_stats.artwork_id')
|
||||
->orderBy("artwork_stats.{$dbCol}", $sortDir)
|
||||
->select('artworks.*');
|
||||
} else {
|
||||
$query->orderBy($dbSortMap[$sortField] ?? 'artworks.created_at', $sortDir);
|
||||
}
|
||||
|
||||
return $query->paginate($perPage);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Studio;
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Models\Tag;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Handles bulk operations on artworks for the Studio module.
|
||||
*/
|
||||
final class StudioBulkActionService
|
||||
{
|
||||
/**
|
||||
* Execute a bulk action on the given artwork IDs, enforcing ownership.
|
||||
*
|
||||
* @param int $userId The authenticated user ID
|
||||
* @param string $action publish|unpublish|archive|unarchive|delete|change_category|add_tags|remove_tags
|
||||
* @param array $artworkIds Array of artwork IDs
|
||||
* @param array $params Extra params (category_id, tag_ids)
|
||||
* @return array{success: int, failed: int, errors: array}
|
||||
*/
|
||||
public function execute(int $userId, string $action, array $artworkIds, array $params = []): array
|
||||
{
|
||||
$result = ['success' => 0, 'failed' => 0, 'errors' => []];
|
||||
|
||||
// Validate ownership — fetch only artworks belonging to this user
|
||||
$query = Artwork::where('user_id', $userId);
|
||||
if ($action === 'unarchive') {
|
||||
$query->onlyTrashed();
|
||||
} elseif ($action === 'delete') {
|
||||
$query->withTrashed();
|
||||
}
|
||||
$artworks = $query->whereIn('id', $artworkIds)->get();
|
||||
|
||||
$foundIds = $artworks->pluck('id')->all();
|
||||
$missingIds = array_diff($artworkIds, $foundIds);
|
||||
foreach ($missingIds as $id) {
|
||||
$result['failed']++;
|
||||
$result['errors'][] = "Artwork #{$id}: not found or not owned by you";
|
||||
}
|
||||
|
||||
if ($artworks->isEmpty()) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
DB::beginTransaction();
|
||||
|
||||
try {
|
||||
foreach ($artworks as $artwork) {
|
||||
$this->applyAction($artwork, $action, $params);
|
||||
$result['success']++;
|
||||
}
|
||||
|
||||
DB::commit();
|
||||
|
||||
// Reindex affected artworks in Meilisearch
|
||||
$this->reindexArtworks($artworks);
|
||||
|
||||
Log::info('Studio bulk action completed', [
|
||||
'user_id' => $userId,
|
||||
'action' => $action,
|
||||
'count' => $result['success'],
|
||||
'ids' => $foundIds,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
DB::rollBack();
|
||||
$result['failed'] += $result['success'];
|
||||
$result['success'] = 0;
|
||||
$result['errors'][] = 'Transaction failed: ' . $e->getMessage();
|
||||
|
||||
Log::error('Studio bulk action failed', [
|
||||
'user_id' => $userId,
|
||||
'action' => $action,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
private function applyAction(Artwork $artwork, string $action, array $params): void
|
||||
{
|
||||
match ($action) {
|
||||
'publish' => $this->publish($artwork),
|
||||
'unpublish' => $this->unpublish($artwork),
|
||||
'archive' => $artwork->delete(), // Soft delete
|
||||
'unarchive' => $artwork->restore(),
|
||||
'delete' => $artwork->forceDelete(),
|
||||
'change_category' => $this->changeCategory($artwork, $params),
|
||||
'add_tags' => $this->addTags($artwork, $params),
|
||||
'remove_tags' => $this->removeTags($artwork, $params),
|
||||
default => throw new \InvalidArgumentException("Unknown action: {$action}"),
|
||||
};
|
||||
}
|
||||
|
||||
private function publish(Artwork $artwork): void
|
||||
{
|
||||
$artwork->update([
|
||||
'is_public' => true,
|
||||
'published_at' => $artwork->published_at ?? now(),
|
||||
]);
|
||||
}
|
||||
|
||||
private function unpublish(Artwork $artwork): void
|
||||
{
|
||||
$artwork->update(['is_public' => false]);
|
||||
}
|
||||
|
||||
private function changeCategory(Artwork $artwork, array $params): void
|
||||
{
|
||||
if (empty($params['category_id'])) {
|
||||
throw new \InvalidArgumentException('category_id required for change_category');
|
||||
}
|
||||
|
||||
$artwork->categories()->sync([(int) $params['category_id']]);
|
||||
}
|
||||
|
||||
private function addTags(Artwork $artwork, array $params): void
|
||||
{
|
||||
if (empty($params['tag_ids'])) {
|
||||
throw new \InvalidArgumentException('tag_ids required for add_tags');
|
||||
}
|
||||
|
||||
$pivotData = [];
|
||||
foreach ((array) $params['tag_ids'] as $tagId) {
|
||||
$pivotData[(int) $tagId] = ['source' => 'studio_bulk', 'confidence' => 1.0];
|
||||
}
|
||||
|
||||
$artwork->tags()->syncWithoutDetaching($pivotData);
|
||||
|
||||
// Increment usage counts
|
||||
Tag::whereIn('id', array_keys($pivotData))
|
||||
->increment('usage_count');
|
||||
}
|
||||
|
||||
private function removeTags(Artwork $artwork, array $params): void
|
||||
{
|
||||
if (empty($params['tag_ids'])) {
|
||||
throw new \InvalidArgumentException('tag_ids required for remove_tags');
|
||||
}
|
||||
|
||||
$tagIds = array_map('intval', (array) $params['tag_ids']);
|
||||
$artwork->tags()->detach($tagIds);
|
||||
|
||||
Tag::whereIn('id', $tagIds)
|
||||
->where('usage_count', '>', 0)
|
||||
->decrement('usage_count');
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger Meilisearch reindex for the given artworks.
|
||||
*/
|
||||
private function reindexArtworks(\Illuminate\Database\Eloquent\Collection $artworks): void
|
||||
{
|
||||
try {
|
||||
$artworks->each->searchable();
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('Studio: Failed to reindex artworks after bulk action', [
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Studio;
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Models\ArtworkStats;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Provides dashboard KPI data for the Studio overview page.
|
||||
*/
|
||||
final class StudioMetricsService
|
||||
{
|
||||
private const CACHE_TTL = 300; // 5 minutes
|
||||
|
||||
/**
|
||||
* Get dashboard KPI metrics for a creator.
|
||||
*
|
||||
* @return array{total_artworks: int, views_30d: int, favourites_30d: int, shares_30d: int, followers: int}
|
||||
*/
|
||||
public function getDashboardKpis(int $userId): array
|
||||
{
|
||||
$cacheKey = "studio.kpi.{$userId}";
|
||||
|
||||
return Cache::remember($cacheKey, self::CACHE_TTL, function () use ($userId) {
|
||||
$totalArtworks = Artwork::where('user_id', $userId)
|
||||
->whereNull('deleted_at')
|
||||
->count();
|
||||
|
||||
// Aggregate stats from artwork_stats for this user's artworks
|
||||
$statsAgg = DB::table('artwork_stats')
|
||||
->join('artworks', 'artworks.id', '=', 'artwork_stats.artwork_id')
|
||||
->where('artworks.user_id', $userId)
|
||||
->whereNull('artworks.deleted_at')
|
||||
->selectRaw('
|
||||
COALESCE(SUM(artwork_stats.views), 0) as total_views,
|
||||
COALESCE(SUM(artwork_stats.favorites), 0) as total_favourites,
|
||||
COALESCE(SUM(artwork_stats.shares_count), 0) as total_shares
|
||||
')
|
||||
->first();
|
||||
|
||||
// Views in last 30 days from hourly snapshots if available, fallback to totals
|
||||
$views30d = 0;
|
||||
try {
|
||||
if (\Illuminate\Support\Facades\Schema::hasTable('artwork_metric_snapshots_hourly')) {
|
||||
$views30d = (int) DB::table('artwork_metric_snapshots_hourly')
|
||||
->join('artworks', 'artworks.id', '=', 'artwork_metric_snapshots_hourly.artwork_id')
|
||||
->where('artworks.user_id', $userId)
|
||||
->where('artwork_metric_snapshots_hourly.bucket_hour', '>=', now()->subDays(30))
|
||||
->sum('artwork_metric_snapshots_hourly.views_count');
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// Table or column doesn't exist — fall back to totals
|
||||
}
|
||||
|
||||
if ($views30d === 0) {
|
||||
$views30d = (int) ($statsAgg->total_views ?? 0);
|
||||
}
|
||||
|
||||
$followers = DB::table('user_followers')
|
||||
->where('user_id', $userId)
|
||||
->count();
|
||||
|
||||
return [
|
||||
'total_artworks' => $totalArtworks,
|
||||
'views_30d' => $views30d,
|
||||
'favourites_30d' => (int) ($statsAgg->total_favourites ?? 0),
|
||||
'shares_30d' => (int) ($statsAgg->total_shares ?? 0),
|
||||
'followers' => $followers,
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get top performing artworks for a creator in the last 7 days.
|
||||
*
|
||||
* @return \Illuminate\Support\Collection
|
||||
*/
|
||||
public function getTopPerformers(int $userId, int $limit = 6): \Illuminate\Support\Collection
|
||||
{
|
||||
$cacheKey = "studio.top_performers.{$userId}";
|
||||
|
||||
return Cache::remember($cacheKey, self::CACHE_TTL, function () use ($userId, $limit) {
|
||||
return Artwork::where('user_id', $userId)
|
||||
->whereNull('deleted_at')
|
||||
->where('is_public', true)
|
||||
->with(['stats', 'tags'])
|
||||
->whereHas('stats')
|
||||
->orderByDesc(
|
||||
ArtworkStats::select('heat_score')
|
||||
->whereColumn('artwork_stats.artwork_id', 'artworks.id')
|
||||
->limit(1)
|
||||
)
|
||||
->limit($limit)
|
||||
->get()
|
||||
->map(fn (Artwork $art) => [
|
||||
'id' => $art->id,
|
||||
'title' => $art->title,
|
||||
'slug' => $art->slug,
|
||||
'thumb_url' => $art->thumbUrl('md'),
|
||||
'favourites' => (int) ($art->stats?->favorites ?? 0),
|
||||
'shares' => (int) ($art->stats?->shares_count ?? 0),
|
||||
'heat_score' => (float) ($art->stats?->heat_score ?? 0),
|
||||
'ranking_score' => (float) ($art->stats?->ranking_score ?? 0),
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recent comments on a creator's artworks.
|
||||
*
|
||||
* @return \Illuminate\Support\Collection
|
||||
*/
|
||||
public function getRecentComments(int $userId, int $limit = 5): \Illuminate\Support\Collection
|
||||
{
|
||||
return DB::table('artwork_comments')
|
||||
->join('artworks', 'artworks.id', '=', 'artwork_comments.artwork_id')
|
||||
->join('users', 'users.id', '=', 'artwork_comments.user_id')
|
||||
->where('artworks.user_id', $userId)
|
||||
->whereNull('artwork_comments.deleted_at')
|
||||
->orderByDesc('artwork_comments.created_at')
|
||||
->limit($limit)
|
||||
->select([
|
||||
'artwork_comments.id',
|
||||
'artwork_comments.content as body',
|
||||
'artwork_comments.created_at',
|
||||
'users.name as author_name',
|
||||
'users.username as author_username',
|
||||
'artworks.title as artwork_title',
|
||||
'artworks.slug as artwork_slug',
|
||||
])
|
||||
->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregate analytics across all artworks for the Studio Analytics page.
|
||||
*
|
||||
* @return array{totals: array, top_artworks: array, content_breakdown: array}
|
||||
*/
|
||||
public function getAnalyticsOverview(int $userId): array
|
||||
{
|
||||
$cacheKey = "studio.analytics_overview.{$userId}";
|
||||
|
||||
return Cache::remember($cacheKey, self::CACHE_TTL, function () use ($userId) {
|
||||
// Totals
|
||||
$totals = DB::table('artwork_stats')
|
||||
->join('artworks', 'artworks.id', '=', 'artwork_stats.artwork_id')
|
||||
->where('artworks.user_id', $userId)
|
||||
->whereNull('artworks.deleted_at')
|
||||
->selectRaw('
|
||||
COALESCE(SUM(artwork_stats.views), 0) as views,
|
||||
COALESCE(SUM(artwork_stats.favorites), 0) as favourites,
|
||||
COALESCE(SUM(artwork_stats.shares_count), 0) as shares,
|
||||
COALESCE(SUM(artwork_stats.downloads), 0) as downloads,
|
||||
COALESCE(SUM(artwork_stats.comments_count), 0) as comments,
|
||||
COALESCE(AVG(artwork_stats.ranking_score), 0) as avg_ranking,
|
||||
COALESCE(AVG(artwork_stats.heat_score), 0) as avg_heat
|
||||
')
|
||||
->first();
|
||||
|
||||
// Top 10 artworks by ranking score
|
||||
$topArtworks = Artwork::where('user_id', $userId)
|
||||
->whereNull('deleted_at')
|
||||
->where('is_public', true)
|
||||
->with(['stats'])
|
||||
->whereHas('stats')
|
||||
->orderByDesc(
|
||||
ArtworkStats::select('ranking_score')
|
||||
->whereColumn('artwork_stats.artwork_id', 'artworks.id')
|
||||
->limit(1)
|
||||
)
|
||||
->limit(10)
|
||||
->get()
|
||||
->map(fn (Artwork $art) => [
|
||||
'id' => $art->id,
|
||||
'title' => $art->title,
|
||||
'slug' => $art->slug,
|
||||
'thumb_url' => $art->thumbUrl('sq'),
|
||||
'views' => (int) ($art->stats?->views ?? 0),
|
||||
'favourites' => (int) ($art->stats?->favorites ?? 0),
|
||||
'shares' => (int) ($art->stats?->shares_count ?? 0),
|
||||
'downloads' => (int) ($art->stats?->downloads ?? 0),
|
||||
'comments' => (int) ($art->stats?->comments_count ?? 0),
|
||||
'ranking_score' => (float) ($art->stats?->ranking_score ?? 0),
|
||||
'heat_score' => (float) ($art->stats?->heat_score ?? 0),
|
||||
]);
|
||||
|
||||
// Content type breakdown
|
||||
$contentBreakdown = DB::table('artworks')
|
||||
->join('artwork_category', 'artwork_category.artwork_id', '=', 'artworks.id')
|
||||
->join('categories', 'categories.id', '=', 'artwork_category.category_id')
|
||||
->join('content_types', 'content_types.id', '=', 'categories.content_type_id')
|
||||
->where('artworks.user_id', $userId)
|
||||
->whereNull('artworks.deleted_at')
|
||||
->groupBy('content_types.id', 'content_types.name', 'content_types.slug')
|
||||
->select([
|
||||
'content_types.name',
|
||||
'content_types.slug',
|
||||
DB::raw('COUNT(DISTINCT artworks.id) as count'),
|
||||
])
|
||||
->orderByDesc('count')
|
||||
->get()
|
||||
->map(fn ($row) => [
|
||||
'name' => $row->name,
|
||||
'slug' => $row->slug,
|
||||
'count' => (int) $row->count,
|
||||
])
|
||||
->values()
|
||||
->all();
|
||||
|
||||
return [
|
||||
'totals' => [
|
||||
'views' => (int) ($totals->views ?? 0),
|
||||
'favourites' => (int) ($totals->favourites ?? 0),
|
||||
'shares' => (int) ($totals->shares ?? 0),
|
||||
'downloads' => (int) ($totals->downloads ?? 0),
|
||||
'comments' => (int) ($totals->comments ?? 0),
|
||||
'avg_ranking' => round((float) ($totals->avg_ranking ?? 0), 1),
|
||||
'avg_heat' => round((float) ($totals->avg_heat ?? 0), 1),
|
||||
],
|
||||
'top_artworks' => $topArtworks->values()->all(),
|
||||
'content_breakdown' => $contentBreakdown,
|
||||
];
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user