feat: ship creator journey v2 and profile updates

This commit is contained in:
2026-04-12 21:42:07 +02:00
parent a2457f4e49
commit d5cff21ea2
335 changed files with 20147 additions and 1545 deletions

View File

@@ -52,6 +52,9 @@ class Artwork extends Model
'hash',
'file_ext',
'thumb_ext',
'has_missing_thumbnails',
'missing_thumbnail_variants_json',
'thumbnails_checked_at',
'file_size',
'mime_type',
'width',
@@ -60,6 +63,27 @@ class Artwork extends Model
'visibility',
'is_approved',
'is_mature',
'maturity_level',
'maturity_source',
'maturity_status',
'maturity_ai_score',
'maturity_ai_labels',
'maturity_ai_label',
'maturity_ai_confidence',
'maturity_ai_model',
'maturity_ai_threshold_used',
'maturity_ai_analysis_time_ms',
'maturity_ai_action_hint',
'maturity_ai_advisory',
'maturity_ai_status',
'maturity_ai_detected_at',
'maturity_declared_at',
'maturity_flagged_at',
'maturity_flag_reason',
'maturity_reviewed_by',
'maturity_reviewed_at',
'maturity_reviewer_note',
'maturity_mismatch_count',
'published_at',
'hash',
'thumb_ext',
@@ -90,7 +114,28 @@ class Artwork extends Model
'visibility' => 'string',
'is_approved' => 'boolean',
'is_mature' => 'boolean',
'maturity_level' => 'string',
'maturity_source' => 'string',
'maturity_status' => 'string',
'maturity_ai_score' => 'float',
'maturity_ai_labels' => 'array',
'maturity_ai_label' => 'string',
'maturity_ai_confidence' => 'float',
'maturity_ai_model' => 'string',
'maturity_ai_threshold_used' => 'float',
'maturity_ai_analysis_time_ms' => 'integer',
'maturity_ai_action_hint' => 'string',
'maturity_ai_advisory' => 'string',
'maturity_ai_status' => 'string',
'maturity_ai_detected_at' => 'datetime',
'maturity_declared_at' => 'datetime',
'maturity_flagged_at' => 'datetime',
'maturity_reviewed_at' => 'datetime',
'maturity_mismatch_count' => 'integer',
'has_missing_thumbnails' => 'boolean',
'published_at' => 'datetime',
'missing_thumbnail_variants_json' => 'array',
'thumbnails_checked_at' => 'datetime',
'published_as_type' => 'string',
'published_as_id' => 'integer',
'publish_at' => 'datetime',
@@ -184,6 +229,11 @@ class Artwork extends Model
return $this->belongsTo(Group::class);
}
public function maturityAuditFinding(): HasOne
{
return $this->hasOne(ArtworkMaturityAuditFinding::class);
}
public function uploadedBy(): BelongsTo
{
return $this->belongsTo(User::class, 'uploaded_by_user_id');
@@ -297,12 +347,31 @@ class Artwork extends Model
return $this->hasMany(ArtworkAward::class);
}
public function medals(): HasMany
{
return $this->hasMany(ArtworkMedal::class, 'artwork_id');
}
/** All file versions for this artwork (oldest first). */
public function versions(): HasMany
{
return $this->hasMany(ArtworkVersion::class)->orderBy('version_number');
}
public function outgoingEvolutionRelations(): HasMany
{
return $this->hasMany(ArtworkRelation::class, 'source_artwork_id')
->orderBy('sort_order')
->orderBy('id');
}
public function incomingEvolutionRelations(): HasMany
{
return $this->hasMany(ArtworkRelation::class, 'target_artwork_id')
->orderByDesc('updated_at')
->orderByDesc('id');
}
/** The currently active version record. */
public function currentVersion(): BelongsTo
{
@@ -319,6 +388,11 @@ class Artwork extends Model
return $this->hasOne(ArtworkAwardStat::class);
}
public function medalStats(): HasOne
{
return $this->hasOne(ArtworkMedalStat::class, 'artwork_id');
}
/**
* Build the Meilisearch document for this artwork.
* Includes all fields required for search, filtering, sorting, and display.
@@ -385,12 +459,20 @@ class Artwork extends Model
'published_at_ts' => $publishedSortAt?->getTimestamp() ?? 0,
'is_public' => (bool) $this->is_public,
'is_approved' => (bool) $this->is_approved,
'is_mature' => (bool) $this->is_mature,
'is_mature_effective' => (bool) ($this->is_mature || $this->maturity_level === 'mature' || $this->maturity_status === 'suspected'),
'maturity_level' => (string) ($this->maturity_level ?? 'safe'),
'maturity_status' => (string) ($this->maturity_status ?? 'clear'),
'has_missing_thumbnails' => (bool) ($this->has_missing_thumbnails ?? false),
'missing_thumbnail_rank' => (int) (($this->has_missing_thumbnails ?? false) ? 1 : 0),
// ── Trending / discovery fields ────────────────────────────────────
'trending_score_1h' => (float) ($this->trending_score_1h ?? 0),
'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),
'awards_score_7d' => (int) ($awardStat?->score_7d ?? 0),
'awards_score_30d' => (int) ($awardStat?->score_30d ?? 0),
'downloads_count' => (int) ($stat?->downloads ?? 0),
// ── Ranking V2 fields ───────────────────────────────────────────────
'ranking_score' => (float) ($stat?->ranking_score ?? 0),
@@ -404,6 +486,8 @@ class Artwork extends Model
'silver' => $awardStat?->silver_count ?? 0,
'bronze' => $awardStat?->bronze_count ?? 0,
'score' => $awardStat?->score_total ?? 0,
'score_7d' => $awardStat?->score_7d ?? 0,
'score_30d' => $awardStat?->score_30d ?? 0,
],
];
}
@@ -432,6 +516,32 @@ class Artwork extends Model
->where("{$table}.published_at", '<=', now());
}
public function scopeSafeForGeneralAudience(Builder $query): Builder
{
$table = $this->getTable();
return $query
->whereRaw('COALESCE(' . $table . '.is_mature, 0) = 0')
->whereRaw("COALESCE(" . $table . ".maturity_status, 'clear') != ?", ['suspected']);
}
public function scopeWithoutMissingThumbnails(Builder $query): Builder
{
$table = $this->getTable();
return $query->where(function (Builder $thumbnailQuery) use ($table): void {
$thumbnailQuery->whereNull("{$table}.has_missing_thumbnails")
->orWhere("{$table}.has_missing_thumbnails", false);
});
}
public function scopeOrderMissingThumbnailsLast(Builder $query): Builder
{
$table = $this->getTable();
return $query->orderByRaw("CASE WHEN {$table}.has_missing_thumbnails = 1 THEN 1 ELSE 0 END ASC");
}
public function getRouteKeyName(): string
{
return 'slug';

View File

@@ -9,11 +9,12 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ArtworkAward extends Model
{
protected $table = 'artwork_awards';
protected $table = 'artwork_medals';
protected $fillable = [
'artwork_id',
'user_id',
'medal_type',
'medal',
'weight',
];
@@ -27,11 +28,26 @@ class ArtworkAward extends Model
public const MEDALS = ['gold', 'silver', 'bronze'];
public const WEIGHTS = [
'gold' => 3,
'silver' => 2,
'gold' => 5,
'silver' => 3,
'bronze' => 1,
];
public static function weightFor(string $medal): int
{
return (int) config('artwork_medals.weights.' . $medal, self::WEIGHTS[$medal] ?? 0);
}
/**
* @return array<string, int>
*/
public static function weights(): array
{
return collect(self::MEDALS)
->mapWithKeys(fn (string $medal): array => [$medal => self::weightFor($medal)])
->all();
}
public function artwork(): BelongsTo
{
return $this->belongsTo(Artwork::class);
@@ -41,4 +57,14 @@ class ArtworkAward extends Model
{
return $this->belongsTo(User::class);
}
public function getMedalAttribute(): ?string
{
return $this->attributes['medal_type'] ?? null;
}
public function setMedalAttribute(?string $value): void
{
$this->attributes['medal_type'] = $value;
}
}

