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,88 @@
<?php
declare(strict_types=1);
namespace App\Filament\Widgets;
use App\Models\ContentView;
use App\Support\ContentTypes;
use Filament\Widgets\StatsOverviewWidget;
use Filament\Widgets\StatsOverviewWidget\Stat;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cache;
/**
* What the site is actually being read for, this month.
*
* The counters this panel started with said how many projects existed. This
* says which three anybody opened, which is the number that changes what
* somebody does next: it is the difference between a portfolio nobody visits
* and one page carrying the whole site.
*
* Absent entirely until there is something to show. A widget reading "0, 0, 0"
* on an installation whose frontend does not report views would be teaching
* everybody to ignore that corner of the dashboard.
*/
class MostRead extends StatsOverviewWidget
{
protected static ?int $sort = 2;
protected const DAYS = 30;
public static function canView(): bool
{
return (Auth::user()?->can('panel.access') ?? false)
&& ContentView::query()->since(self::DAYS)->exists();
}
/**
* @return array<int, Stat>
*/
protected function getStats(): array
{
return collect($this->top())
->map(fn (array $row) => Stat::make($row['title'], (string) $row['count'])
->description(__('panel.views.in_days', ['days' => self::DAYS]))
->descriptionIcon('heroicon-m-eye')
->color('info'))
->all();
}
/**
* The three most-read records of any kind, resolved to their titles.
*
* Cached for a few minutes: this is drawn on the dashboard, which is the
* first page everybody lands on, and the answer does not change minute to
* minute — the underlying figures are daily totals.
*
* @return array<int, array{title: string, count: int}>
*/
protected function top(): array
{
return Cache::remember('views:most-read', now()->addMinutes(5), function (): array {
$rows = ContentView::query()
->since(self::DAYS)
->selectRaw('viewable_type, viewable_id, SUM(count) as total')
->groupBy('viewable_type', 'viewable_id')
->orderByDesc('total')
->limit(3)
->get();
// Loaded per group rather than with the relation, because these
// are three rows of different types and eager loading a morph for
// three records buys nothing.
return $rows
->map(function (ContentView $row): ?array {
$record = $row->viewable;
return $record instanceof Model
? ['title' => ContentTypes::title($record), 'count' => (int) $row->total]
: null;
})
->filter()
->values()
->all();
});
}
}