diff --git a/app/Filament/Resources/ArticleResource.php b/app/Filament/Resources/ArticleResource.php new file mode 100644 index 00000000..793ceb7c --- /dev/null +++ b/app/Filament/Resources/ArticleResource.php @@ -0,0 +1,135 @@ +schema([ + TextInput::make('title') + ->required() + ->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) + ->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() + ->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') + ->badge() + ->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([ + // + ]) + ->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'), + ScheduleAction::make('schedule'), + ]), + ]) + ->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/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/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..307862ed --- /dev/null +++ b/app/Filament/Resources/ArticleResource/Actions/ScheduleAction.php @@ -0,0 +1,32 @@ +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) + ->afterOrEqual('now') + ->required(), + ]) + ->action(function (Article $article, array $data) { + $article->publish(Carbon::parse($data['published_at'])); + }) + ->requiresConfirmation(); + } +} 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/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 @@ +published() + ->paginate(6); + + return view('blog', [ + 'articles' => $articles, + ]); + } + + public function show(Article $article) + { + abort_unless($article->isPublished() || auth()->user()?->isAdmin(), 404); + + return view('article', [ + 'article' => $article, + ]); + } +} diff --git a/app/Models/Article.php b/app/Models/Article.php new file mode 100644 index 00000000..224d39ce --- /dev/null +++ b/app/Models/Article.php @@ -0,0 +1,98 @@ + 'datetime', + ]; + + public function getRouteKeyName() + { + return 'slug'; + } + + /* + |-------------------------------------------------------------------------- + | Scopes + |-------------------------------------------------------------------------- + */ + public function scopePublished(Builder $query): void + { + $query + ->orderByDesc('published_at') + ->whereNotNull('published_at') + ->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, + ]); + } + + public function unpublish() + { + $this->update([ + 'published_at' => null, + ]); + } + + /* + |-------------------------------------------------------------------------- + | Listeners + |-------------------------------------------------------------------------- + */ + protected static function boot() + { + parent::boot(); + + static::creating(function ($article) { + if (auth()->check() && ! $article->author_id) { + $article->author_id = auth()->id(); + } + }); + } +} 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); } 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..efa70e77 --- /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')->nullable(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('articles'); + } +}; 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; +} diff --git a/resources/views/article.blade.php b/resources/views/article.blade.php index 8143a0ce..3b917c5b 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 e9b0d5aa..c88de472 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/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('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'); @@ -33,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]+'); diff --git a/tests/Feature/BlogTest.php b/tests/Feature/BlogTest.php new file mode 100644 index 00000000..5ea450fa --- /dev/null +++ b/tests/Feature/BlogTest.php @@ -0,0 +1,114 @@ +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); + } + + #[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); + } +}