View File

@@ -9,11 +9,11 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ArtworkAwardStat extends Model
{
protected $table = 'artwork_award_stats';
protected $table = 'artwork_medal_stats';
public $primaryKey = 'artwork_id';
public $incrementing = false;
public $timestamps = false;
public $timestamps = true;
protected $fillable = [
'artwork_id',
@@ -21,6 +21,10 @@ class ArtworkAwardStat extends Model
'silver_count',
'bronze_count',
'score_total',
'score_7d',
'score_30d',
'last_medaled_at',
'created_at',
'updated_at',
];
@@ -30,6 +34,10 @@ class ArtworkAwardStat extends Model
'silver_count' => 'integer',
'bronze_count' => 'integer',
'score_total' => 'integer',
'score_7d' => 'integer',
'score_30d' => 'integer',
'last_medaled_at' => 'datetime',
'created_at' => 'datetime',
'updated_at' => 'datetime',
];

View File

@@ -3,21 +3,33 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
class ArtworkFeature extends Model
{
protected $table = 'artwork_features';
use SoftDeletes;
public $timestamps = false;
protected $table = 'artwork_features';
protected $fillable = [
'artwork_id',
'type',
'featured_at',
'expires_at',
'priority',
'label',
'note',
'is_active',
'force_hero',
'created_by',
];
protected $casts = [
'featured_at' => 'datetime',
'expires_at' => 'datetime',
'priority' => 'integer',
'is_active' => 'boolean',
'force_hero' => 'boolean',
];
public function artwork(): BelongsTo

View File

@@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
final class ArtworkMaturityAuditFinding extends Model
{
use HasFactory;
public const STATUS_OPEN = 'open';
public const STATUS_REVIEWED = 'reviewed';
public const STATUS_CLEARED = 'cleared';
protected $fillable = [
'artwork_id',
'status',
'thumbnail_variant',
'ai_label',
'ai_confidence',
'ai_score',
'ai_labels',
'ai_model',
'ai_threshold_used',
'ai_analysis_time_ms',
'ai_action_hint',
'ai_status',
'ai_advisory',
'detected_at',
'last_scanned_at',
'resolution_action',
'resolution_note',
'resolved_by',
'resolved_at',
];
protected $casts = [
'ai_confidence' => 'float',
'ai_score' => 'float',
'ai_labels' => 'array',
'ai_threshold_used' => 'float',
'ai_analysis_time_ms' => 'integer',
'detected_at' => 'datetime',
'last_scanned_at' => 'datetime',
'resolved_at' => 'datetime',
];
public function artwork(): BelongsTo
{
return $this->belongsTo(Artwork::class);
}
public function resolver(): BelongsTo
{
return $this->belongsTo(User::class, 'resolved_by');
}
}

View File

@@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace App\Models;
class ArtworkMedal extends ArtworkAward
{
}

View File

@@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace App\Models;
class ArtworkMedalStat extends ArtworkAwardStat
{
}

View File

@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
final class ArtworkRelation extends Model
{
use HasFactory;
public const TYPE_REMAKE_OF = 'remake_of';
public const TYPE_REMASTER_OF = 'remaster_of';
public const TYPE_REVISION_OF = 'revision_of';
public const TYPE_INSPIRED_BY = 'inspired_by';
public const TYPE_VARIATION_OF = 'variation_of';
protected $fillable = [
'source_artwork_id',
'target_artwork_id',
'relation_type',
'note',
'sort_order',
'created_by_user_id',
];
protected $casts = [
'sort_order' => 'integer',
];
public function sourceArtwork(): BelongsTo
{
return $this->belongsTo(Artwork::class, 'source_artwork_id');
}
public function targetArtwork(): BelongsTo
{
return $this->belongsTo(Artwork::class, 'target_artwork_id');
}
public function creator(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by_user_id');
}
}

View File

@@ -17,6 +17,27 @@ class Category extends Model
protected $casts = ['is_active' => 'boolean'];
public function getNameAttribute(?string $value): ?string
{
return self::decodeHtmlEntities($value);
}
public function setNameAttribute(?string $value): void
{
$normalized = self::decodeHtmlEntities($value);
$this->attributes['name'] = $normalized !== null ? trim($normalized) : null;
}
public function getDescriptionAttribute(?string $value): ?string
{
return self::decodeHtmlEntities($value);
}
public function setDescriptionAttribute(?string $value): void
{
$this->attributes['description'] = self::decodeHtmlEntities($value);
}
/**
* Ensure slug is always lowercase and valid before saving.
*/
@@ -159,4 +180,25 @@ class Category extends Model
return $category;
}
private static function decodeHtmlEntities(?string $value): ?string
{
if ($value === null) {
return null;
}
$decoded = $value;
for ($index = 0; $index < 5; $index++) {
$next = html_entity_decode($decoded, ENT_QUOTES | ENT_HTML5, 'UTF-8');
if ($next === $decoded) {
break;
}
$decoded = $next;
}
return $decoded;
}
}

