486 lines
21 KiB
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);
|
|
}
|
|
} |