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:
65
resources/views/docs/api.blade.php
Normal file
65
resources/views/docs/api.blade.php
Normal file
@@ -0,0 +1,65 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{ __('docs.title') }} · {{ config('app.name') }}</title>
|
||||
<meta name="robots" content="noindex">
|
||||
|
||||
{{-- Self-hosted rather than pulled from a CDN: the docs have to work on a
|
||||
laptop with no internet, and a script tag pointing at somebody else's
|
||||
server is a dependency this application does not otherwise have. --}}
|
||||
<link rel="stylesheet" href="{{ asset('vendor/swagger-ui/swagger-ui.css') }}">
|
||||
|
||||
<style>
|
||||
body { margin: 0; background: #fafafa; }
|
||||
|
||||
/* Swagger UI ships its own header; this replaces it with something
|
||||
that says which server you are actually looking at, because the
|
||||
same page exists in local, staging and production. */
|
||||
.top {
|
||||
display: flex; flex-wrap: wrap; align-items: baseline; gap: .75rem;
|
||||
padding: 1rem 1.5rem; background: #1f2430; color: #fff;
|
||||
font-family: ui-sans-serif, system-ui, sans-serif;
|
||||
}
|
||||
.top strong { font-size: 1rem; font-weight: 600; }
|
||||
.top span { font-size: .8125rem; color: #9aa4b8; }
|
||||
.top a { margin-left: auto; font-size: .8125rem; color: #a5b4fc; }
|
||||
.swagger-ui .topbar { display: none; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="top">
|
||||
<strong>{{ config('app.name') }} · {{ __('docs.title') }}</strong>
|
||||
<span>{{ $environment }} — {{ $server }}</span>
|
||||
<a href="{{ $spec }}">openapi.yaml</a>
|
||||
@if ($types)
|
||||
<a href="{{ $types }}" style="margin-left: 1rem;">api-types.d.ts</a>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div id="swagger"></div>
|
||||
|
||||
<script src="{{ asset('vendor/swagger-ui/swagger-ui-bundle.js') }}"></script>
|
||||
<script src="{{ asset('vendor/swagger-ui/swagger-ui-standalone-preset.js') }}"></script>
|
||||
<script>
|
||||
window.ui = SwaggerUIBundle({
|
||||
url: @json($spec),
|
||||
dom_id: '#swagger',
|
||||
deepLinking: true,
|
||||
presets: [SwaggerUIBundle.presets.apis, SwaggerUIStandalonePreset],
|
||||
layout: 'BaseLayout',
|
||||
// Endpoints are grouped by tag and collapsed: this specification
|
||||
// describes two APIs at once, and opening on a wall of expanded
|
||||
// operations tells you nothing about which is which.
|
||||
docExpansion: 'none',
|
||||
defaultModelsExpandDepth: 0,
|
||||
tryItOutEnabled: true,
|
||||
persistAuthorization: true,
|
||||
// "Try it out" has to reach this application, not the placeholder
|
||||
// origin in the specification.
|
||||
servers: [{ url: @json($apiBase), description: @json($environment) }],
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
138
resources/views/filament/pages/site-health.blade.php
Normal file
138
resources/views/filament/pages/site-health.blade.php
Normal file
@@ -0,0 +1,138 @@
|
||||
<x-filament-panels::page>
|
||||
{{-- The headline. Whether anything is actually wrong is the one thing
|
||||
somebody opening this page came to find out. --}}
|
||||
@if ($this->problems()->isEmpty())
|
||||
<div class="rounded-xl border border-success-300 bg-success-50 p-4 dark:border-success-700/50 dark:bg-success-500/10">
|
||||
<div class="flex items-center gap-3">
|
||||
<x-filament::icon
|
||||
icon="heroicon-o-check-circle"
|
||||
class="h-5 w-5 shrink-0 text-success-600 dark:text-success-400"
|
||||
/>
|
||||
|
||||
<p class="font-semibold text-success-800 dark:text-success-300">
|
||||
{{ __('health.all_clear') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@else
|
||||
@php($tone = $this->hasErrors() ? 'danger' : 'warning')
|
||||
|
||||
<div @class([
|
||||
'rounded-xl border p-4',
|
||||
'border-danger-300 bg-danger-50 dark:border-danger-700/50 dark:bg-danger-500/10' => $tone === 'danger',
|
||||
'border-warning-300 bg-warning-50 dark:border-warning-700/50 dark:bg-warning-500/10' => $tone === 'warning',
|
||||
])>
|
||||
<div class="flex items-start gap-3">
|
||||
<x-filament::icon
|
||||
icon="heroicon-o-exclamation-triangle"
|
||||
@class([
|
||||
'mt-0.5 h-5 w-5 shrink-0',
|
||||
'text-danger-600 dark:text-danger-400' => $tone === 'danger',
|
||||
'text-warning-600 dark:text-warning-400' => $tone === 'warning',
|
||||
])
|
||||
/>
|
||||
|
||||
<div class="space-y-1">
|
||||
<p @class([
|
||||
'font-semibold',
|
||||
'text-danger-800 dark:text-danger-300' => $tone === 'danger',
|
||||
'text-warning-800 dark:text-warning-300' => $tone === 'warning',
|
||||
])>
|
||||
{{ trans_choice('health.problem_summary', $this->problems()->count(), ['count' => $this->problems()->count()]) }}
|
||||
</p>
|
||||
|
||||
<p @class([
|
||||
'text-sm',
|
||||
'text-danger-700 dark:text-danger-400' => $tone === 'danger',
|
||||
'text-warning-700 dark:text-warning-400' => $tone === 'warning',
|
||||
])>
|
||||
{{ __('health.problem_hint') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Failing checks, worst first. --}}
|
||||
@foreach ($this->problems() as $finding)
|
||||
<x-filament::section>
|
||||
<x-slot name="heading">
|
||||
<span class="flex items-center gap-2">
|
||||
<x-filament::icon
|
||||
:icon="$finding->severity->getIcon()"
|
||||
@class([
|
||||
'h-5 w-5 shrink-0',
|
||||
'text-danger-500' => $finding->severity->getColor() === 'danger',
|
||||
'text-warning-500' => $finding->severity->getColor() === 'warning',
|
||||
])
|
||||
/>
|
||||
|
||||
{{ $finding->title() }}
|
||||
|
||||
<x-filament::badge :color="$finding->severity->getColor()" size="sm">
|
||||
{{ $finding->count }}
|
||||
</x-filament::badge>
|
||||
</span>
|
||||
</x-slot>
|
||||
|
||||
<div class="space-y-3">
|
||||
<p class="text-sm text-gray-600 dark:text-gray-300">
|
||||
{{ $finding->description() }}
|
||||
</p>
|
||||
|
||||
{{-- Examples rather than only a number: "12 pages" is a
|
||||
statistic, "12, starting with these" is something
|
||||
somebody can act on. --}}
|
||||
@if (filled($finding->samples))
|
||||
<ul class="space-y-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
@foreach ($finding->samples as $sample)
|
||||
<li class="flex items-start gap-2">
|
||||
<span class="mt-1.5 h-1 w-1 shrink-0 rounded-full bg-gray-400"></span>
|
||||
<span>{{ $sample }}</span>
|
||||
</li>
|
||||
@endforeach
|
||||
|
||||
@if ($finding->count > count($finding->samples))
|
||||
<li class="text-xs italic">
|
||||
{{ __('health.and_more', ['count' => $finding->count - count($finding->samples)]) }}
|
||||
</li>
|
||||
@endif
|
||||
</ul>
|
||||
@endif
|
||||
|
||||
@if ($finding->url)
|
||||
<x-filament::link :href="$finding->url" size="sm">
|
||||
{{ __('health.actions.go_fix') }}
|
||||
</x-filament::link>
|
||||
@endif
|
||||
</div>
|
||||
</x-filament::section>
|
||||
@endforeach
|
||||
|
||||
{{-- What passed, collapsed. Worth showing at all: it says what was
|
||||
actually looked at, so a clean page is not mistaken for a page that
|
||||
never ran. --}}
|
||||
@if ($this->passing()->isNotEmpty())
|
||||
<x-filament::section collapsible collapsed>
|
||||
<x-slot name="heading">
|
||||
{{ trans_choice('health.passing', $this->passing()->count(), ['count' => $this->passing()->count()]) }}
|
||||
</x-slot>
|
||||
|
||||
<ul class="space-y-2">
|
||||
@foreach ($this->passing() as $finding)
|
||||
<li class="flex items-start gap-2 text-sm">
|
||||
<x-filament::icon
|
||||
icon="heroicon-o-check-circle"
|
||||
class="mt-0.5 h-4 w-4 shrink-0 text-success-500"
|
||||
/>
|
||||
|
||||
<span>
|
||||
<span class="font-medium text-gray-700 dark:text-gray-200">{{ $finding->title() }}</span>
|
||||
<span class="text-gray-500 dark:text-gray-400"> — {{ $finding->description() }}</span>
|
||||
</span>
|
||||
</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
</x-filament::section>
|
||||
@endif
|
||||
</x-filament-panels::page>
|
||||
59
resources/views/filament/pages/site-maintenance.blade.php
Normal file
59
resources/views/filament/pages/site-maintenance.blade.php
Normal file
@@ -0,0 +1,59 @@
|
||||
<x-filament-panels::page>
|
||||
{{-- Current state, stated plainly. Whether the public site is down is the
|
||||
one thing this page must never leave ambiguous. --}}
|
||||
@if ($this->isEnabled())
|
||||
<div class="rounded-xl border border-danger-300 bg-danger-50 p-4 dark:border-danger-700/50 dark:bg-danger-500/10">
|
||||
<div class="flex items-start gap-3">
|
||||
<x-filament::icon
|
||||
icon="heroicon-o-exclamation-triangle"
|
||||
class="mt-0.5 h-5 w-5 shrink-0 text-danger-600 dark:text-danger-400"
|
||||
/>
|
||||
|
||||
<div class="space-y-1">
|
||||
<p class="font-semibold text-danger-800 dark:text-danger-300">
|
||||
{{ __('maintenance.status_down') }}
|
||||
</p>
|
||||
|
||||
<p class="text-sm text-danger-700 dark:text-danger-400">
|
||||
@if ($this->startedAt())
|
||||
{{ __('maintenance.since', ['time' => $this->startedAt()]) }}
|
||||
@endif
|
||||
|
||||
@if ($this->endsAt())
|
||||
· {{ __('maintenance.lifts_at', ['time' => $this->endsAt()]) }}
|
||||
@endif
|
||||
</p>
|
||||
|
||||
<p class="text-sm text-danger-700 dark:text-danger-400">
|
||||
{{ __('maintenance.panel_unaffected') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@else
|
||||
<div class="rounded-xl border border-success-300 bg-success-50 p-4 dark:border-success-700/50 dark:bg-success-500/10">
|
||||
<div class="flex items-center gap-3">
|
||||
<x-filament::icon
|
||||
icon="heroicon-o-check-circle"
|
||||
class="h-5 w-5 shrink-0 text-success-600 dark:text-success-400"
|
||||
/>
|
||||
|
||||
<p class="font-semibold text-success-800 dark:text-success-300">
|
||||
{{ __('maintenance.status_up') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{ $this->form }}
|
||||
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400">
|
||||
<p>{{ __('maintenance.bypass_hint') }}</p>
|
||||
</div>
|
||||
|
||||
@unless ($this->canToggle())
|
||||
<div class="rounded-lg border border-gray-200 p-4 text-sm text-gray-500 dark:border-white/10 dark:text-gray-400">
|
||||
{{ __('maintenance.no_permission') }}
|
||||
</div>
|
||||
@endunless
|
||||
</x-filament-panels::page>
|
||||
57
resources/views/filament/pages/trash.blade.php
Normal file
57
resources/views/filament/pages/trash.blade.php
Normal file
@@ -0,0 +1,57 @@
|
||||
<x-filament-panels::page>
|
||||
@php($groups = $this->groups())
|
||||
|
||||
@if ($groups->isEmpty())
|
||||
<div class="rounded-xl border border-gray-200 p-6 text-center dark:border-white/10">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ __('panel.trash.empty') }}
|
||||
</p>
|
||||
</div>
|
||||
@else
|
||||
@foreach ($groups as $group)
|
||||
<x-filament::section :heading="$group['label']">
|
||||
<x-slot name="headerEnd">
|
||||
<x-filament::badge color="gray">{{ $group['records']->count() }}</x-filament::badge>
|
||||
</x-slot>
|
||||
|
||||
<ul class="divide-y divide-gray-100 dark:divide-white/5">
|
||||
@foreach ($group['records'] as $record)
|
||||
<li class="flex flex-wrap items-center justify-between gap-3 py-2.5">
|
||||
<div class="min-w-0">
|
||||
<p class="truncate text-sm font-medium text-gray-800 dark:text-gray-100">
|
||||
{{ $this->labelFor($record) }}
|
||||
</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ __('panel.trash.deleted_at', ['when' => $record->deleted_at?->diffForHumans()]) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex shrink-0 items-center gap-2">
|
||||
<x-filament::button
|
||||
size="sm"
|
||||
color="success"
|
||||
icon="heroicon-o-arrow-uturn-left"
|
||||
wire:click="restoreRecord(@js($group['model']), @js($record->getKey()))"
|
||||
>
|
||||
{{ __('panel.trash.restore') }}
|
||||
</x-filament::button>
|
||||
|
||||
{{-- Erasing is the one thing here that cannot be
|
||||
undone, so it asks first. --}}
|
||||
<x-filament::button
|
||||
size="sm"
|
||||
color="danger"
|
||||
icon="heroicon-o-trash"
|
||||
wire:click="eraseRecord(@js($group['model']), @js($record->getKey()))"
|
||||
wire:confirm="{{ __('panel.trash.erase_confirm') }}"
|
||||
>
|
||||
{{ __('panel.trash.erase') }}
|
||||
</x-filament::button>
|
||||
</div>
|
||||
</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
</x-filament::section>
|
||||
@endforeach
|
||||
@endif
|
||||
</x-filament-panels::page>
|
||||
@@ -0,0 +1,22 @@
|
||||
{{-- Rendered at the very top of every page in the panel, on every install.
|
||||
|
||||
The failure this exists for runs in one direction only: mistaking a real
|
||||
deployment for a throwaway copy, not the other way round. Nobody edits a
|
||||
local clone carefully because they think it is production — the mistake
|
||||
that costs something is the reverse. So `local` stays silent, and
|
||||
anything else says so, loudly in the one case that matters. --}}
|
||||
|
||||
@unless (app()->environment('local'))
|
||||
<div
|
||||
role="status"
|
||||
@class([
|
||||
'w-full py-1 text-center text-xs font-semibold tracking-wide text-white uppercase',
|
||||
'bg-danger-600' => app()->environment('production'),
|
||||
'bg-warning-600' => ! app()->environment('production'),
|
||||
])
|
||||
>
|
||||
{{ app()->environment('production')
|
||||
? __('panel.environment.production')
|
||||
: __('panel.environment.other', ['name' => app()->environment()]) }}
|
||||
</div>
|
||||
@endunless
|
||||
@@ -0,0 +1,30 @@
|
||||
@php
|
||||
$locales = config('locales.supported');
|
||||
$current = app()->getLocale();
|
||||
@endphp
|
||||
|
||||
<x-filament::dropdown placement="bottom-end" teleport>
|
||||
<x-slot name="trigger">
|
||||
<button
|
||||
type="button"
|
||||
title="{{ __('panel.language.switch') }}"
|
||||
class="fi-icon-btn relative flex items-center justify-center rounded-lg p-2 text-sm text-gray-400 outline-none transition duration-75 hover:bg-gray-50 hover:text-gray-500 focus-visible:bg-gray-50 dark:text-gray-500 dark:hover:bg-white/5 dark:hover:text-gray-400"
|
||||
>
|
||||
<span class="text-base leading-none">{{ $locales[$current]['flag'] ?? '🌐' }}</span>
|
||||
<span class="sr-only">{{ __('panel.language.switch') }}</span>
|
||||
</button>
|
||||
</x-slot>
|
||||
|
||||
<x-filament::dropdown.list>
|
||||
@foreach ($locales as $code => $meta)
|
||||
<x-filament::dropdown.list.item
|
||||
:href="route('locale.switch', $code)"
|
||||
tag="a"
|
||||
:icon="$code === $current ? 'heroicon-m-check' : null"
|
||||
:color="$code === $current ? 'primary' : 'gray'"
|
||||
>
|
||||
{{ $meta['flag'] }} {{ $meta['native'] }}
|
||||
</x-filament::dropdown.list.item>
|
||||
@endforeach
|
||||
</x-filament::dropdown.list>
|
||||
</x-filament::dropdown>
|
||||
30
resources/views/filament/partials/login-footer.blade.php
Normal file
30
resources/views/filament/partials/login-footer.blade.php
Normal file
@@ -0,0 +1,30 @@
|
||||
{{-- Rendered under the sign-in form.
|
||||
|
||||
Two things belong here and nowhere else: whatever the identity server had
|
||||
to say about a redirect that failed (those routes still exist), and the
|
||||
language picker — the one place it is needed before there is a user whose
|
||||
preference we could read. --}}
|
||||
|
||||
@if (session('error'))
|
||||
<div
|
||||
role="alert"
|
||||
class="mb-6 rounded-lg border border-danger-300 bg-danger-50 p-4 text-sm text-danger-700 dark:border-danger-700/50 dark:bg-danger-500/10 dark:text-danger-400"
|
||||
>
|
||||
{{ session('error') }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="flex items-center justify-center gap-3 border-t border-gray-200 pt-6 dark:border-white/10">
|
||||
@foreach (config('locales.supported') as $code => $meta)
|
||||
<a
|
||||
href="{{ route('locale.switch', $code) }}"
|
||||
@class([
|
||||
'text-xs transition',
|
||||
'font-semibold text-primary-600 dark:text-primary-400' => app()->getLocale() === $code,
|
||||
'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200' => app()->getLocale() !== $code,
|
||||
])
|
||||
>
|
||||
{{ $meta['flag'] }} {{ $meta['native'] }}
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
23
resources/views/filament/partials/preview-links.blade.php
Normal file
23
resources/views/filament/partials/preview-links.blade.php
Normal file
@@ -0,0 +1,23 @@
|
||||
{{-- One link per language: a draft translated into two has two addresses, and
|
||||
whoever is reviewing the Catalan one needs that one. --}}
|
||||
<div class="space-y-3">
|
||||
@foreach ($links as $language => $url)
|
||||
<div class="space-y-1">
|
||||
<p class="text-sm font-medium text-gray-700 dark:text-gray-200">{{ $language }}</p>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
readonly
|
||||
value="{{ $url }}"
|
||||
onclick="this.select()"
|
||||
class="fi-input w-full rounded-lg border-gray-300 bg-gray-50 px-3 py-2 font-mono text-xs text-gray-700 dark:border-white/10 dark:bg-white/5 dark:text-gray-300"
|
||||
/>
|
||||
|
||||
<x-filament::button tag="a" href="{{ $url }}" target="_blank" size="sm" color="gray">
|
||||
{{ __('panel.preview.open') }}
|
||||
</x-filament::button>
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
44
resources/views/filament/revisions/diff.blade.php
Normal file
44
resources/views/filament/revisions/diff.blade.php
Normal file
@@ -0,0 +1,44 @@
|
||||
@php
|
||||
/** @var \App\Models\Revision $revision */
|
||||
$render = function ($value): string {
|
||||
if ($value === null || $value === '') {
|
||||
return '—';
|
||||
}
|
||||
|
||||
return is_scalar($value)
|
||||
? (string) $value
|
||||
: json_encode($value, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
};
|
||||
@endphp
|
||||
|
||||
{{-- Old beside new, field by field. Reading the difference is the point:
|
||||
"changed the Catalan title" is useful, "saved the page" is not. --}}
|
||||
<div class="space-y-4">
|
||||
@forelse ($revision->fields() as $field)
|
||||
@php($diff = $revision->diff($field))
|
||||
|
||||
<div class="space-y-2">
|
||||
<p class="text-sm font-semibold text-gray-700 dark:text-gray-200">{{ $field }}</p>
|
||||
|
||||
<div class="grid gap-2 sm:grid-cols-2">
|
||||
<div class="rounded-lg border border-danger-200 bg-danger-50/50 p-3 dark:border-danger-800/40 dark:bg-danger-500/5">
|
||||
<p class="mb-1 text-xs font-medium uppercase tracking-wide text-danger-700 dark:text-danger-400">
|
||||
{{ __('panel.revisions.before') }}
|
||||
</p>
|
||||
<pre class="overflow-x-auto whitespace-pre-wrap break-words text-xs text-gray-700 dark:text-gray-300">{{ $render($diff['old']) }}</pre>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border border-success-200 bg-success-50/50 p-3 dark:border-success-800/40 dark:bg-success-500/5">
|
||||
<p class="mb-1 text-xs font-medium uppercase tracking-wide text-success-700 dark:text-success-400">
|
||||
{{ __('panel.revisions.after') }}
|
||||
</p>
|
||||
<pre class="overflow-x-auto whitespace-pre-wrap break-words text-xs text-gray-700 dark:text-gray-300">{{ $render($diff['new']) }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@empty
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ __('panel.revisions.no_changes') }}
|
||||
</p>
|
||||
@endforelse
|
||||
</div>
|
||||
@@ -0,0 +1,8 @@
|
||||
@php
|
||||
$json = json_encode($getState(), JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
|
||||
@endphp
|
||||
|
||||
{{-- A setting's value is arbitrary JSON, so it is shown as-is rather than
|
||||
coerced into a string. Bounded on both axes: one long value must not push
|
||||
the table sideways or turn the row into a screenful. --}}
|
||||
<pre class="max-h-40 max-w-md overflow-auto rounded-lg bg-gray-100 p-2 text-xs text-gray-700 dark:bg-white/5 dark:text-gray-300">{{ $json }}</pre>
|
||||
223
resources/views/welcome.blade.php
Normal file
223
resources/views/welcome.blade.php
Normal file
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user