'boolean', 'is_approved' => 'boolean', 'published_at' => 'datetime', ]; /** * Thumbnail sizes and their options. * Keys are the size dir used in the CDN URL. */ protected const THUMB_SIZES = [ 'sm' => ['height' => 240, 'quality' => 78, 'dir' => 'sm'], 'md' => ['height' => 360, 'quality' => 82, 'dir' => 'md'], 'lg' => ['height' => 1200, 'quality' => 85, 'dir' => 'lg'], 'xl' => ['height' => 2400, 'quality' => 90, 'dir' => 'xl'], ]; /** * Build the thumbnail URL for this artwork. * Returns null when no hash or thumb_ext is available. */ public function thumbUrl(string $size = 'md'): ?string { if (empty($this->hash) || empty($this->thumb_ext)) { return null; } $sizeKey = array_key_exists($size, self::THUMB_SIZES) ? $size : 'md'; return ThumbnailService::fromHash($this->hash, $this->thumb_ext, $sizeKey); } /** * Accessor for `$art->thumb` used in legacy views (default medium size). */ public function getThumbAttribute(): string { return $this->thumbUrl('md') ?? '/gfx/sb_join.jpg'; } /** * Accessor for `$art->thumb_url` used in some views. */ public function getThumbUrlAttribute(): ?string { return $this->thumbUrl('md'); } /** * Backwards-compatible alias used by legacy views: `$art->thumbnail_url`. * Prefer CDN thumbnail URL, then legacy `thumb` accessor, finally a placeholder. */ public function getThumbnailUrlAttribute(): ?string { $url = $this->getThumbUrlAttribute(); if (!empty($url)) return $url; $thumb = $this->getThumbAttribute(); if (!empty($thumb)) return $thumb; return '/images/placeholder.jpg'; } /** * Provide a responsive `srcset` for legacy views. */ public function getThumbSrcsetAttribute(): ?string { if (empty($this->hash) || empty($this->thumb_ext)) return null; $sm = $this->thumbUrl('sm'); $md = $this->thumbUrl('md'); if (!$sm || !$md) return null; return $sm . ' 320w, ' . $md . ' 600w'; } // Relations public function user(): BelongsTo { return $this->belongsTo(User::class); } public function translations(): HasMany { return $this->hasMany(ArtworkTranslation::class); } public function stats(): HasOne { return $this->hasOne(ArtworkStats::class, 'artwork_id'); } public function categories(): BelongsToMany { return $this->belongsToMany(Category::class, 'artwork_category', 'artwork_id', 'category_id'); } public function tags(): BelongsToMany { return $this->belongsToMany(Tag::class, 'artwork_tag', 'artwork_id', 'tag_id') ->withPivot(['source', 'confidence']); } public function comments(): HasMany { return $this->hasMany(ArtworkComment::class); } public function downloads(): HasMany { return $this->hasMany(ArtworkDownload::class); } public function embeddings(): HasMany { return $this->hasMany(ArtworkEmbedding::class, 'artwork_id'); } public function similarities(): HasMany { return $this->hasMany(ArtworkSimilarity::class, 'artwork_id'); } public function features(): HasMany { return $this->hasMany(ArtworkFeature::class, 'artwork_id'); } /** All favourite pivot rows for this artwork. */ public function favourites(): HasMany { return $this->hasMany(ArtworkFavourite::class, 'artwork_id'); } /** Users who have favourited this artwork (many-to-many shortcut). */ public function favouritedBy(): BelongsToMany { return $this->belongsToMany(User::class, 'artwork_favourites', 'artwork_id', 'user_id') ->withPivot('legacy_id') ->withTimestamps(); } public function awards(): HasMany { return $this->hasMany(ArtworkAward::class); } public function awardStat(): HasOne { return $this->hasOne(ArtworkAwardStat::class); } /** * Build the Meilisearch document for this artwork. * Includes all fields required for search, filtering, sorting, and display. */ public function toSearchableArray(): array { $this->loadMissing(['user', 'tags', 'categories.contentType', 'stats', 'awardStat']); $stat = $this->stats; $awardStat = $this->awardStat; // Orientation derived from pixel dimensions $orientation = 'square'; if ($this->width && $this->height) { if ($this->width > $this->height) { $orientation = 'landscape'; } elseif ($this->height > $this->width) { $orientation = 'portrait'; } } // Resolution string e.g. "1920x1080" $resolution = ($this->width && $this->height) ? $this->width . 'x' . $this->height : ''; // Primary category slug (first attached category) $primaryCategory = $this->categories->first(); $category = $primaryCategory?->slug ?? ''; $content_type = $primaryCategory?->contentType?->slug ?? ''; // Tag slugs array $tags = $this->tags->pluck('slug')->values()->all(); return [ 'id' => $this->id, 'slug' => $this->slug, 'title' => $this->title, 'description' => (string) ($this->description ?? ''), 'author_id' => $this->user_id, 'author_name' => $this->user?->name ?? 'Skinbase', 'category' => $category, 'content_type' => $content_type, 'tags' => $tags, 'resolution' => $resolution, 'orientation' => $orientation, 'downloads' => (int) ($stat?->downloads ?? 0), 'likes' => (int) ($stat?->favorites ?? 0), 'views' => (int) ($stat?->views ?? 0), 'created_at' => $this->published_at?->toDateString() ?? $this->created_at?->toDateString() ?? '', 'is_public' => (bool) $this->is_public, 'is_approved' => (bool) $this->is_approved, // ── Trending / discovery fields ──────────────────────────────────── 'trending_score_24h' => (float) ($this->trending_score_24h ?? 0), 'trending_score_7d' => (float) ($this->trending_score_7d ?? 0), 'favorites_count' => (int) ($stat?->favorites ?? 0), 'awards_received_count' => (int) ($awardStat?->score_total ?? 0), 'downloads_count' => (int) ($stat?->downloads ?? 0), // ── Ranking V2 fields ─────────────────────────────────────────────── 'ranking_score' => (float) ($stat?->ranking_score ?? 0), 'engagement_velocity' => (float) ($stat?->engagement_velocity ?? 0), 'shares_count' => (int) ($stat?->shares_count ?? 0), 'comments_count' => (int) ($stat?->comments_count ?? 0), 'awards' => [ 'gold' => $awardStat?->gold_count ?? 0, 'silver' => $awardStat?->silver_count ?? 0, 'bronze' => $awardStat?->bronze_count ?? 0, 'score' => $awardStat?->score_total ?? 0, ], ]; } // Scopes public function scopePublic(Builder $query): Builder { // Compose approved() so behavior is consistent and composable $table = $this->getTable(); return $query->approved()->where("{$table}.is_public", true); } public function scopeApproved(Builder $query): Builder { // Respect soft deletes and mark approved content $table = $this->getTable(); return $query->whereNull("{$table}.deleted_at")->where("{$table}.is_approved", true); } public function scopePublished(Builder $query): Builder { // Respect soft deletes and only include published items up to now $table = $this->getTable(); return $query->whereNull("{$table}.deleted_at") ->whereNotNull("{$table}.published_at") ->where("{$table}.published_at", '<=', now()); } public function getRouteKeyName(): string { return 'slug'; } protected static function booted(): void { static::deleting(function (Artwork $artwork): void { if (! method_exists($artwork, 'isForceDeleting') || ! $artwork->isForceDeleting()) { return; } // Cleanup pivot rows and decrement usage counts on force delete. $tagIds = DB::table('artwork_tag')->where('artwork_id', $artwork->id)->pluck('tag_id')->all(); if ($tagIds === []) { return; } DB::table('artwork_tag')->where('artwork_id', $artwork->id)->delete(); DB::table('tags') ->whereIn('id', $tagIds) ->update(['usage_count' => DB::raw('CASE WHEN usage_count > 0 THEN usage_count - 1 ELSE 0 END')]); }); } }