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,98 @@
<?php
declare(strict_types=1);
namespace App\Filament\RelationManagers;
use App\Filament\Support\ReviewActions;
use App\Models\ReviewComment;
use Filament\Actions\Action;
use Filament\Forms\Components\Textarea;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Model;
/**
* The conversation about one record, on the record.
*
* Sits beside the change history and answers a different question. A revision
* knows the title used to say something else; it cannot know that this went
* back because the photo is missing, because that sentence was never a field
* on the form. Kept together with the two review buttons so the reason and the
* decision are written in the same place, rather than the decision here and
* the reason in somebody's chat window.
*/
class ReviewsRelationManager extends RelationManager
{
protected static string $relationship = 'reviewComments';
public static function getTitle(Model $ownerRecord, string $pageClass): string
{
return __('panel.reviews.title');
}
/**
* The badge says "waiting", not "how many": a count of comments is
* trivia, and whether somebody is waiting for an answer is not.
*/
public static function getBadge(Model $ownerRecord, string $pageClass): ?string
{
return $ownerRecord->isAwaitingReview() ? '!' : null;
}
public static function getBadgeColor(Model $ownerRecord, string $pageClass): ?string
{
return 'warning';
}
public function table(Table $table): Table
{
return $table
->defaultSort('id', 'desc')
->modifyQueryUsing(fn ($query) => $query->with('user'))
->columns([
TextColumn::make('event')
->label(__('panel.reviews.fields.event'))
->badge(),
TextColumn::make('user_name')
->label(__('panel.reviews.fields.who'))
->state(fn (ReviewComment $record) => $record->author()),
TextColumn::make('body')
->label(__('panel.reviews.fields.note'))
->wrap()
->placeholder('—'),
TextColumn::make('created_at')
->label(__('panel.reviews.fields.when'))
->since()
->dateTimeTooltip()
->sortable(),
])
->headerActions([
ReviewActions::request(),
ReviewActions::returnForChanges(),
// Plain comments, for the half of a review that is neither a
// rejection nor an approval: a question, or an answer to one.
Action::make('comment')
->label(__('panel.reviews.actions.comment'))
->icon('heroicon-o-chat-bubble-left-ellipsis')
->color('gray')
->visible(fn () => $this->getOwnerRecord()->exists)
->schema([
Textarea::make('body')
->label(__('panel.reviews.fields.note'))
->rows(3)
->required()
->maxLength(2000),
])
->action(fn (array $data) => $this->getOwnerRecord()->comment($data['body'])),
])
->emptyStateHeading(__('panel.reviews.empty.heading'))
->emptyStateDescription(__('panel.reviews.empty.description'))
->emptyStateIcon('heroicon-o-chat-bubble-left-right');
}
}

View File

@@ -0,0 +1,127 @@
<?php
declare(strict_types=1);
namespace App\Filament\RelationManagers;
use App\Models\Revision;
use Filament\Actions\Action;
use Filament\Notifications\Notification;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Auth;
/**
* Who changed what, on the record it happened to.
*
* One class for every resource: the history of a page and the history of a
* testimonial differ only in what is in them.
*/
class RevisionsRelationManager extends RelationManager
{
protected static string $relationship = 'revisions';
public static function getTitle(Model $ownerRecord, string $pageClass): string
{
return __('panel.revisions.title');
}
public static function getBadge(Model $ownerRecord, string $pageClass): ?string
{
$count = $ownerRecord->revisions()->count();
return $count > 0 ? (string) $count : null;
}
public function table(Table $table): Table
{
return $table
->defaultSort('id', 'desc')
->modifyQueryUsing(fn ($query) => $query->with('user'))
->columns([
TextColumn::make('event')
->label(__('panel.revisions.fields.event'))
->badge()
->formatStateUsing(fn (string $state) => __("panel.revisions.events.{$state}"))
// Publishing is a separate permission because it is a
// bigger act than editing; it reads differently here too.
->color(fn (string $state) => match ($state) {
'published' => 'success',
'unpublished' => 'warning',
'created' => 'info',
'deleted', 'erased' => 'danger',
default => 'gray',
}),
TextColumn::make('user_name')
->label(__('panel.revisions.fields.author'))
->state(fn (Revision $record) => $record->author()),
TextColumn::make('changeset')
->label(__('panel.revisions.fields.fields'))
->state(fn (Revision $record) => $record->fields())
->badge()
->color('gray')
->limitList(4)
->placeholder('—'),
TextColumn::make('created_at')
->label(__('panel.revisions.fields.when'))
->since()
->dateTimeTooltip()
->sortable(),
])
->filters([
SelectFilter::make('event')
->label(__('panel.revisions.fields.event'))
->options(fn () => collect(['created', 'updated', 'published', 'unpublished', 'deleted', 'restored', 'erased'])
->mapWithKeys(fn (string $e) => [$e => __("panel.revisions.events.{$e}")])
->all()),
])
->recordActions([
Action::make('diff')
->label(__('panel.revisions.actions.diff'))
->icon('heroicon-o-eye')
->modalHeading(fn (Revision $record) => __('panel.revisions.diff_heading', [
'author' => $record->author(),
'when' => $record->created_at?->isoFormat('LLL'),
]))
->modalSubmitAction(false)
->modalContent(fn (Revision $record) => view('filament.revisions.diff', [
'revision' => $record,
])),
Action::make('restore')
->label(__('panel.revisions.actions.restore'))
->icon('heroicon-o-arrow-uturn-left')
->color('warning')
->requiresConfirmation()
->modalDescription(__('panel.revisions.restore_confirm'))
->authorize(fn () => (bool) Auth::user()?->can('update', $this->getOwnerRecord()))
// Nothing to put back for a creation or a deletion, and
// the newest revision already is the current state.
->visible(fn (Revision $record) => filled($record->changeset)
&& $record->isNot($this->getOwnerRecord()->revisions()->first()))
->action(function (Revision $record): void {
$owner = $this->getOwnerRecord();
// Restoring is itself a change, so it lands in the
// history like any other — the way back is never lost.
$owner->forceFill($record->previousValues())->save();
Notification::make()
->title(__('panel.revisions.restored'))
->success()
->send();
}),
]);
}
public function isReadOnly(): bool
{
return true;
}
}