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:
183
app/Filament/Pages/Trash.php
Normal file
183
app/Filament/Pages/Trash.php
Normal file
@@ -0,0 +1,183 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user