Skip to content

Commit 40b1a5e

Browse files
simonhampclaude
andauthored
Add admin plugin listing preview, table of contents, and narrower license page (#306)
* Add admin preview for plugin listings, table of contents, and narrow license page - Allow admins to preview plugin listing pages before approval, with an amber preview banner showing the current status - Make "View Listing Page" Filament action visible for pending plugins - Add Alpine.js-powered table of contents to plugin listing sidebar, extracted from readme headings - Narrow the plugin license page layout to match the sponsor page (max-w-3xl) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Move ToC above article to avoid prose link styles, remove border Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Allow admins to bypass all access checks on plugin listing pages Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 7dcada3 commit 40b1a5e

File tree

9 files changed

+208
-26
lines changed

9 files changed

+208
-26
lines changed

app/Filament/Resources/PluginResource/Pages/EditPlugin.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -164,7 +164,7 @@ protected function getHeaderActions(): array
164164
->color('gray')
165165
->url(fn () => route('plugins.show', $this->record->routeParams()))
166166
->openUrlInNewTab()
167-
->visible(fn () => $this->record->isApproved()),
167+
->visible(fn () => $this->record->isApproved() || $this->record->isPending()),
168168

169169
Actions\Action::make('viewPackagist')
170170
->label('View on Packagist')

app/Filament/Resources/PluginResource/Pages/ViewPlugin.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ protected function getHeaderActions(): array
2020
->color('gray')
2121
->url(fn () => route('plugins.show', $this->record->routeParams()))
2222
->openUrlInNewTab()
23-
->visible(fn () => $this->record->isApproved()),
23+
->visible(fn () => $this->record->isApproved() || $this->record->isPending()),
2424

2525
Actions\Action::make('approve')
2626
->icon('heroicon-o-check')

app/Http/Controllers/PluginDirectoryController.php

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -49,12 +49,14 @@ public function show(string $vendor, string $package): View
4949
{
5050
$plugin = Plugin::findByVendorPackageOrFail($vendor, $package);
5151

52-
abort_unless($plugin->isApproved(), 404);
53-
5452
$user = Auth::user();
5553

56-
// For paid plugins, check if user has an accessible price
57-
if ($plugin->isPaid() && ! $plugin->hasAccessiblePriceFor($user)) {
54+
$isAdmin = $user?->isAdmin() ?? false;
55+
56+
abort_unless($plugin->isApproved() || $isAdmin, 404);
57+
58+
// For paid plugins, check if user has an accessible price (admins bypass)
59+
if (! $isAdmin && $plugin->isPaid() && ! $plugin->hasAccessiblePriceFor($user)) {
5860
abort(404);
5961
}
6062

@@ -72,6 +74,7 @@ public function show(string $vendor, string $package): View
7274
'bestPrice' => $bestPrice,
7375
'regularPrice' => $regularPrice,
7476
'hasDiscount' => $bestPrice && $regularPrice && $bestPrice->id !== $regularPrice->id,
77+
'isAdminPreview' => ! $plugin->isApproved(),
7578
]);
7679
}
7780

package-lock.json

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
<div
2+
x-data="{
3+
headings: [],
4+
init() {
5+
const article = document.querySelector('article')
6+
if (! article) return
7+
8+
const elements = article.querySelectorAll('h2[id], h3[id]')
9+
this.headings = Array.from(elements).map(el => ({
10+
id: el.id,
11+
text: el.textContent.replace(/^#\s*/, '').trim(),
12+
level: parseInt(el.tagName.substring(1)),
13+
}))
14+
},
15+
}"
16+
x-show="headings.length > 0"
17+
x-cloak
18+
class="mb-6"
19+
>
20+
<h3 class="flex items-center gap-1.5 text-sm opacity-60">
21+
<x-icons.stacked-lines class="size-[18px]" />
22+
<div>On this page</div>
23+
</h3>
24+
25+
<div class="mt-4 flex flex-col space-y-2 overflow-y-auto overflow-x-hidden border-l text-xs dark:border-l-white/15">
26+
<template x-for="heading in headings" :key="heading.id">
27+
<a
28+
:href="'#' + heading.id"
29+
:class="heading.level === 2 ? 'pb-1 pl-3' : 'py-1 pl-6'"
30+
class="transition duration-300 ease-in-out will-change-transform hover:translate-x-0.5 hover:text-violet-400 hover:opacity-100 dark:text-white/80"
31+
x-text="heading.text"
32+
></a>
33+
</template>
34+
</div>
35+
</div>

