Wire admin studio SSR and search infrastructure

This commit is contained in:
2026-05-01 11:46:06 +02:00
parent 257b0dbef6
commit 18cea8b0f0
329 changed files with 197465 additions and 2741 deletions

View File

@@ -28,6 +28,19 @@ class Artwork extends Model
{
use HasFactory, SoftDeletes, Searchable;
/**
* Override Scout's bootSearchable to skip the ModelObserver (which fires MakeSearchable
* on every save). We still register SearchableScope and Builder macros so that
* scout:import and Builder::searchable() continue to work.
* All indexing is managed explicitly via IndexArtworkJob.
*/
public static function bootSearchable(): void
{
static::addGlobalScope(new \Laravel\Scout\SearchableScope);
(new static)->registerSearchableMacros();
// ModelObserver intentionally omitted — indexing is handled by IndexArtworkJob.
}
public const PUBLISHED_AS_USER = 'user';
public const PUBLISHED_AS_GROUP = 'group';
@@ -254,6 +267,11 @@ class Artwork extends Model
return $this->hasMany(WorldSubmission::class)->orderByDesc('reviewed_at')->orderByDesc('created_at');
}
public function worldRewardGrants(): HasMany
{
return $this->hasMany(WorldRewardGrant::class)->orderByDesc('granted_at')->orderByDesc('id');
}
public function isPublishedByGroup(): bool
{
return $this->publishedAsType() === self::PUBLISHED_AS_GROUP;
@@ -409,6 +427,14 @@ class Artwork extends Model
$stat = $this->stats;
$awardStat = $this->awardStat;
$publishedSortAt = $this->published_at ?? $this->created_at;
$sortedCategories = $this->categories->sortBy(
fn ($category) => sprintf(
'%010d|%s|%010d',
(int) ($category->sort_order ?? 999999999),
strtolower((string) ($category->name ?? '')),
(int) ($category->id ?? 0)
)
)->values();
// Orientation derived from pixel dimensions
$orientation = 'square';
@@ -425,8 +451,22 @@ class Artwork extends Model
? $this->width . 'x' . $this->height
: '';
// Primary category slug (first attached category)
$primaryCategory = $this->categories->first();
// Primary category slug follows the same sort_order-first semantics used by page presenters.
$primaryCategory = $sortedCategories->first();
$categorySlugs = $sortedCategories
->pluck('slug')
->filter()
->map(static fn ($slug) => (string) $slug)
->unique()
->values()
->all();
$contentTypeSlugs = $sortedCategories
->map(static fn ($category) => $category->contentType?->slug)
->filter()
->map(static fn ($slug) => (string) $slug)
->unique()
->values()
->all();
$category = $primaryCategory?->slug ?? '';
$content_type = $primaryCategory?->contentType?->slug ?? '';
@@ -442,7 +482,9 @@ class Artwork extends Model
'author_name' => $this->group?->name ?? $this->user?->name ?? 'Skinbase',
'published_as_type' => $this->publishedAsType(),
'category' => $category,
'categories' => $categorySlugs,
'content_type' => $content_type,
'content_types' => $contentTypeSlugs,
'tags' => $tags,
'ai_clip_tags' => collect((array) ($this->clip_tags_json ?? []))
->map(static fn ($row) => is_array($row) ? (string) ($row['tag'] ?? '') : '')

View File

@@ -83,6 +83,6 @@ final class Country extends Model
return null;
}
return '/gfx/flags/shiny/24/'.rawurlencode($iso2).'.png';
return rtrim((string) \config('cdn.files_url', ''), '/').'/images/flags/shiny/24/'.rawurlencode($iso2).'.png';
}
}

View File

@@ -19,11 +19,13 @@ final class Tag extends Model
'name',
'slug',
'usage_count',
'artworks_count',
'is_active',
];
protected $casts = [
'usage_count' => 'integer',
'artworks_count' => 'integer',
'is_active' => 'boolean',
];

View File

