*/ public static function relationTypes(): array { return [ ArtworkRelation::TYPE_REMAKE_OF, ArtworkRelation::TYPE_REMASTER_OF, ArtworkRelation::TYPE_REVISION_OF, ArtworkRelation::TYPE_INSPIRED_BY, ArtworkRelation::TYPE_VARIATION_OF, ]; } public function __construct( private readonly ArtworkMaturityService $maturity, private readonly GroupService $groups, ) { } /** * @return array> */ public function relationTypeOptions(): array { return array_map(fn (string $type): array => [ 'value' => $type, 'label' => $this->relationTypeLabel($type), 'short_label' => $this->relationTypeShortLabel($type), ], self::relationTypes()); } /** * @param array{target_artwork_id?: int|null, relation_type?: string|null, note?: string|null} $payload */ public function syncPrimaryRelation(Artwork $sourceArtwork, User $actor, array $payload): ?ArtworkRelation { $this->ensureManageable($actor, $sourceArtwork, 'You can only update evolution links for artworks you manage.'); $targetArtworkId = (int) ($payload['target_artwork_id'] ?? 0); $relationType = $this->normalizeRelationType((string) ($payload['relation_type'] ?? ArtworkRelation::TYPE_REMAKE_OF)); $note = $this->normalizeNote($payload['note'] ?? null); if ($targetArtworkId <= 0) { ArtworkRelation::query()->where('source_artwork_id', (int) $sourceArtwork->id)->delete(); return null; } if ($targetArtworkId === (int) $sourceArtwork->id) { throw ValidationException::withMessages([ 'evolution_target_artwork_id' => 'Choose an older artwork, not the artwork you are editing right now.', ]); } $targetArtwork = Artwork::query() ->with(['group.members']) ->find($targetArtworkId); if (! $targetArtwork) { throw ValidationException::withMessages([ 'evolution_target_artwork_id' => 'Choose a valid artwork to link as the original version.', ]); } $this->ensureManageable($actor, $targetArtwork, 'You can only link artworks that you are allowed to manage.'); if (! $this->isPubliclyVisible($targetArtwork)) { throw ValidationException::withMessages([ 'evolution_target_artwork_id' => 'Choose a published public artwork for the original version.', ]); } if (! $this->isOlderVersionCandidate($sourceArtwork, $targetArtwork)) { throw ValidationException::withMessages([ 'evolution_target_artwork_id' => 'Choose an older artwork as the original version for this Then & Now story.', ]); } return DB::transaction(function () use ($sourceArtwork, $targetArtwork, $actor, $relationType, $note): ArtworkRelation { ArtworkRelation::query() ->where('source_artwork_id', (int) $sourceArtwork->id) ->delete(); return ArtworkRelation::query()->create([ 'source_artwork_id' => (int) $sourceArtwork->id, 'target_artwork_id' => (int) $targetArtwork->id, 'relation_type' => $relationType, 'note' => $note, 'sort_order' => 0, 'created_by_user_id' => (int) $actor->id, ])->load([ 'targetArtwork.user.profile', 'targetArtwork.group', 'targetArtwork.categories.contentType', ]); }); } /** * @return array|null */ public function editorRelation(Artwork $artwork, User $actor): ?array { $relation = ArtworkRelation::query() ->with(['targetArtwork.user.profile', 'targetArtwork.group', 'targetArtwork.categories.contentType']) ->where('source_artwork_id', (int) $artwork->id) ->orderBy('sort_order') ->orderBy('id') ->first(); if (! $relation || ! $relation->targetArtwork) { return null; } return [ 'id' => (int) $relation->id, 'relation_type' => (string) $relation->relation_type, 'relation_label' => $this->relationTypeLabel((string) $relation->relation_type), 'short_label' => $this->relationTypeShortLabel((string) $relation->relation_type), 'note' => $relation->note, 'target_artwork' => $this->mapStudioOption($relation->targetArtwork, $actor), ]; } /** * @return array> */ public function manageableSearchOptions(Artwork $sourceArtwork, User $actor, string $search = '', int $limit = 18): array { $this->ensureManageable($actor, $sourceArtwork, 'You can only search evolution links for artworks you manage.'); $manageableGroupIds = collect($this->groups->studioOptionsForUser($actor)) ->filter(fn (array $group): bool => (bool) data_get($group, 'permissions.can_publish_artworks', false)) ->pluck('id') ->map(static fn ($id): int => (int) $id) ->filter() ->values(); $term = trim($search); $query = Artwork::query() ->with(['user.profile', 'group', 'categories.contentType']) ->whereKeyNot((int) $sourceArtwork->id) ->whereNull('deleted_at') ->where('is_public', true) ->where('is_approved', true) ->whereNotNull('published_at') ->where('published_at', '<=', now()) ->where(function ($builder) use ($actor, $manageableGroupIds): void { $builder->where('user_id', (int) $actor->id); if ($manageableGroupIds->isNotEmpty()) { $builder->orWhereIn('group_id', $manageableGroupIds->all()); } }); if ($term !== '') { $like = '%' . str_replace(['%', '_'], ['\\%', '\\_'], $term) . '%'; $query->where(function ($builder) use ($like): void { $builder->where('title', 'like', $like) ->orWhere('slug', 'like', $like) ->orWhereHas('group', fn ($groupQuery) => $groupQuery->where('name', 'like', $like)) ->orWhereHas('user', fn ($userQuery) => $userQuery ->where('name', 'like', $like) ->orWhere('username', 'like', $like)); }); } $referenceTimestamp = $this->comparisonTimestamp($sourceArtwork); return $query ->orderByRaw('CASE WHEN user_id = ? THEN 0 ELSE 1 END', [(int) $actor->id]) ->orderByRaw('CASE WHEN published_at IS NULL THEN 1 ELSE 0 END') ->orderByDesc('published_at') ->limit(max(1, min($limit, 36))) ->get() ->filter(fn (Artwork $candidate): bool => $referenceTimestamp === null || $this->comparisonTimestamp($candidate)?->lte($referenceTimestamp) || $candidate->published_at === null) ->map(fn (Artwork $candidate): array => $this->mapStudioOption($candidate, $actor)) ->values() ->all(); } /** * @return array|null */ public function publicPayload(Artwork $artwork, ?User $viewer = null): ?array { $primaryRelation = ArtworkRelation::query() ->with([ 'sourceArtwork.user.profile', 'sourceArtwork.group', 'sourceArtwork.categories.contentType', 'targetArtwork.user.profile', 'targetArtwork.group', 'targetArtwork.categories.contentType', ]) ->where('source_artwork_id', (int) $artwork->id) ->orderBy('sort_order') ->orderBy('id') ->first(); $incomingRelations = ArtworkRelation::query() ->with([ 'sourceArtwork.user.profile', 'sourceArtwork.group', 'sourceArtwork.categories.contentType', 'targetArtwork.user.profile', 'targetArtwork.group', 'targetArtwork.categories.contentType', ]) ->where('target_artwork_id', (int) $artwork->id) ->orderByDesc('updated_at') ->orderByDesc('id') ->limit(4) ->get(); $primary = $primaryRelation ? $this->mapPrimaryPanel($primaryRelation, $viewer) : null; $updates = $incomingRelations ->map(fn (ArtworkRelation $relation): ?array => $this->mapIncomingUpdate($relation, $viewer)) ->filter() ->values() ->all(); if ($primary === null && $updates === []) { return null; } return [ 'eyebrow' => 'Artwork Evolution', 'primary' => $primary, 'updates' => $updates, ]; } private function ensureManageable(User $actor, Artwork $artwork, string $message): void { if (! Gate::forUser($actor)->allows('update', $artwork)) { throw ValidationException::withMessages([ 'evolution_target_artwork_id' => $message, ]); } } private function isPubliclyVisible(Artwork $artwork): bool { return ! $artwork->trashed() && (bool) $artwork->is_public && (bool) $artwork->is_approved && $artwork->published_at !== null && $artwork->published_at->lte(now()); } private function isOlderVersionCandidate(Artwork $sourceArtwork, Artwork $targetArtwork): bool { $sourceTimestamp = $this->comparisonTimestamp($sourceArtwork); $targetTimestamp = $this->comparisonTimestamp($targetArtwork); if ($sourceTimestamp === null || $targetTimestamp === null) { return true; } return $targetTimestamp->lt($sourceTimestamp); } private function comparisonTimestamp(Artwork $artwork): ?Carbon { $value = $artwork->published_at ?: $artwork->created_at; return $value instanceof Carbon ? $value : ($value ? Carbon::parse($value) : null); } private function normalizeRelationType(string $type): string { $normalized = Str::lower(trim($type)); return in_array($normalized, self::relationTypes(), true) ? $normalized : ArtworkRelation::TYPE_REMAKE_OF; } private function normalizeNote(mixed $note): ?string { $resolved = trim((string) $note); return $resolved !== '' ? $resolved : null; } /** * @return array */ private function mapStudioOption(Artwork $artwork, User $actor): array { $category = $artwork->categories->sortBy('sort_order')->first(); $publishedAt = $artwork->published_at; $year = $publishedAt?->year ?: $artwork->created_at?->year; return [ 'id' => (int) $artwork->id, 'title' => html_entity_decode((string) $artwork->title, ENT_QUOTES | ENT_HTML5, 'UTF-8'), 'publisher' => $artwork->group?->name ?: $artwork->user?->name ?: $artwork->user?->username ?: 'Artist', 'year' => $year, 'published_at' => optional($publishedAt)->toIsoString(), 'thumbnail' => ThumbnailPresenter::present($artwork, 'md')['url'] ?? null, 'url' => route('art.show', [ 'id' => (int) $artwork->id, 'slug' => Str::slug((string) ($artwork->slug ?: $artwork->title)) ?: (string) $artwork->id, ]), 'studio_edit_url' => route('studio.artworks.edit', ['id' => (int) $artwork->id]), 'content_type' => $category?->contentType?->name, 'category' => $category?->name, 'is_manageable' => Gate::forUser($actor)->allows('update', $artwork), ]; } /** * @return array|null */ private function mapPrimaryPanel(ArtworkRelation $relation, ?User $viewer): ?array { $beforeArtwork = $relation->targetArtwork; $afterArtwork = $relation->sourceArtwork; if (! $beforeArtwork || ! $afterArtwork) { return null; } if (! $this->isPubliclyVisible($beforeArtwork) || ! $this->isPubliclyVisible($afterArtwork)) { return null; } $before = $this->mapPublicCard($beforeArtwork, $viewer, 'Original'); $after = $this->mapPublicCard($afterArtwork, $viewer, $this->relationTypeShortLabel((string) $relation->relation_type)); if ($this->shouldOmitForViewer($before) || $this->shouldOmitForViewer($after)) { return null; } $beforeYear = $before['year'] ?? null; $afterYear = $after['year'] ?? null; $yearsApart = $this->yearsApart($beforeYear, $afterYear); return [ 'id' => (int) $relation->id, 'relation_type' => (string) $relation->relation_type, 'relation_label' => $this->relationTypeLabel((string) $relation->relation_type), 'heading' => 'Then & Now', 'summary' => $this->primarySummary($beforeYear, $yearsApart), 'years_apart' => $yearsApart, 'years_apart_label' => $yearsApart !== null && $yearsApart > 0 ? $yearsApart . ' years later' : null, 'note' => $relation->note, 'before' => $before, 'after' => $after, 'compare' => [ 'available' => $this->compareAvailable($before, $after), 'title' => 'Then & Now comparison', ], ]; } /** * @return array|null */ private function mapIncomingUpdate(ArtworkRelation $relation, ?User $viewer): ?array { $beforeArtwork = $relation->targetArtwork; $afterArtwork = $relation->sourceArtwork; if (! $beforeArtwork || ! $afterArtwork) { return null; } if (! $this->isPubliclyVisible($beforeArtwork) || ! $this->isPubliclyVisible($afterArtwork)) { return null; } $before = $this->mapPublicCard($beforeArtwork, $viewer, 'Original'); $after = $this->mapPublicCard($afterArtwork, $viewer, $this->relationTypeShortLabel((string) $relation->relation_type)); if ($this->shouldOmitForViewer($before) || $this->shouldOmitForViewer($after)) { return null; } $yearsApart = $this->yearsApart($before['year'] ?? null, $after['year'] ?? null); return [ 'id' => (int) $relation->id, 'relation_type' => (string) $relation->relation_type, 'relation_label' => $this->relationTypeLabel((string) $relation->relation_type), 'heading' => 'Updated Version', 'summary' => $this->incomingSummary($after['year'] ?? null, $yearsApart), 'years_apart' => $yearsApart, 'years_apart_label' => $yearsApart !== null && $yearsApart > 0 ? $yearsApart . ' years later' : null, 'note' => $relation->note, 'before' => $before, 'after' => $after, 'compare' => [ 'available' => $this->compareAvailable($before, $after), 'title' => 'Compare versions', ], ]; } /** * @return array */ private function mapPublicCard(Artwork $artwork, ?User $viewer, string $roleLabel): array { $category = $artwork->categories->sortBy('sort_order')->first(); $md = ThumbnailPresenter::present($artwork, 'md'); $lg = ThumbnailPresenter::present($artwork, 'lg'); $xl = ThumbnailPresenter::present($artwork, 'xl'); $publishedAt = $artwork->published_at; return $this->maturity->decoratePayload([ 'id' => (int) $artwork->id, 'title' => html_entity_decode((string) $artwork->title, ENT_QUOTES | ENT_HTML5, 'UTF-8'), 'url' => route('art.show', [ 'id' => (int) $artwork->id, 'slug' => Str::slug((string) ($artwork->slug ?: $artwork->title)) ?: (string) $artwork->id, ]), 'publisher' => $artwork->group?->name ?: $artwork->user?->name ?: $artwork->user?->username ?: 'Artist', 'published_at' => optional($publishedAt)->toIsoString(), 'year' => $publishedAt?->year ?: $artwork->created_at?->year, 'role_label' => $roleLabel, 'thumbnail' => $md['url'] ?? null, 'image_md' => $md['url'] ?? null, 'image_lg' => $lg['url'] ?? null, 'image_xl' => $xl['url'] ?? null, 'width' => (int) ($artwork->width ?? 0), 'height' => (int) ($artwork->height ?? 0), 'content_type' => $category?->contentType?->name, 'category' => $category?->name, ], $artwork, $viewer); } /** * @param array $card */ private function shouldOmitForViewer(array $card): bool { return (bool) data_get($card, 'maturity.should_hide', false); } /** * @param array $before * @param array $after */ private function compareAvailable(array $before, array $after): bool { return ! empty($before['image_lg']) && ! empty($after['image_lg']); } private function yearsApart(mixed $beforeYear, mixed $afterYear): ?int { if (! is_numeric($beforeYear) || ! is_numeric($afterYear)) { return null; } return max(0, (int) $afterYear - (int) $beforeYear); } private function primarySummary(mixed $beforeYear, ?int $yearsApart): string { if (is_numeric($beforeYear) && $yearsApart !== null && $yearsApart > 0) { return sprintf('This artwork revisits an earlier version from %d, %d years later.', (int) $beforeYear, $yearsApart); } if (is_numeric($beforeYear)) { return sprintf('This artwork revisits an earlier version from %d.', (int) $beforeYear); } return 'This artwork revisits an earlier version from the creator archive.'; } private function incomingSummary(mixed $afterYear, ?int $yearsApart): string { if (is_numeric($afterYear) && $yearsApart !== null && $yearsApart > 0) { return sprintf('This artwork was later revisited in %d, %d years later.', (int) $afterYear, $yearsApart); } if (is_numeric($afterYear)) { return sprintf('This artwork was later revisited in %d.', (int) $afterYear); } return 'This artwork later received an updated version from the same creator.'; } private function relationTypeLabel(string $type): string { return match ($type) { ArtworkRelation::TYPE_REMASTER_OF => 'Remaster of', ArtworkRelation::TYPE_REVISION_OF => 'Revision of', ArtworkRelation::TYPE_INSPIRED_BY => 'Inspired by', ArtworkRelation::TYPE_VARIATION_OF => 'Variation of', default => 'Remake of', }; } private function relationTypeShortLabel(string $type): string { return match ($type) { ArtworkRelation::TYPE_REMASTER_OF => 'Remaster', ArtworkRelation::TYPE_REVISION_OF => 'Update', ArtworkRelation::TYPE_INSPIRED_BY => 'Inspired take', ArtworkRelation::TYPE_VARIATION_OF => 'Variation', default => 'Remake', }; } }