Skip to content

Commit cc081fd

Browse files
authored
Add devblog (#179)
* add blog to menu's * add article model & factory * wire up article listing & pagination * wire up article detail * add published_at guards * add article scheduling tests * wip - tidy scope * add filament resource * add dark mode styles for prose elements * add publish & schedule actions * tidy - improve filament article sorting * wip - validation * add unpublish action * add preview action and update user admin check * add article preview tests * update auto-slug logic
1 parent 60228fe commit cc081fd

File tree

20 files changed

+699
-142
lines changed

20 files changed

+699
-142
lines changed
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
<?php
2+
3+
namespace App\Filament\Resources;
4+
5+
use App\Filament\Resources\ArticleResource\Actions\PreviewAction;
6+
use App\Filament\Resources\ArticleResource\Actions\PublishAction;
7+
use App\Filament\Resources\ArticleResource\Actions\ScheduleAction;
8+
use App\Filament\Resources\ArticleResource\Actions\UnpublishAction;
9+
use App\Filament\Resources\ArticleResource\Pages;
10+
use App\Models\Article;
11+
use Filament\Forms\Components\MarkdownEditor;
12+
use Filament\Forms\Components\Textarea;
13+
use Filament\Forms\Components\TextInput;
14+
use Filament\Forms\Form;
15+
use Filament\Forms\Set;
16+
use Filament\Resources\Resource;
17+
use Filament\Tables;
18+
use Filament\Tables\Actions\ActionGroup;
19+
use Filament\Tables\Columns\TextColumn;
20+
use Filament\Tables\Table;
21+
use Illuminate\Contracts\Database\Eloquent\Builder;
22+
use Illuminate\Support\Str;
23+
24+
class ArticleResource extends Resource
25+
{
26+
protected static ?string $model = Article::class;
27+
28+
protected static ?string $recordRouteKeyName = 'id';
29+
30+
protected static ?string $recordTitleAttribute = 'title';
31+
32+
protected static ?string $navigationIcon = 'heroicon-o-newspaper';
33+
34+
public static function form(Form $form): Form
35+
{
36+
return $form
37+
->schema([
38+
TextInput::make('title')
39+
->required()
40+
->maxLength(255)
41+
->live(onBlur: true)
42+
->afterStateUpdated(function (Article $article, Set $set, ?string $state) {
43+
if ($article->isPublished()) {
44+
return;
45+
}
46+
47+
$set('slug', Str::slug($state));
48+
}),
49+
50+
TextInput::make('slug')
51+
->required()
52+
->maxLength(255)
53+
->live(onBlur: true)
54+
->unique(Article::class, 'slug', ignoreRecord: true)
55+
->disabled(fn (Article $article) => $article->isPublished())
56+
->afterStateUpdated(
57+
fn (Set $set, ?string $state) => $set('slug', Str::slug($state))
58+
)
59+
->helperText(fn (Article $article) => $article->isPublished()
60+
? 'The slug cannot be changed after the article is published.'
61+
: false
62+
),
63+
64+
Textarea::make('excerpt')
65+
->required()
66+
->maxLength(400)
67+
->columnSpanFull(),
68+
69+
MarkdownEditor::make('content')
70+
->required()
71+
->columnSpanFull(),
72+
]);
73+
}
74+
75+
public static function table(Table $table): Table
76+
{
77+
return $table
78+
->columns([
79+
TextColumn::make('title')
80+
->searchable()
81+
->sortable(),
82+
83+
TextColumn::make('excerpt')
84+
->searchable()
85+
->limit(50),
86+
87+
TextColumn::make('author.name')
88+
->label('Author')
89+
->searchable()
90+
->sortable(),
91+
92+
TextColumn::make('published_at')
93+
->badge()
94+
->dateTime('M j, Y H:i')
95+
->color(fn ($state) => $state && $state->isPast() ? 'success' : 'warning')
96+
->sortable(query: function (Builder $query, string $direction): Builder {
97+
return $query->orderByRaw("published_at IS NULL {$direction}, published_at {$direction}");
98+
}),
99+
])
100+
->filters([
101+
//
102+
])
103+
->actions([
104+
ActionGroup::make([
105+
PreviewAction::make('preview'),
106+
Tables\Actions\EditAction::make()->url(fn ($record) => static::getUrl('edit', ['record' => $record->id])),
107+
UnpublishAction::make('unpublish'),
108+
PublishAction::make('publish'),
109+
ScheduleAction::make('schedule'),
110+
]),
111+
])
112+
->bulkActions([
113+
Tables\Actions\BulkActionGroup::make([
114+
Tables\Actions\DeleteBulkAction::make(),
115+
]),
116+
])
117+
->defaultSort('published_at', 'desc');
118+
}
119+
120+
public static function getRelations(): array
121+
{
122+
return [
123+
//
124+
];
125+
}
126+
127+
public static function getPages(): array
128+
{
129+
return [
130+
'index' => Pages\ListArticles::route('/'),
131+
'create' => Pages\CreateArticle::route('/create'),
132+
'edit' => Pages\EditArticle::route('/{record}/edit'),
133+
];
134+
}
135+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
<?php
2+
3+
namespace App\Filament\Resources\ArticleResource\Actions;
4+
5+
use App\Models\Article;
6+
use Filament\Tables\Actions\Action;
7+
8+
class PreviewAction extends Action
9+
{
10+
protected function setUp(): void
11+
{
12+
$this
13+
->label('Preview')
14+
->icon('heroicon-o-eye')
15+
->url(fn (Article $article) => route('article', $article))
16+
->openUrlInNewTab();
17+
}
18+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
<?php
2+
3+
namespace App\Filament\Resources\ArticleResource\Actions;
4+
5+
use App\Models\Article;
6+
use Filament\Tables\Actions\Action;
7+
8+
class PublishAction extends Action
9+
{
10+
protected function setUp(): void
11+
{
12+
$this
13+
->label('Publish')
14+
->icon('heroicon-o-newspaper')
15+
->action(fn (Article $article) => $article->publish())
16+
->visible(fn (Article $article) => ! $article->isPublished())
17+
->requiresConfirmation();
18+
}
19+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
<?php
2+
3+
namespace App\Filament\Resources\ArticleResource\Actions;
4+
5+
use App\Models\Article;
6+
use Filament\Forms\Components\DateTimePicker;
7+
use Filament\Tables\Actions\Action;
8+
use Illuminate\Support\Carbon;
9+
10+
class ScheduleAction extends Action
11+
{
12+
protected function setUp(): void
13+
{
14+
$this
15+
->label('Schedule')
16+
->icon('heroicon-o-calendar-days')
17+
->visible(fn (Article $record) => ! $record->isPublished())
18+
->form(fn (Article $article) => [
19+
DateTimePicker::make('published_at')
20+
->label('Published At')
21+
->displayFormat('M j, Y H:i')
22+
->seconds(false)
23+
->default($article->published_at)
24+
->afterOrEqual('now')
25+
->required(),
26+
])
27+
->action(function (Article $article, array $data) {
28+
$article->publish(Carbon::parse($data['published_at']));
29+
})
30+
->requiresConfirmation();
31+
}
32+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
<?php
2+
3+
namespace App\Filament\Resources\ArticleResource\Actions;
4+
5+
use App\Models\Article;
6+
use Filament\Tables\Actions\Action;
7+
8+
class UnpublishAction extends Action
9+
{
10+
protected function setUp(): void
11+
{
12+
$this
13+
->label('Unpublish')
14+
->icon('heroicon-o-archive-box-x-mark')
15+
->action(fn (Article $article) => $article->unpublish())
16+
->visible(fn (Article $article) => $article->isPublished())
17+
->requiresConfirmation();
18+
}
19+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
<?php
2+
3+
namespace App\Filament\Resources\ArticleResource\Pages;
4+
5+
use App\Filament\Resources\ArticleResource;
6+
use Filament\Resources\Pages\CreateRecord;
7+
8+
class CreateArticle extends CreateRecord
9+
{
10+
protected static string $resource = ArticleResource::class;
11+
12+
protected function getRedirectUrl(): string
13+
{
14+
return static::getResource()::getUrl('edit', ['record' => $this->record->id]);
15+
}
16+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
<?php
2+
3+
namespace App\Filament\Resources\ArticleResource\Pages;
4+
5+
use App\Filament\Resources\ArticleResource;
6+
use Filament\Actions;
7+
use Filament\Resources\Pages\EditRecord;
8+
9+
class EditArticle extends EditRecord
10+
{
11+
protected static string $resource = ArticleResource::class;
12+
13+
protected function getHeaderActions(): array
14+
{
15+
return [
16+
Actions\DeleteAction::make(),
17+
];
18+
}
19+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
<?php
2+
3+
namespace App\Filament\Resources\ArticleResource\Pages;
4+
5+
use App\Filament\Resources\ArticleResource;
6+
use Filament\Actions;
7+
use Filament\Resources\Pages\ListRecords;
8+
9+
class ListArticles extends ListRecords
10+
{
11+
protected static string $resource = ArticleResource::class;
12+
13+
protected function getHeaderActions(): array
14+
{
15+
return [
16+
Actions\CreateAction::make(),
17+
];
18+
}
19+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
<?php
2+
3+
namespace App\Http\Controllers;
4+
5+
use App\Models\Article;
6+
7+
class ShowBlogController extends Controller
8+
{
9+
public function index()
10+
{
11+
$articles = Article::query()
12+
->published()
13+
->paginate(6);
14+
15+
return view('blog', [
16+
'articles' => $articles,
17+
]);
18+
}
19+
20+
public function show(Article $article)
21+
{
22+
abort_unless($article->isPublished() || auth()->user()?->isAdmin(), 404);
23+
24+
return view('article', [
25+
'article' => $article,
26+
]);
27+
}
28+
}

0 commit comments

Comments
 (0)