Skip to content

Commit d1edc2e

Browse files
authored
Merge pull request #34 from bahman026/implement_pages
feat: implement Pages CMS entity
2 parents 7c52944 + f254347 commit d1edc2e

13 files changed

Lines changed: 546 additions & 1 deletion

File tree

‎admin/CACHE.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ Legend: `[ ]` not started, `[x]` implemented.
2121
| 5 | `products.slug.{slug}` | Single product record (with images, attributes, brand, category) | 30 min | Product saved / deleted |
2222
| 6 | `products.category.{category_id}` | Published products list for a category page | 15 min | Product saved / deleted |
2323
| 7 | `varieties.product.{product_id}` | All varieties for a product (price, inventory, status) | 15 min | Variety saved / deleted |
24+
| 9 | `pages.{slug}` | Single published page record | 1 hour | Page saved / deleted |
2425

2526
---
2627

‎admin/IMPLEMENTATION.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ Catalog layer and platform basics.
2727
- [x] Banners (polymorphic images via `images` table)
2828
- [x] Sliders + Slides (each slide has one polymorphic image via `images` table)
2929
- [x] Menus + Menu Items (nested via `parent_id`; optional polymorphic image per item)
30+
- [x] Pages (CMS static pages; polymorphic image; SCHEDULED status with `published_at`)
3031
- [~] Addresses (model only, no resource yet)
3132

3233
Sample data for manual admin testing lives in `TestSeeder` (`php artisan db:seed --class=TestSeeder`); `DatabaseSeeder` holds only necessary data.
@@ -62,7 +63,7 @@ Depend mostly on Images only.
6263
- [x] Banners
6364
- [x] Sliders + Slides
6465
- [x] Menus + Menu Items
65-
- [ ] Pages
66+
- [x] Pages
6667
- [ ] FAQs
6768
- [ ] Reviews
6869
- [ ] Wishlists

