*/ use BumpsContentVersion, HasContentViews, HasFactory, HasImages, HasPublicationWindow, HasReviews, HasRevisions, HasTranslations, RecordsSlugChanges, SoftDeletes; /** @var array */ public array $translatable = [ 'title', 'slug', 'tagline', 'description', 'decisions', 'postmortem', 'meta_title', 'meta_description', // What the cover shows, described in each language. 'cover_image_alt', ]; /** * Mirrors the column default so `status` is a ProjectStatus from the * moment a model is instantiated, not only after a re-read. * * @var array */ protected $attributes = [ 'status' => ProjectStatus::Idea->value, ]; protected function casts(): array { return [ 'status' => ProjectStatus::class, 'is_featured' => 'boolean', 'needs_help' => 'boolean', 'started_at' => 'date', 'ended_at' => 'date', 'published_at' => 'datetime', 'unpublish_at' => 'datetime', 'position' => 'integer', ]; } protected static function booted(): void { // A morph relation has no foreign key to cascade for it, so a project // erased for good would otherwise leave its gallery behind. Soft // deletes keep it, which is the point of a soft delete. static::forceDeleted(fn (Project $project) => $project->images()->delete()); } /** * Whether the site is showing this right now. A project has no status of * its own for this — its dates are the whole answer. */ public function isPublishedNow(): bool { return $this->published_at !== null && ! $this->isScheduled() && ! $this->hasExpired(); } /** * The `site.paths` key this model publishes under, so a slug change knows * which URL it just broke. */ public function publicPathType(): string { return 'project'; } /** * @return array */ public function imageAttributes(): array { return ['cover_image']; } /** * The gallery: screenshots, before-and-after, whatever the write-up needs. * * Ordered here rather than at every call site, because an unordered * gallery is a different gallery each time it is read. * * @return MorphMany */ public function images(): MorphMany { return $this->morphMany(ImageAttachment::class, 'attachable') ->where('collection', 'gallery') ->ordered(); } /** * Dimensions and derived versions of the cover, joined on the stored path. * * @return BelongsTo */ public function coverMedia(): BelongsTo { return $this->imageRelation('cover_image'); } /** * Who the work was for. Used to be a name typed in by hand, which made the * same association two different strings. * * @return BelongsTo */ public function client(): BelongsTo { return $this->belongsTo(Client::class); } /** * @return BelongsToMany */ public function services(): BelongsToMany { return $this->belongsToMany(Service::class); } /** * @return HasMany */ public function testimonials(): HasMany { return $this->hasMany(Testimonial::class); } /** * @return HasMany */ public function talks(): HasMany { return $this->hasMany(Talk::class); } /** * @return BelongsTo */ public function lead(): BelongsTo { return $this->belongsTo(Member::class, 'lead_member_id'); } /** * @return BelongsToMany */ public function members(): BelongsToMany { return $this->belongsToMany(Member::class)->withPivot('role'); } /** * @return BelongsToMany */ public function technologies(): BelongsToMany { return $this->belongsToMany(Technology::class); } /** * @return HasMany */ public function repositories(): HasMany { return $this->hasMany(Repository::class); } /** * @return HasMany */ public function calls(): HasMany { return $this->hasMany(Call::class); } /** * The repository whose stats represent the project as a whole. */ public function primaryRepository(): ?Repository { return $this->repositories->firstWhere('is_primary', true) ?? $this->repositories->first(); } /** * Visible on the public site: published, and not scheduled for later. * * @param Builder<$this> $query * @return Builder<$this> */ public function scopePublished(Builder $query): Builder { return $query ->whereNotNull('published_at') ->where('published_at', '<=', now()) ->where(fn (Builder $q) => $q ->whereNull('unpublish_at') ->orWhere('unpublish_at', '>', now())); } /** * Projects actively looking for contributors, either flagged directly or * carrying an open call. * * @param Builder<$this> $query * @return Builder<$this> */ public function scopeNeedingHelp(Builder $query): Builder { return $query->where(fn (Builder $q) => $q ->where('needs_help', true) ->orWhereHas('calls', fn (Builder $c) => $c->open())); } /** * @param Builder<$this> $query * @return Builder<$this> */ public function scopeWhereSlug(Builder $query, string $slug, ?string $locale = null): Builder { $locale ??= app()->getLocale(); return $query->where("slug->{$locale}", $slug); } }