@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
final class UploadBatch extends Model
{
public const STATUS_UPLOADING = 'uploading';
public const STATUS_PROCESSING = 'processing';
public const STATUS_COMPLETED = 'completed';
public const STATUS_COMPLETED_WITH_ERRORS = 'completed_with_errors';
public const STATUS_CANCELLED = 'cancelled';
protected $fillable = [
'user_id',
'name',
'status',
'total_items',
'processed_items',
'failed_items',
'published_items',
'defaults_json',
];
protected $casts = [
'defaults_json' => 'array',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function items(): HasMany
{
return $this->hasMany(UploadBatchItem::class)->orderBy('id');
}
}

View File

@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
final class UploadBatchItem extends Model
{
public const STATUS_UPLOADED = 'uploaded';
public const STATUS_PROCESSING = 'processing';
public const STATUS_NEEDS_METADATA = 'needs_metadata';
public const STATUS_NEEDS_REVIEW = 'needs_review';
public const STATUS_READY = 'ready';
public const STATUS_FAILED = 'failed';
public const STATUS_PUBLISHED = 'published';
public const STATUS_DELETED = 'deleted';
public const STAGE_QUEUED = 'queued';
public const STAGE_STORED = 'stored';
public const STAGE_THUMBNAILS = 'thumbnails';
public const STAGE_VISION_ANALYSIS = 'vision_analysis';
public const STAGE_MATURITY_CHECK = 'maturity_check';
public const STAGE_METADATA_SUGGESTIONS = 'metadata_suggestions';
public const STAGE_FINALIZED = 'finalized';
protected $fillable = [
'upload_batch_id',
'user_id',
'artwork_id',
'original_filename',
'status',
'processing_stage',
'error_code',
'error_message',
'metadata_completeness',
'is_ready_to_publish',
'uploaded_at',
'processed_at',
'published_at',
];
protected $casts = [
'is_ready_to_publish' => 'boolean',
'uploaded_at' => 'datetime',
'processed_at' => 'datetime',
'published_at' => 'datetime',
];
public function batch(): BelongsTo
{
return $this->belongsTo(UploadBatch::class, 'upload_batch_id');
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function artwork(): BelongsTo
{
return $this->belongsTo(Artwork::class);
}
}

View File

@@ -265,6 +265,11 @@ class User extends Authenticatable
return $this->hasMany(UserActivity::class, 'user_id');
}
public function worldRewardGrants(): HasMany
{
return $this->hasMany(WorldRewardGrant::class, 'user_id')->orderByDesc('granted_at')->orderByDesc('id');
}
public function achievements(): BelongsToMany
{
return $this->belongsToMany(Achievement::class, 'user_achievements', 'user_id', 'achievement_id')
@@ -385,6 +390,25 @@ class User extends Authenticatable
return $this->hasRole('admin') || $this->hasLegacyPrivilegeFlag('isAdmin');
}
public function isManager(): bool
{
return $this->hasRole('manager');
}
public function isEditorial(): bool
{
return $this->hasRole('editorial');
}
/**
* Returns true for any role that grants access to the /admin panel
* (admin, manager, editorial).
*/
public function hasStaffAccess(): bool
{
return $this->isAdmin() || $this->isManager() || $this->isEditorial();
}
public function isModerator(): bool
{
return $this->hasRole('moderator') || $this->hasLegacyPrivilegeFlag('isModerator');

View File

@@ -23,6 +23,7 @@ class UserActivity extends Model
public const TYPE_FAVOURITE = 'favourite';
public const TYPE_FOLLOW = 'follow';
public const TYPE_ACHIEVEMENT = 'achievement';
public const TYPE_WORLD_REWARD = 'world_reward';
public const TYPE_FORUM_POST = 'forum_post';
public const TYPE_FORUM_REPLY = 'forum_reply';
@@ -30,6 +31,7 @@ class UserActivity extends Model
public const ENTITY_ARTWORK_COMMENT = 'artwork_comment';
public const ENTITY_USER = 'user';
public const ENTITY_ACHIEVEMENT = 'achievement';
public const ENTITY_WORLD_REWARD = 'world_reward';
public const ENTITY_FORUM_THREAD = 'forum_thread';
public const ENTITY_FORUM_POST = 'forum_post';