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:
45
app/Filament/Support/PreviewAction.php
Normal file
45
app/Filament/Support/PreviewAction.php
Normal file
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Support;
|
||||
|
||||
use App\Support\PreviewLink;
|
||||
use Filament\Actions\Action;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
/**
|
||||
* A button that produces a shareable link to a draft.
|
||||
*
|
||||
* The shared preview secret still works, but using it meant pasting a token
|
||||
* into a curl command. This is a link somebody can send: scoped to one record
|
||||
* and one language, and dead a few hours later.
|
||||
*/
|
||||
final class PreviewAction
|
||||
{
|
||||
public static function for(string $type): Action
|
||||
{
|
||||
return Action::make('preview')
|
||||
->label(__('panel.preview.label'))
|
||||
->icon('heroicon-o-eye')
|
||||
->color('gray')
|
||||
->modalHeading(__('panel.preview.heading'))
|
||||
->modalDescription(__('panel.preview.hint', ['hours' => PreviewLink::HOURS]))
|
||||
->modalSubmitAction(false)
|
||||
// Nothing to preview without a public address to preview it at.
|
||||
->visible(fn (Model $record) => filled(array_filter(
|
||||
array_map(
|
||||
fn (string $locale) => $record->getTranslation('slug', $locale, false),
|
||||
array_keys((array) config('locales.supported')),
|
||||
),
|
||||
)))
|
||||
->modalContent(fn (Model $record) => view('filament.partials.preview-links', [
|
||||
'links' => collect(config('locales.supported'))
|
||||
->mapWithKeys(fn (array $meta, string $locale) => [
|
||||
($meta['native'] ?? $locale) => PreviewLink::for($record, $type, $locale),
|
||||
])
|
||||
->filter()
|
||||
->all(),
|
||||
]));
|
||||
}
|
||||
}
|
||||
181
app/Filament/Support/ReviewActions.php
Normal file
181
app/Filament/Support/ReviewActions.php
Normal file
@@ -0,0 +1,181 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Support;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Support\ReviewTarget;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Actions\BulkAction;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\Textarea;
|
||||
use Filament\Notifications\Notification;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
/**
|
||||
* The two buttons a review is made of.
|
||||
*
|
||||
* One class for pages, projects and services, because the difference between
|
||||
* reviewing them is nothing: somebody who cannot publish says "this is ready",
|
||||
* and somebody who can either publishes it or sends it back with a reason.
|
||||
*
|
||||
* Note which permissions they ask for. Asking for a review needs no permission
|
||||
* of its own — being able to edit the thing is already the right to say you
|
||||
* have finished with it. Answering one asks for `publish`, which is the
|
||||
* permission that made the whole handover necessary.
|
||||
*/
|
||||
final class ReviewActions
|
||||
{
|
||||
/**
|
||||
* "This is ready." Offered to whoever can edit the record and is not
|
||||
* already able to publish it — for somebody holding both, the button
|
||||
* would be a detour around a decision they are allowed to make.
|
||||
*/
|
||||
public static function request(): Action
|
||||
{
|
||||
return Action::make('request_review')
|
||||
->label(__('panel.reviews.actions.request'))
|
||||
->icon('heroicon-o-hand-raised')
|
||||
->color('warning')
|
||||
->visible(fn (Model $record) => ! $record->isAwaitingReview()
|
||||
&& ! $record->isPublishedNow()
|
||||
&& Auth::user()?->can('update', $record)
|
||||
&& Auth::user()?->cannot(ReviewTarget::permission($record)))
|
||||
->modalHeading(__('panel.reviews.actions.request_heading'))
|
||||
->modalDescription(__('panel.reviews.actions.request_hint'))
|
||||
->schema([
|
||||
Select::make('reviewer_id')
|
||||
->label(__('panel.reviews.fields.reviewer'))
|
||||
->helperText(__('panel.reviews.fields.reviewer_hint'))
|
||||
->options(fn (Model $record) => self::reviewers($record))
|
||||
// Nobody in particular is a real answer, and the common
|
||||
// one: the author usually has no idea who is free.
|
||||
->placeholder(__('panel.reviews.anyone')),
|
||||
|
||||
Textarea::make('note')
|
||||
->label(__('panel.reviews.fields.note'))
|
||||
->helperText(__('panel.reviews.fields.note_hint'))
|
||||
->rows(3)
|
||||
->maxLength(2000),
|
||||
])
|
||||
->action(function (Model $record, array $data): void {
|
||||
$record->requestReview(
|
||||
$data['reviewer_id'] ? User::find($data['reviewer_id']) : null,
|
||||
$data['note'] ?: null,
|
||||
);
|
||||
|
||||
Notification::make()
|
||||
->title(__('panel.reviews.requested'))
|
||||
->success()
|
||||
->send();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The same "this is ready", for a whole selection at once.
|
||||
*
|
||||
* The visibility check reads the raw permission string rather than
|
||||
* asking the model's policy, and deliberately so: `update` on a page is
|
||||
* not a plain permission check, it also lets an author edit their own
|
||||
* draft without the blanket ability — a nuance that means something for
|
||||
* one record and nothing for a bulk action, and a policy call with no
|
||||
* record to test it against does not even have a record to fall back to.
|
||||
* The permission behind the ability is what a role actually holds, which
|
||||
* is the question a button's visibility needs answered.
|
||||
*
|
||||
* What does vary per record is whether asking makes sense at all, so the
|
||||
* action itself quietly skips anything already awaiting review or
|
||||
* already live rather than erroring on it — a selection made across a
|
||||
* whole page of mixed states is the normal case, not an edge one.
|
||||
*
|
||||
* @param class-string<Model> $model
|
||||
*/
|
||||
public static function requestBulk(string $model): BulkAction
|
||||
{
|
||||
$table = (new $model)->getTable();
|
||||
|
||||
return BulkAction::make('request_review_bulk')
|
||||
->label(__('panel.reviews.actions.request_bulk'))
|
||||
->icon('heroicon-o-hand-raised')
|
||||
->color('warning')
|
||||
->visible(fn () => (Auth::user()?->can("{$table}.update") ?? false)
|
||||
&& (Auth::user()?->cannot("{$table}.publish") ?? true))
|
||||
->deselectRecordsAfterCompletion()
|
||||
->action(function (Collection $records): void {
|
||||
$requested = 0;
|
||||
|
||||
foreach ($records as $record) {
|
||||
if ($record->isAwaitingReview() || $record->isPublishedNow()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$record->requestReview();
|
||||
$requested++;
|
||||
}
|
||||
|
||||
Notification::make()
|
||||
->title(trans_choice('panel.reviews.requested_count', $requested, ['count' => $requested]))
|
||||
->success()
|
||||
->send();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* "Not yet, and here is why." The reason is required: work handed back
|
||||
* without one is what turns a review step into something people route
|
||||
* around.
|
||||
*/
|
||||
public static function returnForChanges(): Action
|
||||
{
|
||||
return Action::make('return_review')
|
||||
->label(__('panel.reviews.actions.return'))
|
||||
->icon('heroicon-o-arrow-uturn-left')
|
||||
->color('danger')
|
||||
->visible(fn (Model $record) => $record->isAwaitingReview()
|
||||
&& Auth::user()?->can(ReviewTarget::permission($record)))
|
||||
->modalHeading(__('panel.reviews.actions.return_heading'))
|
||||
->schema([
|
||||
Textarea::make('note')
|
||||
->label(__('panel.reviews.fields.reason'))
|
||||
->helperText(__('panel.reviews.fields.reason_hint'))
|
||||
->rows(3)
|
||||
->required()
|
||||
->maxLength(2000),
|
||||
])
|
||||
->action(function (Model $record, array $data): void {
|
||||
$record->returnForChanges($data['note']);
|
||||
|
||||
Notification::make()
|
||||
->title(__('panel.reviews.returned'))
|
||||
->success()
|
||||
->send();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Who could actually answer this, as a list to choose from.
|
||||
*
|
||||
* Resolved through `can()` rather than a permission query so super admins
|
||||
* appear: they hold no explicit permissions, only the gate that grants
|
||||
* them everything, and leaving them out of the list would hide the person
|
||||
* most likely to be able to help.
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
protected static function reviewers(Model $record): array
|
||||
{
|
||||
$permission = ReviewTarget::permission($record);
|
||||
|
||||
return User::query()
|
||||
->where('is_active', true)
|
||||
->whereKeyNot(Auth::id())
|
||||
->orderBy('name')
|
||||
->get()
|
||||
->filter(fn (User $user) => $user->can($permission))
|
||||
->pluck('name', 'id')
|
||||
->all();
|
||||
}
|
||||
}
|
||||
49
app/Filament/Support/ReviewStatusColumn.php
Normal file
49
app/Filament/Support/ReviewStatusColumn.php
Normal file
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Support;
|
||||
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
/**
|
||||
* Whether anybody is waiting on this record, in the listing.
|
||||
*
|
||||
* Deliberately blank when nothing is waiting. A column that says "no" on every
|
||||
* row is a column that gets read once; this one is empty until it matters, so
|
||||
* the rows that need somebody are the only ones with anything in them.
|
||||
*/
|
||||
class ReviewStatusColumn extends TextColumn
|
||||
{
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->badge();
|
||||
$this->color('warning');
|
||||
$this->icon('heroicon-o-hand-raised');
|
||||
$this->placeholder('—');
|
||||
|
||||
$this->state(fn (Model $record): ?string => $record->isAwaitingReview()
|
||||
? __('panel.reviews.waiting')
|
||||
: null);
|
||||
|
||||
// Who it is waiting for, since that is the next question anybody asks
|
||||
// — and "anybody" is a real answer rather than a missing one.
|
||||
$this->description(function (Model $record): ?string {
|
||||
if (! $record->isAwaitingReview()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $record->reviewer?->name ?? __('panel.reviews.anyone');
|
||||
});
|
||||
|
||||
$this->tooltip(fn (Model $record): ?string => $record->isAwaitingReview()
|
||||
? $record->review_requested_at?->diffForHumans()
|
||||
: null);
|
||||
|
||||
$this->sortable(query: fn ($query, string $direction) => $query
|
||||
->orderBy('review_requested_at', $direction));
|
||||
}
|
||||
}
|
||||
58
app/Filament/Support/TranslatableTabs.php
Normal file
58
app/Filament/Support/TranslatableTabs.php
Normal file
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Support;
|
||||
|
||||
use Closure;
|
||||
use Filament\Schemas\Components\Tabs;
|
||||
use Filament\Schemas\Components\Tabs\Tab;
|
||||
|
||||
/**
|
||||
* Renders one tab per supported locale for a model's translatable fields.
|
||||
*
|
||||
* This leans on how spatie/laravel-translatable already behaves rather than
|
||||
* adding a translation layer of its own: `toArray()` hands Filament the full
|
||||
* locale => value map, and assigning that map back through `setAttribute`
|
||||
* writes each translation. So a field whose state path is "title.ca" hydrates
|
||||
* and saves the Catalan translation with no extra wiring.
|
||||
*/
|
||||
final class TranslatableTabs
|
||||
{
|
||||
/**
|
||||
* @param Closure(string $locale, bool $isDefault): array<int, mixed> $fields
|
||||
* Receives the locale code and whether it is the primary one,
|
||||
* and returns the components for that tab.
|
||||
*/
|
||||
public static function make(Closure $fields, ?string $label = null): Tabs
|
||||
{
|
||||
$default = (string) config('locales.default');
|
||||
|
||||
$tabs = [];
|
||||
|
||||
foreach (config('locales.supported') as $locale => $meta) {
|
||||
$tabs[] = Tab::make($locale)
|
||||
->label(trim(($meta['flag'] ?? '').' '.($meta['native'] ?? $locale)))
|
||||
// Marks the primary language, the one every other tab falls
|
||||
// back to when left empty.
|
||||
->badge($locale === $default ? '★' : null)
|
||||
->schema($fields($locale, $locale === $default));
|
||||
}
|
||||
|
||||
$tabs = Tabs::make($label ?? __('panel.pages.fields.translations'))
|
||||
->tabs($tabs)
|
||||
->columnSpanFull();
|
||||
|
||||
// Open the tab for the language the admin is currently working in.
|
||||
return $tabs->persistTabInQueryString('locale_tab');
|
||||
}
|
||||
|
||||
/**
|
||||
* Suffix for a field label inside a locale tab, e.g. "Título (Català)".
|
||||
* Useful when a field sits outside the tabs but still targets one locale.
|
||||
*/
|
||||
public static function suffix(string $locale): string
|
||||
{
|
||||
return (string) (config("locales.supported.{$locale}.native") ?? $locale);
|
||||
}
|
||||
}
|
||||
90
app/Filament/Support/TranslationStatusColumn.php
Normal file
90
app/Filament/Support/TranslationStatusColumn.php
Normal file
@@ -0,0 +1,90 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Support;
|
||||
|
||||
use App\Support\TranslationFreshness;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
/**
|
||||
* Shows at a glance which languages a record has been translated into.
|
||||
*
|
||||
* Each supported locale renders as a badge — solid when a translation exists,
|
||||
* muted when it is still missing — so an editor can spot untranslated content
|
||||
* without opening every record.
|
||||
*
|
||||
* A third state matters as much as the first two and is invisible everywhere
|
||||
* else: a translation that exists and has fallen behind the language it was
|
||||
* made from. Nothing else in the panel would ever mention it, because by every
|
||||
* other measure that record is finished.
|
||||
*/
|
||||
class TranslationStatusColumn extends TextColumn
|
||||
{
|
||||
/**
|
||||
* The translatable attribute whose presence stands in for "translated".
|
||||
* Defaults to the model's first translatable attribute.
|
||||
*/
|
||||
protected ?string $trackedAttribute = null;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->badge();
|
||||
$this->sortable(false);
|
||||
$this->searchable(false);
|
||||
|
||||
$this->state(function (Model $record): array {
|
||||
$attribute = $this->resolveTrackedAttribute($record);
|
||||
$translations = $record->getTranslations($attribute);
|
||||
$stale = TranslationFreshness::for($record);
|
||||
|
||||
$states = [];
|
||||
|
||||
foreach (array_keys(config('locales.supported')) as $locale) {
|
||||
if (blank($translations[$locale] ?? null)) {
|
||||
$states[] = strtolower($locale);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Translated, but the language it was translated from has
|
||||
// moved on since. The badge has to say something other than
|
||||
// "done", because as far as everything else is concerned this
|
||||
// record is finished.
|
||||
$states[] = isset($stale[$locale])
|
||||
? strtoupper($locale).'!'
|
||||
: strtoupper($locale);
|
||||
}
|
||||
|
||||
return $states;
|
||||
});
|
||||
|
||||
/*
|
||||
* Uppercase = translated, lowercase = missing, and a "!" for a
|
||||
* translation the source has since moved past. Colour carries the same
|
||||
* three states for anyone who does not notice the casing.
|
||||
*/
|
||||
$this->color(fn (string $state): string => match (true) {
|
||||
str_ends_with($state, '!') => 'warning',
|
||||
$state === strtoupper($state) => 'success',
|
||||
default => 'gray',
|
||||
});
|
||||
}
|
||||
|
||||
public function trackedAttribute(string $attribute): static
|
||||
{
|
||||
$this->trackedAttribute = $attribute;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
protected function resolveTrackedAttribute(Model $record): string
|
||||
{
|
||||
return $this->trackedAttribute
|
||||
?? $record->getTranslatableAttributes()[0]
|
||||
?? 'title';
|
||||
}
|
||||
}
|
||||
46
app/Filament/Support/ViewsColumn.php
Normal file
46
app/Filament/Support/ViewsColumn.php
Normal file
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Support;
|
||||
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
/**
|
||||
* How often a record was read lately, in a listing.
|
||||
*
|
||||
* Reads the sum the query already carries — `withRecentViews()` on the
|
||||
* table's own query — rather than counting per row, because a column that
|
||||
* costs a query per row is a column that makes the whole list slow enough to
|
||||
* stop being opened.
|
||||
*
|
||||
* A published record with no views at all reads differently from one with
|
||||
* none because nothing has been reported yet, and the column cannot tell the
|
||||
* two apart on its own. So it says nothing rather than "0": until the frontend
|
||||
* starts reporting, an empty column is the honest answer.
|
||||
*/
|
||||
class ViewsColumn extends TextColumn
|
||||
{
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->badge();
|
||||
$this->placeholder('—');
|
||||
$this->alignEnd();
|
||||
|
||||
$this->state(fn (Model $record): ?int => $record->recent_views > 0
|
||||
? (int) $record->recent_views
|
||||
: null);
|
||||
|
||||
$this->color(fn (?int $state) => match (true) {
|
||||
$state === null => 'gray',
|
||||
$state >= 100 => 'success',
|
||||
default => 'info',
|
||||
});
|
||||
|
||||
$this->sortable(query: fn ($query, string $direction) => $query
|
||||
->orderBy('recent_views', $direction));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user