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,13 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\Testimonials\Pages;
use App\Filament\Resources\Testimonials\TestimonialResource;
use Filament\Resources\Pages\CreateRecord;
class CreateTestimonial extends CreateRecord
{
protected static string $resource = TestimonialResource::class;
}

View File

@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\Testimonials\Pages;
use App\Filament\Concerns\WarnsOfConcurrentEditing;
use App\Filament\Resources\Testimonials\TestimonialResource;
use Filament\Actions\DeleteAction;
use Filament\Resources\Pages\EditRecord;
class EditTestimonial extends EditRecord
{
use WarnsOfConcurrentEditing;
protected static string $resource = TestimonialResource::class;
protected function getHeaderActions(): array
{
return [
DeleteAction::make(),
];
}
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\Testimonials\Pages;
use App\Filament\Resources\Testimonials\TestimonialResource;
use Filament\Actions\CreateAction;
use Filament\Resources\Pages\ListRecords;
class ListTestimonials extends ListRecords
{
protected static string $resource = TestimonialResource::class;
protected function getHeaderActions(): array
{
return [
CreateAction::make(),
];
}
}

View File

@@ -0,0 +1,252 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\Testimonials;
use App\Filament\RelationManagers\RevisionsRelationManager;
use App\Filament\Resources\Testimonials\Pages\CreateTestimonial;
use App\Filament\Resources\Testimonials\Pages\EditTestimonial;
use App\Filament\Resources\Testimonials\Pages\ListTestimonials;
use App\Filament\Support\TranslatableTabs;
use App\Models\Testimonial;
use BackedEnum;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\DeleteAction;
use Filament\Actions\DeleteBulkAction;
use Filament\Actions\EditAction;
use Filament\Actions\ForceDeleteBulkAction;
use Filament\Actions\RestoreBulkAction;
use Filament\Forms\Components\DatePicker;
use Filament\Forms\Components\FileUpload;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Resources\Resource;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\ImageColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Filters\TernaryFilter;
use Filament\Tables\Filters\TrashedFilter;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Model;
use UnitEnum;
/**
* What somebody else says about the work.
*
* Attached to a client, a project or a service rather than to "the home page":
* the frontend asks for the ones belonging to whatever it is rendering, which
* is what keeps this a content type rather than a page-builder block.
*/
class TestimonialResource extends Resource
{
protected static ?string $model = Testimonial::class;
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedChatBubbleBottomCenterText;
protected static ?int $navigationSort = 6;
public static function getModelLabel(): string
{
return __('panel.testimonials.label');
}
public static function getPluralModelLabel(): string
{
return __('panel.testimonials.plural');
}
public static function getNavigationGroup(): string|UnitEnum|null
{
return __('panel.groups.collective');
}
public static function getRecordTitle(?Model $record): ?string
{
return $record?->author_name;
}
public static function form(Schema $schema): Schema
{
return $schema->components([
Section::make(__('panel.testimonials.sections.quote'))
->description(__('panel.testimonials.sections.quote_hint'))
->schema([
Select::make('original_locale')
->label(__('panel.testimonials.fields.original_locale'))
->helperText(__('panel.testimonials.fields.original_locale_hint'))
->options(fn () => collect(config('locales.supported'))
->map(fn (array $meta, string $code) => $meta['native'] ?? $code)
->all())
->default(config('locales.default'))
->required(),
TranslatableTabs::make(fn (string $locale, bool $isDefault) => [
Textarea::make("quote.{$locale}")
->label(__('panel.testimonials.fields.quote'))
->required($isDefault)
->rows(4)
->maxLength(1000)
->columnSpanFull(),
])->columnSpanFull(),
]),
Section::make(__('panel.testimonials.sections.author'))
->columns(2)
->schema([
TextInput::make('author_name')
->label(__('panel.testimonials.fields.author_name'))
->required()
->maxLength(255),
DatePicker::make('given_at')
->label(__('panel.testimonials.fields.given_at')),
TranslatableTabs::make(fn (string $locale) => [
TextInput::make("author_role.{$locale}")
->label(__('panel.testimonials.fields.author_role'))
->maxLength(255)
->columnSpanFull(),
])->columnSpanFull(),
FileUpload::make('avatar')
->label(__('panel.testimonials.fields.avatar'))
->image()
->avatar()
->disk(config('media.disk'))
->directory('testimonials')
->columnSpanFull(),
]),
Section::make(__('panel.testimonials.sections.about'))
->description(__('panel.testimonials.sections.about_hint'))
->columns(3)
->schema([
Select::make('client_id')
->label(__('panel.testimonials.fields.client'))
->relationship('client', 'name')
->searchable()
->preload(),
Select::make('project_id')
->label(__('panel.testimonials.fields.project'))
->relationship('project', 'title->'.config('locales.default'))
->searchable()
->preload(),
Select::make('service_id')
->label(__('panel.testimonials.fields.service'))
->relationship('service', 'title->'.config('locales.default'))
->searchable()
->preload(),
Toggle::make('is_public')
->label(__('panel.testimonials.fields.is_public'))
->default(true),
TextInput::make('position')
->label(__('panel.testimonials.fields.position'))
->numeric()
->default(0),
]),
]);
}
public static function table(Table $table): Table
{
$locale = app()->getLocale();
return $table
->defaultSort('position')
->reorderable('position')
->columns([
ImageColumn::make('avatar')
->label('')
->circular()
->disk(config('media.disk')),
TextColumn::make('quote')
->label(__('panel.testimonials.fields.quote'))
->state(fn (Testimonial $record) => $record->getTranslation('quote', $locale, false) ?: null)
->wrap()
->limit(120),
TextColumn::make('author_name')
->label(__('panel.testimonials.fields.author_name'))
->description(fn (Testimonial $record) => $record->getTranslation('author_role', $locale, false) ?: null)
->searchable(),
// Whose words these are matters: a quote with nothing behind
// it is a sentence somebody wrote about themselves.
TextColumn::make('client.name')
->label(__('panel.testimonials.fields.client'))
->badge()
->color('gray')
->placeholder('—')
->toggleable(),
TextColumn::make('original_locale')
->label(__('panel.testimonials.fields.original_locale'))
->badge()
->color('gray')
->formatStateUsing(fn (string $state) => config("locales.supported.{$state}.native", $state))
->toggleable(),
IconColumn::make('is_public')
->label(__('panel.testimonials.fields.is_public'))
->boolean(),
])
->filters([
TernaryFilter::make('is_public')->label(__('panel.testimonials.fields.is_public')),
SelectFilter::make('client')
->label(__('panel.testimonials.fields.client'))
->relationship('client', 'name')
->searchable()
->preload(),
TrashedFilter::make(),
])
->recordActions([
EditAction::make(),
DeleteAction::make(),
])
->toolbarActions([
BulkActionGroup::make([
DeleteBulkAction::make(),
ForceDeleteBulkAction::make(),
RestoreBulkAction::make(),
]),
]);
}
/**
* @return array<int, string>
*/
public static function getGloballySearchableAttributes(): array
{
return ['author_name'];
}
public static function getRelations(): array
{
return [
RevisionsRelationManager::class,
];
}
public static function getPages(): array
{
return [
'index' => ListTestimonials::route('/'),
'create' => CreateTestimonial::route('/create'),
'edit' => EditTestimonial::route('/{record}/edit'),
];
}
}