Save workspace changes
This commit is contained in:
204
.deploy/artwork-evolution-release/app/Models/Category.php
Normal file
204
.deploy/artwork-evolution-release/app/Models/Category.php
Normal file
@@ -0,0 +1,204 @@
|
||||
<?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'];
|
||||
|
||||
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.
|
||||
*/
|
||||
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';
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user