From 7d073efac06213db1cf5985af8ab0aeda38cb892 Mon Sep 17 00:00:00 2001 From: gwleuverink Date: Tue, 8 Jul 2025 10:11:17 +0200 Subject: [PATCH 01/16] add blog to menu's --- .../views/components/mobile-menu.blade.php | 28 +++++++++++++++++++ .../views/components/navigation-bar.blade.php | 25 ++++++++--------- routes/web.php | 3 ++ 3 files changed, 43 insertions(+), 13 deletions(-) diff --git a/resources/views/components/mobile-menu.blade.php b/resources/views/components/mobile-menu.blade.php index 054f6e12..f1ae9c9e 100644 --- a/resources/views/components/mobile-menu.blade.php +++ b/resources/views/components/mobile-menu.blade.php @@ -180,6 +180,7 @@ class="flex flex-1 flex-col items-start text-xl" $isHomeActive = request()->routeIs('welcome*'); $isMobileActive = request()->routeIs('early-adopter*'); $isDocsActive = request()->is('docs*'); + $isBlogActive = request()->routeIs('blog*'); $isSponsorActive = request()->routeIs('sponsoring*'); @endphp @@ -264,6 +265,33 @@ class="gsap-mobile-menu-divider h-0.5 w-full rounded-full bg-current opacity-5" role="presentation" > + {{-- Blog Link --}} + + + + {{-- Sponsor Link --}} - {{-- - Link - request()->routeIs('blog*'), - 'opacity-60 hover:opacity-100' => ! request()->routeIs('blog*'), + 'transition duration-200', + 'font-medium' => request()->routeIs('blog*'), + 'opacity-60 hover:opacity-100' => ! request()->routeIs('blog*'), ]) aria-current="{{ request()->routeIs('blog*') ? 'page' : 'false' }}" - > + > Blog - - {{-- Decorative circle -- }} -
+ + {{-- Decorative circle --}} + - --}} + >
{{-- Link --}} name('docs')->where('page', '.*'); Route::get('/order/{checkoutSessionId}', App\Livewire\OrderSuccess::class)->name('order.success'); + +Route::view('blog', 'blog')->name('blog'); +Route::view('article', 'article')->name('article'); From 2234621acb41ccb863a710d4a4148900d01047bb Mon Sep 17 00:00:00 2001 From: gwleuverink Date: Tue, 8 Jul 2025 11:06:19 +0200 Subject: [PATCH 02/16] add article model & factory --- app/Models/Article.php | 27 ++++++++++++ database/factories/ArticleFactory.php | 38 +++++++++++++++++ ...025_07_08_083141_create_articles_table.php | 41 +++++++++++++++++++ 3 files changed, 106 insertions(+) create mode 100644 app/Models/Article.php create mode 100644 database/factories/ArticleFactory.php create mode 100644 database/migrations/2025_07_08_083141_create_articles_table.php diff --git a/app/Models/Article.php b/app/Models/Article.php new file mode 100644 index 00000000..64161ced --- /dev/null +++ b/app/Models/Article.php @@ -0,0 +1,27 @@ + 'datetime', + ]; + + public function scopePublished(Builder $query): void + { + $query->whereDate('published_at', '<=', now()); + } + + public function author(): BelongsTo + { + return $this->belongsTo(User::class, 'author_id'); + } +} diff --git a/database/factories/ArticleFactory.php b/database/factories/ArticleFactory.php new file mode 100644 index 00000000..a66b7ea1 --- /dev/null +++ b/database/factories/ArticleFactory.php @@ -0,0 +1,38 @@ + + */ +class ArticleFactory extends Factory +{ + public function definition(): array + { + return [ + 'author_id' => User::factory(), + 'slug' => fake()->unique()->slug(3), + 'title' => fake()->sentence(), + 'excerpt' => fake()->paragraph(1, false), + 'content' => implode(PHP_EOL.PHP_EOL, fake()->paragraphs()), + ]; + } + + public function published(): static + { + return $this->state(fn () => [ + 'published_at' => now(), + ]); + } + + public function scheduled(): static + { + return $this->state(fn () => [ + 'published_at' => now()->addMinute(), + ]); + } +} diff --git a/database/migrations/2025_07_08_083141_create_articles_table.php b/database/migrations/2025_07_08_083141_create_articles_table.php new file mode 100644 index 00000000..3dd5c910 --- /dev/null +++ b/database/migrations/2025_07_08_083141_create_articles_table.php @@ -0,0 +1,41 @@ +id(); + + $table->unsignedBigInteger('author_id') + ->nullable(); + + $table->foreign('author_id') + ->references('id')->on('users') + ->nullOnDelete(); + + $table->string('slug')->unique(); + $table->string('title', 255); + $table->string('excerpt', 400); + $table->text('content'); + + $table->timestamp('published_at')->default(now()); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('articles'); + } +}; From 975a805de33d3f952e5a58a436019c1b99e90af1 Mon Sep 17 00:00:00 2001 From: gwleuverink Date: Tue, 8 Jul 2025 11:52:20 +0200 Subject: [PATCH 03/16] wire up article listing & pagination --- resources/views/blog.blade.php | 82 +++++++++++++--------------------- routes/web.php | 8 +++- 2 files changed, 38 insertions(+), 52 deletions(-) diff --git a/resources/views/blog.blade.php b/resources/views/blog.blade.php index e9b0d5aa..0228b1c6 100644 --- a/resources/views/blog.blade.php +++ b/resources/views/blog.blade.php @@ -64,7 +64,7 @@ class="absolute -top-1/2 right-0 -z-20 h-60 w-60 rounded-full bg-violet-300/60 b {{-- Blurred circle - Decorative --}} @@ -105,37 +105,15 @@ class="sr-only" " class="flex flex-col gap-5" > - - 🎉 WE DID IT! We finally got to v1. I almost don't believe it! - This is an awesome milestone. For a project that started as just - an idea, to see it reach a truly stable place and support - building powerful apps across all major platforms is just - incredible. - - - Hey team, this is just a quick note about Laravel version - support. Per our Support Policy matrix, we will be dropping - Laravel 10 support for NativePHP for Desktop v1. Laravel 10 - reached end of life back in February 2025. - - - Earlier this week I spoke at the Laravel Worldwide Meetup where - I unveiled: 🌐 A brand new nativephp.com, lovingly (and - painstakingly!) crafted by the incredible - @HassanZahirnia - + @foreach ($articles as $article) + + {{ $article->excerpt }} + + @endforeach {{-- Pagination --}} @@ -164,15 +142,17 @@ class="flex items-center justify-between gap-5 pt-2.5" " class="will-change-transform" > - + @if (! $articles->onFirstPage()) + + @endif {{-- Next --}} @@ -196,15 +176,17 @@ class="inline-block p-1.5 opacity-60 transition duration-200 hover:opacity-100" " class="will-change-transform" > - + @if (! $articles->onLastPage()) + + @endif diff --git a/routes/web.php b/routes/web.php index 504168b0..9ac74fde 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,6 +1,7 @@ name('order.success'); -Route::view('blog', 'blog')->name('blog'); -Route::view('article', 'article')->name('article'); +Route::view('blog', 'blog', [ + 'articles' => Article::latest()->paginate(6), +])->name('blog'); + +Route::view('blog/{article}', 'article')->name('article'); From 7d5a86f0ad98e90cd328fb7694054d83e6a341a5 Mon Sep 17 00:00:00 2001 From: gwleuverink Date: Tue, 8 Jul 2025 12:48:12 +0200 Subject: [PATCH 04/16] wire up article detail --- app/Http/Controllers/ShowBlogController.php | 22 ++++++ app/Models/Article.php | 5 ++ resources/views/article.blade.php | 83 ++------------------- resources/views/blog.blade.php | 2 +- 4 files changed, 34 insertions(+), 78 deletions(-) create mode 100644 app/Http/Controllers/ShowBlogController.php diff --git a/app/Http/Controllers/ShowBlogController.php b/app/Http/Controllers/ShowBlogController.php new file mode 100644 index 00000000..c7e4aa90 --- /dev/null +++ b/app/Http/Controllers/ShowBlogController.php @@ -0,0 +1,22 @@ + Article::latest()->paginate(6), + ]); + } + + public function show(Article $article) + { + return view('article', [ + 'article' => $article, + ]); + } +} diff --git a/app/Models/Article.php b/app/Models/Article.php index 64161ced..6a367da2 100644 --- a/app/Models/Article.php +++ b/app/Models/Article.php @@ -15,6 +15,11 @@ class Article extends Model 'published_at' => 'datetime', ]; + public function getRouteKeyName() + { + return 'slug'; + } + public function scopePublished(Builder $query): void { $query->whereDate('published_at', '<=', now()); diff --git a/resources/views/article.blade.php b/resources/views/article.blade.php index 8143a0ce..d321866e 100644 --- a/resources/views/article.blade.php +++ b/resources/views/article.blade.php @@ -7,7 +7,7 @@ class="mx-auto mt-10 w-full max-w-3xl px-5 md:mt-14"
{{-- Blurred circle - Decorative --}} @@ -66,7 +66,7 @@ class="size-3 shrink-0 -scale-x-100" " class="mt-8 text-3xl font-extrabold will-change-transform sm:text-4xl" > - NativePHP for Desktop v1 is finally here! + {{ $article->title }} {{-- Date --}} @@ -82,7 +82,7 @@ class="size-5 shrink-0" datetime="2025-04-09" class="text-sm" > - April 9, 2025 + {{ $article->published_at->format('F j, Y') }}
@@ -106,7 +106,7 @@ class="text-sm" }) } " - class="flex items-center pb-3 pt-3.5 will-change-transform" + class="flex items-center pt-3.5 pb-3 will-change-transform" aria-hidden="true" >
- Lorem ipsum dolor sit amet consectetur adipisicing elit. Pariatur - facere dolore praesentium eius amet ex suscipit quam quibusdam - rerum, ratione, veritatis quidem, repudiandae ipsum in. Dolore - voluptatibus iusto saepe cum. Maiores tenetur nobis aliquid - recusandae hic, illo, aliquam laudantium aspernatur iste commodi - temporibus vero maxime, deserunt consequuntur fugiat animi dicta - debitis alias quos amet facere repellendus? Nesciunt, fugiat? Vel, - harum. Eos magnam, totam blanditiis nemo facilis culpa voluptate sed - dolores delectus alias velit, deleniti ex id quasi. Maiores - laboriosam repellendus vitae aliquam voluptas delectus deserunt - provident. Saepe, ullam. Error, ipsam. Facere dolore ullam - reprehenderit debitis et aperiam exercitationem numquam deserunt? - Temporibus asperiores exercitationem commodi vel? Autem, optio? - Tempora, dicta, pariatur dolores repudiandae corrupti beatae - voluptate dignissimos omnis consectetur ratione rerum. - Exercitationem eligendi, sint necessitatibus cumque voluptatum - corrupti incidunt inventore natus cupiditate, obcaecati nisi unde - nesciunt commodi! Eveniet itaque nihil ducimus repellendus et atque - laborum quos? Nostrum, aperiam aut. Ratione, earum! Voluptate - deleniti labore dolor quod nobis atque nam repellendus? Fugiat, - aliquam voluptatum quam cum, veniam mollitia, autem consequatur - officiis dolorem assumenda tempore. Distinctio officiis numquam - omnis quos aperiam minima voluptatibus? Magnam laborum nesciunt eos - qui sed repellendus tenetur harum, id, mollitia a provident - accusantium sint architecto, laudantium sit dolore quia. Vel impedit - quasi nam necessitatibus accusantium saepe praesentium laudantium - ut. Sit dolor voluptas dignissimos doloremque qui atque dolorum. - Aperiam eaque sapiente dicta nulla error laborum eius ex illum - harum, dolor quae illo praesentium ad hic at dolorem iusto - recusandae unde. Ipsa alias tenetur magni reprehenderit nam - consequuntur pariatur consequatur quas aspernatur cumque harum ullam - asperiores corporis distinctio consectetur dicta iusto, iure rem - quos nobis laboriosam eos nulla accusamus et. Similique. Consectetur - hic vel explicabo id assumenda, dolores quos neque asperiores ut, - aperiam a blanditiis est, ullam officia cum error eligendi delectus! - Cupiditate iusto est ad. Magni porro blanditiis quo delectus! - Possimus quis repellat aliquam, quia repudiandae deserunt ipsum - laudantium quaerat impedit veniam quibusdam rerum libero! Nam - laboriosam qui blanditiis nihil soluta, magnam ut fuga voluptatem - voluptatibus doloremque aut, aliquam velit? Soluta in esse dolorem - harum excepturi incidunt qui omnis quidem perferendis, alias culpa - ipsa quaerat delectus quam dolores nulla inventore dicta rerum enim - obcaecati. Officia esse nostrum voluptate mollitia corporis. Numquam - exercitationem fuga debitis soluta. Accusantium a voluptatum - reprehenderit, perferendis dolorum sequi ab consequatur molestiae - necessitatibus consequuntur asperiores expedita? Corporis vel fugiat - distinctio sint magnam eveniet facilis. Corrupti, quisquam - perferendis. Architecto recusandae dolores aspernatur eligendi - laborum iure libero sit vitae optio error cupiditate illo magnam - harum quam, porro debitis repellendus quis iusto nemo, atque nobis - aliquam consequatur? Quo, saepe repudiandae. Rem nostrum quos illo - eos cupiditate culpa eum dolorem debitis odit accusantium quibusdam - eligendi ea quisquam, nam beatae, nihil vitae mollitia totam laborum - necessitatibus veritatis porro error molestias. Exercitationem, - soluta. Eaque suscipit amet impedit illum hic rerum nesciunt. Totam - culpa, quia fuga at blanditiis dolorum rerum iusto ipsa quae - distinctio a placeat dolorem omnis praesentium libero obcaecati - molestiae porro aliquid. Accusantium itaque rerum nobis, quam, non - numquam animi qui cupiditate repellendus repellat veritatis pariatur - expedita debitis veniam beatae rem dicta vel vitae, eaque eos - placeat. Consequatur, facilis commodi. Soluta, incidunt. Beatae - nobis nesciunt quis reiciendis? Velit voluptatum et placeat - accusantium illo suscipit id dolorum cupiditate rem fugiat! Libero - esse, ad dolorum commodi officiis incidunt enim corrupti, fuga - beatae aspernatur expedita? Earum rerum laborum dolore architecto? - Culpa vitae at ipsum sapiente? Labore aliquid, dolor optio voluptas - mollitia recusandae quas sequi tempora corporis. Ipsa voluptate - fugiat omnis perferendis deserunt, itaque quos perspiciatis. - Asperiores explicabo dolore, molestiae, consequatur sint soluta - vitae quae iure reprehenderit hic officia aliquid omnis reiciendis - voluptatibus tempora provident veniam in magni eum et exercitationem - doloribus ullam. Neque, culpa temporibus. + {!! App\Support\CommonMark\CommonMark::convertToHtml($article->content) !!} diff --git a/resources/views/blog.blade.php b/resources/views/blog.blade.php index 0228b1c6..c88de472 100644 --- a/resources/views/blog.blade.php +++ b/resources/views/blog.blade.php @@ -109,7 +109,7 @@ class="flex flex-col gap-5" {{ $article->excerpt }} From 802ebbea95a6a6baecc453f70e0ea9f6621b2d0c Mon Sep 17 00:00:00 2001 From: gwleuverink Date: Tue, 8 Jul 2025 13:18:14 +0200 Subject: [PATCH 05/16] add published_at guards --- app/Http/Controllers/ShowBlogController.php | 8 +++++++- app/Models/Article.php | 4 +++- routes/web.php | 13 ++++--------- 3 files changed, 14 insertions(+), 11 deletions(-) diff --git a/app/Http/Controllers/ShowBlogController.php b/app/Http/Controllers/ShowBlogController.php index c7e4aa90..dd470b18 100644 --- a/app/Http/Controllers/ShowBlogController.php +++ b/app/Http/Controllers/ShowBlogController.php @@ -8,13 +8,19 @@ class ShowBlogController extends Controller { public function index() { + $articles = Article::query() + ->published() + ->paginate(6); + return view('blog', [ - 'articles' => Article::latest()->paginate(6), + 'articles' => $articles, ]); } public function show(Article $article) { + abort_if($article->published_at->isFuture(), 404); + return view('article', [ 'article' => $article, ]); diff --git a/app/Models/Article.php b/app/Models/Article.php index 6a367da2..b237ce88 100644 --- a/app/Models/Article.php +++ b/app/Models/Article.php @@ -22,7 +22,9 @@ public function getRouteKeyName() public function scopePublished(Builder $query): void { - $query->whereDate('published_at', '<=', now()); + $query + ->orderByDesc('published_at') + ->where('published_at', '<=', now()); } public function author(): BelongsTo diff --git a/routes/web.php b/routes/web.php index 9ac74fde..d177faac 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,7 +1,7 @@ name('welcome'); -Route::view('/blog', 'blog')->name('blog'); -Route::view('/article', 'article')->name('article'); Route::view('mobile', 'early-adopter')->name('early-adopter'); Route::view('laracon-us-2025-giveaway', 'laracon-us-2025-giveaway')->name('laracon-us-2025-giveaway'); Route::redirect('ios', 'mobile'); @@ -34,6 +32,9 @@ Route::view('partners', 'partners')->name('partners'); Route::view('sponsor', 'sponsoring')->name('sponsoring'); +Route::get('blog', [ShowBlogController::class, 'index'])->name('blog'); +Route::get('blog/{article}', [ShowBlogController::class, 'show'])->name('article'); + Route::redirect('/docs/{version}/{page?}', '/docs/mobile/{version}/{page?}') ->where('page', '(.*)') ->where('version', '[0-9]+'); @@ -62,9 +63,3 @@ })->name('docs')->where('page', '.*'); Route::get('/order/{checkoutSessionId}', App\Livewire\OrderSuccess::class)->name('order.success'); - -Route::view('blog', 'blog', [ - 'articles' => Article::latest()->paginate(6), -])->name('blog'); - -Route::view('blog/{article}', 'article')->name('article'); From 7bf7b15594b154e98b1f903fd3993ffb3b2f1af6 Mon Sep 17 00:00:00 2001 From: gwleuverink Date: Tue, 8 Jul 2025 13:18:43 +0200 Subject: [PATCH 06/16] add article scheduling tests --- tests/Feature/BlogTest.php | 77 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 tests/Feature/BlogTest.php diff --git a/tests/Feature/BlogTest.php b/tests/Feature/BlogTest.php new file mode 100644 index 00000000..583c4cde --- /dev/null +++ b/tests/Feature/BlogTest.php @@ -0,0 +1,77 @@ +published()->create(); + + $this->get(route('blog')) + ->assertOk() + ->assertSee($article->title) + ->assertSee(route('article', $article)); + } + + #[Test] + public function published_articles_are_shown_in_antichronological_order() + { + [$article1, $article2, $article3] = [ + Article::factory()->create([ + 'published_at' => now()->subDays(2), + ]), + Article::factory()->create([ + 'published_at' => now()->subDays(1), + ]), + Article::factory()->create([ + 'published_at' => now()->subDays(3), + ]), + ]; + + $this->get(route('blog')) + ->assertOk() + ->assertSeeInOrder([ + $article2->title, + $article1->title, + $article3->title, + ]); + } + + #[Test] + public function scheduled_articles_are_not_shown_on_the_blog_listing() + { + $article = Article::factory()->scheduled()->create(); + + $this->get(route('blog')) + ->assertOk() + ->assertDontSee($article->title) + ->assertDontSee(route('article', $article)); + } + + #[Test] + public function published_articles_are_visitable() + { + $article = Article::factory()->published()->create(); + + $this->get(route('article', $article)) + ->assertOk(); + } + + #[Test] + public function scheduled_articles_return_a_404() + { + $article = Article::factory()->scheduled()->create(); + + $this->get(route('article', $article)) + ->assertStatus(404); + } +} From 9136db1741341ca85b208716a80a7f30b10f02ba Mon Sep 17 00:00:00 2001 From: gwleuverink Date: Tue, 8 Jul 2025 13:36:51 +0200 Subject: [PATCH 07/16] wip - tidy scope --- app/Models/Article.php | 1 + database/migrations/2025_07_08_083141_create_articles_table.php | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/app/Models/Article.php b/app/Models/Article.php index b237ce88..eeda912e 100644 --- a/app/Models/Article.php +++ b/app/Models/Article.php @@ -24,6 +24,7 @@ public function scopePublished(Builder $query): void { $query ->orderByDesc('published_at') + ->whereNotNull('published_at') ->where('published_at', '<=', now()); } diff --git a/database/migrations/2025_07_08_083141_create_articles_table.php b/database/migrations/2025_07_08_083141_create_articles_table.php index 3dd5c910..efa70e77 100644 --- a/database/migrations/2025_07_08_083141_create_articles_table.php +++ b/database/migrations/2025_07_08_083141_create_articles_table.php @@ -26,7 +26,7 @@ public function up(): void $table->string('excerpt', 400); $table->text('content'); - $table->timestamp('published_at')->default(now()); + $table->timestamp('published_at')->nullable(); $table->timestamps(); }); } From 9b62780506604ed5210ef77a3c286d6612fbf7c7 Mon Sep 17 00:00:00 2001 From: gwleuverink Date: Tue, 8 Jul 2025 14:40:38 +0200 Subject: [PATCH 08/16] add filament resource --- app/Filament/Resources/ArticleResource.php | 115 ++++++++++++++++++ .../ArticleResource/Pages/CreateArticle.php | 16 +++ .../ArticleResource/Pages/EditArticle.php | 19 +++ .../ArticleResource/Pages/ListArticles.php | 19 +++ app/Models/Article.php | 19 +++ 5 files changed, 188 insertions(+) create mode 100644 app/Filament/Resources/ArticleResource.php create mode 100644 app/Filament/Resources/ArticleResource/Pages/CreateArticle.php create mode 100644 app/Filament/Resources/ArticleResource/Pages/EditArticle.php create mode 100644 app/Filament/Resources/ArticleResource/Pages/ListArticles.php diff --git a/app/Filament/Resources/ArticleResource.php b/app/Filament/Resources/ArticleResource.php new file mode 100644 index 00000000..345b68d9 --- /dev/null +++ b/app/Filament/Resources/ArticleResource.php @@ -0,0 +1,115 @@ +schema([ + TextInput::make('title') + ->required() + ->maxLength(255), + + TextInput::make('slug') + ->required() + ->maxLength(255) + ->live(onBlur: true) + ->unique(Article::class, 'slug', ignoreRecord: true) + ->afterStateUpdated(fn (Set $set, ?string $state) => $set('slug', Str::slug($state))), + + DateTimePicker::make('published_at') + ->label('Published At') + ->displayFormat('M j, Y H:i') + ->seconds(false) + ->dehydrated() + ->reactive() + ->default(now()), + + Textarea::make('excerpt') + ->required() + ->maxLength(400) + ->columnSpanFull(), + + MarkdownEditor::make('content') + ->required() + ->columnSpanFull(), + ]); + } + + public static function table(Table $table): Table + { + return $table + ->columns([ + TextColumn::make('title') + ->searchable() + ->sortable(), + + TextColumn::make('excerpt') + ->searchable() + ->limit(50), + + TextColumn::make('author.name') + ->label('Author') + ->searchable() + ->sortable(), + TextColumn::make('published_at') + ->dateTime('M j, Y H:i') + ->sortable() + ->badge() + ->color(fn ($state) => $state && $state->isPast() ? 'success' : 'warning'), + ]) + ->filters([ + // + ]) + ->actions([ + Tables\Actions\EditAction::make() + ->url(fn ($record) => static::getUrl('edit', ['record' => $record->id])), + ]) + ->bulkActions([ + Tables\Actions\BulkActionGroup::make([ + Tables\Actions\DeleteBulkAction::make(), + ]), + ]) + ->defaultSort('published_at', 'desc'); + } + + public static function getRelations(): array + { + return [ + // + ]; + } + + public static function getPages(): array + { + return [ + 'index' => Pages\ListArticles::route('/'), + 'create' => Pages\CreateArticle::route('/create'), + 'edit' => Pages\EditArticle::route('/{record}/edit'), + ]; + } +} diff --git a/app/Filament/Resources/ArticleResource/Pages/CreateArticle.php b/app/Filament/Resources/ArticleResource/Pages/CreateArticle.php new file mode 100644 index 00000000..6c4b0ae4 --- /dev/null +++ b/app/Filament/Resources/ArticleResource/Pages/CreateArticle.php @@ -0,0 +1,16 @@ + $this->record->id]); + } +} diff --git a/app/Filament/Resources/ArticleResource/Pages/EditArticle.php b/app/Filament/Resources/ArticleResource/Pages/EditArticle.php new file mode 100644 index 00000000..2484e4b4 --- /dev/null +++ b/app/Filament/Resources/ArticleResource/Pages/EditArticle.php @@ -0,0 +1,19 @@ + 'datetime', ]; @@ -32,4 +40,15 @@ public function author(): BelongsTo { return $this->belongsTo(User::class, 'author_id'); } + + protected static function boot() + { + parent::boot(); + + static::creating(function ($article) { + if (auth()->check() && ! $article->author_id) { + $article->author_id = auth()->id(); + } + }); + } } From 007090bfbe3edaf926db5a8dc399a6812ec04523 Mon Sep 17 00:00:00 2001 From: gwleuverink Date: Tue, 8 Jul 2025 20:43:09 +0200 Subject: [PATCH 09/16] add dark mode styles for prose elements --- resources/css/app.css | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/resources/css/app.css b/resources/css/app.css index 990f833f..affa26d8 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -164,6 +164,7 @@ nav.docs-navigation > ul > li > ul { } } +/* Prose */ .prose h1 { @apply text-2xl; } @@ -241,3 +242,18 @@ nav.docs-navigation > ul > li > ul { .prose pre code.torchlight .summary-caret { @apply mr-4; } + +/* + Prose dark mode +*/ +.dark .prose strong { + @apply text-gray-300; +} + +.dark .prose blockquote { + @apply text-gray-300; +} + +.dark .prose code { + @apply text-gray-300; +} From 00f083dd42ff4d3940c9b79b05f8b5e2c9d303b0 Mon Sep 17 00:00:00 2001 From: gwleuverink Date: Tue, 8 Jul 2025 21:45:47 +0200 Subject: [PATCH 10/16] add publish & schedule actions --- app/Filament/Resources/ArticleResource.php | 20 +++++----- .../ArticleResource/Actions/PublishAction.php | 19 ++++++++++ .../Actions/ScheduleAction.php | 31 ++++++++++++++++ app/Http/Controllers/ShowBlogController.php | 2 +- app/Models/Article.php | 37 +++++++++++++++++++ 5 files changed, 97 insertions(+), 12 deletions(-) create mode 100644 app/Filament/Resources/ArticleResource/Actions/PublishAction.php create mode 100644 app/Filament/Resources/ArticleResource/Actions/ScheduleAction.php diff --git a/app/Filament/Resources/ArticleResource.php b/app/Filament/Resources/ArticleResource.php index 345b68d9..d3805cef 100644 --- a/app/Filament/Resources/ArticleResource.php +++ b/app/Filament/Resources/ArticleResource.php @@ -2,9 +2,10 @@ namespace App\Filament\Resources; +use App\Filament\Resources\ArticleResource\Actions\PublishAction; +use App\Filament\Resources\ArticleResource\Actions\ScheduleAction; use App\Filament\Resources\ArticleResource\Pages; use App\Models\Article; -use Filament\Forms\Components\DateTimePicker; use Filament\Forms\Components\MarkdownEditor; use Filament\Forms\Components\Textarea; use Filament\Forms\Components\TextInput; @@ -12,6 +13,7 @@ use Filament\Forms\Set; use Filament\Resources\Resource; use Filament\Tables; +use Filament\Tables\Actions\ActionGroup; use Filament\Tables\Columns\TextColumn; use Filament\Tables\Table; use Illuminate\Support\Str; @@ -41,14 +43,6 @@ public static function form(Form $form): Form ->unique(Article::class, 'slug', ignoreRecord: true) ->afterStateUpdated(fn (Set $set, ?string $state) => $set('slug', Str::slug($state))), - DateTimePicker::make('published_at') - ->label('Published At') - ->displayFormat('M j, Y H:i') - ->seconds(false) - ->dehydrated() - ->reactive() - ->default(now()), - Textarea::make('excerpt') ->required() ->maxLength(400) @@ -76,6 +70,7 @@ public static function table(Table $table): Table ->label('Author') ->searchable() ->sortable(), + TextColumn::make('published_at') ->dateTime('M j, Y H:i') ->sortable() @@ -86,8 +81,11 @@ public static function table(Table $table): Table // ]) ->actions([ - Tables\Actions\EditAction::make() - ->url(fn ($record) => static::getUrl('edit', ['record' => $record->id])), + ActionGroup::make([ + Tables\Actions\EditAction::make()->url(fn ($record) => static::getUrl('edit', ['record' => $record->id])), + PublishAction::make('publish'), + ScheduleAction::make('schedule'), + ]), ]) ->bulkActions([ Tables\Actions\BulkActionGroup::make([ diff --git a/app/Filament/Resources/ArticleResource/Actions/PublishAction.php b/app/Filament/Resources/ArticleResource/Actions/PublishAction.php new file mode 100644 index 00000000..5396af61 --- /dev/null +++ b/app/Filament/Resources/ArticleResource/Actions/PublishAction.php @@ -0,0 +1,19 @@ +label('Publish') + ->icon('heroicon-o-newspaper') + ->action(fn (Article $article) => $article->publish()) + ->visible(fn (Article $article) => ! $article->isPublished()) + ->requiresConfirmation(); + } +} diff --git a/app/Filament/Resources/ArticleResource/Actions/ScheduleAction.php b/app/Filament/Resources/ArticleResource/Actions/ScheduleAction.php new file mode 100644 index 00000000..1f15617f --- /dev/null +++ b/app/Filament/Resources/ArticleResource/Actions/ScheduleAction.php @@ -0,0 +1,31 @@ +label('Schedule') + ->icon('heroicon-o-calendar-days') + ->visible(fn (Article $record) => ! $record->isPublished()) + ->form(fn (Article $article) => [ + DateTimePicker::make('published_at') + ->label('Published At') + ->displayFormat('M j, Y H:i') + ->seconds(false) + ->default($article->published_at) + ->required(), + ]) + ->action(function (Article $article, array $data) { + $article->publish(Carbon::parse($data['published_at'])); + }) + ->requiresConfirmation(); + } +} diff --git a/app/Http/Controllers/ShowBlogController.php b/app/Http/Controllers/ShowBlogController.php index dd470b18..8651c4ef 100644 --- a/app/Http/Controllers/ShowBlogController.php +++ b/app/Http/Controllers/ShowBlogController.php @@ -19,7 +19,7 @@ public function index() public function show(Article $article) { - abort_if($article->published_at->isFuture(), 404); + abort_if(! $article->isPublished(), 404); return view('article', [ 'article' => $article, diff --git a/app/Models/Article.php b/app/Models/Article.php index 4d0e514b..2f0c2277 100644 --- a/app/Models/Article.php +++ b/app/Models/Article.php @@ -2,6 +2,7 @@ namespace App\Models; +use DateTime; use Illuminate\Contracts\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; @@ -28,6 +29,11 @@ public function getRouteKeyName() return 'slug'; } + /* + |-------------------------------------------------------------------------- + | Scopes + |-------------------------------------------------------------------------- + */ public function scopePublished(Builder $query): void { $query @@ -36,11 +42,42 @@ public function scopePublished(Builder $query): void ->where('published_at', '<=', now()); } + /* + |-------------------------------------------------------------------------- + | Relationships + |-------------------------------------------------------------------------- + */ public function author(): BelongsTo { return $this->belongsTo(User::class, 'author_id'); } + /* + |-------------------------------------------------------------------------- + | Support + |-------------------------------------------------------------------------- + */ + public function isPublished(): bool + { + return $this->published_at && $this->published_at->isPast(); + } + + public function publish(?DateTime $on = null) + { + if (! $on) { + $on = now(); + } + + $this->update([ + 'published_at' => $on, + ]); + } + + /* + |-------------------------------------------------------------------------- + | Listeners + |-------------------------------------------------------------------------- + */ protected static function boot() { parent::boot(); From f0efe11ea2923b3fc18d357ad5e1396ecaa4bd31 Mon Sep 17 00:00:00 2001 From: gwleuverink Date: Tue, 8 Jul 2025 21:53:32 +0200 Subject: [PATCH 11/16] tidy - improve filament article sorting --- app/Filament/Resources/ArticleResource.php | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/app/Filament/Resources/ArticleResource.php b/app/Filament/Resources/ArticleResource.php index d3805cef..7a8e4b0d 100644 --- a/app/Filament/Resources/ArticleResource.php +++ b/app/Filament/Resources/ArticleResource.php @@ -16,6 +16,7 @@ use Filament\Tables\Actions\ActionGroup; use Filament\Tables\Columns\TextColumn; use Filament\Tables\Table; +use Illuminate\Contracts\Database\Eloquent\Builder; use Illuminate\Support\Str; class ArticleResource extends Resource @@ -72,10 +73,12 @@ public static function table(Table $table): Table ->sortable(), TextColumn::make('published_at') - ->dateTime('M j, Y H:i') - ->sortable() ->badge() - ->color(fn ($state) => $state && $state->isPast() ? 'success' : 'warning'), + ->dateTime('M j, Y H:i') + ->color(fn ($state) => $state && $state->isPast() ? 'success' : 'warning') + ->sortable(query: function (Builder $query, string $direction): Builder { + return $query->orderByRaw("published_at IS NULL {$direction}, published_at {$direction}"); + }), ]) ->filters([ // From daa961cd70da88851c9a1fb4484fa774cbe7b5d8 Mon Sep 17 00:00:00 2001 From: gwleuverink Date: Tue, 8 Jul 2025 21:56:45 +0200 Subject: [PATCH 12/16] wip - validation --- .../Resources/ArticleResource/Actions/ScheduleAction.php | 1 + 1 file changed, 1 insertion(+) diff --git a/app/Filament/Resources/ArticleResource/Actions/ScheduleAction.php b/app/Filament/Resources/ArticleResource/Actions/ScheduleAction.php index 1f15617f..307862ed 100644 --- a/app/Filament/Resources/ArticleResource/Actions/ScheduleAction.php +++ b/app/Filament/Resources/ArticleResource/Actions/ScheduleAction.php @@ -21,6 +21,7 @@ protected function setUp(): void ->displayFormat('M j, Y H:i') ->seconds(false) ->default($article->published_at) + ->afterOrEqual('now') ->required(), ]) ->action(function (Article $article, array $data) { From beb8a4dfcd1f151e5e9d460b17a3e6711f2b01c9 Mon Sep 17 00:00:00 2001 From: gwleuverink Date: Tue, 8 Jul 2025 22:03:34 +0200 Subject: [PATCH 13/16] add unpublish action --- app/Filament/Resources/ArticleResource.php | 2 ++ .../Actions/UnpublishAction.php | 19 +++++++++++++++++++ app/Models/Article.php | 7 +++++++ 3 files changed, 28 insertions(+) create mode 100644 app/Filament/Resources/ArticleResource/Actions/UnpublishAction.php diff --git a/app/Filament/Resources/ArticleResource.php b/app/Filament/Resources/ArticleResource.php index 7a8e4b0d..b1734cc8 100644 --- a/app/Filament/Resources/ArticleResource.php +++ b/app/Filament/Resources/ArticleResource.php @@ -4,6 +4,7 @@ use App\Filament\Resources\ArticleResource\Actions\PublishAction; use App\Filament\Resources\ArticleResource\Actions\ScheduleAction; +use App\Filament\Resources\ArticleResource\Actions\UnpublishAction; use App\Filament\Resources\ArticleResource\Pages; use App\Models\Article; use Filament\Forms\Components\MarkdownEditor; @@ -86,6 +87,7 @@ public static function table(Table $table): Table ->actions([ ActionGroup::make([ Tables\Actions\EditAction::make()->url(fn ($record) => static::getUrl('edit', ['record' => $record->id])), + UnpublishAction::make('unpublish'), PublishAction::make('publish'), ScheduleAction::make('schedule'), ]), diff --git a/app/Filament/Resources/ArticleResource/Actions/UnpublishAction.php b/app/Filament/Resources/ArticleResource/Actions/UnpublishAction.php new file mode 100644 index 00000000..9e094052 --- /dev/null +++ b/app/Filament/Resources/ArticleResource/Actions/UnpublishAction.php @@ -0,0 +1,19 @@ +label('Unpublish') + ->icon('heroicon-o-archive-box-x-mark') + ->action(fn (Article $article) => $article->unpublish()) + ->visible(fn (Article $article) => $article->isPublished()) + ->requiresConfirmation(); + } +} diff --git a/app/Models/Article.php b/app/Models/Article.php index 2f0c2277..224d39ce 100644 --- a/app/Models/Article.php +++ b/app/Models/Article.php @@ -73,6 +73,13 @@ public function publish(?DateTime $on = null) ]); } + public function unpublish() + { + $this->update([ + 'published_at' => null, + ]); + } + /* |-------------------------------------------------------------------------- | Listeners From 53c3927116efc4a13bdb37606bbb95ba7b823e7e Mon Sep 17 00:00:00 2001 From: gwleuverink Date: Tue, 8 Jul 2025 22:23:52 +0200 Subject: [PATCH 14/16] add preview action and update user admin check --- app/Filament/Resources/ArticleResource.php | 2 ++ .../ArticleResource/Actions/PreviewAction.php | 18 ++++++++++++++++++ app/Http/Controllers/ShowBlogController.php | 2 +- app/Models/User.php | 5 +++++ 4 files changed, 26 insertions(+), 1 deletion(-) create mode 100644 app/Filament/Resources/ArticleResource/Actions/PreviewAction.php diff --git a/app/Filament/Resources/ArticleResource.php b/app/Filament/Resources/ArticleResource.php index b1734cc8..d150ceaa 100644 --- a/app/Filament/Resources/ArticleResource.php +++ b/app/Filament/Resources/ArticleResource.php @@ -2,6 +2,7 @@ namespace App\Filament\Resources; +use App\Filament\Resources\ArticleResource\Actions\PreviewAction; use App\Filament\Resources\ArticleResource\Actions\PublishAction; use App\Filament\Resources\ArticleResource\Actions\ScheduleAction; use App\Filament\Resources\ArticleResource\Actions\UnpublishAction; @@ -86,6 +87,7 @@ public static function table(Table $table): Table ]) ->actions([ ActionGroup::make([ + PreviewAction::make('preview'), Tables\Actions\EditAction::make()->url(fn ($record) => static::getUrl('edit', ['record' => $record->id])), UnpublishAction::make('unpublish'), PublishAction::make('publish'), diff --git a/app/Filament/Resources/ArticleResource/Actions/PreviewAction.php b/app/Filament/Resources/ArticleResource/Actions/PreviewAction.php new file mode 100644 index 00000000..79bbf28b --- /dev/null +++ b/app/Filament/Resources/ArticleResource/Actions/PreviewAction.php @@ -0,0 +1,18 @@ +label('Preview') + ->icon('heroicon-o-eye') + ->url(fn (Article $article) => route('article', $article)) + ->openUrlInNewTab(); + } +} diff --git a/app/Http/Controllers/ShowBlogController.php b/app/Http/Controllers/ShowBlogController.php index 8651c4ef..1f0f1d39 100644 --- a/app/Http/Controllers/ShowBlogController.php +++ b/app/Http/Controllers/ShowBlogController.php @@ -19,7 +19,7 @@ public function index() public function show(Article $article) { - abort_if(! $article->isPublished(), 404); + abort_unless($article->isPublished() || auth()->user()?->isAdmin(), 404); return view('article', [ 'article' => $article, diff --git a/app/Models/User.php b/app/Models/User.php index 238d777d..3fa763d4 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -29,6 +29,11 @@ class User extends Authenticatable implements FilamentUser ]; public function canAccessPanel(Panel $panel): bool + { + return $this->isAdmin(); + } + + public function isAdmin(): bool { return in_array($this->email, config('filament.users'), true); } From 1afbdf9fafbc1bab919ad542e68a6036d7f49452 Mon Sep 17 00:00:00 2001 From: gwleuverink Date: Tue, 8 Jul 2025 22:37:33 +0200 Subject: [PATCH 15/16] add article preview tests --- resources/views/article.blade.php | 2 +- tests/Feature/BlogTest.php | 37 +++++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/resources/views/article.blade.php b/resources/views/article.blade.php index d321866e..3b917c5b 100644 --- a/resources/views/article.blade.php +++ b/resources/views/article.blade.php @@ -82,7 +82,7 @@ class="size-5 shrink-0" datetime="2025-04-09" class="text-sm" > - {{ $article->published_at->format('F j, Y') }} + {{ $article->published_at?->format('F j, Y') }}
diff --git a/tests/Feature/BlogTest.php b/tests/Feature/BlogTest.php index 583c4cde..5ea450fa 100644 --- a/tests/Feature/BlogTest.php +++ b/tests/Feature/BlogTest.php @@ -3,7 +3,9 @@ namespace Tests\Feature; use App\Models\Article; +use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Facades\Config; use PHPUnit\Framework\Attributes\Test; use Tests\TestCase; @@ -74,4 +76,39 @@ public function scheduled_articles_return_a_404() $this->get(route('article', $article)) ->assertStatus(404); } + + #[Test] + public function articles_can_be_previewed_by_admin_users() + { + $article = Article::factory()->create([ + 'published_at' => null, + ]); + + $admin = User::factory()->create(); + Config::set('filament.users', [$admin->email]); + + // Visitors + $this->get(route('article', $article)) + ->assertStatus(404); + + // Admins + $this->actingAs($admin) + ->get(route('article', $article)) + ->assertOk(); + } + + #[Test] + public function articles_cant_be_previewed_by_regular_users() + { + $article = Article::factory()->create([ + 'published_at' => null, + ]); + + $user = User::factory()->create(); + + // Non-admin users + $this->actingAs($user) + ->get(route('article', $article)) + ->assertStatus(404); + } } From 4f50cf929c0342149e00e3feae290f34db0b8f06 Mon Sep 17 00:00:00 2001 From: gwleuverink Date: Tue, 8 Jul 2025 23:07:15 +0200 Subject: [PATCH 16/16] update auto-slug logic --- app/Filament/Resources/ArticleResource.php | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/app/Filament/Resources/ArticleResource.php b/app/Filament/Resources/ArticleResource.php index d150ceaa..793ceb7c 100644 --- a/app/Filament/Resources/ArticleResource.php +++ b/app/Filament/Resources/ArticleResource.php @@ -37,14 +37,29 @@ public static function form(Form $form): Form ->schema([ TextInput::make('title') ->required() - ->maxLength(255), + ->maxLength(255) + ->live(onBlur: true) + ->afterStateUpdated(function (Article $article, Set $set, ?string $state) { + if ($article->isPublished()) { + return; + } + + $set('slug', Str::slug($state)); + }), TextInput::make('slug') ->required() ->maxLength(255) ->live(onBlur: true) ->unique(Article::class, 'slug', ignoreRecord: true) - ->afterStateUpdated(fn (Set $set, ?string $state) => $set('slug', Str::slug($state))), + ->disabled(fn (Article $article) => $article->isPublished()) + ->afterStateUpdated( + fn (Set $set, ?string $state) => $set('slug', Str::slug($state)) + ) + ->helperText(fn (Article $article) => $article->isPublished() + ? 'The slug cannot be changed after the article is published.' + : false + ), Textarea::make('excerpt') ->required()