‎admin/app/Enums/PageStatusEnum.php‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace App\Enums;
6+
7+
use App\Traits\HasOptions;
8+
9+
enum PageStatusEnum: int
10+
{
11+
use HasOptions;
12+
13+
case DELETED = 10;
14+
case PUBLISHED = 20;
15+
case DRAFT = 30;
16+
case SCHEDULED = 40;
17+
18+
public function label(): string
19+
{
20+
return match ($this) {
21+
self::DELETED => 'Deleted',
22+
self::PUBLISHED => 'Published',
23+
self::DRAFT => 'Draft',
24+
self::SCHEDULED => 'Scheduled',
25+
};
26+
}
27+
28+
public function color(): string
29+
{
30+
return match ($this) {
31+
self::DELETED => 'danger',
32+
self::PUBLISHED => 'success',
33+
self::DRAFT => 'warning',
34+
self::SCHEDULED => 'info',
35+
};
36+
}
37+
}
Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace App\Filament\Resources;
6+
7+
use AmidEsfahani\FilamentTinyEditor\TinyEditor;
8+
use App\Enums\PageStatusEnum;
9+
use App\Filament\Resources\PageResource\Pages\CreatePage;
10+
use App\Filament\Resources\PageResource\Pages\EditPage;
11+
use App\Filament\Resources\PageResource\Pages\ListPages;
12+
use App\Models\Page as PageModel;
13+
use Filament\Actions\BulkActionGroup;
14+
use Filament\Actions\DeleteBulkAction;
15+
use Filament\Actions\EditAction;
16+
use Filament\Forms\Components\DateTimePicker;
17+
use Filament\Forms\Components\FileUpload;
18+
use Filament\Forms\Components\Select;
19+
use Filament\Forms\Components\TextInput;
20+
use Filament\Forms\Components\Toggle;
21+
use Filament\Resources\Resource;
22+
use Filament\Schemas\Components\Fieldset;
23+
use Filament\Schemas\Components\Utilities\Get;
24+
use Filament\Schemas\Components\Utilities\Set;
25+
use Filament\Schemas\Schema;
26+
use Filament\Tables\Columns\IconColumn;
27+
use Filament\Tables\Columns\TextColumn;
28+
use Filament\Tables\Table;
29+
use Illuminate\Support\Str;
30+
31+
class PageResource extends Resource
32+
{
33+
protected static ?string $model = PageModel::class;
34+
35+
protected static string | \UnitEnum | null $navigationGroup = 'Content';
36+
37+
protected static string | \BackedEnum | null $navigationIcon = 'heroicon-o-document-text';
38+
39+
protected static ?int $navigationSort = 6;
40+
41+
public static function form(Schema $schema): Schema
42+
{
43+
return $schema
44+
->components([
45+
TextInput::make('heading')
46+
->required()
47+
->maxLength(255)
48+
->live(onBlur: true)
49+
->afterStateUpdated(function (string $operation, ?string $state, Set $set): void {
50+
if ($operation === 'create') {
51+
$set('slug', Str::slug((string) $state));
52+
}
53+
})
54+
->hintIcon('heroicon-o-information-circle')
55+
->hintIconTooltip('The page title shown to visitors, e.g. "About Us".'),
56+
TextInput::make('slug')
57+
->disabled()
58+
->dehydrated()
59+
->required()
60+
->maxLength(255)
61+
->unique(PageModel::class, 'slug', ignoreRecord: true)
62+
->hintIcon('heroicon-o-information-circle')
63+
->hintIconTooltip('Auto-generated from the heading. Used as the URL path, e.g. /about-us.'),
64+
TinyEditor::make('content')
65+
->nullable()
66+
->columnSpanFull()
67+
->hintIcon('heroicon-o-information-circle')
68+
->hintIconTooltip('The main body content of the page, edited with the rich text editor.'),
69+
TextInput::make('title')
70+
->maxLength(255)
71+
->hintIcon('heroicon-o-information-circle')
72+
->hintIconTooltip('SEO page title shown in the browser tab and search results. Leave blank to use the heading.'),
73+
TextInput::make('description')
74+
->maxLength(255)
75+
->hintIcon('heroicon-o-information-circle')
76+
->hintIconTooltip('Meta description shown in search engine results. Aim for 150-160 characters.'),
77+
Toggle::make('no_index')
78+
->hintIcon('heroicon-o-information-circle')
79+
->hintIconTooltip('When enabled, search engines are instructed not to index this page.'),
80+
TextInput::make('canonical')
81+
->maxLength(255)
82+
->url()
83+
->hintIcon('heroicon-o-information-circle')
84+
->hintIconTooltip('Canonical URL to prevent duplicate content issues. Leave blank unless this page should point to another URL.'),
85+
Select::make('status')
86+
->required()
87+
->live()
88+
->options(PageStatusEnum::options())
89+
->default(PageStatusEnum::PUBLISHED->value)
90+
->hintIcon('heroicon-o-information-circle')
91+
->hintIconTooltip('Published: visible on the frontend. Draft: hidden. Scheduled: published on the date set below. Deleted: removed from listings.'),
92+
DateTimePicker::make('published_at')
93+
->nullable()
94+
->required(fn (Get $get): bool => (int) $get('status') === PageStatusEnum::SCHEDULED->value)
95+
->hidden(fn (Get $get): bool => (int) $get('status') !== PageStatusEnum::SCHEDULED->value)
96+
->hintIcon('heroicon-o-information-circle')
97+
->hintIconTooltip('The date and time this page will be made public. Only applies when status is Scheduled.'),
98+
Fieldset::make('Image')
99+
->relationship('image')
100+
->schema([
101+
FileUpload::make('path')
102+
->image()
103+
->nullable()
104+
->columnSpanFull(),
105+
TextInput::make('alt_text')
106+
->nullable()
107+
->maxLength(255),
108+
])
109+
->mutateRelationshipDataBeforeSaveUsing(function (array $data, PageModel $record): array {
110+
if (empty($data['path'])) {
111+
$record->image?->delete();
112+
}
113+
114+
return $data;
115+
})
116+
->columnSpanFull(),
117+
]);
118+
}
119+
120+
public static function table(Table $table): Table
121+
{
122+
return $table
123+
->columns([
124+
TextColumn::make('heading')
125+
->limit(30)
126+
->wrap()
127+
->searchable(),
128+
TextColumn::make('slug')
129+
->limit(30)
130+
->wrap()
131+
->searchable(),
132+
IconColumn::make('no_index')
133+
->boolean(),
134+
TextColumn::make('status')
135+
->getStateUsing(fn (PageModel $record): string => $record->status->label())
136+
->color(fn (PageModel $record): string => $record->status->color())
137+
->sortable(),
138+
TextColumn::make('published_at')
139+
->dateTime()
140+
->sortable()
141+
->toggleable(isToggledHiddenByDefault: true),
142+
TextColumn::make('created_at')
143+
->dateTime()
144+
->sortable()
145+
->toggleable(isToggledHiddenByDefault: true),
146+
TextColumn::make('updated_at')
147+
->dateTime()
148+
->sortable()
149+
->toggleable(isToggledHiddenByDefault: true),
150+
])
151+
->filters([
152+
//
153+
])
154+
->recordActions([
155+
EditAction::make(),
156+
])
157+
->toolbarActions([
158+
BulkActionGroup::make([
159+
DeleteBulkAction::make(),
160+
]),
161+
]);
162+
}
163+
164+
public static function getRelations(): array
165+
{
166+
return [];
167+
}
168+
169+
public static function getPages(): array
170+
{
171+
return [
172+
'index' => ListPages::route('/'),
173+
'create' => CreatePage::route('/create'),
174+
'edit' => EditPage::route('/{record}/edit'),
175+
];
176+
}
177+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace App\Filament\Resources\PageResource\Pages;
6+
7+
use App\Filament\Resources\PageResource;
8+
use Filament\Resources\Pages\CreateRecord;
9+
10+
class CreatePage extends CreateRecord
11+
{
12+
protected static string $resource = PageResource::class;
13+
14+
protected function getRedirectUrl(): string
15+
{
16+
return $this->getResource()::getUrl('index');
17+
}
18+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace App\Filament\Resources\PageResource\Pages;
6+
7+
use App\Filament\Resources\PageResource;
8+
use Filament\Actions\DeleteAction;
9+
use Filament\Resources\Pages\EditRecord;
10+
11+
class EditPage extends EditRecord
12+
{
13+
protected static string $resource = PageResource::class;
14+
15+
protected function getRedirectUrl(): string
16+
{
17+
return $this->getResource()::getUrl('index');
18+
}
19+
20+
protected function getHeaderActions(): array
21+
{
22+
return [
23+
DeleteAction::make(),
24+
];
25+
}
26+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace App\Filament\Resources\PageResource\Pages;
6+
7+
use App\Filament\Resources\PageResource;
8+
use Filament\Actions\CreateAction;
9+
use Filament\Resources\Pages\ListRecords;
10+
11+
class ListPages extends ListRecords
12+
{
13+
protected static string $resource = PageResource::class;
14+
15+
protected ?string $subheading = 'Manage static content pages such as About Us, Contact, and FAQ.';
16+
17+
protected function getHeaderActions(): array
18+
{
19+
return [
20+
CreateAction::make(),
21+
];
22+
}
23+
}

‎admin/app/Models/Page.php‎

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace App\Models;
6+
7+
use App\Enums\PageStatusEnum;
8+
use Carbon\Carbon;
9+
use Database\Factories\PageFactory;
10+
use Illuminate\Database\Eloquent\Factories\HasFactory;
11+
use Illuminate\Database\Eloquent\Model;
12+
use Illuminate\Database\Eloquent\Relations\MorphOne;
13+
14+
/**
15+
* @property positive-int $id
16+
* @property string $heading
17+
* @property string $slug
18+
* @property string|null $content
19+
* @property string|null $title
20+
* @property string|null $description
21+
* @property bool $no_index
22+
* @property string|null $canonical
23+
* @property PageStatusEnum $status
24+
* @property Carbon|null $published_at
25+
* @property Image|null $image
26+
*/
27+
class Page extends Model
28+
{
29+
/** @use HasFactory<PageFactory> */
30+
use HasFactory;
31+
32+
protected $fillable = [
33+
'heading',
34+
'slug',
35+
'content',
36+
'title',
37+
'description',
38+
'no_index',
39+
'canonical',
40+
'status',
41+
'published_at',
42+
];
43+
44+
protected $casts = [
45+
'no_index' => 'boolean',
46+
'status' => PageStatusEnum::class,
47+
'published_at' => 'datetime',
48+
];
49+
50+
protected static function booted(): void
51+
{
52+
static::deleting(function (Page $page): void {
53+
$page->image?->delete();
54+
});
55+
}
56+
57+
public function image(): MorphOne
58+
{
59+
return $this->morphOne(Image::class, 'imageable');
60+
}
61+
}

0 commit comments

Comments
 (0)