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:
287
app/Filament/Resources/MissingUrls/MissingUrlResource.php
Normal file
287
app/Filament/Resources/MissingUrls/MissingUrlResource.php
Normal file
@@ -0,0 +1,287 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources\MissingUrls;
|
||||
|
||||
use App\Enums\MissingUrlStatus;
|
||||
use App\Filament\Resources\MissingUrls\Pages\ListMissingUrls;
|
||||
use App\Models\MissingUrl;
|
||||
use App\Models\Redirect;
|
||||
use BackedEnum;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Actions\BulkAction;
|
||||
use Filament\Actions\BulkActionGroup;
|
||||
use Filament\Actions\DeleteAction;
|
||||
use Filament\Actions\DeleteBulkAction;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Support\Icons\Heroicon;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Filters\Filter;
|
||||
use Filament\Tables\Filters\SelectFilter;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use UnitEnum;
|
||||
|
||||
/**
|
||||
* Addresses visitors asked for and the frontend could not answer.
|
||||
*
|
||||
* Redirects were only ever written from one side: a slug renamed here leaves a
|
||||
* note behind, because that is the moment the old address is still known. The
|
||||
* links that break without anybody renaming anything — the previous website's,
|
||||
* an address on a poster, another site's link to a page that moved years ago —
|
||||
* failed silently, in front of a visitor, and never reached this panel.
|
||||
*
|
||||
* This is the other half. Nothing here is authored: rows arrive from the
|
||||
* frontend, and the only decision to make about one is whether it deserves a
|
||||
* redirect. Which is why the whole screen is really one button.
|
||||
*/
|
||||
class MissingUrlResource extends Resource
|
||||
{
|
||||
protected static ?string $model = MissingUrl::class;
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedMagnifyingGlassCircle;
|
||||
|
||||
// Directly under Redirects: the two are read together, and this one is
|
||||
// where the other one's entries come from.
|
||||
protected static ?int $navigationSort = 86;
|
||||
|
||||
public static function getModelLabel(): string
|
||||
{
|
||||
return __('panel.missing_urls.label');
|
||||
}
|
||||
|
||||
public static function getPluralModelLabel(): string
|
||||
{
|
||||
return __('panel.missing_urls.plural');
|
||||
}
|
||||
|
||||
public static function getNavigationGroup(): string|UnitEnum|null
|
||||
{
|
||||
return __('panel.groups.administration');
|
||||
}
|
||||
|
||||
public static function getRecordTitle(?Model $record): ?string
|
||||
{
|
||||
return $record?->path;
|
||||
}
|
||||
|
||||
public static function canCreate(): bool
|
||||
{
|
||||
// These are reported, not written. A hand-typed one would be a
|
||||
// redirect somebody had not got round to creating yet.
|
||||
return false;
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
// Worst first: how many people hit it, not when it last happened.
|
||||
// One address requested two hundred times is the whole point of
|
||||
// the screen; a hundred requested once each are noise.
|
||||
->defaultSort('hits', 'desc')
|
||||
->columns([
|
||||
TextColumn::make('path')
|
||||
->label(__('panel.missing_urls.fields.path'))
|
||||
->fontFamily('mono')
|
||||
->searchable()
|
||||
->wrap()
|
||||
// The strongest signal on the row: a referrer on our own
|
||||
// domain means something we publish links here.
|
||||
->description(fn (MissingUrl $record) => $record->isInternalReferrer()
|
||||
? __('panel.missing_urls.linked_from_us')
|
||||
: null),
|
||||
|
||||
TextColumn::make('hits')
|
||||
->label(__('panel.missing_urls.fields.hits'))
|
||||
->badge()
|
||||
->color(fn (int $state) => match (true) {
|
||||
$state >= 10 => 'danger',
|
||||
$state >= 2 => 'warning',
|
||||
default => 'gray',
|
||||
})
|
||||
->sortable(),
|
||||
|
||||
TextColumn::make('locale')
|
||||
->label(__('panel.missing_urls.fields.locale'))
|
||||
->badge()
|
||||
->color('gray')
|
||||
->toggleable(),
|
||||
|
||||
TextColumn::make('status')
|
||||
->label(__('panel.missing_urls.fields.status'))
|
||||
->badge(),
|
||||
|
||||
TextColumn::make('referrer')
|
||||
->label(__('panel.missing_urls.fields.referrer'))
|
||||
->placeholder(__('panel.missing_urls.no_referrer'))
|
||||
->wrap()
|
||||
->size('xs')
|
||||
->color(fn (MissingUrl $record) => $record->isInternalReferrer() ? 'danger' : 'gray')
|
||||
->toggleable(),
|
||||
|
||||
TextColumn::make('last_seen_at')
|
||||
->label(__('panel.missing_urls.fields.last_seen'))
|
||||
->since()
|
||||
->dateTimeTooltip()
|
||||
->sortable(),
|
||||
])
|
||||
->filters([
|
||||
SelectFilter::make('status')
|
||||
->label(__('panel.missing_urls.fields.status'))
|
||||
->options(MissingUrlStatus::options())
|
||||
// The list is a to-do list, so it opens as one.
|
||||
->default(MissingUrlStatus::Pending->value),
|
||||
|
||||
Filter::make('internal')
|
||||
->label(__('panel.missing_urls.filters.internal'))
|
||||
->query(fn (Builder $query) => $query
|
||||
->where('referrer', 'like', rtrim((string) config('site.url'), '/').'%')),
|
||||
|
||||
Filter::make('repeated')
|
||||
->label(__('panel.missing_urls.filters.repeated'))
|
||||
->query(fn (Builder $query) => $query->where('hits', '>=', 2)),
|
||||
])
|
||||
->recordActions([
|
||||
Action::make('redirect')
|
||||
->label(__('panel.missing_urls.actions.redirect'))
|
||||
->icon('heroicon-o-arrows-right-left')
|
||||
->color('primary')
|
||||
->authorize('redirect')
|
||||
->visible(fn (MissingUrl $record) => $record->status !== MissingUrlStatus::Resolved)
|
||||
->modalHeading(__('panel.missing_urls.actions.redirect_heading'))
|
||||
->modalDescription(__('panel.missing_urls.actions.redirect_hint'))
|
||||
->schema([
|
||||
// Read-only: the origin is the address that failed. It
|
||||
// is here to be seen while typing the destination, not
|
||||
// to be edited.
|
||||
TextInput::make('from')
|
||||
->label(__('panel.redirects.fields.from'))
|
||||
->disabled()
|
||||
->dehydrated(false)
|
||||
->default(fn (MissingUrl $record) => $record->path),
|
||||
|
||||
TextInput::make('to')
|
||||
->label(__('panel.redirects.fields.to'))
|
||||
->required()
|
||||
->maxLength(500)
|
||||
->placeholder('/proyectos/nombre-nuevo')
|
||||
->dehydrateStateUsing(fn (string $state) => Redirect::normalise($state)),
|
||||
|
||||
Select::make('status')
|
||||
->label(__('panel.redirects.fields.status'))
|
||||
->options([
|
||||
301 => __('panel.redirects.statuses.301'),
|
||||
302 => __('panel.redirects.statuses.302'),
|
||||
])
|
||||
->default(301)
|
||||
->required(),
|
||||
|
||||
Select::make('locale')
|
||||
->label(__('panel.redirects.fields.locale'))
|
||||
->helperText(__('panel.missing_urls.actions.locale_hint'))
|
||||
->options(fn () => collect(config('locales.supported'))
|
||||
->map(fn (array $meta, string $code) => $meta['native'] ?? $code)
|
||||
->all())
|
||||
->default(fn (MissingUrl $record) => $record->locale)
|
||||
->placeholder(__('panel.redirects.every_locale')),
|
||||
])
|
||||
->action(function (MissingUrl $record, array $data): void {
|
||||
// The record is marked resolved by the redirect being
|
||||
// created, not from here, so a rule written anywhere
|
||||
// else closes its report too.
|
||||
Redirect::create([
|
||||
'from' => $record->path,
|
||||
'to' => $data['to'],
|
||||
'status' => $data['status'],
|
||||
'locale' => $data['locale'] ?? null,
|
||||
'is_enabled' => true,
|
||||
]);
|
||||
|
||||
Notification::make()
|
||||
->title(__('panel.missing_urls.actions.redirected'))
|
||||
->success()
|
||||
->send();
|
||||
}),
|
||||
|
||||
Action::make('ignore')
|
||||
->label(__('panel.missing_urls.actions.ignore'))
|
||||
->icon('heroicon-o-eye-slash')
|
||||
->color('gray')
|
||||
->authorize('update')
|
||||
->visible(fn (MissingUrl $record) => $record->status !== MissingUrlStatus::Ignored)
|
||||
->action(fn (MissingUrl $record) => $record->update([
|
||||
'status' => MissingUrlStatus::Ignored,
|
||||
])),
|
||||
|
||||
Action::make('unignore')
|
||||
->label(__('panel.missing_urls.actions.unignore'))
|
||||
->icon('heroicon-o-eye')
|
||||
->color('gray')
|
||||
->authorize('update')
|
||||
->visible(fn (MissingUrl $record) => $record->status === MissingUrlStatus::Ignored)
|
||||
->action(fn (MissingUrl $record) => $record->update([
|
||||
'status' => MissingUrlStatus::Pending,
|
||||
])),
|
||||
|
||||
DeleteAction::make(),
|
||||
])
|
||||
->toolbarActions([
|
||||
BulkActionGroup::make([
|
||||
// The bulk action that matters: a scan produces dozens of
|
||||
// rows at once and dismissing them one by one is how a
|
||||
// list stops being read.
|
||||
BulkAction::make('ignore')
|
||||
->label(__('panel.missing_urls.actions.ignore'))
|
||||
->icon('heroicon-o-eye-slash')
|
||||
->authorize('updateAny')
|
||||
->deselectRecordsAfterCompletion()
|
||||
->action(fn (Collection $records) => MissingUrl::query()
|
||||
->whereKey($records->pluck('id'))
|
||||
->update(['status' => MissingUrlStatus::Ignored])),
|
||||
|
||||
DeleteBulkAction::make(),
|
||||
]),
|
||||
])
|
||||
->emptyStateHeading(__('panel.missing_urls.empty.heading'))
|
||||
->emptyStateDescription(__('panel.missing_urls.empty.description'))
|
||||
->emptyStateIcon('heroicon-o-magnifying-glass-circle');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public static function getGloballySearchableAttributes(): array
|
||||
{
|
||||
return ['path'];
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => ListMissingUrls::route('/'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Only what somebody still has to decide about, and only when it has
|
||||
* happened more than once — a badge counting one-off scans would be a
|
||||
* number that never reaches zero, which is a number nobody looks at.
|
||||
*/
|
||||
public static function getNavigationBadge(): ?string
|
||||
{
|
||||
$pending = static::getModel()::query()->pending()->where('hits', '>=', 2)->count();
|
||||
|
||||
return $pending > 0 ? (string) $pending : null;
|
||||
}
|
||||
|
||||
public static function getNavigationBadgeColor(): ?string
|
||||
{
|
||||
return 'warning';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user