Save workspace changes

This commit is contained in:
2026-04-18 17:02:56 +02:00
parent f02ea9a711
commit 87d60af5a9
4220 changed files with 1388603 additions and 1554 deletions

View File

@@ -0,0 +1,479 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\Artwork;
use App\Models\ArtworkFeature;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection as SupportCollection;
use Illuminate\Support\Str;
class FeaturedArtworkAdminService
{
public function __construct(private readonly ArtworkService $artworks)
{
}
/**
* @return array<string, mixed>
*/
public function pageProps(): array
{
$now = Carbon::now();
$features = ArtworkFeature::query()
->with([
'artwork' => fn ($query) => $query->withTrashed()->with([
'user:id,username,name',
'user.profile:user_id,avatar_hash',
'group:id,name,slug,avatar_path',
'awardStat:artwork_id,gold_count,silver_count,bronze_count,score_total,score_7d,score_30d,last_medaled_at,updated_at',
]),
])
->orderByDesc('priority')
->orderByDesc('featured_at')
->orderByDesc('id')
->get();
$duplicateCounts = $features->countBy(fn (ArtworkFeature $feature): int => (int) $feature->artwork_id);
$entries = $features
->map(fn (ArtworkFeature $feature): array => $this->mapFeature($feature, $duplicateCounts, $now))
->sort(function (array $left, array $right): int {
$comparisons = [
(int) $right['eligibility']['is_eligible'] <=> (int) $left['eligibility']['is_eligible'],
(int) $right['is_force_hero'] <=> (int) $left['is_force_hero'],
(int) $right['is_active'] <=> (int) $left['is_active'],
(int) $left['is_expired'] <=> (int) $right['is_expired'],
(int) $right['priority'] <=> (int) $left['priority'],
(int) $right['medals']['score_30d'] <=> (int) $left['medals']['score_30d'],
$this->timestamp($right['featured_at']) <=> $this->timestamp($left['featured_at']),
$this->timestamp($right['artwork']['published_at']) <=> $this->timestamp($left['artwork']['published_at']),
(int) $right['id'] <=> (int) $left['id'],
];
foreach ($comparisons as $comparison) {
if ($comparison !== 0) {
return $comparison;
}
}
return 0;
})
->values();
$eligibleEntries = $this->sortForHeroSelection(
$entries->filter(fn (array $entry): bool => (bool) $entry['eligibility']['is_eligible'])->values()
);
$sharedWinnerArtworkId = $this->artworks->getFeaturedArtworkWinner()?->id;
$winner = $sharedWinnerArtworkId
? $entries->first(fn (array $entry): bool => (int) $entry['artwork_id'] === (int) $sharedWinnerArtworkId)
: null;
if (! is_array($winner) && $eligibleEntries->isNotEmpty()) {
$winner = $eligibleEntries->first();
}
$winnerReason = is_array($winner) ? $this->buildWinnerReason($winner, $eligibleEntries) : null;
$winnerId = is_array($winner) ? (int) $winner['id'] : null;
$entries = $entries
->map(function (array $entry) use ($winnerId, $winnerReason): array {
$isWinner = $winnerId !== null && (int) $entry['id'] === $winnerId;
if ($isWinner) {
array_unshift($entry['status_badges'], [
'label' => 'Winner',
'tone' => 'amber',
]);
}
$entry['is_winner'] = $isWinner;
$entry['winner_reason'] = $isWinner ? $winnerReason : null;
return $entry;
})
->values();
$winner = is_array($winner)
? array_merge($winner, ['selection_reason' => $winnerReason])
: null;
return [
'entries' => $entries->all(),
'winner' => $winner,
'stats' => [
'total' => $entries->count(),
'active' => $entries->where('is_active', true)->count(),
'inactive' => $entries->where('is_active', false)->count(),
'expired' => $entries->where('is_expired', true)->count(),
'eligible' => $entries->filter(fn (array $entry): bool => (bool) $entry['eligibility']['is_eligible'])->count(),
'ineligible' => $entries->filter(fn (array $entry): bool => ! $entry['eligibility']['is_eligible'])->count(),
],
];
}
/**
* @return array<int, array<string, mixed>>
*/
public function searchArtworks(string $term, int $limit = 12): array
{
$term = trim($term);
if ($term === '') {
return [];
}
$now = Carbon::now();
$artworks = Artwork::query()
->with([
'user:id,username,name',
'user.profile:user_id,avatar_hash',
'group:id,name,slug,avatar_path',
'awardStat:artwork_id,gold_count,silver_count,bronze_count,score_total,score_7d,score_30d,last_medaled_at,updated_at',
])
->where(function ($query) use ($term): void {
if (ctype_digit($term)) {
$query->where('artworks.id', (int) $term);
}
$query->orWhere('artworks.title', 'like', '%' . $term . '%')
->orWhere('artworks.slug', 'like', '%' . $term . '%')
->orWhereHas('user', function ($userQuery) use ($term): void {
$userQuery->where('username', 'like', '%' . $term . '%')
->orWhere('name', 'like', '%' . $term . '%');
})
->orWhereHas('group', function ($groupQuery) use ($term): void {
$groupQuery->where('name', 'like', '%' . $term . '%')
->orWhere('slug', 'like', '%' . $term . '%');
});
});
if (ctype_digit($term)) {
$artworks->orderByRaw('CASE WHEN artworks.id = ? THEN 0 ELSE 1 END', [(int) $term]);
}
$artworks = $artworks
->orderByDesc('published_at')
->limit($limit)
->get();
$featureCounts = ArtworkFeature::query()
->whereNull('deleted_at')
->whereIn('artwork_id', $artworks->pluck('id'))
->selectRaw('artwork_id, COUNT(*) as aggregate')
->groupBy('artwork_id')
->pluck('aggregate', 'artwork_id');
return $artworks
->map(fn (Artwork $artwork): array => $this->mapArtworkCandidate($artwork, (int) ($featureCounts[(int) $artwork->id] ?? 0), $now))
->values()
->all();
}
/**
* @param SupportCollection<int, int> $duplicateCounts
* @return array<string, mixed>
*/
private function mapFeature(ArtworkFeature $feature, SupportCollection $duplicateCounts, Carbon $now): array
{
$context = $this->mapArtworkContext($feature->artwork, $now);
$isExpired = $feature->expires_at !== null && $feature->expires_at->lte($now);
$isEligible = (bool) $feature->is_active && ! $isExpired && (bool) $context['eligibility']['is_eligible'];
$eligibilityReasons = $context['eligibility']['reasons'];
if (! $feature->is_active) {
$eligibilityReasons[] = 'Inactive';
}
if ($isExpired) {
$eligibilityReasons[] = 'Expired';
}
$statusBadges = [];
if ($feature->is_active && ! $isExpired) {
$statusBadges[] = ['label' => 'Active', 'tone' => 'emerald'];
}
if ((bool) $feature->force_hero) {
$statusBadges[] = ['label' => 'Force Hero', 'tone' => 'amber'];
}
if (! $feature->is_active) {
$statusBadges[] = ['label' => 'Inactive', 'tone' => 'slate'];
}
if ($isExpired) {
$statusBadges[] = ['label' => 'Expired', 'tone' => 'amber'];
}
$statusBadges[] = $isEligible
? ['label' => 'Eligible', 'tone' => 'sky']
: ['label' => 'Not eligible', 'tone' => 'rose'];
if ((bool) $context['flags']['is_private']) {
$statusBadges[] = ['label' => 'Private', 'tone' => 'slate'];
}
if ((bool) $context['flags']['is_unpublished']) {
$statusBadges[] = ['label' => 'Unpublished', 'tone' => 'slate'];
}
if ((bool) $context['flags']['missing_preview']) {
$statusBadges[] = ['label' => 'Missing preview', 'tone' => 'rose'];
}
if ((bool) $context['flags']['is_deleted']) {
$statusBadges[] = ['label' => 'Deleted', 'tone' => 'slate'];
}
if ((int) $duplicateCounts->get((int) $feature->artwork_id, 0) > 1) {
$statusBadges[] = ['label' => 'Duplicate', 'tone' => 'sky'];
}
return [
'id' => (int) $feature->id,
'artwork_id' => (int) $feature->artwork_id,
'priority' => (int) $feature->priority,
'featured_at' => $feature->featured_at?->toIsoString(),
'expires_at' => $feature->expires_at?->toIsoString(),
'created_at' => $feature->created_at?->toIsoString(),
'updated_at' => $feature->updated_at?->toIsoString(),
'is_active' => (bool) $feature->is_active,
'is_force_hero' => (bool) $feature->force_hero,
'is_expired' => $isExpired,
'duplicate_count' => (int) $duplicateCounts->get((int) $feature->artwork_id, 0),
'artwork' => $context['artwork'],
'medals' => $context['medals'],
'eligibility' => [
'is_eligible' => $isEligible,
'reasons' => array_values(array_unique($eligibilityReasons)),
],
'status_badges' => $statusBadges,
'is_winner' => false,
'winner_reason' => null,
];
}
/**
* @return array<string, mixed>
*/
private function mapArtworkCandidate(Artwork $artwork, int $existingFeatureCount, Carbon $now): array
{
$context = $this->mapArtworkContext($artwork, $now);
return array_merge($context['artwork'], [
'medals' => $context['medals'],
'eligibility' => $context['eligibility'],
'existing_feature_count' => $existingFeatureCount,
'already_featured' => $existingFeatureCount > 0,
]);
}
/**
* @return array<string, mixed>
*/
private function mapArtworkContext(?Artwork $artwork, Carbon $now): array
{
if (! $artwork instanceof Artwork) {
return [
'artwork' => [
'id' => null,
'title' => 'Missing artwork',
'slug' => null,
'canonical_url' => null,
'thumbnail' => ThumbnailPresenter::present(['id' => null, 'name' => 'Missing artwork'], 'sm'),
'published_at' => null,
'visibility' => null,
'is_public' => false,
'is_approved' => false,
'has_missing_preview' => true,
'is_deleted' => true,
'owner' => null,
],
'medals' => [
'score_30d' => 0,
],
'eligibility' => [
'is_eligible' => false,
'reasons' => ['Deleted'],
],
'flags' => [
'is_private' => false,
'is_unpublished' => true,
'missing_preview' => true,
'is_deleted' => true,
],
];
}
$isDeleted = $artwork->deleted_at !== null;
$isPublic = ! $isDeleted && (bool) $artwork->is_public;
$isApproved = ! $isDeleted && (bool) $artwork->is_approved;
$isPublished = ! $isDeleted && $artwork->published_at !== null && $artwork->published_at->lte($now);
$hasPreview = ! (bool) $artwork->has_missing_thumbnails;
$owner = $this->mapOwner($artwork);
$reasons = [];
if ($isDeleted) {
$reasons[] = 'Deleted';
}
if (! $isPublic) {
$reasons[] = 'Private';
}
if (! $isApproved) {
$reasons[] = 'Not approved';
}
if (! $isPublished) {
$reasons[] = 'Unpublished';
}
if (! $hasPreview) {
$reasons[] = 'Missing preview';
}
return [
'artwork' => [
'id' => (int) $artwork->id,
'title' => html_entity_decode((string) $artwork->title, ENT_QUOTES | ENT_HTML5, 'UTF-8'),
'slug' => (string) $artwork->slug,
'canonical_url' => route('art.show', ['id' => (int) $artwork->id, 'slug' => $this->artworkSlug($artwork)]),
'thumbnail' => ThumbnailPresenter::present($artwork, 'sm'),
'published_at' => $artwork->published_at?->toIsoString(),
'visibility' => (string) ($artwork->visibility ?? ''),
'is_public' => (bool) $artwork->is_public,
'is_approved' => (bool) $artwork->is_approved,
'has_missing_preview' => (bool) $artwork->has_missing_thumbnails,
'is_deleted' => $isDeleted,
'owner' => $owner,
],
'medals' => [
'score_30d' => (int) ($artwork->awardStat?->score_30d ?? 0),
],
'eligibility' => [
'is_eligible' => $isPublic && $isApproved && $isPublished && $hasPreview,
'reasons' => $reasons,
],
'flags' => [
'is_private' => ! $isPublic,
'is_unpublished' => ! $isPublished,
'missing_preview' => ! $hasPreview,
'is_deleted' => $isDeleted,
],
];
}
/**
* @param SupportCollection<int, array<string, mixed>> $entries
* @return SupportCollection<int, array<string, mixed>>
*/
private function sortForHeroSelection(SupportCollection $entries): SupportCollection
{
return $entries
->sort(function (array $left, array $right): int {
$comparisons = [
(int) $right['is_force_hero'] <=> (int) $left['is_force_hero'],
(int) $right['priority'] <=> (int) $left['priority'],
(int) $right['medals']['score_30d'] <=> (int) $left['medals']['score_30d'],
$this->timestamp($right['featured_at']) <=> $this->timestamp($left['featured_at']),
$this->timestamp($right['artwork']['published_at']) <=> $this->timestamp($left['artwork']['published_at']),
(int) $right['id'] <=> (int) $left['id'],
];
foreach ($comparisons as $comparison) {
if ($comparison !== 0) {
return $comparison;
}
}
return 0;
})
->values();
}
/**
* @param array<string, mixed> $winner
* @param SupportCollection<int, array<string, mixed>> $eligibleEntries
*/
private function buildWinnerReason(array $winner, SupportCollection $eligibleEntries): string
{
if ((bool) ($winner['is_force_hero'] ?? false)) {
return 'Forced hero override is enabled for this featured artwork.';
}
$runnerUp = $eligibleEntries->skip(1)->first();
if (! is_array($runnerUp)) {
return 'Only eligible featured artwork right now.';
}
if ((int) $winner['priority'] > (int) $runnerUp['priority']) {
return 'Highest priority among active, eligible featured artworks.';
}
if ((int) $winner['medals']['score_30d'] > (int) $runnerUp['medals']['score_30d']) {
return 'Tied on priority, won on higher 30-day medal score.';
}
if ($this->timestamp($winner['featured_at']) > $this->timestamp($runnerUp['featured_at'])) {
return 'Tied on priority and medal score, won on newer featured date.';
}
if ($this->timestamp($winner['artwork']['published_at']) > $this->timestamp($runnerUp['artwork']['published_at'])) {
return 'Tied on priority, medal score, and featured date, won on newer published date.';
}
return 'Selected by the shared homepage hero ordering.';
}
/**
* @return array<string, mixed>|null
*/
private function mapOwner(Artwork $artwork): ?array
{
if ($artwork->group) {
return [
'type' => 'group',
'display_name' => (string) $artwork->group->name,
'username' => (string) $artwork->group->slug,
'profile_url' => $artwork->group->publicUrl(),
];
}
if (! $artwork->user) {
return null;
}
return [
'type' => 'user',
'display_name' => (string) ($artwork->user->name ?: '@' . $artwork->user->username),
'username' => (string) $artwork->user->username,
'profile_url' => $artwork->user->username !== '' ? '/@' . $artwork->user->username : null,
];
}
private function artworkSlug(Artwork $artwork): string
{
$slug = trim((string) $artwork->slug);
if ($slug !== '') {
return $slug;
}
$titleSlug = Str::slug((string) $artwork->title);
return $titleSlug !== '' ? $titleSlug : (string) $artwork->id;
}
private function timestamp(?string $value): int
{
if (! is_string($value) || trim($value) === '') {
return 0;
}
return (int) (strtotime($value) ?: 0);
}
}