Files
SkinbaseNova/app/DTOs/UserRecoProfileDTO.php
Gregor Klevze 67ef79766c fix(gallery): fill tall portrait cards to full block width with object-cover crop
- ArtworkCard: add w-full to nova-card-media, use absolute inset-0 on img so
  object-cover fills the max-height capped box instead of collapsing the width
- MasonryGallery.css: add width:100% to media container, position img
  absolutely so top/bottom is cropped rather than leaving dark gaps
- Add React MasonryGallery + ArtworkCard components and entry point
- Add recommendation system: UserRecoProfile model/DTO/migration,
  SuggestedCreatorsController, SuggestedTagsController, Recommendation
  services, config/recommendations.php
- SimilarArtworksController, DiscoverController, HomepageService updates
- Update routes (api + web) and discover/for-you views
- Refresh favicon assets, update vite.config.js
2026-02-27 13:34:08 +01:00

95 lines
3.3 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
namespace App\DTOs;
/**
* Lightweight value object representing a user's recommendation preference profile.
*
* Built by UserPreferenceBuilder from signals:
* - favourited artworks (+3)
* - awards given (+5)
* - creator follows (+2 for their tags)
* - own uploads (category bias)
*
* Cached in `user_reco_profiles` with a configurable TTL (default 6 hours).
*/
final class UserRecoProfileDTO
{
/**
* @param array<int, string> $topTagSlugs Top tag slugs by weighted score (up to 20)
* @param array<int, string> $topCategorySlugs Top category slugs (up to 5)
* @param array<int, int> $strongCreatorIds Followed creator user IDs (up to 50)
* @param array<string, float> $tagWeights Tag slug → normalised weight (01)
* @param array<string, float> $categoryWeights Category slug → normalised weight
* @param array<int, string> $dislikedTagSlugs Future: blocked/hidden tag slugs
*/
public function __construct(
public readonly array $topTagSlugs = [],
public readonly array $topCategorySlugs = [],
public readonly array $strongCreatorIds = [],
public readonly array $tagWeights = [],
public readonly array $categoryWeights = [],
public readonly array $dislikedTagSlugs = [],
) {}
/**
* True if the user has enough signals to drive personalised recommendations.
*/
public function hasSignals(): bool
{
return $this->topTagSlugs !== [] || $this->strongCreatorIds !== [];
}
/**
* Returns the normalised tag weight for a given slug (0.0 if unknown).
*/
public function tagWeight(string $slug): float
{
return (float) ($this->tagWeights[$slug] ?? 0.0);
}
/**
* Returns true when the creator is in the user's strong-follow list.
*/
public function followsCreator(int $userId): bool
{
return in_array($userId, $this->strongCreatorIds, true);
}
/**
* Serialise for storage in the DB / Redis cache.
*
* @return array<string, mixed>
*/
public function toArray(): array
{
return [
'top_tags' => $this->topTagSlugs,
'top_categories' => $this->topCategorySlugs,
'strong_creators' => $this->strongCreatorIds,
'tag_weights' => $this->tagWeights,
'category_weights' => $this->categoryWeights,
'disliked_tags' => $this->dislikedTagSlugs,
];
}
/**
* Re-hydrate from a stored array (e.g. from the DB JSON column).
*
* @param array<string, mixed> $data
*/
public static function fromArray(array $data): self
{
return new self(
topTagSlugs: (array) ($data['top_tags'] ?? []),
topCategorySlugs: (array) ($data['top_categories'] ?? []),
strongCreatorIds: array_map('intval', (array) ($data['strong_creators'] ?? [])),
tagWeights: array_map('floatval', (array) ($data['tag_weights'] ?? [])),
categoryWeights: array_map('floatval', (array) ($data['category_weights'] ?? [])),
dislikedTagSlugs: (array) ($data['disliked_tags'] ?? []),
);
}
}