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:
2026-08-19 17:55:27 +02:00
commit 3897113313
534 changed files with 64050 additions and 0 deletions

View 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';
}
}

View File

@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\MissingUrls\Pages;
use App\Filament\Resources\MissingUrls\MissingUrlResource;
use Filament\Resources\Pages\ListRecords;
class ListMissingUrls extends ListRecords
{
protected static string $resource = MissingUrlResource::class;
// No create action: the frontend is the only author there is.
protected function getHeaderActions(): array
{
return [];
}
}