resources/views/plugin-license.blade.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
<x-layout :title="$plugin->name . ' - License'">
22
<section
3-
class="mx-auto mt-10 w-full max-w-7xl"
3+
class="mx-auto mt-10 w-full max-w-3xl px-5 md:mt-14"
44
aria-labelledby="license-title"
55
>
66
<header class="relative">

resources/views/plugin-show.blade.php

Lines changed: 33 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,14 @@
33
class="mx-auto mt-10 w-full max-w-7xl"
44
aria-labelledby="plugin-title"
55
>
6+
@if ($isAdminPreview ?? false)
7+
<div class="mb-6 rounded-xl border border-amber-300 bg-amber-50 p-4 text-center dark:border-amber-600 dark:bg-amber-950/50">
8+
<p class="text-sm font-medium text-amber-800 dark:text-amber-200">
9+
Admin Preview &mdash; This plugin is not yet published. Status: {{ $plugin->status->label() }}
10+
</p>
11+
</div>
12+
@endif
13+
614
<header class="relative">
715
{{-- Blurred circle - Decorative --}}
816
<div
@@ -88,31 +96,37 @@ class="font-mono text-2xl font-bold sm:text-3xl"
8896

8997
<div class="mt-2 flex flex-col-reverse gap-8 lg:flex-row lg:items-start">
9098
{{-- Main content - README --}}
91-
<article
92-
x-init="
93-
() => {
94-
motion.inView($el, () => {
95-
gsap.fromTo(
96-
$el,
97-
{ autoAlpha: 0, y: 5 },
98-
{ autoAlpha: 1, y: 0, duration: 0.7, ease: 'power1.out' },
99-
)
100-
})
101-
}
102-
"
103-
class="prose min-w-0 max-w-none grow text-gray-600 dark:text-gray-400 dark:prose-headings:text-white"
104-
aria-labelledby="plugin-title"
105-
>
99+
<div class="min-w-0 grow">
106100
@if ($plugin->readme_html)
107-
{!! $plugin->readme_html !!}
101+
<x-plugin-toc />
102+
@endif
103+
104+
<article
105+
x-init="
106+
() => {
107+
motion.inView($el, () => {
108+
gsap.fromTo(
109+
$el,
110+
{ autoAlpha: 0, y: 5 },
111+
{ autoAlpha: 1, y: 0, duration: 0.7, ease: 'power1.out' },
112+
)
113+
})
114+
}
115+
"
116+
class="prose min-w-0 max-w-none grow text-gray-600 dark:text-gray-400 dark:prose-headings:text-white"
117+
aria-labelledby="plugin-title"
118+
>
119+
@if ($plugin->readme_html)
120+
{!! $plugin->readme_html !!}
108121
@else
109122
<div class="rounded-xl border border-gray-200 bg-gray-50 p-8 text-center dark:border-gray-700 dark:bg-slate-800/50">
110123
<p class="text-gray-500 dark:text-gray-400">
111124
README not available yet.
112125
</p>
113126
</div>
114-
@endif
115-
</article>
127+
@endif
128+
</article>
129+
</div>
116130

