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:
244
app/Models/Media.php
Normal file
244
app/Models/Media.php
Normal file
@@ -0,0 +1,244 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Jobs\ProcessMedia;
|
||||
use Database\Factories\MediaFactory;
|
||||
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\SoftDeletes;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* An uploaded file and everything derived from it.
|
||||
*
|
||||
* Identified by (disk, path) rather than by an id chosen here, so the record
|
||||
* follows the file: uploading the same image twice, or pointing two models at
|
||||
* one upload, converges on a single row and a single set of derived versions.
|
||||
*
|
||||
* Nothing here is authored. If the table were dropped it could be rebuilt from
|
||||
* the files with `php artisan media:process --all`.
|
||||
*/
|
||||
#[Fillable(['disk', 'path', 'mime', 'size', 'width', 'height', 'placeholder', 'variants', 'processed_at', 'error'])]
|
||||
class Media extends Model
|
||||
{
|
||||
/** @use HasFactory<MediaFactory> */
|
||||
use HasFactory;
|
||||
|
||||
protected $table = 'media';
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'size' => 'integer',
|
||||
'width' => 'integer',
|
||||
'height' => 'integer',
|
||||
'variants' => 'array',
|
||||
'processed_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* The row for a stored path, creating and queueing it the first time.
|
||||
*
|
||||
* Returns null for a blank path so callers can hand it whatever an
|
||||
* optional image field holds without checking first.
|
||||
*/
|
||||
public static function for(?string $path, ?string $disk = null): ?self
|
||||
{
|
||||
if (blank($path)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$disk ??= (string) config('media.disk', 'public');
|
||||
|
||||
$media = static::query()->firstOrCreate(
|
||||
['disk' => $disk, 'path' => ltrim($path, '/')],
|
||||
);
|
||||
|
||||
if ($media->wasRecentlyCreated) {
|
||||
ProcessMedia::dispatch($media);
|
||||
}
|
||||
|
||||
return $media;
|
||||
}
|
||||
|
||||
/**
|
||||
* Absolute URL. The frontend runs on another host, so a path only this
|
||||
* application can resolve would be no use to it.
|
||||
*/
|
||||
public function url(): string
|
||||
{
|
||||
return static::absolute(Storage::disk($this->disk)->url($this->path));
|
||||
}
|
||||
|
||||
public function exists(): bool
|
||||
{
|
||||
return Storage::disk($this->disk)->exists($this->path);
|
||||
}
|
||||
|
||||
public function isProcessed(): bool
|
||||
{
|
||||
return $this->processed_at !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Aspect ratio, for reserving the right box before the image arrives.
|
||||
*/
|
||||
public function aspectRatio(): ?float
|
||||
{
|
||||
if (! $this->width || ! $this->height) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return round($this->width / $this->height, 4);
|
||||
}
|
||||
|
||||
/**
|
||||
* Derived versions grouped by format, widest last.
|
||||
*
|
||||
* @return array<string, array<int, array{width: int, url: string}>>
|
||||
*/
|
||||
public function variantsByFormat(): array
|
||||
{
|
||||
$grouped = [];
|
||||
|
||||
foreach ($this->variants ?? [] as $variant) {
|
||||
$grouped[$variant['format']][] = [
|
||||
'width' => (int) $variant['width'],
|
||||
'url' => static::absolute(Storage::disk($this->disk)->url($variant['path'])),
|
||||
];
|
||||
}
|
||||
|
||||
foreach ($grouped as $format => $variants) {
|
||||
usort($variants, fn (array $a, array $b) => $a['width'] <=> $b['width']);
|
||||
$grouped[$format] = $variants;
|
||||
}
|
||||
|
||||
return $grouped;
|
||||
}
|
||||
|
||||
/**
|
||||
* A ready-to-use `srcset` string per format.
|
||||
*
|
||||
* Built here rather than in the frontend because only this side knows
|
||||
* which widths actually exist — and offering one that does not would put
|
||||
* a broken image on the page.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function srcset(): array
|
||||
{
|
||||
$sets = [];
|
||||
|
||||
foreach ($this->variantsByFormat() as $format => $variants) {
|
||||
$sets[$format] = collect($variants)
|
||||
->map(fn (array $v) => "{$v['url']} {$v['width']}w")
|
||||
->implode(', ');
|
||||
}
|
||||
|
||||
return $sets;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every file this record owns, original included. Used when reprocessing,
|
||||
* so a second run does not leave the first run's versions behind.
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public function derivedPaths(): array
|
||||
{
|
||||
return collect($this->variants ?? [])->pluck('path')->all();
|
||||
}
|
||||
|
||||
public function deleteDerived(): void
|
||||
{
|
||||
$paths = $this->derivedPaths();
|
||||
|
||||
if ($paths !== []) {
|
||||
Storage::disk($this->disk)->delete($paths);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Every path a record currently points at.
|
||||
*
|
||||
* The answer to "is anything still using this file?", which is the only
|
||||
* question that makes deleting one safe.
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public static function referencedPaths(): array
|
||||
{
|
||||
$paths = [];
|
||||
|
||||
$sources = [
|
||||
[Page::class, 'featured_image'],
|
||||
[Project::class, 'cover_image'],
|
||||
[Member::class, 'avatar'],
|
||||
[Client::class, 'logo'],
|
||||
[Testimonial::class, 'avatar'],
|
||||
[ImageAttachment::class, 'path'],
|
||||
];
|
||||
|
||||
foreach ($sources as [$model, $column]) {
|
||||
$query = $model::query()->whereNotNull($column);
|
||||
|
||||
// Something in the bin can still be restored, so its images are
|
||||
// still in use — deleting them would restore a broken record.
|
||||
if (in_array(SoftDeletes::class, class_uses_recursive($model), true)) {
|
||||
$query->withTrashed();
|
||||
}
|
||||
|
||||
$paths = [...$paths, ...$query->pluck($column)->all()];
|
||||
}
|
||||
|
||||
return array_values(array_unique(array_filter($paths)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether any record still points at this file.
|
||||
*
|
||||
* The question that decides whether deleting it is safe, and the reason
|
||||
* the panel refuses to delete one that is in use.
|
||||
*/
|
||||
public function isReferenced(): bool
|
||||
{
|
||||
return in_array($this->path, static::referencedPaths(), true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Total bytes on disk, original and every derived version.
|
||||
*/
|
||||
public function totalSize(): int
|
||||
{
|
||||
return (int) $this->size + collect($this->variants ?? [])->sum('size');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Builder<$this> $query
|
||||
* @return Builder<$this>
|
||||
*/
|
||||
public function scopePending(Builder $query): Builder
|
||||
{
|
||||
return $query->whereNull('processed_at');
|
||||
}
|
||||
|
||||
/**
|
||||
* Disks configured with a relative URL (the `local` driver's own route,
|
||||
* for instance) would hand the frontend a link it cannot resolve.
|
||||
*/
|
||||
protected static function absolute(string $url): string
|
||||
{
|
||||
if (Str::startsWith($url, ['http://', 'https://'])) {
|
||||
return $url;
|
||||
}
|
||||
|
||||
return rtrim((string) config('app.url'), '/').'/'.ltrim($url, '/');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user