Initial commit: Pireon Admin — headless admin panel and public API
Laravel 13 + Filament 5 admin panel and REST API for a non-profit developer collective, backed by the Pireon OAuth identity server. The public site is a separate, server-rendered application consuming /api/public/v1. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
197
app/Models/Page.php
Normal file
197
app/Models/Page.php
Normal file
@@ -0,0 +1,197 @@
|
||||
<?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\SoftDeletes;
|
||||
use Spatie\Translatable\HasTranslations;
|
||||
|
||||
/**
|
||||
* @property array<string, string> $title
|
||||
* @property array<string, string> $slug
|
||||
*/
|
||||
#[Fillable([
|
||||
'category_id', 'author_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');
|
||||
}
|
||||
|
||||
/**
|
||||
* 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}", '!=', '');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user