117131
{{-- Sidebar - Plugin details --}}
118132
<aside
@@ -329,6 +343,7 @@ class="size-10 shrink-0 rounded-lg object-cover"
329343
</ul>
330344
</div>
331345
@endif
346+
332347
</aside>
333348
</div>
334349
</section>
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
<?php
2+
3+
namespace Tests\Feature;
4+
5+
use App\Features\ShowPlugins;
6+
use App\Models\Plugin;
7+
use App\Models\User;
8+
use Illuminate\Foundation\Testing\RefreshDatabase;
9+
use Laravel\Pennant\Feature;
10+
use Tests\TestCase;
11+
12+
class AdminPluginPreviewTest extends TestCase
13+
{
14+
use RefreshDatabase;
15+
16+
protected function setUp(): void
17+
{
18+
parent::setUp();
19+
20+
Feature::define(ShowPlugins::class, true);
21+
}
22+
23+
public function test_guest_cannot_view_pending_plugin(): void
24+
{
25+
$plugin = Plugin::factory()->pending()->create();
26+
27+
$this->get(route('plugins.show', $plugin->routeParams()))
28+
->assertStatus(404);
29+
}
30+
31+
public function test_regular_user_cannot_view_pending_plugin(): void
32+
{
33+
$user = User::factory()->create();
34+
$plugin = Plugin::factory()->pending()->create();
35+
36+
$this->actingAs($user)
37+
->get(route('plugins.show', $plugin->routeParams()))
38+
->assertStatus(404);
39+
}
40+
41+
public function test_admin_can_view_pending_plugin(): void
42+
{
43+
$admin = User::factory()->create(['email' => 'admin@test.com']);
44+
config(['filament.users' => ['admin@test.com']]);
45+
46+
$plugin = Plugin::factory()->pending()->create();
47+
48+
$this->actingAs($admin)
49+
->get(route('plugins.show', $plugin->routeParams()))
50+
->assertStatus(200);
51+
}
52+
53+
public function test_admin_sees_preview_banner_on_pending_plugin(): void
54+
{
55+
$admin = User::factory()->create(['email' => 'admin@test.com']);
56+
config(['filament.users' => ['admin@test.com']]);
57+
58+
$plugin = Plugin::factory()->pending()->create();
59+
60+
$this->actingAs($admin)
61+
->get(route('plugins.show', $plugin->routeParams()))
62+
->assertSee('Admin Preview')
63+
->assertSee('Pending Review');
64+
}
65+
66+
public function test_approved_plugin_does_not_show_preview_banner(): void
67+
{
68+
$plugin = Plugin::factory()->approved()->create();
69+
70+
$this->get(route('plugins.show', $plugin->routeParams()))
71+
->assertDontSee('Admin Preview');
72+
}
73+
74+
public function test_admin_can_view_approved_plugin_without_preview_banner(): void
75+
{
76+
$admin = User::factory()->create(['email' => 'admin@test.com']);
77+
config(['filament.users' => ['admin@test.com']]);
78+
79+
$plugin = Plugin::factory()->approved()->create();
80+
81+
$this->actingAs($admin)
82+
->get(route('plugins.show', $plugin->routeParams()))
83+
->assertStatus(200)
84+
->assertDontSee('Admin Preview');
85+
}
86+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
<?php
2+
3+
namespace Tests\Feature;
4+
5+
use App\Features\ShowPlugins;
6+
use App\Models\Plugin;
7+
use Illuminate\Foundation\Testing\RefreshDatabase;
8+
use Laravel\Pennant\Feature;
9+
use Tests\TestCase;
10+
11+
class PluginTableOfContentsTest extends TestCase
12+
{
13+
use RefreshDatabase;
14+
15+
protected function setUp(): void
16+
{
17+
parent::setUp();
18+
19+
Feature::define(ShowPlugins::class, true);
20+
}
21+
22+
public function test_toc_component_rendered_when_readme_has_content(): void
23+
{
24+
$plugin = Plugin::factory()->approved()->create([
25+
'readme_html' => '<h2 id="installation">Installation</h2><p>Steps here.</p><h2 id="usage">Usage</h2><p>More content.</p>',
26+
]);
27+
28+
$this->get(route('plugins.show', $plugin->routeParams()))
29+
->assertStatus(200)
30+
->assertSee('On this page');
31+
}
32+
33+
public function test_toc_component_not_rendered_when_no_readme(): void
34+
{
35+
$plugin = Plugin::factory()->approved()->create([
36+
'readme_html' => null,
37+
]);
38+
39+
$this->get(route('plugins.show', $plugin->routeParams()))
40+
->assertStatus(200)
41+
->assertDontSee('On this page');
42+
}
43+
}

0 commit comments

Comments
 (0)