Implement creator studio and upload updates
This commit is contained in:
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user