View File

@@ -653,19 +653,26 @@ class Collection extends Model
return $this->isPubliclyAccessible();
}
public function resolvedCoverArtwork(bool $publicOnly = false): ?Artwork
public function resolvedCoverArtwork(bool $publicOnly = false, bool $hideMature = false): ?Artwork
{
$cover = $this->relationLoaded('coverArtwork') ? $this->coverArtwork : $this->coverArtwork()->first();
if ($cover && (! $publicOnly || $this->artworkIsPubliclyVisible($cover))) {
if ($cover && $this->artworkMatchesCoverVisibility($cover, $publicOnly, $hideMature)) {
return $cover;
}
$relation = $publicOnly ? 'publicArtworks' : 'artworks';
$artworks = $this->relationLoaded($relation)
? $this->getRelation($relation)
: $this->{$relation}()->limit(1)->get();
: $this->{$relation}()
->when($hideMature, function ($query): void {
$query->whereRaw('COALESCE(artworks.is_mature, 0) = 0')
->whereRaw("COALESCE(artworks.maturity_status, 'clear') != ?", ['suspected']);
})
->limit(1)
->get();
return $artworks->first();
return $artworks
->first(fn (Artwork $artwork): bool => $this->artworkMatchesCoverVisibility($artwork, $publicOnly, $hideMature));
}
public function syncArtworksCount(): void
@@ -712,4 +719,18 @@ class Collection extends Model
&& $artwork->published_at !== null
&& $artwork->published_at->lte(now());
}
private function artworkMatchesCoverVisibility(Artwork $artwork, bool $publicOnly, bool $hideMature): bool
{
if ($publicOnly && ! $this->artworkIsPubliclyVisible($artwork)) {
return false;
}
if (! $hideMature) {
return true;
}
return ! (bool) $artwork->is_mature
&& (string) ($artwork->maturity_status ?? 'clear') !== 'suspected';
}
}

