Upload beautify
This commit is contained in:
@@ -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')]);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
37
app/Models/ArtworkEmbedding.php
Normal file
37
app/Models/ArtworkEmbedding.php
Normal 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');
|
||||
}
|
||||
}
|
||||
42
app/Models/ArtworkSimilarity.php
Normal file
42
app/Models/ArtworkSimilarity.php
Normal 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');
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
39
app/Models/Tag.php
Normal 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
51
app/Models/Upload.php
Normal 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',
|
||||
];
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
39
app/Models/UserDiscoveryEvent.php
Normal file
39
app/Models/UserDiscoveryEvent.php
Normal 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);
|
||||
}
|
||||
}
|
||||
31
app/Models/UserInterestProfile.php
Normal file
31
app/Models/UserInterestProfile.php
Normal 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);
|
||||
}
|
||||
}
|
||||
69
app/Models/UserProfile.php
Normal file
69
app/Models/UserProfile.php
Normal 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;
|
||||
}
|
||||
}
|
||||
29
app/Models/UserRecommendationCache.php
Normal file
29
app/Models/UserRecommendationCache.php
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user