Tags are named in every language and made from the page form; the byline is the member who signs a post on the site. The public API sends both, filters posts by tag and lists the tags in use. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
218 lines
6.4 KiB
PHP
218 lines
6.4 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Models;
|
|
|
|
use App\Enums\PageStatus;
|
|
use App\Models\Concerns\BumpsContentVersion;
|
|
use App\Models\Concerns\HasContentViews;
|
|
use App\Models\Concerns\HasImages;
|
|
use App\Models\Concerns\HasPublicationWindow;
|
|
use App\Models\Concerns\HasReviews;
|
|
use App\Models\Concerns\HasRevisions;
|
|
use App\Models\Concerns\RecordsSlugChanges;
|
|
use Database\Factories\PageFactory;
|
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
|
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\BelongsToMany;
|
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
|
use Spatie\Translatable\HasTranslations;
|
|
|
|
/**
|
|
* @property array<string, string> $title
|
|
* @property array<string, string> $slug
|
|
*/
|
|
#[Fillable([
|
|
'category_id', 'author_id', 'byline_member_id', 'title', 'slug', 'excerpt', 'body',
|
|
'meta_title', 'meta_description', 'status', 'featured_image',
|
|
'featured_image_alt', 'is_featured', 'published_at', 'unpublish_at',
|
|
])]
|
|
class Page extends Model
|
|
{
|
|
/** @use HasFactory<PageFactory> */
|
|
use BumpsContentVersion, HasContentViews, HasFactory, HasImages, HasPublicationWindow, HasReviews, HasRevisions, HasTranslations, RecordsSlugChanges, SoftDeletes;
|
|
|
|
/**
|
|
* Columns stored as a locale => value JSON map.
|
|
*
|
|
* @var array<int, string>
|
|
*/
|
|
public array $translatable = [
|
|
'title', 'slug', 'excerpt', 'body', 'meta_title', 'meta_description',
|
|
|
|
// What the featured image shows, described in each language.
|
|
'featured_image_alt',
|
|
];
|
|
|
|
/**
|
|
* Mirrors the column default so `status` is a PageStatus from the moment a
|
|
* model is instantiated. Relying on the database default alone leaves the
|
|
* attribute null until the row is re-read, which anything reading
|
|
* `$page->status->value` would trip over.
|
|
*
|
|
* @var array<string, mixed>
|
|
*/
|
|
protected $attributes = [
|
|
'status' => PageStatus::Draft->value,
|
|
];
|
|
|
|
protected function casts(): array
|
|
{
|
|
return [
|
|
'status' => PageStatus::class,
|
|
'is_featured' => 'boolean',
|
|
'published_at' => 'datetime',
|
|
'unpublish_at' => 'datetime',
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Whether the site is showing this right now — what "published" means for
|
|
* a page, which has both a status and a window.
|
|
*/
|
|
public function isPublishedNow(): bool
|
|
{
|
|
return $this->status === PageStatus::Published
|
|
&& ! $this->isScheduled()
|
|
&& ! $this->hasExpired();
|
|
}
|
|
|
|
/**
|
|
* A page is the one model here whose status can already say "in review",
|
|
* and that value is shown in the table and offered as a filter. Asking for
|
|
* a review writes it, so the column keeps telling the truth; sending the
|
|
* work back returns it to a draft.
|
|
*
|
|
* Only ever called from the review methods, which is what stops the two
|
|
* from disagreeing. An archived page is left alone: it is not a draft, and
|
|
* silently turning it into one would throw away a deliberate decision.
|
|
*/
|
|
protected function syncReviewStatus(bool $awaiting): void
|
|
{
|
|
if ($awaiting) {
|
|
if ($this->status === PageStatus::Draft) {
|
|
$this->status = PageStatus::Review;
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
if ($this->status === PageStatus::Review) {
|
|
$this->status = PageStatus::Draft;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The `site.paths` key this model publishes under, so a slug change knows
|
|
* which URL it just broke.
|
|
*/
|
|
public function publicPathType(): string
|
|
{
|
|
return 'page';
|
|
}
|
|
|
|
/**
|
|
* @return array<int, string>
|
|
*/
|
|
public function imageAttributes(): array
|
|
{
|
|
return ['featured_image'];
|
|
}
|
|
|
|
/**
|
|
* @return BelongsTo<Media, $this>
|
|
*/
|
|
public function featuredMedia(): BelongsTo
|
|
{
|
|
return $this->imageRelation('featured_image');
|
|
}
|
|
|
|
/**
|
|
* @return BelongsTo<Category, $this>
|
|
*/
|
|
public function category(): BelongsTo
|
|
{
|
|
return $this->belongsTo(Category::class);
|
|
}
|
|
|
|
/**
|
|
* @return BelongsTo<User, $this>
|
|
*/
|
|
public function author(): BelongsTo
|
|
{
|
|
return $this->belongsTo(User::class, 'author_id');
|
|
}
|
|
|
|
/**
|
|
* Who signs it on the site: a member, with a profile to link to. Not the
|
|
* account that typed it in, which is `author` and stays in the panel.
|
|
*
|
|
* @return BelongsTo<Member, $this>
|
|
*/
|
|
public function byline(): BelongsTo
|
|
{
|
|
return $this->belongsTo(Member::class, 'byline_member_id');
|
|
}
|
|
|
|
/**
|
|
* @return BelongsToMany<Tag, $this>
|
|
*/
|
|
public function tags(): BelongsToMany
|
|
{
|
|
return $this->belongsToMany(Tag::class);
|
|
}
|
|
|
|
/**
|
|
* Publicly visible pages: published and not scheduled for the future.
|
|
*
|
|
* @param Builder<$this> $query
|
|
* @return Builder<$this>
|
|
*/
|
|
public function scopePublished(Builder $query): Builder
|
|
{
|
|
return $query
|
|
->where('status', PageStatus::Published)
|
|
->where(fn (Builder $q) => $q
|
|
->whereNull('published_at')
|
|
->orWhere('published_at', '<=', now()))
|
|
// Both dates are enforced in one place, so nothing that reads
|
|
// published content has to remember the second one.
|
|
->where(fn (Builder $q) => $q
|
|
->whereNull('unpublish_at')
|
|
->orWhere('unpublish_at', '>', now()));
|
|
}
|
|
|
|
/**
|
|
* Match a slug within one locale. Slugs live inside a JSON map, so this
|
|
* targets the locale's key rather than the column as a whole.
|
|
*
|
|
* @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);
|
|
}
|
|
|
|
/**
|
|
* Restrict to records that actually carry a translation for a locale —
|
|
* used by the API's `?translated_in=ca` filter so clients can avoid
|
|
* fallback content.
|
|
*
|
|
* @param Builder<$this> $query
|
|
* @return Builder<$this>
|
|
*/
|
|
public function scopeTranslatedIn(Builder $query, string $locale): Builder
|
|
{
|
|
return $query
|
|
->whereNotNull("title->{$locale}")
|
|
->where("title->{$locale}", '!=', '');
|
|
}
|
|
}
|