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>
184 lines
5.0 KiB
PHP
184 lines
5.0 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Filament\Pages;
|
|
|
|
use App\Models\Application;
|
|
use App\Models\Category;
|
|
use App\Models\Client;
|
|
use App\Models\Member;
|
|
use App\Models\Page;
|
|
use App\Models\Project;
|
|
use App\Models\Service;
|
|
use App\Models\Testimonial;
|
|
use BackedEnum;
|
|
use Filament\Notifications\Notification;
|
|
use Filament\Pages\Page as FilamentPage;
|
|
use Filament\Support\Icons\Heroicon;
|
|
use Illuminate\Contracts\Support\Htmlable;
|
|
use Illuminate\Database\Eloquent\Model;
|
|
use Illuminate\Support\Collection;
|
|
use Illuminate\Support\Facades\Auth;
|
|
use UnitEnum;
|
|
|
|
/**
|
|
* Everything deleted, in one place.
|
|
*
|
|
* Soft deletes are everywhere, but recovering something meant remembering
|
|
* which resource it had been in and then turning on a filter — which is a lot
|
|
* to ask of somebody who has just deleted the wrong thing and is looking for
|
|
* the undo button.
|
|
*/
|
|
class Trash extends FilamentPage
|
|
{
|
|
protected string $view = 'filament.pages.trash';
|
|
|
|
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedTrash;
|
|
|
|
protected static ?int $navigationSort = 87;
|
|
|
|
/**
|
|
* Every model that can be recovered, and the permission that governs it.
|
|
*
|
|
* @var array<class-string<Model>, string>
|
|
*/
|
|
public const RECOVERABLE = [
|
|
Page::class => 'pages',
|
|
Project::class => 'projects',
|
|
Service::class => 'services',
|
|
Member::class => 'members',
|
|
Client::class => 'clients',
|
|
Testimonial::class => 'testimonials',
|
|
Category::class => 'categories',
|
|
|
|
// The inbox soft-deletes too, and it is gated by its own permission
|
|
// below — a viewer never sees a stranger's message here either.
|
|
Application::class => 'applications',
|
|
];
|
|
|
|
public static function canAccess(): bool
|
|
{
|
|
return Auth::user()?->can('panel.access') ?? false;
|
|
}
|
|
|
|
public static function getNavigationGroup(): string|UnitEnum|null
|
|
{
|
|
return __('panel.groups.administration');
|
|
}
|
|
|
|
public static function getNavigationLabel(): string
|
|
{
|
|
return __('panel.trash.title');
|
|
}
|
|
|
|
public function getTitle(): string|Htmlable
|
|
{
|
|
return __('panel.trash.title');
|
|
}
|
|
|
|
public function getSubheading(): string|Htmlable|null
|
|
{
|
|
return __('panel.trash.subheading');
|
|
}
|
|
|
|
/**
|
|
* What is in the bin, grouped by what it is — and only the kinds this user
|
|
* is allowed to see in the first place.
|
|
*
|
|
* @return Collection<string, array{label: string, records: Collection<int, Model>}>
|
|
*/
|
|
public function groups(): Collection
|
|
{
|
|
$user = Auth::user();
|
|
|
|
return collect(self::RECOVERABLE)
|
|
->filter(fn (string $resource) => $user?->can("{$resource}.view_any") ?? false)
|
|
->map(fn (string $resource, string $model) => [
|
|
'model' => $model,
|
|
'label' => __("panel.{$resource}.plural"),
|
|
'records' => $model::onlyTrashed()->latest('deleted_at')->limit(25)->get(),
|
|
])
|
|
->filter(fn (array $group) => $group['records']->isNotEmpty());
|
|
}
|
|
|
|
public function total(): int
|
|
{
|
|
return $this->groups()->sum(fn (array $group) => $group['records']->count());
|
|
}
|
|
|
|
/**
|
|
* Put one thing back.
|
|
*/
|
|
public function restoreRecord(string $model, int|string $id): void
|
|
{
|
|
$record = $this->find($model, $id);
|
|
|
|
if (! $record || ! Auth::user()?->can('restore', $record)) {
|
|
return;
|
|
}
|
|
|
|
$record->restore();
|
|
|
|
Notification::make()
|
|
->title(__('panel.trash.restored'))
|
|
->success()
|
|
->send();
|
|
}
|
|
|
|
/**
|
|
* Erase one thing for good.
|
|
*/
|
|
public function eraseRecord(string $model, int|string $id): void
|
|
{
|
|
$record = $this->find($model, $id);
|
|
|
|
if (! $record || ! Auth::user()?->can('forceDelete', $record)) {
|
|
return;
|
|
}
|
|
|
|
$record->forceDelete();
|
|
|
|
Notification::make()
|
|
->title(__('panel.trash.erased'))
|
|
->warning()
|
|
->send();
|
|
}
|
|
|
|
/**
|
|
* How a record is described here, whatever kind it is.
|
|
*/
|
|
public function labelFor(Model $record): string
|
|
{
|
|
foreach (['name', 'title', 'author_name', 'full_name'] as $attribute) {
|
|
$value = $record->getAttribute($attribute);
|
|
|
|
if (blank($value)) {
|
|
continue;
|
|
}
|
|
|
|
return is_array($value)
|
|
? (string) ($value[config('locales.default')] ?? reset($value))
|
|
: (string) $value;
|
|
}
|
|
|
|
return '#'.$record->getKey();
|
|
}
|
|
|
|
protected function find(string $model, int|string $id): ?Model
|
|
{
|
|
// Only the models this page declares: an id and a class name arriving
|
|
// from the browser must not be able to reach anything else.
|
|
if (! array_key_exists($model, self::RECOVERABLE)) {
|
|
return null;
|
|
}
|
|
|
|
return $model::onlyTrashed()->find($id);
|
|
}
|
|
|
|
public static function getNavigationBadge(): ?string
|
|
{
|
|
return null;
|
|
}
|
|
}
|