Upload beautify

This commit is contained in:
2026-02-14 15:14:12 +01:00
parent e129618910
commit 79192345e3
249 changed files with 24436 additions and 1021 deletions

View File

@@ -9,6 +9,8 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
use App\Services\ThumbnailService;
use Illuminate\Support\Facades\DB;
/**
* App\Models\Artwork
@@ -74,13 +76,8 @@ class Artwork extends Model
return null;
}
$size = array_key_exists($size, self::THUMB_SIZES) ? $size : 'md';
$h = $this->hash;
$h1 = substr($h, 0, 2);
$h2 = substr($h, 2, 2);
$ext = $this->thumb_ext;
return "https://files.skinbase.org/{$size}/{$h1}/{$h2}/{$h}.{$ext}";
$sizeKey = array_key_exists($size, self::THUMB_SIZES) ? $size : 'md';
return ThumbnailService::fromHash($this->hash, $this->thumb_ext, $sizeKey);
}
/**
@@ -99,6 +96,19 @@ class Artwork extends Model
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.
*/
@@ -132,6 +142,12 @@ class Artwork extends Model
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);
@@ -142,6 +158,16 @@ class Artwork extends Model
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');
@@ -175,4 +201,24 @@ class Artwork extends Model
{
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')]);
});
}
}

View File

@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
final class ArtworkEmbedding extends Model
{
protected $table = 'artwork_embeddings';
protected $fillable = [
'artwork_id',
'model',
'model_version',
'algo_version',
'dim',
'embedding_json',
'source_hash',
'is_normalized',
'generated_at',
'meta',
];
protected $casts = [
'is_normalized' => 'boolean',
'generated_at' => 'datetime',
'meta' => 'array',
];
public function artwork(): BelongsTo
{
return $this->belongsTo(Artwork::class, 'artwork_id');
}
}

View File

@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
final class ArtworkSimilarity extends Model
{
protected $table = 'artwork_similarities';
protected $fillable = [
'artwork_id',
'similar_artwork_id',
'model',
'model_version',
'algo_version',
'rank',
'score',
'generated_at',
'meta',
];
protected $casts = [
'rank' => 'integer',
'score' => 'float',
'generated_at' => 'datetime',
'meta' => 'array',
];
public function artwork(): BelongsTo
{
return $this->belongsTo(Artwork::class, 'artwork_id');
}
public function similarArtwork(): BelongsTo
{
return $this->belongsTo(Artwork::class, 'similar_artwork_id');
}
}

View File

@@ -113,4 +113,50 @@ class Category extends Model
{
return 'slug';
}
/**
* Resolve a category by a content-type slug and a category path (e.g. "audio/winamp").
* This will locate the category with the final slug and verify its parent chain
* matches the provided path and that the category belongs to the given content type.
*
* @param string $contentTypeSlug
* @param string|array $categoryPath
* @return Category|null
*/
public static function findByPath(string $contentTypeSlug, $categoryPath): ?Category
{
$parts = is_array($categoryPath)
? array_values(array_map('strtolower', array_filter($categoryPath)))
: array_values(array_map('strtolower', array_filter(explode('/', (string) $categoryPath))));
if (empty($parts)) {
return null;
}
$last = end($parts);
$category = static::where('slug', $last)
->whereHas('contentType', function ($q) use ($contentTypeSlug) {
$q->where('slug', strtolower($contentTypeSlug));
})
->first();
if (! $category) {
return null;
}
// Verify parent chain matches the preceding parts in the path
$idx = count($parts) - 2;
$current = $category;
while ($idx >= 0) {
$parent = $current->parent;
if (! $parent || $parent->slug !== $parts[$idx]) {
return null;
}
$current = $parent;
$idx--;
}
return $category;
}
}

View File

