Implement creator studio and upload updates
This commit is contained in:
31
app/Services/Studio/Contracts/CreatorStudioProvider.php
Normal file
31
app/Services/Studio/Contracts/CreatorStudioProvider.php
Normal 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;
|
||||
}
|
||||
354
app/Services/Studio/CreatorStudioActivityService.php
Normal file
354
app/Services/Studio/CreatorStudioActivityService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
242
app/Services/Studio/CreatorStudioAnalyticsService.php
Normal file
242
app/Services/Studio/CreatorStudioAnalyticsService.php
Normal 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();
|
||||
}
|
||||
}
|
||||
300
app/Services/Studio/CreatorStudioAssetService.php
Normal file
300
app/Services/Studio/CreatorStudioAssetService.php
Normal 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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
224
app/Services/Studio/CreatorStudioCalendarService.php
Normal file
224
app/Services/Studio/CreatorStudioCalendarService.php
Normal 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();
|
||||
}
|
||||
}
|
||||
200
app/Services/Studio/CreatorStudioChallengeService.php
Normal file
200
app/Services/Studio/CreatorStudioChallengeService.php
Normal 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();
|
||||
}
|
||||
}
|
||||
379
app/Services/Studio/CreatorStudioCommentService.php
Normal file
379
app/Services/Studio/CreatorStudioCommentService.php
Normal 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';
|
||||
}
|
||||
}
|
||||
486
app/Services/Studio/CreatorStudioContentService.php
Normal file
486
app/Services/Studio/CreatorStudioContentService.php
Normal 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);
|
||||
}
|
||||
}
|
||||
58
app/Services/Studio/CreatorStudioEventService.php
Normal file
58
app/Services/Studio/CreatorStudioEventService.php
Normal 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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
115
app/Services/Studio/CreatorStudioFollowersService.php
Normal file
115
app/Services/Studio/CreatorStudioFollowersService.php
Normal 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'],
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
243
app/Services/Studio/CreatorStudioGrowthService.php
Normal file
243
app/Services/Studio/CreatorStudioGrowthService.php
Normal 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);
|
||||
}
|
||||
}
|
||||
156
app/Services/Studio/CreatorStudioInboxService.php
Normal file
156
app/Services/Studio/CreatorStudioInboxService.php
Normal 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';
|
||||
}
|
||||
}
|
||||
322
app/Services/Studio/CreatorStudioOverviewService.php
Normal file
322
app/Services/Studio/CreatorStudioOverviewService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
260
app/Services/Studio/CreatorStudioPreferenceService.php
Normal file
260
app/Services/Studio/CreatorStudioPreferenceService.php
Normal 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',
|
||||
];
|
||||
}
|
||||
}
|
||||
194
app/Services/Studio/CreatorStudioScheduledService.php
Normal file
194
app/Services/Studio/CreatorStudioScheduledService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
154
app/Services/Studio/CreatorStudioSearchService.php
Normal file
154
app/Services/Studio/CreatorStudioSearchService.php
Normal 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';
|
||||
}
|
||||
}
|
||||
280
app/Services/Studio/Providers/ArtworkStudioProvider.php
Normal file
280
app/Services/Studio/Providers/ArtworkStudioProvider.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
261
app/Services/Studio/Providers/CardStudioProvider.php
Normal file
261
app/Services/Studio/Providers/CardStudioProvider.php
Normal 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;
|
||||
}
|
||||
}
|
||||
308
app/Services/Studio/Providers/CollectionStudioProvider.php
Normal file
308
app/Services/Studio/Providers/CollectionStudioProvider.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
277
app/Services/Studio/Providers/StoryStudioProvider.php
Normal file
277
app/Services/Studio/Providers/StoryStudioProvider.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user