Implement creator studio and upload updates

This commit is contained in:
2026-04-04 10:12:02 +02:00
parent 1da7d3bf88
commit 0b216b7ecd
15107 changed files with 31206 additions and 626514 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,486 @@
<?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()));
$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',
};
$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' => [
'score' => $score,
'max' => 4,
'label' => $label,
'can_publish' => $score >= 3,
'missing' => $missing,
],
'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,280 @@
<?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->where('is_public', false)
->orWhere('artwork_status', 'draft');
})
->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'])
->orderByDesc('updated_at')
->limit($limit);
if ($bucket === 'drafts') {
$query->whereNull('deleted_at')
->where(function (Builder $builder): void {
$builder->where('is_public', false)
->orWhere('artwork_status', 'draft');
});
} 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' => false,
'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,
];
}
}