Files
SkinbaseNova/app/Services/Studio/CreatorStudioContentService.php

486 lines
21 KiB
PHP

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