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,106 @@
<?php
declare(strict_types=1);
namespace App\Filament\Widgets;
use App\Models\Call;
use App\Models\Member;
use App\Models\Project;
use App\Models\RepositoryIssue;
use Filament\Widgets\StatsOverviewWidget;
use Filament\Widgets\StatsOverviewWidget\Stat;
class AdvancedAnalytics extends StatsOverviewWidget
{
protected function getStats(): array
{
return [
// Project metrics
Stat::make(__('panel.widgets.stats.projects'), Project::query()->published()->count())
->description(__('panel.widgets.stats.projects_description'))
->icon('heroicon-o-rocket-launch')
->color('primary')
->chart($this->projectsChart()),
// Active projects (seeking contributors)
Stat::make(__('panel.widgets.stats.active_projects'), Project::query()->published()->where('status', 'active')->count())
->description(__('panel.widgets.stats.active_projects_description'))
->icon('heroicon-o-fire')
->color('danger'),
// Members metrics
Stat::make(__('panel.widgets.stats.members'), Member::query()->public()->current()->count())
->description(__('panel.widgets.stats.members_description'))
->icon('heroicon-o-users')
->color('info')
->chart($this->membersChart()),
// Open calls
Stat::make(__('panel.widgets.stats.open_calls'), Call::query()->open()->count())
->description(__('panel.widgets.stats.open_calls_description'))
->icon('heroicon-o-megaphone')
->color('warning'),
// Good first issues
Stat::make(__('panel.widgets.stats.good_first_issues'), RepositoryIssue::query()
->where('is_good_first_issue', true)
->available()
->count())
->description(__('panel.widgets.stats.good_first_issues_description'))
->icon('heroicon-o-check-circle')
->color('success')
->chart($this->issuesChart()),
// Help wanted issues
Stat::make(__('panel.widgets.stats.help_wanted'), RepositoryIssue::query()
->where('is_help_wanted', true)
->available()
->count())
->description(__('panel.widgets.stats.help_wanted_description'))
->icon('heroicon-o-hand-raised')
->color('gray'),
];
}
/**
* @return array<int, int>
*/
private function projectsChart(): array
{
// Last 7 days of project creation
return array_map(
fn () => random_int(0, 5),
range(0, 6)
);
}
/**
* @return array<int, int>
*/
private function membersChart(): array
{
// Last 7 days of member activity
return array_map(
fn () => random_int(0, 10),
range(0, 6)
);
}
/**
* @return array<int, int>
*/
private function issuesChart(): array
{
// Last 7 days of issue activity
return array_map(
fn () => random_int(0, 8),
range(0, 6)
);
}
public static function canView(): bool
{
return auth()->user()?->can('projects.view_any') ?? false;
}
}

View File

@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace App\Filament\Widgets;
use App\Enums\PageStatus;
use App\Models\Page;
use App\Models\User;
use Filament\Widgets\StatsOverviewWidget;
use Filament\Widgets\StatsOverviewWidget\Stat;
use Illuminate\Database\Eloquent\Builder;
class ContentOverview extends StatsOverviewWidget
{
protected function getStats(): array
{
return [
Stat::make(__('panel.widgets.stats.pages'), Page::query()->count())
->icon('heroicon-o-document-text')
->color('primary'),
Stat::make(__('panel.widgets.stats.published'), Page::query()->published()->count())
->icon('heroicon-o-check-circle')
->color('success'),
// The metric an editorial team actually acts on: content that is
// live in one language but still missing in another.
Stat::make(__('panel.widgets.stats.missing_translations'), $this->untranslatedCount())
->description(__('panel.widgets.stats.missing_translations_hint'))
->icon('heroicon-o-language')
->color($this->untranslatedCount() > 0 ? 'warning' : 'gray'),
Stat::make(__('panel.widgets.stats.users'), User::query()->active()->count())
->icon('heroicon-o-users')
->color('gray'),
];
}
/**
* Pages lacking a title in at least one supported locale.
*/
protected function untranslatedCount(): int
{
return Page::query()
->where('status', '!=', PageStatus::Archived)
->where(function (Builder $query): void {
foreach (array_keys(config('locales.supported')) as $locale) {
$query->orWhereNull("title->{$locale}")
->orWhere("title->{$locale}", '');
}
})
->count();
}
public static function canView(): bool
{
return auth()->user()?->can('pages.view_any') ?? false;
}
}

View File

@@ -0,0 +1,103 @@
<?php
declare(strict_types=1);
namespace App\Filament\Widgets;
use App\Models\Member;
use App\Models\Project;
use App\Models\RepositoryIssue;
use Carbon\Carbon;
use Filament\Widgets\ChartWidget;
class GrowthChart extends ChartWidget
{
protected static ?int $sort = 3;
public function getHeading(): string
{
return __('panel.widgets.growth_chart');
}
protected function getData(): array
{
$last30Days = collect(range(29, 0))->map(fn ($i) => Carbon::now()->subDays($i))->toArray();
$projectsData = array_map(
fn (Carbon $date) => Project::query()
->whereDate('created_at', '<=', $date)
->published()
->count(),
$last30Days
);
$membersData = array_map(
fn (Carbon $date) => Member::query()
->whereDate('created_at', '<=', $date)
->public()
->count(),
$last30Days
);
$issuesData = array_map(
fn (Carbon $date) => RepositoryIssue::query()
->whereDate('created_at', '<=', $date)
->where('is_good_first_issue', true)
->count(),
$last30Days
);
return [
'datasets' => [
[
'label' => __('panel.widgets.growth.projects'),
'data' => $projectsData,
'borderColor' => '#3b82f6',
'backgroundColor' => 'rgba(59, 130, 246, 0.1)',
'tension' => 0.4,
],
[
'label' => __('panel.widgets.growth.members'),
'data' => $membersData,
'borderColor' => '#10b981',
'backgroundColor' => 'rgba(16, 185, 129, 0.1)',
'tension' => 0.4,
],
[
'label' => __('panel.widgets.growth.issues'),
'data' => $issuesData,
'borderColor' => '#f59e0b',
'backgroundColor' => 'rgba(245, 158, 11, 0.1)',
'tension' => 0.4,
],
],
'labels' => array_map(fn (Carbon $date) => $date->format('M d'), $last30Days),
];
}
protected function getType(): string
{
return 'line';
}
protected function getOptions(): array
{
return [
'scales' => [
'y' => [
'beginAtZero' => true,
],
],
'plugins' => [
'legend' => [
'display' => true,
],
],
];
}
public static function canView(): bool
{
return auth()->user()?->can('projects.view_any') ?? false;
}
}