@@ -4,6 +4,9 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Builder as EloquentBuilder;
use App\Models\Artwork;
class ContentType extends Model
{
@@ -19,6 +22,18 @@ class ContentType extends Model
return $this->categories()->whereNull('parent_id');
}
/**
* Return an Eloquent builder for Artworks that belong to this content type.
* This traverses the pivot `artwork_category` via the `categories` relation.
* Note: not a direct Eloquent relation (uses whereHas) so it can be queried/eager-loaded manually.
*/
public function artworks(): EloquentBuilder
{
return Artwork::whereHas('categories', function ($q) {
$q->where('content_type_id', $this->id);
});
}
public function getRouteKeyName(): string
{
return 'slug';

39
app/Models/Tag.php Normal file
View File

@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
final class Tag extends Model
{
use HasFactory;
protected $table = 'tags';
protected $fillable = [
'name',
'slug',
'usage_count',
'is_active',
];
protected $casts = [
'usage_count' => 'integer',
'is_active' => 'boolean',
];
public function artworks(): BelongsToMany
{
return $this->belongsToMany(Artwork::class, 'artwork_tag', 'tag_id', 'artwork_id')
->withPivot(['source', 'confidence']);
}
public function getRouteKeyName(): string
{
return 'slug';
}
}

51
app/Models/Upload.php Normal file
View File

@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Upload extends Model
{
protected $table = 'uploads';
public $incrementing = false;
protected $keyType = 'string';
protected $fillable = [
'id',
'user_id',
'type',
'status',
'processing_state',
'title',
'slug',
'category_id',
'description',
'tags',
'license',
'nsfw',
'is_scanned',
'has_tags',
'preview_path',
'published_at',
'final_path',
'expires_at',
'moderation_status',
'moderated_at',
'moderated_by',
'moderation_note',
];
protected $casts = [
'tags' => 'array',
'nsfw' => 'boolean',
'is_scanned' => 'boolean',
'has_tags' => 'boolean',
'published_at' => 'datetime',
'expires_at' => 'datetime',
'moderated_at' => 'datetime',
];
}

View File

@@ -23,6 +23,7 @@ class User extends Authenticatable
'name',
'email',
'password',
'role',
];
/**
@@ -53,4 +54,19 @@ class User extends Authenticatable
{
return $this->hasMany(Artwork::class);
}
public function hasRole(string $role): bool
{
return strtolower((string) ($this->role ?? '')) === strtolower($role);
}
public function isAdmin(): bool
{
return $this->hasRole('admin');
}
public function isModerator(): bool
{
return $this->hasRole('moderator');
}
}

View File

@@ -0,0 +1,39 @@
<?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 UserDiscoveryEvent extends Model
{
use HasFactory;
protected $table = 'user_discovery_events';
protected $guarded = [];
protected $casts = [
'occurred_at' => 'datetime',
'meta' => 'array',
'weight' => 'float',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function artwork(): BelongsTo
{
return $this->belongsTo(Artwork::class);
}
public function category(): BelongsTo
{
return $this->belongsTo(Category::class);
}
}

View File

@@ -0,0 +1,31 @@
<?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 UserInterestProfile extends Model
{
use HasFactory;
protected $table = 'user_interest_profiles';
protected $guarded = [];
protected $casts = [
'raw_scores_json' => 'array',
'normalized_scores_json' => 'array',
'last_event_at' => 'datetime',
'half_life_hours' => 'float',
'total_weight' => 'float',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}

View File

@@ -0,0 +1,69 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Facades\Storage;
class UserProfile extends Model
{
protected $table = 'user_profiles';
protected $primaryKey = 'user_id';
public $incrementing = false;
protected $keyType = 'int';
protected $fillable = [
'user_id',
'about',
'signature',
'description',
'avatar',
'avatar_hash',
'avatar_mime',
'avatar_updated_at',
'cover_image',
'country',
'country_code',
'language',
'birthdate',
'gender',
'website',
];
protected $casts = [
'birthdate' => 'date',
'avatar_updated_at' => 'datetime',
];
public $timestamps = true;
public function user(): BelongsTo
{
return $this->belongsTo(User::class, 'user_id');
}
/**
* Return a public URL for the avatar when stored on the `public` disk under `avatars/`.
*/
public function getAvatarUrlAttribute(): ?string
{
if (empty($this->avatar)) {
return null;
}
// If the stored value already looks like a full URL, return it.
if (preg_match('#^https?://#i', $this->avatar)) {
return $this->avatar;
}
// Prefer `public` disk and avatars folder.
$path = 'avatars/' . ltrim($this->avatar, '/');
if (Storage::disk('public')->exists($path)) {
return Storage::disk('public')->url($path);
}
// Fallback: return null if not found
return null;
}
}

View File

@@ -0,0 +1,29 @@
<?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 UserRecommendationCache extends Model
{
use HasFactory;
protected $table = 'user_recommendation_cache';
protected $guarded = [];
protected $casts = [
'recommendations_json' => 'array',
'generated_at' => 'datetime',
'expires_at' => 'datetime',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}