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:
106
app/Filament/Widgets/AdvancedAnalytics.php
Normal file
106
app/Filament/Widgets/AdvancedAnalytics.php
Normal 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;
|
||||
}
|
||||
}
|
||||
60
app/Filament/Widgets/ContentOverview.php
Normal file
60
app/Filament/Widgets/ContentOverview.php
Normal 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;
|
||||
}
|
||||
}
|
||||
103
app/Filament/Widgets/GrowthChart.php
Normal file
103
app/Filament/Widgets/GrowthChart.php
Normal 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;
|
||||
}
|
||||
}
|
||||
75
app/Filament/Widgets/LanguagesChart.php
Normal file
75
app/Filament/Widgets/LanguagesChart.php
Normal 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;
|
||||
}
|
||||
}
|
||||
88
app/Filament/Widgets/MostRead.php
Normal file
88
app/Filament/Widgets/MostRead.php
Normal 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();
|
||||
});
|
||||
}
|
||||
}
|
||||
106
app/Filament/Widgets/WhatNeedsYou.php
Normal file
106
app/Filament/Widgets/WhatNeedsYou.php
Normal 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'),
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user