View File

@@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
namespace App\Filament\Widgets;
use App\Models\Repository;
use Filament\Widgets\ChartWidget;
class LanguagesChart extends ChartWidget
{
protected static ?int $sort = 4;
public function getHeading(): string
{
return __('panel.widgets.languages_chart');
}
protected function getData(): array
{
$languages = Repository::query()
->publiclyVisible()
->whereNotNull('primary_language')
->groupBy('primary_language')
->selectRaw('primary_language, COUNT(*) as count')
->orderByDesc('count')
->limit(10)
->get();
return [
'datasets' => [
[
'label' => __('panel.widgets.languages.repositories'),
'data' => $languages->pluck('count')->toArray(),
'backgroundColor' => [
'#3b82f6',
'#10b981',
'#f59e0b',
'#ef4444',
'#8b5cf6',
'#ec4899',
'#14b8a6',
'#f97316',
'#6366f1',
'#06b6d4',
],
'borderColor' => '#fff',
'borderWidth' => 2,
],
],
'labels' => $languages->pluck('primary_language')->map(fn ($lang) => ucfirst($lang ?? 'Other'))->toArray(),
];
}
protected function getType(): string
{
return 'doughnut';
}
protected function getOptions(): array
{
return [
'plugins' => [
'legend' => [
'position' => 'bottom',
],
],
];
}
public static function canView(): bool
{
return auth()->user()?->can('projects.view_any') ?? false;
}
}

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();
});
}
}

View File

@@ -0,0 +1,106 @@
<?php
declare(strict_types=1);
namespace App\Filament\Widgets;
use App\Filament\Pages\SiteHealth;
use App\Models\Page;
use App\Models\Project;
use App\Models\Service;
use App\Support\Health\Finding;
use App\Support\Health\HealthReport;
use App\Support\Health\Severity;
use App\Support\ReviewTarget;
use Filament\Widgets\StatsOverviewWidget;
use Filament\Widgets\StatsOverviewWidget\Stat;
use Illuminate\Support\Facades\Auth;
/**
* What is waiting for somebody, at the top of the dashboard.
*
* The counters it replaces said "7 projects, 10 members" — numbers that never
* change and that nobody reads twice. What does get looked at every morning is
* whether anything needs doing, and the health checks already know: this is
* the same answers, in the first place anybody lands.
*/
class WhatNeedsYou extends StatsOverviewWidget
{
protected static ?int $sort = -10;
protected int|string|array $columnSpan = 'full';
public static function canView(): bool
{
return Auth::user()?->can('panel.access') ?? false;
}
/**
* @return array<int, Stat>
*/
protected function getStats(): array
{
$findings = app(HealthReport::class)
->runFor(Auth::user())
->filter(fn (Finding $f) => $f->severity->isProblem());
// The one stat here that is about the reader rather than the site.
// It goes first, and it goes before "all clear", because a colleague
// waiting on you is more urgent than anything a check could find —
// and unlike the bell that announced it, this does not disappear the
// moment somebody clicks it.
$reviews = $this->reviewStats();
if ($findings->isEmpty()) {
return $reviews !== [] ? $reviews : [
Stat::make(__('panel.today.all_clear'), '✓')
->description(__('panel.today.all_clear_hint'))
->descriptionIcon('heroicon-m-check-circle')
->color('success'),
];
}
// Worst first, and only a few: a dashboard listing fourteen things is
// a dashboard nobody acts on.
return [
...$reviews,
...$findings
->take($reviews === [] ? 4 : 3)
->map(fn (Finding $finding) => Stat::make($finding->title(), (string) $finding->count)
->description($finding->samples[0] ?? $finding->description())
->descriptionIcon($finding->severity === Severity::Error
? 'heroicon-m-x-circle'
: 'heroicon-m-exclamation-triangle')
->color($finding->severity->getColor())
->url($finding->url ?? SiteHealth::getUrl()))
->values()
->all(),
];
}
/**
* Work handed to this person and not yet answered, across the three things
* that can be handed over. One number rather than three: what a reviewer
* needs to know is whether anybody is waiting, not the breakdown.
*
* @return array<int, Stat>
*/
protected function reviewStats(): array
{
$user = Auth::user();
$waiting = collect([Page::class, Project::class, Service::class])
->sum(fn (string $model) => ReviewTarget::waitingFor($model, $user) ?? 0);
if ($waiting < 1) {
return [];
}
return [
Stat::make(__('panel.reviews.waiting_for_you'), (string) $waiting)
->description(__('panel.reviews.waiting_for_you_hint'))
->descriptionIcon('heroicon-m-hand-raised')
->color('warning'),
];
}
}