Save workspace changes

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

View File

@@ -0,0 +1,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;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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