Save workspace changes
This commit is contained in:
@@ -249,6 +249,11 @@ class Artwork extends Model
|
||||
return $this->hasMany(ArtworkContributor::class)->orderBy('sort_order');
|
||||
}
|
||||
|
||||
public function worldSubmissions(): HasMany
|
||||
{
|
||||
return $this->hasMany(WorldSubmission::class)->orderByDesc('reviewed_at')->orderByDesc('created_at');
|
||||
}
|
||||
|
||||
public function isPublishedByGroup(): bool
|
||||
{
|
||||
return $this->publishedAsType() === self::PUBLISHED_AS_GROUP;
|
||||
@@ -516,6 +521,20 @@ class Artwork extends Model
|
||||
->where("{$table}.published_at", '<=', now());
|
||||
}
|
||||
|
||||
public function scopeCatalogVisible(Builder $query): Builder
|
||||
{
|
||||
$table = $this->getTable();
|
||||
|
||||
return $query
|
||||
->approved()
|
||||
->where("{$table}.is_public", true)
|
||||
->where(function (Builder $visibilityQuery) use ($table): void {
|
||||
$visibilityQuery->whereNull("{$table}.visibility")
|
||||
->orWhere("{$table}.visibility", self::VISIBILITY_PUBLIC);
|
||||
})
|
||||
->published();
|
||||
}
|
||||
|
||||
public function scopeSafeForGeneralAudience(Builder $query): Builder
|
||||
{
|
||||
$table = $this->getTable();
|
||||
|
||||
100
app/Models/CreatorAiBiography.php
Normal file
100
app/Models/CreatorAiBiography.php
Normal file
@@ -0,0 +1,100 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* @property int $id
|
||||
* @property int $user_id
|
||||
* @property string|null $text
|
||||
* @property string|null $source_hash
|
||||
* @property string|null $model
|
||||
* @property string|null $prompt_version
|
||||
* @property string|null $input_quality_tier
|
||||
* @property string|null $generation_reason
|
||||
* @property string $status
|
||||
* @property bool $is_active
|
||||
* @property bool $is_hidden
|
||||
* @property bool $is_user_edited
|
||||
* @property bool $needs_review
|
||||
* @property \Carbon\Carbon|null $generated_at
|
||||
* @property \Carbon\Carbon|null $approved_at
|
||||
* @property \Carbon\Carbon|null $last_attempted_at
|
||||
* @property string|null $last_error_code
|
||||
* @property string|null $last_error_reason
|
||||
* @property \Carbon\Carbon $created_at
|
||||
* @property \Carbon\Carbon $updated_at
|
||||
*/
|
||||
final class CreatorAiBiography extends Model
|
||||
{
|
||||
public const STATUS_GENERATED = 'generated';
|
||||
public const STATUS_APPROVED = 'approved';
|
||||
public const STATUS_EDITED = 'edited';
|
||||
public const STATUS_HIDDEN = 'hidden';
|
||||
public const STATUS_FAILED = 'failed';
|
||||
public const STATUS_NEEDS_REVIEW = 'needs_review';
|
||||
public const STATUS_SUPPRESSED = 'suppressed_low_signal';
|
||||
|
||||
public const TIER_RICH = 'rich';
|
||||
public const TIER_MEDIUM = 'medium';
|
||||
public const TIER_SPARSE = 'sparse';
|
||||
|
||||
public const REASON_INITIAL_GENERATE = 'initial_generate';
|
||||
public const REASON_MANUAL_REGENERATE = 'manual_regenerate';
|
||||
public const REASON_STALE_REFRESH = 'stale_refresh';
|
||||
public const REASON_MILESTONE_CHANGE = 'milestone_change';
|
||||
public const REASON_ERA_CHANGE = 'era_change';
|
||||
public const REASON_FEATURED_CHANGE = 'featured_change';
|
||||
public const REASON_ADMIN_BATCH = 'admin_batch';
|
||||
public const REASON_RETRY_AFTER_FAILURE = 'retry_after_validation_failure';
|
||||
|
||||
protected $table = 'creator_ai_biographies';
|
||||
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'text',
|
||||
'source_hash',
|
||||
'model',
|
||||
'prompt_version',
|
||||
'input_quality_tier',
|
||||
'generation_reason',
|
||||
'status',
|
||||
'is_active',
|
||||
'is_hidden',
|
||||
'is_user_edited',
|
||||
'needs_review',
|
||||
'generated_at',
|
||||
'approved_at',
|
||||
'last_attempted_at',
|
||||
'last_error_code',
|
||||
'last_error_reason',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'is_active' => 'boolean',
|
||||
'is_hidden' => 'boolean',
|
||||
'is_user_edited' => 'boolean',
|
||||
'needs_review' => 'boolean',
|
||||
'generated_at' => 'datetime',
|
||||
'approved_at' => 'datetime',
|
||||
'last_attempted_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function isVisible(): bool
|
||||
{
|
||||
return $this->is_active
|
||||
&& ! $this->is_hidden
|
||||
&& in_array($this->status, [self::STATUS_GENERATED, self::STATUS_APPROVED, self::STATUS_EDITED, self::STATUS_NEEDS_REVIEW], true)
|
||||
&& $this->text !== null
|
||||
&& trim($this->text) !== '';
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ class Leaderboard extends Model
|
||||
public const TYPE_ARTWORK = 'artwork';
|
||||
public const TYPE_GROUP = 'group';
|
||||
public const TYPE_STORY = 'story';
|
||||
public const TYPE_WORLD = 'world';
|
||||
|
||||
public const PERIOD_DAILY = 'daily';
|
||||
public const PERIOD_WEEKLY = 'weekly';
|
||||
|
||||
@@ -29,6 +29,11 @@ use Laravel\Scout\Searchable;
|
||||
|
||||
class User extends Authenticatable
|
||||
{
|
||||
private const EMAIL_LOGIN_UPGRADE_PLACEHOLDER_DOMAINS = [
|
||||
'users.skinbase.org',
|
||||
'legacy.skinbase.org',
|
||||
];
|
||||
|
||||
/** @use HasFactory<\Database\Factories\UserFactory> */
|
||||
use HasFactory, Notifiable, SoftDeletes;
|
||||
use Searchable {
|
||||
@@ -114,6 +119,40 @@ class User extends Authenticatable
|
||||
];
|
||||
}
|
||||
|
||||
public function hasCompletedOnboarding(): bool
|
||||
{
|
||||
return strtolower(trim((string) ($this->onboarding_step ?? ''))) === 'complete';
|
||||
}
|
||||
|
||||
public function requiresEmailLoginUpgrade(): bool
|
||||
{
|
||||
return ! $this->hasCompletedOnboarding()
|
||||
&& self::isEmailLoginUpgradePlaceholder($this->email);
|
||||
}
|
||||
|
||||
public static function isEmailLoginUpgradePlaceholder(?string $email): bool
|
||||
{
|
||||
$email = strtolower(trim((string) ($email ?? '')));
|
||||
|
||||
if ($email === '') {
|
||||
return true;
|
||||
}
|
||||
|
||||
$atPos = strrpos($email, '@');
|
||||
if ($atPos === false) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$domain = substr($email, $atPos + 1);
|
||||
|
||||
return in_array($domain, self::EMAIL_LOGIN_UPGRADE_PLACEHOLDER_DOMAINS, true);
|
||||
}
|
||||
|
||||
public function supportsUsernameLogin(): bool
|
||||
{
|
||||
return ! $this->hasCompletedOnboarding();
|
||||
}
|
||||
|
||||
public function novaCards(): HasMany
|
||||
{
|
||||
return $this->hasMany(NovaCard::class);
|
||||
@@ -297,6 +336,15 @@ class User extends Authenticatable
|
||||
return strtolower((string) ($this->role ?? '')) === strtolower($role);
|
||||
}
|
||||
|
||||
private function hasLegacyPrivilegeFlag(string $attribute): bool
|
||||
{
|
||||
if (! array_key_exists($attribute, $this->getAttributes())) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (bool) $this->getAttribute($attribute);
|
||||
}
|
||||
|
||||
// ─── Follow helpers ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -334,12 +382,12 @@ class User extends Authenticatable
|
||||
|
||||
public function isAdmin(): bool
|
||||
{
|
||||
return $this->hasRole('admin');
|
||||
return $this->hasRole('admin') || $this->hasLegacyPrivilegeFlag('isAdmin');
|
||||
}
|
||||
|
||||
public function isModerator(): bool
|
||||
{
|
||||
return $this->hasRole('moderator');
|
||||
return $this->hasRole('moderator') || $this->hasLegacyPrivilegeFlag('isModerator');
|
||||
}
|
||||
|
||||
public function posts(): HasMany
|
||||
|
||||
290
app/Models/World.php
Normal file
290
app/Models/World.php
Normal file
@@ -0,0 +1,290 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class World extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
use SoftDeletes;
|
||||
|
||||
public const STATUS_DRAFT = 'draft';
|
||||
public const STATUS_PUBLISHED = 'published';
|
||||
public const STATUS_ARCHIVED = 'archived';
|
||||
|
||||
public const TYPE_SEASONAL = 'seasonal';
|
||||
public const TYPE_EVENT = 'event';
|
||||
public const TYPE_CAMPAIGN = 'campaign';
|
||||
public const TYPE_TRIBUTE = 'tribute';
|
||||
|
||||
public const PARTICIPATION_MODE_MANUAL_APPROVAL = 'manual_approval';
|
||||
public const PARTICIPATION_MODE_AUTO_ADD = 'auto_add';
|
||||
public const PARTICIPATION_MODE_CLOSED = 'closed';
|
||||
|
||||
protected $fillable = [
|
||||
'title',
|
||||
'slug',
|
||||
'tagline',
|
||||
'summary',
|
||||
'description',
|
||||
'cover_path',
|
||||
'theme_key',
|
||||
'accent_color',
|
||||
'accent_color_secondary',
|
||||
'background_motif',
|
||||
'icon_name',
|
||||
'status',
|
||||
'type',
|
||||
'starts_at',
|
||||
'ends_at',
|
||||
'submission_starts_at',
|
||||
'submission_ends_at',
|
||||
'is_featured',
|
||||
'accepts_submissions',
|
||||
'participation_mode',
|
||||
'submission_note_enabled',
|
||||
'community_section_enabled',
|
||||
'allow_readd_after_removal',
|
||||
'is_recurring',
|
||||
'recurrence_key',
|
||||
'recurrence_rule',
|
||||
'edition_year',
|
||||
'cta_label',
|
||||
'cta_url',
|
||||
'badge_label',
|
||||
'badge_description',
|
||||
'submission_guidelines',
|
||||
'badge_url',
|
||||
'seo_title',
|
||||
'seo_description',
|
||||
'og_image_path',
|
||||
'related_tags_json',
|
||||
'section_order_json',
|
||||
'section_visibility_json',
|
||||
'parent_world_id',
|
||||
'created_by_user_id',
|
||||
'published_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'starts_at' => 'datetime',
|
||||
'ends_at' => 'datetime',
|
||||
'submission_starts_at' => 'datetime',
|
||||
'submission_ends_at' => 'datetime',
|
||||
'published_at' => 'datetime',
|
||||
'is_featured' => 'boolean',
|
||||
'accepts_submissions' => 'boolean',
|
||||
'allow_readd_after_removal' => 'boolean',
|
||||
'submission_note_enabled' => 'boolean',
|
||||
'community_section_enabled' => 'boolean',
|
||||
'is_recurring' => 'boolean',
|
||||
'edition_year' => 'integer',
|
||||
'related_tags_json' => 'array',
|
||||
'section_order_json' => 'array',
|
||||
'section_visibility_json' => 'array',
|
||||
];
|
||||
|
||||
public function createdBy(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'created_by_user_id');
|
||||
}
|
||||
|
||||
public function parentWorld(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(self::class, 'parent_world_id');
|
||||
}
|
||||
|
||||
public function archiveEditions(): HasMany
|
||||
{
|
||||
return $this->hasMany(self::class, 'parent_world_id')->orderByDesc('edition_year')->orderByDesc('starts_at');
|
||||
}
|
||||
|
||||
public function worldRelations(): HasMany
|
||||
{
|
||||
return $this->hasMany(WorldRelation::class)->orderBy('section_key')->orderBy('sort_order')->orderBy('id');
|
||||
}
|
||||
|
||||
public function worldSubmissions(): HasMany
|
||||
{
|
||||
return $this->hasMany(WorldSubmission::class)->orderByDesc('reviewed_at')->orderByDesc('created_at');
|
||||
}
|
||||
|
||||
public function scopePublished(Builder $query): Builder
|
||||
{
|
||||
return $query
|
||||
->where('status', self::STATUS_PUBLISHED)
|
||||
->where(function (Builder $builder): void {
|
||||
$builder->whereNull('published_at')
|
||||
->orWhere('published_at', '<=', now());
|
||||
});
|
||||
}
|
||||
|
||||
public function scopePubliclyVisible(Builder $query): Builder
|
||||
{
|
||||
return $query
|
||||
->whereIn('status', [self::STATUS_PUBLISHED, self::STATUS_ARCHIVED])
|
||||
->where(function (Builder $builder): void {
|
||||
$builder->whereNull('published_at')
|
||||
->orWhere('published_at', '<=', now());
|
||||
});
|
||||
}
|
||||
|
||||
public function scopeCurrent(Builder $query): Builder
|
||||
{
|
||||
return $query
|
||||
->published()
|
||||
->where(function (Builder $builder): void {
|
||||
$builder->whereNull('starts_at')
|
||||
->orWhere('starts_at', '<=', now());
|
||||
})
|
||||
->where(function (Builder $builder): void {
|
||||
$builder->whereNull('ends_at')
|
||||
->orWhere('ends_at', '>=', now());
|
||||
});
|
||||
}
|
||||
|
||||
public function scopeUpcoming(Builder $query): Builder
|
||||
{
|
||||
return $query
|
||||
->published()
|
||||
->whereNotNull('starts_at')
|
||||
->where('starts_at', '>', now());
|
||||
}
|
||||
|
||||
public function scopeArchive(Builder $query): Builder
|
||||
{
|
||||
return $query
|
||||
->publiclyVisible()
|
||||
->where(function (Builder $builder): void {
|
||||
$builder->where('status', self::STATUS_ARCHIVED)
|
||||
->orWhere(function (Builder $expired): void {
|
||||
$expired->whereNotNull('ends_at')
|
||||
->where('ends_at', '<', now());
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public function isPubliclyVisible(): bool
|
||||
{
|
||||
if (! in_array($this->status, [self::STATUS_PUBLISHED, self::STATUS_ARCHIVED], true)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($this->published_at && $this->published_at->isFuture()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function isCurrent(): bool
|
||||
{
|
||||
if (! $this->isPubliclyVisible()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($this->starts_at && $this->starts_at->isFuture()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($this->ends_at && $this->ends_at->isPast()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function isAcceptingSubmissions(): bool
|
||||
{
|
||||
if (! $this->isPubliclyVisible() || ! $this->accepts_submissions || ! $this->allowsCreatorParticipation()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($this->submission_starts_at && $this->submission_starts_at->isFuture()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($this->submission_ends_at && $this->submission_ends_at->isPast()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function allowsCreatorParticipation(): bool
|
||||
{
|
||||
return in_array((string) $this->participation_mode, [
|
||||
self::PARTICIPATION_MODE_MANUAL_APPROVAL,
|
||||
self::PARTICIPATION_MODE_AUTO_ADD,
|
||||
], true);
|
||||
}
|
||||
|
||||
public function submissionStartsAsLive(): bool
|
||||
{
|
||||
return (string) $this->participation_mode === self::PARTICIPATION_MODE_AUTO_ADD;
|
||||
}
|
||||
|
||||
public function coverUrl(): ?string
|
||||
{
|
||||
$path = trim((string) $this->cover_path);
|
||||
|
||||
if ($path === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (str_starts_with($path, 'http://') || str_starts_with($path, 'https://')) {
|
||||
return $path;
|
||||
}
|
||||
|
||||
return rtrim((string) config('cdn.files_url', 'https://files.skinbase.org'), '/') . '/' . ltrim($path, '/');
|
||||
}
|
||||
|
||||
public function ogImageUrl(): ?string
|
||||
{
|
||||
$path = trim((string) ($this->og_image_path ?: $this->cover_path ?: ''));
|
||||
|
||||
if ($path === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (str_starts_with($path, 'http://') || str_starts_with($path, 'https://')) {
|
||||
return $path;
|
||||
}
|
||||
|
||||
return rtrim((string) config('cdn.files_url', 'https://files.skinbase.org'), '/') . '/' . ltrim($path, '/');
|
||||
}
|
||||
|
||||
public function publicUrl(): string
|
||||
{
|
||||
return route('worlds.show', ['world' => $this->slug]);
|
||||
}
|
||||
|
||||
public function sectionOrder(): array
|
||||
{
|
||||
$defaults = array_values(array_filter(config('worlds.default_section_order', []), 'is_string'));
|
||||
$custom = array_values(array_filter($this->section_order_json ?? [], 'is_string'));
|
||||
|
||||
return array_values(array_unique(array_merge($custom, $defaults)));
|
||||
}
|
||||
|
||||
public function sectionVisibility(): array
|
||||
{
|
||||
$defaults = collect(array_keys((array) config('worlds.sections', [])))
|
||||
->mapWithKeys(fn (string $key): array => [$key => true])
|
||||
->all();
|
||||
|
||||
$custom = collect((array) $this->section_visibility_json)
|
||||
->mapWithKeys(fn ($value, $key): array => [(string) $key => (bool) $value])
|
||||
->all();
|
||||
|
||||
return array_merge($defaults, $custom);
|
||||
}
|
||||
}
|
||||
45
app/Models/WorldRelation.php
Normal file
45
app/Models/WorldRelation.php
Normal file
@@ -0,0 +1,45 @@
|
||||
<?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 WorldRelation extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
public const TYPE_ARTWORK = 'artwork';
|
||||
public const TYPE_COLLECTION = 'collection';
|
||||
public const TYPE_USER = 'user';
|
||||
public const TYPE_GROUP = 'group';
|
||||
public const TYPE_NEWS = 'news';
|
||||
public const TYPE_CHALLENGE = 'challenge';
|
||||
public const TYPE_EVENT = 'event';
|
||||
public const TYPE_RELEASE = 'release';
|
||||
public const TYPE_CARD = 'card';
|
||||
|
||||
protected $fillable = [
|
||||
'world_id',
|
||||
'related_type',
|
||||
'related_id',
|
||||
'section_key',
|
||||
'context_label',
|
||||
'sort_order',
|
||||
'is_featured',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'related_id' => 'integer',
|
||||
'sort_order' => 'integer',
|
||||
'is_featured' => 'boolean',
|
||||
];
|
||||
|
||||
public function world(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(World::class);
|
||||
}
|
||||
}
|
||||
74
app/Models/WorldSubmission.php
Normal file
74
app/Models/WorldSubmission.php
Normal file
@@ -0,0 +1,74 @@
|
||||
<?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 WorldSubmission extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
public const STATUS_PENDING = 'pending';
|
||||
public const STATUS_LIVE = 'live';
|
||||
public const STATUS_REMOVED = 'removed';
|
||||
public const STATUS_BLOCKED = 'blocked';
|
||||
|
||||
protected $fillable = [
|
||||
'world_id',
|
||||
'artwork_id',
|
||||
'submitted_by_user_id',
|
||||
'status',
|
||||
'is_featured',
|
||||
'mode_snapshot',
|
||||
'note',
|
||||
'reviewer_note',
|
||||
'moderation_reason',
|
||||
'reviewed_by_user_id',
|
||||
'reviewed_at',
|
||||
'removed_at',
|
||||
'blocked_at',
|
||||
'featured_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'is_featured' => 'boolean',
|
||||
'reviewed_at' => 'datetime',
|
||||
'removed_at' => 'datetime',
|
||||
'blocked_at' => 'datetime',
|
||||
'featured_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function canBeReadded(): bool
|
||||
{
|
||||
return (string) $this->status === self::STATUS_REMOVED;
|
||||
}
|
||||
|
||||
public function isBlockingResubmission(): bool
|
||||
{
|
||||
return (string) $this->status === self::STATUS_BLOCKED;
|
||||
}
|
||||
|
||||
public function world(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(World::class);
|
||||
}
|
||||
|
||||
public function artwork(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Artwork::class);
|
||||
}
|
||||
|
||||
public function submittedBy(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'submitted_by_user_id');
|
||||
}
|
||||
|
||||
public function reviewer(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'reviewed_by_user_id');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user