Current state

This commit is contained in:
2026-02-07 08:23:18 +01:00
commit 0a4372c40d
22479 changed files with 1553543 additions and 0 deletions

178
app/Models/Artwork.php Normal file
View File

@@ -0,0 +1,178 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
/**
* App\Models\Artwork
*
* @property-read User $user
* @property-read \Illuminate\Database\Eloquent\Collection|ArtworkTranslation[] $translations
* @property-read ArtworkStats $stats
* @property-read \Illuminate\Database\Eloquent\Collection|ArtworkComment[] $comments
* @property-read \Illuminate\Database\Eloquent\Collection|ArtworkDownload[] $downloads
*/
class Artwork extends Model
{
use HasFactory, SoftDeletes;
protected $table = 'artworks';
protected $fillable = [
'user_id',
'title',
'slug',
'description',
'file_name',
'file_path',
'hash',
'file_ext',
'thumb_ext',
'file_size',
'mime_type',
'width',
'height',
'is_public',
'is_approved',
'published_at',
'hash',
'thumb_ext',
'file_ext'
];
protected $casts = [
'is_public' => '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;
}
$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}";
}
/**
* 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');
}
/**
* 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 comments(): HasMany
{
return $this->hasMany(ArtworkComment::class);
}
public function downloads(): HasMany
{
return $this->hasMany(ArtworkDownload::class);
}
public function features(): HasMany
{
return $this->hasMany(ArtworkFeature::class, 'artwork_id');
}
// 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';
}
}

View File

@@ -0,0 +1 @@
<?php

View File

@@ -0,0 +1,40 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* App\Models\ArtworkComment
*
* @property-read Artwork $artwork
* @property-read User $user
*/
class ArtworkComment extends Model
{
use SoftDeletes;
protected $table = 'artwork_comments';
protected $fillable = [
'artwork_id',
'user_id',
'content',
'is_approved',
];
protected $casts = [
'is_approved' => 'boolean',
];
public function artwork(): BelongsTo
{
return $this->belongsTo(Artwork::class);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}

View File

@@ -0,0 +1,33 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* App\Models\ArtworkDownload
*
* @property-read Artwork $artwork
* @property-read User|null $user
*/
class ArtworkDownload extends Model
{
protected $table = 'artwork_downloads';
protected $fillable = [
'artwork_id',
'user_id',
'ip',
'user_agent',
];
public function artwork(): BelongsTo
{
return $this->belongsTo(Artwork::class);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}

View File

@@ -0,0 +1,27 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ArtworkFeature extends Model
{
protected $table = 'artwork_features';
public $timestamps = false;
protected $fillable = [
'artwork_id',
'type',
'featured_at',
];
protected $casts = [
'featured_at' => 'datetime',
];
public function artwork(): BelongsTo
{
return $this->belongsTo(Artwork::class);
}
}

View File

@@ -0,0 +1,33 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* App\Models\ArtworkStats
*
* @property-read Artwork $artwork
*/
class ArtworkStats extends Model
{
protected $table = 'artwork_stats';
protected $primaryKey = 'artwork_id';
public $incrementing = false;
protected $fillable = [
'artwork_id',
'views',
'downloads',
'favorites',
'rating_avg',
'rating_count',
];
public function artwork(): BelongsTo
{
return $this->belongsTo(Artwork::class, 'artwork_id');
}
}

View File

@@ -0,0 +1,30 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* App\Models\ArtworkTranslation
*
* @property-read Artwork $artwork
*/
class ArtworkTranslation extends Model
{
use SoftDeletes;
protected $table = 'artwork_translations';
protected $fillable = [
'artwork_id',
'locale',
'title',
'description',
];
public function artwork(): BelongsTo
{
return $this->belongsTo(Artwork::class);
}
}

116
app/Models/Category.php Normal file
View File

@@ -0,0 +1,116 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\{BelongsTo, HasMany, BelongsToMany, HasOne};
use Illuminate\Database\Eloquent\SoftDeletes;
class Category extends Model
{
use SoftDeletes;
protected $fillable = [
'content_type_id','parent_id','name','slug',
'description','image','is_active','sort_order'
];
protected $casts = ['is_active' => 'boolean'];
/**
* Ensure slug is always lowercase and valid before saving.
*/
protected static function boot()
{
parent::boot();
static::saving(function (Category $model) {
if (isset($model->slug)) {
$model->slug = strtolower($model->slug);
if (!preg_match('/^[a-z0-9-]+$/', $model->slug)) {
throw new \InvalidArgumentException('Category slug must be lowercase and contain only a-z, 0-9, and dashes.');
}
}
});
}
public function contentType(): BelongsTo
{
return $this->belongsTo(ContentType::class);
}
public function parent(): BelongsTo
{
return $this->belongsTo(Category::class, 'parent_id');
}
public function children(): HasMany
{
return $this->hasMany(Category::class, 'parent_id')
->orderBy('sort_order')->orderBy('name');
}
public function descendants(): HasMany
{
return $this->children()->with('descendants');
}
public function seo(): HasOne
{
return $this->hasOne(CategorySeo::class);
}
public function artworks(): BelongsToMany
{
return $this->belongsToMany(Artwork::class, 'artwork_category');
}
public function scopeActive($query)
{
return $query->where('is_active', true);
}
public function scopeRoots($query)
{
return $query->whereNull('parent_id');
}
public function getFullSlugPathAttribute(): string
{
return $this->parent
? $this->parent->full_slug_path . '/' . $this->slug
: $this->slug;
}
/**
* Get the full public URL for this category (authoritative spec).
* Example: /photography/abstract/dark
*/
public function getUrlAttribute(): string
{
$contentTypeSlug = strtolower($this->contentType->slug);
$path = strtolower($this->full_slug_path);
return '/' . $contentTypeSlug . ($path ? '/' . $path : '');
}
/**
* Get the canonical URL for SEO (authoritative spec).
* Example: https://skinbase.org/photography/abstract/dark
*/
public function getCanonicalUrlAttribute(): string
{
return 'https://skinbase.org' . $this->url;
}
public function getBreadcrumbsAttribute(): array
{
return $this->parent
? array_merge($this->parent->breadcrumbs, [$this])
: [$this];
}
public function getRouteKeyName(): string
{
return 'slug';
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class CategorySeo extends Model
{
protected $table = 'category_seo';
protected $primaryKey = 'category_id';
public $incrementing = false;
public $timestamps = false;
protected $fillable = [
'category_id','meta_title','meta_description',
'meta_keywords','canonical_url'
];
public function category(): BelongsTo
{
return $this->belongsTo(Category::class);
}
}

View File

@@ -0,0 +1,23 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
class CategoryTranslation extends Model
{
use SoftDeletes;
public $timestamps = false;
protected $fillable = [
'category_id','locale','name','description'
];
public function category(): BelongsTo
{
return $this->belongsTo(Category::class);
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
class ContentType extends Model
{
protected $fillable = ['name','slug','description'];
public function categories(): HasMany
{
return $this->hasMany(Category::class);
}
public function rootCategories(): HasMany
{
return $this->categories()->whereNull('parent_id');
}
public function getRouteKeyName(): string
{
return 'slug';
}
}

50
app/Models/User.php Normal file
View File

@@ -0,0 +1,50 @@
<?php
namespace App\Models;
// use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
class User extends Authenticatable
{
/** @use HasFactory<\Database\Factories\UserFactory> */
use HasFactory, Notifiable, SoftDeletes;
/**
* The attributes that are mass assignable.
*
* @var list<string>
*/
protected $fillable = [
'name',
'email',
'password',
];
/**
* The attributes that should be hidden for serialization.
*
* @var list<string>
*/
protected $hidden = [
'password',
'remember_token',
];
/**
* Get the attributes that should be cast.
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'email_verified_at' => 'datetime',
'deleted_at' => 'datetime',
'password' => 'hashed',
];
}
}