296 lines
12 KiB
PHP
296 lines
12 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace App\Services\WebStories;
|
||
|
||
use App\Models\Artwork;
|
||
use App\Models\User;
|
||
use App\Models\World;
|
||
use App\Models\WorldSubmission;
|
||
use App\Models\WorldWebStory;
|
||
use App\Models\WorldWebStoryPage;
|
||
use Illuminate\Support\Collection;
|
||
use Illuminate\Support\Facades\DB;
|
||
use Illuminate\Support\Str;
|
||
use Illuminate\Validation\ValidationException;
|
||
|
||
final class WorldWebStoryGenerator
|
||
{
|
||
public function __construct(
|
||
private readonly WorldWebStoryAssetService $assets,
|
||
private readonly WorldWebStoryValidationService $validation,
|
||
) {
|
||
}
|
||
|
||
/**
|
||
* @return array{story: WorldWebStory, created: bool, validation: array{valid: bool, errors: list<string>, warnings: list<string>, page_count: int}}
|
||
*/
|
||
public function generateFromWorld(World $world, ?User $actor = null, int $pages = 7, bool $force = false, bool $publish = false, bool $dryRun = false): array
|
||
{
|
||
$pageCount = max(5, min(10, $pages));
|
||
$existing = WorldWebStory::query()->where('world_id', $world->id)->orderByDesc('id')->first();
|
||
|
||
if ($existing && ! $force && ! $dryRun) {
|
||
throw ValidationException::withMessages([
|
||
'world' => ['A web story already exists for this world. Use --force to rebuild it.'],
|
||
]);
|
||
}
|
||
|
||
$selectedArtworks = $this->candidateArtworks($world)->take(max(3, $pageCount - 3))->values();
|
||
$storyAttributes = [
|
||
'world_id' => $world->id,
|
||
'slug' => $existing?->slug ?: $this->uniqueSlug($world->slug, $existing?->id),
|
||
'title' => $existing?->title ?: (string) $world->title,
|
||
'subtitle' => $world->tagline,
|
||
'excerpt' => $world->summary ?: $world->tagline,
|
||
'description' => $world->description ?: $world->summary,
|
||
'seo_title' => trim((string) ($world->seo_title ?: ($world->title . ' – Skinbase Web Story'))),
|
||
'seo_description' => trim((string) ($world->seo_description ?: $world->summary ?: $world->description ?: '')),
|
||
'status' => WorldWebStory::STATUS_DRAFT,
|
||
'active' => true,
|
||
'noindex' => false,
|
||
'featured' => false,
|
||
'updated_by' => $actor?->id,
|
||
];
|
||
|
||
if (! $existing) {
|
||
$storyAttributes['created_by'] = $actor?->id;
|
||
}
|
||
|
||
$pagePayloads = $this->buildPagePayloads($world, $selectedArtworks, $pageCount);
|
||
|
||
if ($dryRun) {
|
||
$story = $existing ?? new WorldWebStory($storyAttributes);
|
||
$story->fill($storyAttributes);
|
||
$story->setRelation('orderedPages', collect($pagePayloads)->map(fn (array $page): WorldWebStoryPage => new WorldWebStoryPage($page)));
|
||
|
||
$this->assets->buildAssets($story, force: $force, dryRun: true);
|
||
$validation = $this->validation->validate($story);
|
||
|
||
return [
|
||
'story' => $story,
|
||
'created' => ! $existing,
|
||
'validation' => $validation,
|
||
];
|
||
}
|
||
|
||
$story = DB::transaction(function () use ($existing, $storyAttributes, $pagePayloads): WorldWebStory {
|
||
$story = $existing ?? new WorldWebStory();
|
||
$story->fill($storyAttributes);
|
||
$story->save();
|
||
|
||
$story->pages()->delete();
|
||
|
||
foreach ($pagePayloads as $pagePayload) {
|
||
$story->pages()->create($pagePayload);
|
||
}
|
||
|
||
return $story->fresh(['orderedPages', 'world']);
|
||
});
|
||
|
||
$this->assets->buildAssets($story, force: $force);
|
||
$story->refresh()->load('orderedPages', 'world');
|
||
|
||
if ($publish) {
|
||
$this->validation->assertPublishable($story);
|
||
$story->forceFill([
|
||
'status' => WorldWebStory::STATUS_PUBLISHED,
|
||
'published_at' => now(),
|
||
])->save();
|
||
}
|
||
|
||
return [
|
||
'story' => $story->fresh(['orderedPages', 'world']),
|
||
'created' => ! $existing,
|
||
'validation' => $this->validation->validate($story),
|
||
];
|
||
}
|
||
|
||
/**
|
||
* @return Collection<int, Artwork>
|
||
*/
|
||
private function candidateArtworks(World $world): Collection
|
||
{
|
||
$relationIds = $world->worldRelations()
|
||
->where('related_type', 'artwork')
|
||
->orderByDesc('is_featured')
|
||
->orderBy('sort_order')
|
||
->pluck('related_id')
|
||
->map(fn ($id): int => (int) $id)
|
||
->filter()
|
||
->values();
|
||
|
||
$artworks = collect();
|
||
|
||
if ($relationIds->isNotEmpty()) {
|
||
$artworks = Artwork::query()
|
||
->whereIn('id', $relationIds)
|
||
->get()
|
||
->sortBy(fn (Artwork $artwork): int => $relationIds->search((int) $artwork->id))
|
||
->values();
|
||
}
|
||
|
||
if ($artworks->count() < 3) {
|
||
$submissionArtworks = WorldSubmission::query()
|
||
->with('artwork.user')
|
||
->where('world_id', $world->id)
|
||
->where('status', WorldSubmission::STATUS_LIVE)
|
||
->orderByDesc('is_featured')
|
||
->orderByDesc('featured_at')
|
||
->orderByDesc('id')
|
||
->get()
|
||
->pluck('artwork')
|
||
->filter(fn ($artwork): bool => $artwork instanceof Artwork);
|
||
|
||
$artworks = $artworks->concat($submissionArtworks)->unique(fn (Artwork $artwork): int => (int) $artwork->id)->values();
|
||
}
|
||
|
||
return $artworks;
|
||
}
|
||
|
||
/**
|
||
* @param Collection<int, Artwork> $artworks
|
||
* @return list<array<string, mixed>>
|
||
*/
|
||
private function buildPagePayloads(World $world, Collection $artworks, int $pageCount): array
|
||
{
|
||
$primaryArtwork = $artworks->get(0);
|
||
$secondaryArtwork = $artworks->get(1) ?: $primaryArtwork;
|
||
$tertiaryArtwork = $artworks->get(2) ?: $secondaryArtwork;
|
||
|
||
$pages = [
|
||
[
|
||
'position' => 1,
|
||
'layout' => WorldWebStoryPage::LAYOUT_COVER,
|
||
'background_type' => WorldWebStoryPage::BACKGROUND_IMAGE,
|
||
'headline' => (string) $world->title,
|
||
'body' => Str::limit((string) ($world->tagline ?: $world->summary ?: 'A cinematic Skinbase World.'), 160, ''),
|
||
'caption' => 'Skinbase World',
|
||
'alt_text' => (string) $world->title,
|
||
'text_position' => 'bottom',
|
||
'overlay_strength' => 45,
|
||
'animation' => 'fade-in',
|
||
'active' => true,
|
||
],
|
||
[
|
||
'position' => 2,
|
||
'layout' => WorldWebStoryPage::LAYOUT_MOOD,
|
||
'background_type' => WorldWebStoryPage::BACKGROUND_IMAGE,
|
||
'headline' => 'Step into ' . $world->title,
|
||
'body' => Str::limit((string) ($world->summary ?: $world->description ?: 'Curated visuals, featured creators, and a clear editorial mood.'), 170, ''),
|
||
'caption' => 'World intro',
|
||
'alt_text' => 'Intro for ' . $world->title,
|
||
'text_position' => 'bottom',
|
||
'overlay_strength' => 35,
|
||
'animation' => 'fly-in-bottom',
|
||
'active' => true,
|
||
],
|
||
];
|
||
|
||
if ($primaryArtwork instanceof Artwork) {
|
||
$pages[] = [
|
||
'position' => count($pages) + 1,
|
||
'layout' => WorldWebStoryPage::LAYOUT_ARTWORK,
|
||
'artwork_id' => $primaryArtwork->id,
|
||
'background_type' => WorldWebStoryPage::BACKGROUND_IMAGE,
|
||
'headline' => (string) ($primaryArtwork->title ?: 'Featured artwork'),
|
||
'body' => Str::limit('A featured visual from ' . $world->title . ' by ' . ($primaryArtwork->user?->name ?: $primaryArtwork->user?->username ?: 'a Skinbase creator') . '.', 160, ''),
|
||
'caption' => 'Featured artwork',
|
||
'alt_text' => (string) ($primaryArtwork->title ?: 'Featured artwork'),
|
||
'text_position' => 'bottom',
|
||
'overlay_strength' => 35,
|
||
'animation' => 'pan-left',
|
||
'active' => true,
|
||
];
|
||
}
|
||
|
||
if ($secondaryArtwork instanceof Artwork) {
|
||
$pages[] = [
|
||
'position' => count($pages) + 1,
|
||
'layout' => WorldWebStoryPage::LAYOUT_CREATOR,
|
||
'artwork_id' => $secondaryArtwork->id,
|
||
'background_type' => WorldWebStoryPage::BACKGROUND_IMAGE,
|
||
'headline' => 'Creator spotlight',
|
||
'body' => Str::limit(($secondaryArtwork->user?->name ?: $secondaryArtwork->user?->username ?: 'A featured creator') . ' helps define the mood of ' . $world->title . '.', 160, ''),
|
||
'caption' => 'Creator spotlight',
|
||
'alt_text' => (string) ($secondaryArtwork->title ?: 'Creator spotlight artwork'),
|
||
'text_position' => 'bottom',
|
||
'overlay_strength' => 40,
|
||
'animation' => 'fade-in',
|
||
'active' => true,
|
||
];
|
||
}
|
||
|
||
if ($tertiaryArtwork instanceof Artwork) {
|
||
$pages[] = [
|
||
'position' => count($pages) + 1,
|
||
'layout' => WorldWebStoryPage::LAYOUT_COLLECTION,
|
||
'artwork_id' => $tertiaryArtwork->id,
|
||
'background_type' => WorldWebStoryPage::BACKGROUND_IMAGE,
|
||
'headline' => 'More from this World',
|
||
'body' => Str::limit('Explore more wallpapers, digital art, and creator picks collected inside ' . $world->title . '.', 155, ''),
|
||
'caption' => 'Community picks',
|
||
'alt_text' => (string) ($tertiaryArtwork->title ?: 'World picks'),
|
||
'text_position' => 'bottom',
|
||
'overlay_strength' => 35,
|
||
'animation' => 'pan-right',
|
||
'active' => true,
|
||
];
|
||
}
|
||
|
||
while (count($pages) < max(5, $pageCount - 1)) {
|
||
$pages[] = [
|
||
'position' => count($pages) + 1,
|
||
'layout' => WorldWebStoryPage::LAYOUT_MOOD,
|
||
'background_type' => WorldWebStoryPage::BACKGROUND_IMAGE,
|
||
'headline' => 'Inside the theme',
|
||
'body' => Str::limit('A short visual pause that keeps the story connected to ' . $world->title . '.', 150, ''),
|
||
'caption' => 'World mood',
|
||
'alt_text' => 'Mood page for ' . $world->title,
|
||
'text_position' => 'bottom',
|
||
'overlay_strength' => 35,
|
||
'animation' => 'fade-in',
|
||
'active' => true,
|
||
];
|
||
}
|
||
|
||
$pages[] = [
|
||
'position' => count($pages) + 1,
|
||
'layout' => WorldWebStoryPage::LAYOUT_CTA,
|
||
'background_type' => WorldWebStoryPage::BACKGROUND_IMAGE,
|
||
'headline' => 'Explore ' . $world->title,
|
||
'body' => Str::limit('Open the full World page for the complete artwork grid, featured picks, and related creator content.', 160, ''),
|
||
'caption' => 'Continue on Skinbase',
|
||
'cta_label' => 'View World',
|
||
'cta_url' => $world->publicUrl(),
|
||
'alt_text' => 'Explore ' . $world->title . ' on Skinbase',
|
||
'text_position' => 'bottom',
|
||
'overlay_strength' => 45,
|
||
'animation' => 'pulse',
|
||
'active' => true,
|
||
];
|
||
|
||
return collect($pages)
|
||
->take($pageCount)
|
||
->values()
|
||
->map(fn (array $page, int $index): array => array_merge($page, [
|
||
'position' => $index + 1,
|
||
]))
|
||
->all();
|
||
}
|
||
|
||
private function uniqueSlug(string $base, ?int $ignoreId = null): string
|
||
{
|
||
$candidate = Str::slug($base) ?: 'web-story';
|
||
$slug = $candidate;
|
||
$suffix = 2;
|
||
|
||
while (WorldWebStory::query()->when($ignoreId, fn ($query) => $query->whereKeyNot($ignoreId))->where('slug', $slug)->exists()) {
|
||
$slug = $candidate . '-' . $suffix;
|
||
$suffix++;
|
||
}
|
||
|
||
return $slug;
|
||
}
|
||
} |