View File

@@ -10,10 +10,11 @@ use App\Models\Artwork;
class ContentType extends Model
{
protected $fillable = ['name','slug','description','order'];
protected $fillable = ['name','slug','description','order','hide_from_menu','mascot_path','cover_art_path'];
protected $casts = [
'order' => 'integer',
'hide_from_menu' => 'boolean',
];
public function scopeOrdered(EloquentBuilder $query): EloquentBuilder
@@ -21,6 +22,11 @@ class ContentType extends Model
return $query->orderBy('order')->orderBy('name')->orderBy('id');
}
public function scopeVisibleInToolbar(EloquentBuilder $query): EloquentBuilder
{
return $query->where('hide_from_menu', false);
}
public function categories(): HasMany
{
return $this->hasMany(Category::class);
@@ -31,6 +37,11 @@ class ContentType extends Model
return $this->categories()->whereNull('parent_id');
}
public function slugHistories(): HasMany
{
return $this->hasMany(ContentTypeSlugHistory::class);
}
/**
* Return an Eloquent builder for Artworks that belong to this content type.
* This traverses the pivot `artwork_category` via the `categories` relation.
@@ -43,8 +54,33 @@ class ContentType extends Model
});
}
public function getMascotUrlAttribute(): ?string
{
return $this->resolveAssetUrl($this->mascot_path);
}
public function getCoverArtUrlAttribute(): ?string
{
return $this->resolveAssetUrl($this->cover_art_path);
}
public function getRouteKeyName(): string
{
return 'slug';
}
private function resolveAssetUrl(?string $path): ?string
{
$path = trim((string) $path);
if ($path === '') {
return null;
}
if (str_starts_with($path, 'http://') || str_starts_with($path, 'https://') || str_starts_with($path, '/')) {
return $path;
}
return rtrim((string) config('cdn.files_url', 'https://cdn.skinbase.org'), '/') . '/' . ltrim($path, '/');
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ContentTypeSlugHistory extends Model
{
protected $fillable = [
'content_type_id',
'old_slug',
];
public function contentType(): BelongsTo
{
return $this->belongsTo(ContentType::class);
}
}

45
app/Models/CreatorEra.php Normal file
View File

@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* @property int $id
* @property int $user_id
* @property string $era_type
* @property string $title
* @property string|null $description
* @property \Carbon\Carbon $starts_at
* @property \Carbon\Carbon|null $ends_at
* @property bool $is_current
* @property array|null $metadata
*/
final class CreatorEra extends Model
{
protected $fillable = [
'user_id',
'era_type',
'title',
'description',
'starts_at',
'ends_at',
'is_current',
'metadata',
];
protected $casts = [
'starts_at' => 'datetime',
'ends_at' => 'datetime',
'is_current' => 'boolean',
'metadata' => 'array',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}

View File

@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class CreatorMilestone extends Model
{
use HasFactory;
protected $fillable = [
'user_id',
'type',
'occurred_at',
'occurred_year',
'related_artwork_id',
'is_public',
'priority',
'payload_json',
'computed_at',
];
protected $casts = [
'occurred_at' => 'datetime',
'occurred_year' => 'integer',
'related_artwork_id' => 'integer',
'is_public' => 'boolean',
'priority' => 'integer',
'payload_json' => 'array',
'computed_at' => 'datetime',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function artwork(): BelongsTo
{
return $this->belongsTo(Artwork::class, 'related_artwork_id');
}
}

View File

@@ -124,6 +124,11 @@ class User extends Authenticatable
return $this->hasMany(Artwork::class);
}
public function givenArtworkMedals(): HasMany
{
return $this->hasMany(ArtworkMedal::class, 'user_id');
}
public function collections(): HasMany
{
return $this->hasMany(Collection::class)->latest('updated_at');

View File

@@ -35,6 +35,8 @@ class UserProfile extends Model
'follower_notifications',
'comment_notifications',
'newsletter',
'mature_content_visibility',
'mature_content_warning_enabled',
];
protected $casts = [
@@ -46,6 +48,7 @@ class UserProfile extends Model
'follower_notifications' => 'boolean',
'comment_notifications' => 'boolean',
'newsletter' => 'boolean',
'mature_content_warning_enabled' => 'boolean',
];
public $timestamps = true;