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"