first commit
This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
use Filament\Support\Contracts\HasLabel;
|
||||
|
||||
enum PostStatus: string implements HasLabel
|
||||
{
|
||||
case Draft = 'draft';
|
||||
case Published = 'published';
|
||||
|
||||
public function getLabel(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::Draft => 'Draft',
|
||||
self::Published => 'Published',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\Categories;
|
||||
|
||||
use App\Filament\Resources\Categories\Pages\CreateCategory;
|
||||
use App\Filament\Resources\Categories\Pages\EditCategory;
|
||||
use App\Filament\Resources\Categories\Pages\ListCategories;
|
||||
use App\Filament\Resources\Categories\Schemas\CategoryForm;
|
||||
use App\Filament\Resources\Categories\Tables\CategoriesTable;
|
||||
use App\Models\Category;
|
||||
use BackedEnum;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Support\Icons\Heroicon;
|
||||
use Filament\Tables\Table;
|
||||
|
||||
class CategoryResource extends Resource
|
||||
{
|
||||
protected static ?string $model = Category::class;
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedRectangleStack;
|
||||
|
||||
protected static string|\UnitEnum|null $navigationGroup = 'Blog';
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
{
|
||||
return CategoryForm::configure($schema);
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return CategoriesTable::configure($table);
|
||||
}
|
||||
|
||||
public static function getRelations(): array
|
||||
{
|
||||
return [
|
||||
//
|
||||
];
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => ListCategories::route('/'),
|
||||
'create' => CreateCategory::route('/create'),
|
||||
'edit' => EditCategory::route('/{record}/edit'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\Categories\Pages;
|
||||
|
||||
use App\Filament\Resources\Categories\CategoryResource;
|
||||
use Filament\Resources\Pages\CreateRecord;
|
||||
|
||||
class CreateCategory extends CreateRecord
|
||||
{
|
||||
protected static string $resource = CategoryResource::class;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\Categories\Pages;
|
||||
|
||||
use App\Filament\Resources\Categories\CategoryResource;
|
||||
use Filament\Actions\DeleteAction;
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
|
||||
class EditCategory extends EditRecord
|
||||
{
|
||||
protected static string $resource = CategoryResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
DeleteAction::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\Categories\Pages;
|
||||
|
||||
use App\Filament\Resources\Categories\CategoryResource;
|
||||
use Filament\Actions\CreateAction;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListCategories extends ListRecords
|
||||
{
|
||||
protected static string $resource = CategoryResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
CreateAction::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\Categories\Schemas;
|
||||
|
||||
use App\Models\Category;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\Textarea;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Schemas\Schema;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class CategoryForm
|
||||
{
|
||||
public static function configure(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->components([
|
||||
Section::make('Category')
|
||||
->columns(2)
|
||||
->components([
|
||||
Select::make('parent_id')
|
||||
->label('Parent category')
|
||||
->relationship(
|
||||
'parent',
|
||||
'name',
|
||||
fn ($query, $record) => $record ? $query->whereKeyNot($record->id) : $query,
|
||||
)
|
||||
->searchable()
|
||||
->preload()
|
||||
->helperText('Leave empty for a top-level category.'),
|
||||
TextInput::make('name')
|
||||
->required()
|
||||
->live(onBlur: true)
|
||||
->afterStateUpdated(function (string $operation, $state, callable $set) {
|
||||
if ($operation === 'create') {
|
||||
$set('slug', Str::slug($state));
|
||||
}
|
||||
}),
|
||||
TextInput::make('slug')
|
||||
->required()
|
||||
->unique(Category::class, 'slug', ignoreRecord: true)
|
||||
->helperText('Used in the URL, e.g. /category/your-slug'),
|
||||
Textarea::make('description')
|
||||
->columnSpanFull(),
|
||||
]),
|
||||
Section::make('SEO')
|
||||
->description('Optional overrides for search engines and social sharing.')
|
||||
->collapsible()
|
||||
->components([
|
||||
TextInput::make('meta_title')
|
||||
->maxLength(60)
|
||||
->helperText('Falls back to the category name if left blank.'),
|
||||
TextInput::make('meta_description')
|
||||
->maxLength(160)
|
||||
->helperText('Falls back to the description if left blank.'),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\Categories\Tables;
|
||||
|
||||
use Filament\Actions\BulkActionGroup;
|
||||
use Filament\Actions\DeleteBulkAction;
|
||||
use Filament\Actions\EditAction;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Table;
|
||||
|
||||
class CategoriesTable
|
||||
{
|
||||
public static function configure(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->columns([
|
||||
TextColumn::make('name')
|
||||
->searchable()
|
||||
->weight('medium'),
|
||||
TextColumn::make('parent.name')
|
||||
->label('Parent')
|
||||
->placeholder('—')
|
||||
->searchable(),
|
||||
TextColumn::make('slug')
|
||||
->searchable(),
|
||||
TextColumn::make('posts_count')
|
||||
->label('Posts')
|
||||
->counts('posts')
|
||||
->sortable(),
|
||||
TextColumn::make('created_at')
|
||||
->dateTime()
|
||||
->sortable()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
TextColumn::make('updated_at')
|
||||
->dateTime()
|
||||
->sortable()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
])
|
||||
->defaultSort('name')
|
||||
->filters([
|
||||
//
|
||||
])
|
||||
->recordActions([
|
||||
EditAction::make(),
|
||||
])
|
||||
->toolbarActions([
|
||||
BulkActionGroup::make([
|
||||
DeleteBulkAction::make(),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\Pages;
|
||||
|
||||
use App\Filament\Resources\Pages\Pages\CreatePage;
|
||||
use App\Filament\Resources\Pages\Pages\EditPage;
|
||||
use App\Filament\Resources\Pages\Pages\ListPages;
|
||||
use App\Filament\Resources\Pages\Schemas\PageForm;
|
||||
use App\Filament\Resources\Pages\Tables\PagesTable;
|
||||
use App\Models\Page;
|
||||
use BackedEnum;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Support\Icons\Heroicon;
|
||||
use Filament\Tables\Table;
|
||||
|
||||
class PageResource extends Resource
|
||||
{
|
||||
protected static ?string $model = Page::class;
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedDocument;
|
||||
|
||||
protected static ?string $recordTitleAttribute = 'title';
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
{
|
||||
return PageForm::configure($schema);
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return PagesTable::configure($table);
|
||||
}
|
||||
|
||||
public static function getRelations(): array
|
||||
{
|
||||
return [
|
||||
//
|
||||
];
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => ListPages::route('/'),
|
||||
'create' => CreatePage::route('/create'),
|
||||
'edit' => EditPage::route('/{record}/edit'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\Pages\Pages;
|
||||
|
||||
use App\Filament\Resources\Pages\PageResource;
|
||||
use Filament\Resources\Pages\CreateRecord;
|
||||
|
||||
class CreatePage extends CreateRecord
|
||||
{
|
||||
protected static string $resource = PageResource::class;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\Pages\Pages;
|
||||
|
||||
use App\Filament\Resources\Pages\PageResource;
|
||||
use Filament\Actions\DeleteAction;
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
|
||||
class EditPage extends EditRecord
|
||||
{
|
||||
protected static string $resource = PageResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
DeleteAction::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\Pages\Pages;
|
||||
|
||||
use App\Filament\Resources\Pages\PageResource;
|
||||
use Filament\Actions\CreateAction;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListPages extends ListRecords
|
||||
{
|
||||
protected static string $resource = PageResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
CreateAction::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\Pages\Schemas;
|
||||
|
||||
use App\Models\Page;
|
||||
use Filament\Forms\Components\MarkdownEditor;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Components\Toggle;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Schemas\Schema;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class PageForm
|
||||
{
|
||||
public static function configure(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->columns(3)
|
||||
->components([
|
||||
Section::make('Content')
|
||||
->columnSpan(2)
|
||||
->components([
|
||||
TextInput::make('title')
|
||||
->required()
|
||||
->live(onBlur: true)
|
||||
->afterStateUpdated(function (string $operation, $state, callable $set) {
|
||||
if ($operation === 'create') {
|
||||
$set('slug', Str::slug($state));
|
||||
}
|
||||
}),
|
||||
TextInput::make('slug')
|
||||
->required()
|
||||
->unique(Page::class, 'slug', ignoreRecord: true)
|
||||
->helperText('Used in the URL, e.g. /your-slug'),
|
||||
MarkdownEditor::make('body')
|
||||
->required()
|
||||
->columnSpanFull(),
|
||||
]),
|
||||
Section::make('Visibility')
|
||||
->columnSpan(1)
|
||||
->components([
|
||||
Toggle::make('published')
|
||||
->default(true),
|
||||
Toggle::make('show_in_footer')
|
||||
->label('Show in footer')
|
||||
->default(true),
|
||||
TextInput::make('order')
|
||||
->numeric()
|
||||
->default(0)
|
||||
->helperText('Lower numbers appear first in the footer.'),
|
||||
]),
|
||||
Section::make('SEO')
|
||||
->description('Optional overrides for search engines and social sharing.')
|
||||
->columnSpan(3)
|
||||
->columns(2)
|
||||
->collapsible()
|
||||
->components([
|
||||
TextInput::make('meta_title')
|
||||
->maxLength(60)
|
||||
->helperText('Falls back to the page title if left blank.'),
|
||||
TextInput::make('meta_description')
|
||||
->maxLength(160),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\Pages\Tables;
|
||||
|
||||
use Filament\Actions\BulkActionGroup;
|
||||
use Filament\Actions\DeleteBulkAction;
|
||||
use Filament\Actions\EditAction;
|
||||
use Filament\Tables\Columns\IconColumn;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Filters\TernaryFilter;
|
||||
use Filament\Tables\Table;
|
||||
|
||||
class PagesTable
|
||||
{
|
||||
public static function configure(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->columns([
|
||||
TextColumn::make('title')
|
||||
->searchable()
|
||||
->weight('medium'),
|
||||
TextColumn::make('slug')
|
||||
->searchable(),
|
||||
IconColumn::make('published')
|
||||
->boolean()
|
||||
->sortable(),
|
||||
IconColumn::make('show_in_footer')
|
||||
->label('In footer')
|
||||
->boolean()
|
||||
->sortable(),
|
||||
TextColumn::make('order')
|
||||
->numeric()
|
||||
->sortable(),
|
||||
TextColumn::make('updated_at')
|
||||
->dateTime()
|
||||
->sortable()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
])
|
||||
->defaultSort('order')
|
||||
->filters([
|
||||
TernaryFilter::make('published'),
|
||||
])
|
||||
->recordActions([
|
||||
EditAction::make(),
|
||||
])
|
||||
->toolbarActions([
|
||||
BulkActionGroup::make([
|
||||
DeleteBulkAction::make(),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\Posts\Pages;
|
||||
|
||||
use App\Filament\Resources\Posts\PostResource;
|
||||
use Filament\Resources\Pages\CreateRecord;
|
||||
|
||||
class CreatePost extends CreateRecord
|
||||
{
|
||||
protected static string $resource = PostResource::class;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\Posts\Pages;
|
||||
|
||||
use App\Filament\Resources\Posts\PostResource;
|
||||
use Filament\Actions\DeleteAction;
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
|
||||
class EditPost extends EditRecord
|
||||
{
|
||||
protected static string $resource = PostResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
DeleteAction::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\Posts\Pages;
|
||||
|
||||
use App\Filament\Resources\Posts\PostResource;
|
||||
use Filament\Actions\CreateAction;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListPosts extends ListRecords
|
||||
{
|
||||
protected static string $resource = PostResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
CreateAction::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\Posts;
|
||||
|
||||
use App\Filament\Resources\Posts\Pages\CreatePost;
|
||||
use App\Filament\Resources\Posts\Pages\EditPost;
|
||||
use App\Filament\Resources\Posts\Pages\ListPosts;
|
||||
use App\Filament\Resources\Posts\Schemas\PostForm;
|
||||
use App\Filament\Resources\Posts\Tables\PostsTable;
|
||||
use App\Models\Post;
|
||||
use BackedEnum;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Support\Icons\Heroicon;
|
||||
use Filament\Tables\Table;
|
||||
|
||||
class PostResource extends Resource
|
||||
{
|
||||
protected static ?string $model = Post::class;
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedDocumentText;
|
||||
|
||||
protected static string|\UnitEnum|null $navigationGroup = 'Blog';
|
||||
|
||||
protected static ?string $recordTitleAttribute = 'title';
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
{
|
||||
return PostForm::configure($schema);
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return PostsTable::configure($table);
|
||||
}
|
||||
|
||||
public static function getRelations(): array
|
||||
{
|
||||
return [
|
||||
//
|
||||
];
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => ListPosts::route('/'),
|
||||
'create' => CreatePost::route('/create'),
|
||||
'edit' => EditPost::route('/{record}/edit'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\Posts\Schemas;
|
||||
|
||||
use App\Enums\PostStatus;
|
||||
use App\Models\Post;
|
||||
use Filament\Forms\Components\DateTimePicker;
|
||||
use Filament\Forms\Components\FileUpload;
|
||||
use Filament\Forms\Components\MarkdownEditor;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\Textarea;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Schemas\Schema;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class PostForm
|
||||
{
|
||||
public static function configure(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->columns(3)
|
||||
->components([
|
||||
Section::make('Content')
|
||||
->columnSpan(2)
|
||||
->components([
|
||||
TextInput::make('title')
|
||||
->required()
|
||||
->live(onBlur: true)
|
||||
->afterStateUpdated(function (string $operation, $state, callable $set) {
|
||||
if ($operation === 'create') {
|
||||
$set('slug', Str::slug($state));
|
||||
}
|
||||
}),
|
||||
TextInput::make('slug')
|
||||
->required()
|
||||
->unique(Post::class, 'slug', ignoreRecord: true)
|
||||
->helperText('Used in the URL, e.g. /blog/your-slug'),
|
||||
Textarea::make('excerpt')
|
||||
->rows(2)
|
||||
->maxLength(255)
|
||||
->helperText('Short summary shown in listings. Falls back to the start of the post.'),
|
||||
MarkdownEditor::make('body')
|
||||
->required()
|
||||
->columnSpanFull(),
|
||||
]),
|
||||
Section::make('Publishing')
|
||||
->columnSpan(1)
|
||||
->components([
|
||||
Select::make('status')
|
||||
->options(PostStatus::class)
|
||||
->default(PostStatus::Draft)
|
||||
->required()
|
||||
->live(),
|
||||
DateTimePicker::make('published_at')
|
||||
->default(now())
|
||||
->helperText('Post is only public once this is in the past.'),
|
||||
Select::make('user_id')
|
||||
->label('Author')
|
||||
->relationship('user', 'name')
|
||||
->searchable()
|
||||
->preload()
|
||||
->required(),
|
||||
Select::make('category_id')
|
||||
->label('Category')
|
||||
->relationship('category', 'name')
|
||||
->searchable()
|
||||
->preload(),
|
||||
Select::make('tags')
|
||||
->relationship('tags', 'name')
|
||||
->multiple()
|
||||
->searchable()
|
||||
->preload()
|
||||
->createOptionForm([
|
||||
TextInput::make('name')->required(),
|
||||
]),
|
||||
FileUpload::make('featured_image')
|
||||
->image()
|
||||
->disk('public')
|
||||
->directory('posts')
|
||||
->imageEditor(),
|
||||
]),
|
||||
Section::make('SEO')
|
||||
->description('Optional overrides for search engines and social sharing.')
|
||||
->columnSpan(3)
|
||||
->columns(2)
|
||||
->collapsible()
|
||||
->components([
|
||||
TextInput::make('meta_title')
|
||||
->maxLength(60)
|
||||
->helperText('Falls back to the post title if left blank.'),
|
||||
TextInput::make('meta_description')
|
||||
->maxLength(160)
|
||||
->helperText('Falls back to the excerpt if left blank.'),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\Posts\Tables;
|
||||
|
||||
use App\Enums\PostStatus;
|
||||
use Filament\Actions\BulkActionGroup;
|
||||
use Filament\Actions\DeleteBulkAction;
|
||||
use Filament\Actions\EditAction;
|
||||
use Filament\Tables\Columns\ImageColumn;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Filters\SelectFilter;
|
||||
use Filament\Tables\Table;
|
||||
|
||||
class PostsTable
|
||||
{
|
||||
public static function configure(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->columns([
|
||||
ImageColumn::make('featured_image')
|
||||
->disk('public')
|
||||
->label(''),
|
||||
TextColumn::make('title')
|
||||
->searchable()
|
||||
->sortable()
|
||||
->weight('medium')
|
||||
->limit(50),
|
||||
TextColumn::make('category.name')
|
||||
->label('Category')
|
||||
->badge()
|
||||
->placeholder('—')
|
||||
->searchable(),
|
||||
TextColumn::make('status')
|
||||
->badge()
|
||||
->sortable(),
|
||||
TextColumn::make('user.name')
|
||||
->label('Author')
|
||||
->searchable(),
|
||||
TextColumn::make('published_at')
|
||||
->dateTime()
|
||||
->sortable()
|
||||
->placeholder('—'),
|
||||
TextColumn::make('created_at')
|
||||
->dateTime()
|
||||
->sortable()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
TextColumn::make('updated_at')
|
||||
->dateTime()
|
||||
->sortable()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
])
|
||||
->defaultSort('created_at', 'desc')
|
||||
->filters([
|
||||
SelectFilter::make('status')
|
||||
->options(PostStatus::class),
|
||||
SelectFilter::make('category')
|
||||
->relationship('category', 'name'),
|
||||
])
|
||||
->recordActions([
|
||||
EditAction::make(),
|
||||
])
|
||||
->toolbarActions([
|
||||
BulkActionGroup::make([
|
||||
DeleteBulkAction::make(),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\Tags\Pages;
|
||||
|
||||
use App\Filament\Resources\Tags\TagResource;
|
||||
use Filament\Resources\Pages\CreateRecord;
|
||||
|
||||
class CreateTag extends CreateRecord
|
||||
{
|
||||
protected static string $resource = TagResource::class;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\Tags\Pages;
|
||||
|
||||
use App\Filament\Resources\Tags\TagResource;
|
||||
use Filament\Actions\DeleteAction;
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
|
||||
class EditTag extends EditRecord
|
||||
{
|
||||
protected static string $resource = TagResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
DeleteAction::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\Tags\Pages;
|
||||
|
||||
use App\Filament\Resources\Tags\TagResource;
|
||||
use Filament\Actions\CreateAction;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListTags extends ListRecords
|
||||
{
|
||||
protected static string $resource = TagResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
CreateAction::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\Tags\Schemas;
|
||||
|
||||
use App\Models\Tag;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Schemas\Schema;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class TagForm
|
||||
{
|
||||
public static function configure(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->components([
|
||||
TextInput::make('name')
|
||||
->required()
|
||||
->live(onBlur: true)
|
||||
->afterStateUpdated(function (string $operation, $state, callable $set) {
|
||||
if ($operation === 'create') {
|
||||
$set('slug', Str::slug($state));
|
||||
}
|
||||
}),
|
||||
TextInput::make('slug')
|
||||
->required()
|
||||
->unique(Tag::class, 'slug', ignoreRecord: true)
|
||||
->helperText('Used in the URL, e.g. /tag/your-slug'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\Tags\Tables;
|
||||
|
||||
use Filament\Actions\BulkActionGroup;
|
||||
use Filament\Actions\DeleteBulkAction;
|
||||
use Filament\Actions\EditAction;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Table;
|
||||
|
||||
class TagsTable
|
||||
{
|
||||
public static function configure(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->columns([
|
||||
TextColumn::make('name')
|
||||
->searchable()
|
||||
->weight('medium'),
|
||||
TextColumn::make('slug')
|
||||
->searchable(),
|
||||
TextColumn::make('posts_count')
|
||||
->label('Posts')
|
||||
->counts('posts')
|
||||
->sortable(),
|
||||
TextColumn::make('created_at')
|
||||
->dateTime()
|
||||
->sortable()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
TextColumn::make('updated_at')
|
||||
->dateTime()
|
||||
->sortable()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
])
|
||||
->defaultSort('name')
|
||||
->filters([
|
||||
//
|
||||
])
|
||||
->recordActions([
|
||||
EditAction::make(),
|
||||
])
|
||||
->toolbarActions([
|
||||
BulkActionGroup::make([
|
||||
DeleteBulkAction::make(),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\Tags;
|
||||
|
||||
use App\Filament\Resources\Tags\Pages\CreateTag;
|
||||
use App\Filament\Resources\Tags\Pages\EditTag;
|
||||
use App\Filament\Resources\Tags\Pages\ListTags;
|
||||
use App\Filament\Resources\Tags\Schemas\TagForm;
|
||||
use App\Filament\Resources\Tags\Tables\TagsTable;
|
||||
use App\Models\Tag;
|
||||
use BackedEnum;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Support\Icons\Heroicon;
|
||||
use Filament\Tables\Table;
|
||||
|
||||
class TagResource extends Resource
|
||||
{
|
||||
protected static ?string $model = Tag::class;
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedTag;
|
||||
|
||||
protected static string|\UnitEnum|null $navigationGroup = 'Blog';
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
{
|
||||
return TagForm::configure($schema);
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return TagsTable::configure($table);
|
||||
}
|
||||
|
||||
public static function getRelations(): array
|
||||
{
|
||||
return [
|
||||
//
|
||||
];
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => ListTags::route('/'),
|
||||
'create' => CreateTag::route('/create'),
|
||||
'edit' => EditTag::route('/{record}/edit'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\Tools\Pages;
|
||||
|
||||
use App\Filament\Resources\Tools\ToolResource;
|
||||
use Filament\Resources\Pages\CreateRecord;
|
||||
|
||||
class CreateTool extends CreateRecord
|
||||
{
|
||||
protected static string $resource = ToolResource::class;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\Tools\Pages;
|
||||
|
||||
use App\Filament\Resources\Tools\ToolResource;
|
||||
use Filament\Actions\DeleteAction;
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
|
||||
class EditTool extends EditRecord
|
||||
{
|
||||
protected static string $resource = ToolResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
DeleteAction::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\Tools\Pages;
|
||||
|
||||
use App\Filament\Resources\Tools\ToolResource;
|
||||
use Filament\Actions\CreateAction;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListTools extends ListRecords
|
||||
{
|
||||
protected static string $resource = ToolResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
CreateAction::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\Tools\Schemas;
|
||||
|
||||
use App\Models\Tool;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\TagsInput;
|
||||
use Filament\Forms\Components\Textarea;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Components\Toggle;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Schemas\Schema;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class ToolForm
|
||||
{
|
||||
public static function configure(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->columns(3)
|
||||
->components([
|
||||
Section::make('Details')
|
||||
->columnSpan(2)
|
||||
->components([
|
||||
TextInput::make('name')
|
||||
->required()
|
||||
->live(onBlur: true)
|
||||
->afterStateUpdated(function (string $operation, $state, callable $set) {
|
||||
if ($operation === 'create') {
|
||||
$set('slug', Str::slug($state));
|
||||
}
|
||||
}),
|
||||
TextInput::make('slug')
|
||||
->required()
|
||||
->unique(Tool::class, 'slug', ignoreRecord: true)
|
||||
->helperText('Used in the URL, e.g. /tools/your-slug'),
|
||||
Textarea::make('description')
|
||||
->required()
|
||||
->rows(4)
|
||||
->columnSpanFull(),
|
||||
TextInput::make('url')
|
||||
->label('Website URL')
|
||||
->url()
|
||||
->required(),
|
||||
TextInput::make('logo_url')
|
||||
->label('Logo URL')
|
||||
->url(),
|
||||
TagsInput::make('tags')
|
||||
->placeholder('Add a tag and press enter')
|
||||
->helperText('e.g. free, api, open-source')
|
||||
->columnSpanFull(),
|
||||
TagsInput::make('features')
|
||||
->placeholder('Add a feature and press enter')
|
||||
->columnSpanFull(),
|
||||
]),
|
||||
Section::make('Classification')
|
||||
->columnSpan(1)
|
||||
->components([
|
||||
Select::make('category')
|
||||
->options(config('tools.categories'))
|
||||
->required()
|
||||
->searchable(),
|
||||
TextInput::make('subcategory')
|
||||
->helperText('e.g. Code Assistants'),
|
||||
TextInput::make('pricing')
|
||||
->helperText('e.g. Free, Freemium, $10/month'),
|
||||
]),
|
||||
Section::make('Moderation')
|
||||
->columnSpan(3)
|
||||
->columns(4)
|
||||
->components([
|
||||
Toggle::make('approved')
|
||||
->helperText('Only approved tools are shown publicly.')
|
||||
->default(false),
|
||||
Toggle::make('featured')
|
||||
->helperText('Highlight this tool.')
|
||||
->default(false),
|
||||
Select::make('user_id')
|
||||
->label('Submitted by')
|
||||
->relationship('user', 'name')
|
||||
->searchable()
|
||||
->preload(),
|
||||
TextInput::make('views')
|
||||
->numeric()
|
||||
->default(0)
|
||||
->disabled()
|
||||
->dehydrated(),
|
||||
TextInput::make('clicks')
|
||||
->numeric()
|
||||
->default(0)
|
||||
->disabled()
|
||||
->dehydrated(),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\Tools\Tables;
|
||||
|
||||
use Filament\Actions\BulkActionGroup;
|
||||
use Filament\Actions\DeleteBulkAction;
|
||||
use Filament\Actions\EditAction;
|
||||
use Filament\Tables\Columns\IconColumn;
|
||||
use Filament\Tables\Columns\ImageColumn;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Filters\SelectFilter;
|
||||
use Filament\Tables\Filters\TernaryFilter;
|
||||
use Filament\Tables\Table;
|
||||
|
||||
class ToolsTable
|
||||
{
|
||||
public static function configure(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->columns([
|
||||
ImageColumn::make('logo_url')
|
||||
->label(''),
|
||||
TextColumn::make('name')
|
||||
->searchable()
|
||||
->weight('medium'),
|
||||
TextColumn::make('category')
|
||||
->formatStateUsing(fn (string $state) => config("tools.categories.{$state}", $state))
|
||||
->badge()
|
||||
->sortable(),
|
||||
TextColumn::make('subcategory')
|
||||
->placeholder('—'),
|
||||
TextColumn::make('pricing')
|
||||
->placeholder('—'),
|
||||
TextColumn::make('views')
|
||||
->numeric()
|
||||
->sortable(),
|
||||
TextColumn::make('clicks')
|
||||
->numeric()
|
||||
->sortable(),
|
||||
IconColumn::make('featured')
|
||||
->boolean()
|
||||
->sortable(),
|
||||
IconColumn::make('approved')
|
||||
->boolean()
|
||||
->sortable(),
|
||||
TextColumn::make('user.name')
|
||||
->label('Submitted by')
|
||||
->placeholder('—')
|
||||
->searchable(),
|
||||
TextColumn::make('created_at')
|
||||
->dateTime()
|
||||
->sortable()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
])
|
||||
->defaultSort('created_at', 'desc')
|
||||
->filters([
|
||||
SelectFilter::make('category')
|
||||
->options(config('tools.categories')),
|
||||
TernaryFilter::make('approved'),
|
||||
TernaryFilter::make('featured'),
|
||||
])
|
||||
->recordActions([
|
||||
EditAction::make(),
|
||||
])
|
||||
->toolbarActions([
|
||||
BulkActionGroup::make([
|
||||
DeleteBulkAction::make(),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\Tools;
|
||||
|
||||
use App\Filament\Resources\Tools\Pages\CreateTool;
|
||||
use App\Filament\Resources\Tools\Pages\EditTool;
|
||||
use App\Filament\Resources\Tools\Pages\ListTools;
|
||||
use App\Filament\Resources\Tools\Schemas\ToolForm;
|
||||
use App\Filament\Resources\Tools\Tables\ToolsTable;
|
||||
use App\Models\Tool;
|
||||
use BackedEnum;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Support\Icons\Heroicon;
|
||||
use Filament\Tables\Table;
|
||||
|
||||
class ToolResource extends Resource
|
||||
{
|
||||
protected static ?string $model = Tool::class;
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedWrenchScrewdriver;
|
||||
|
||||
protected static string|\UnitEnum|null $navigationGroup = 'Tools';
|
||||
|
||||
protected static ?string $recordTitleAttribute = 'name';
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
{
|
||||
return ToolForm::configure($schema);
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return ToolsTable::configure($table);
|
||||
}
|
||||
|
||||
public static function getRelations(): array
|
||||
{
|
||||
return [
|
||||
//
|
||||
];
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => ListTools::route('/'),
|
||||
'create' => CreateTool::route('/create'),
|
||||
'edit' => EditTool::route('/{record}/edit'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Resources\UserResource;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class AuthController extends Controller
|
||||
{
|
||||
public function login(Request $request)
|
||||
{
|
||||
$credentials = $request->validate([
|
||||
'email' => ['required', 'email'],
|
||||
'password' => ['required', 'string'],
|
||||
'device_name' => ['nullable', 'string'],
|
||||
]);
|
||||
|
||||
if (! Auth::attempt(['email' => $credentials['email'], 'password' => $credentials['password']])) {
|
||||
throw ValidationException::withMessages([
|
||||
'email' => ['The provided credentials are incorrect.'],
|
||||
]);
|
||||
}
|
||||
|
||||
/** @var User $user */
|
||||
$user = User::query()->where('email', $credentials['email'])->firstOrFail();
|
||||
|
||||
$token = $user->createToken($credentials['device_name'] ?? 'api')->plainTextToken;
|
||||
|
||||
return response()->json([
|
||||
'user' => new UserResource($user),
|
||||
'token' => $token,
|
||||
]);
|
||||
}
|
||||
|
||||
public function logout(Request $request)
|
||||
{
|
||||
$request->user()->currentAccessToken()->delete();
|
||||
|
||||
return response()->json(status: 204);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\StoreCategoryRequest;
|
||||
use App\Http\Requests\UpdateCategoryRequest;
|
||||
use App\Http\Resources\CategoryResource;
|
||||
use App\Models\Category;
|
||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||
use Illuminate\Http\Response;
|
||||
|
||||
class CategoryController extends Controller
|
||||
{
|
||||
public function index(): AnonymousResourceCollection
|
||||
{
|
||||
return CategoryResource::collection(
|
||||
Category::query()->orderBy('name')->paginate(20)
|
||||
);
|
||||
}
|
||||
|
||||
public function store(StoreCategoryRequest $request): CategoryResource
|
||||
{
|
||||
$category = Category::query()->create($request->validated());
|
||||
|
||||
return new CategoryResource($category);
|
||||
}
|
||||
|
||||
public function show(Category $category): CategoryResource
|
||||
{
|
||||
return new CategoryResource($category);
|
||||
}
|
||||
|
||||
public function update(UpdateCategoryRequest $request, Category $category): CategoryResource
|
||||
{
|
||||
$category->update($request->validated());
|
||||
|
||||
return new CategoryResource($category);
|
||||
}
|
||||
|
||||
public function destroy(Category $category): Response
|
||||
{
|
||||
$category->delete();
|
||||
|
||||
return response()->noContent();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\StorePostRequest;
|
||||
use App\Http\Requests\UpdatePostRequest;
|
||||
use App\Http\Resources\PostResource;
|
||||
use App\Models\Post;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||
use Illuminate\Http\Response;
|
||||
|
||||
class PostController extends Controller
|
||||
{
|
||||
public function index(Request $request): AnonymousResourceCollection
|
||||
{
|
||||
$posts = Post::query()
|
||||
->with(['user', 'category', 'tags'])
|
||||
->when($request->string('status')->toString(), fn ($query, $status) => $query->where('status', $status))
|
||||
->when($request->integer('category_id'), fn ($query, $categoryId) => $query->where('category_id', $categoryId))
|
||||
->latest('created_at')
|
||||
->paginate(20);
|
||||
|
||||
return PostResource::collection($posts);
|
||||
}
|
||||
|
||||
public function store(StorePostRequest $request): PostResource
|
||||
{
|
||||
$post = Post::query()->create([
|
||||
...$request->safe()->except('tags'),
|
||||
'user_id' => $request->user()->id,
|
||||
]);
|
||||
|
||||
$post->tags()->sync($request->safe()->input('tags', []));
|
||||
|
||||
return new PostResource($post->load(['user', 'category', 'tags']));
|
||||
}
|
||||
|
||||
public function show(Post $post): PostResource
|
||||
{
|
||||
return new PostResource($post->load(['user', 'category', 'tags']));
|
||||
}
|
||||
|
||||
public function update(UpdatePostRequest $request, Post $post): PostResource
|
||||
{
|
||||
$post->update($request->safe()->except('tags'));
|
||||
|
||||
if ($request->has('tags')) {
|
||||
$post->tags()->sync($request->safe()->input('tags', []));
|
||||
}
|
||||
|
||||
return new PostResource($post->load(['user', 'category', 'tags']));
|
||||
}
|
||||
|
||||
public function destroy(Post $post): Response
|
||||
{
|
||||
$post->delete();
|
||||
|
||||
return response()->noContent();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\StoreTagRequest;
|
||||
use App\Http\Requests\UpdateTagRequest;
|
||||
use App\Http\Resources\TagResource;
|
||||
use App\Models\Tag;
|
||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||
use Illuminate\Http\Response;
|
||||
|
||||
class TagController extends Controller
|
||||
{
|
||||
public function index(): AnonymousResourceCollection
|
||||
{
|
||||
return TagResource::collection(
|
||||
Tag::query()->orderBy('name')->paginate(20)
|
||||
);
|
||||
}
|
||||
|
||||
public function store(StoreTagRequest $request): TagResource
|
||||
{
|
||||
$tag = Tag::query()->create($request->validated());
|
||||
|
||||
return new TagResource($tag);
|
||||
}
|
||||
|
||||
public function show(Tag $tag): TagResource
|
||||
{
|
||||
return new TagResource($tag);
|
||||
}
|
||||
|
||||
public function update(UpdateTagRequest $request, Tag $tag): TagResource
|
||||
{
|
||||
$tag->update($request->validated());
|
||||
|
||||
return new TagResource($tag);
|
||||
}
|
||||
|
||||
public function destroy(Tag $tag): Response
|
||||
{
|
||||
$tag->delete();
|
||||
|
||||
return response()->noContent();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\StoreToolRequest;
|
||||
use App\Http\Requests\UpdateToolRequest;
|
||||
use App\Http\Resources\ToolResource;
|
||||
use App\Models\Tool;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||
use Illuminate\Http\Response;
|
||||
|
||||
class ToolController extends Controller
|
||||
{
|
||||
public function index(Request $request): AnonymousResourceCollection
|
||||
{
|
||||
$tools = Tool::query()
|
||||
->with('user')
|
||||
->when($request->string('category')->toString(), fn ($query, $category) => $query->where('category', $category))
|
||||
->when($request->has('approved'), fn ($query) => $query->where('approved', $request->boolean('approved')))
|
||||
->latest('created_at')
|
||||
->paginate(20);
|
||||
|
||||
return ToolResource::collection($tools);
|
||||
}
|
||||
|
||||
public function store(StoreToolRequest $request): ToolResource
|
||||
{
|
||||
$tool = Tool::query()->create([
|
||||
'featured' => false,
|
||||
'approved' => false,
|
||||
...$request->validated(),
|
||||
'user_id' => $request->user()->id,
|
||||
]);
|
||||
|
||||
return new ToolResource($tool->refresh()->load('user'));
|
||||
}
|
||||
|
||||
public function show(Tool $tool): ToolResource
|
||||
{
|
||||
return new ToolResource($tool->load('user'));
|
||||
}
|
||||
|
||||
public function update(UpdateToolRequest $request, Tool $tool): ToolResource
|
||||
{
|
||||
$tool->update($request->validated());
|
||||
|
||||
return new ToolResource($tool->load('user'));
|
||||
}
|
||||
|
||||
public function destroy(Tool $tool): Response
|
||||
{
|
||||
$tool->delete();
|
||||
|
||||
return response()->noContent();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Enums\PostStatus;
|
||||
use App\Models\Category;
|
||||
use App\Models\Post;
|
||||
use App\Models\Tag;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Http\Response;
|
||||
|
||||
class BlogController extends Controller
|
||||
{
|
||||
public function index(): View
|
||||
{
|
||||
$posts = Post::published()
|
||||
->with(['category', 'tags', 'user'])
|
||||
->latest('published_at')
|
||||
->paginate(10);
|
||||
|
||||
return view('blog.index', [
|
||||
'posts' => $posts,
|
||||
'title' => config('app.name').' — Blog',
|
||||
'description' => 'Latest articles from '.config('app.name').'.',
|
||||
'canonical' => route('blog.index'),
|
||||
]);
|
||||
}
|
||||
|
||||
public function show(Post $post): View
|
||||
{
|
||||
abort_unless($post->status === PostStatus::Published && $post->published_at?->isPast(), 404);
|
||||
|
||||
$post->load(['category', 'tags', 'user']);
|
||||
|
||||
return view('blog.show', [
|
||||
'post' => $post,
|
||||
'title' => $post->meta_title ?: $post->title,
|
||||
'description' => $post->meta_description ?: $post->excerpt_or_summary,
|
||||
'canonical' => route('blog.show', $post),
|
||||
'image' => $post->featured_image ? asset('storage/'.$post->featured_image) : null,
|
||||
'type' => 'article',
|
||||
]);
|
||||
}
|
||||
|
||||
public function category(Category $category): View
|
||||
{
|
||||
$categoryIds = $category->children()->pluck('id')->push($category->id);
|
||||
|
||||
$posts = Post::published()
|
||||
->whereIn('category_id', $categoryIds)
|
||||
->with(['category', 'tags', 'user'])
|
||||
->latest('published_at')
|
||||
->paginate(10);
|
||||
|
||||
return view('blog.category', [
|
||||
'category' => $category,
|
||||
'posts' => $posts,
|
||||
'title' => $category->meta_title ?: $category->name.' — '.config('app.name'),
|
||||
'description' => $category->meta_description ?: $category->description,
|
||||
'canonical' => route('blog.category', $category),
|
||||
]);
|
||||
}
|
||||
|
||||
public function tag(Tag $tag): View
|
||||
{
|
||||
$posts = $tag->posts()
|
||||
->published()
|
||||
->with(['category', 'tags', 'user'])
|
||||
->latest('published_at')
|
||||
->paginate(10);
|
||||
|
||||
return view('blog.tag', [
|
||||
'tag' => $tag,
|
||||
'posts' => $posts,
|
||||
'title' => '#'.$tag->name.' — '.config('app.name'),
|
||||
'description' => 'Posts tagged with '.$tag->name.'.',
|
||||
'canonical' => route('blog.tag', $tag),
|
||||
]);
|
||||
}
|
||||
|
||||
public function feed(): Response
|
||||
{
|
||||
$posts = Post::published()
|
||||
->with('user')
|
||||
->latest('published_at')
|
||||
->limit(20)
|
||||
->get();
|
||||
|
||||
$xml = view('blog.feed', ['posts' => $posts])->render();
|
||||
|
||||
return response($xml, 200)->header('Content-Type', 'application/rss+xml; charset=UTF-8');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
abstract class Controller
|
||||
{
|
||||
//
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Page;
|
||||
use Illuminate\Contracts\View\View;
|
||||
|
||||
class PageController extends Controller
|
||||
{
|
||||
public function show(Page $page): View
|
||||
{
|
||||
abort_unless($page->published, 404);
|
||||
|
||||
return view('pages.show', [
|
||||
'page' => $page,
|
||||
'title' => $page->meta_title ?: $page->title,
|
||||
'description' => $page->meta_description ?: str($page->body_html)->stripTags()->limit(160)->toString(),
|
||||
'canonical' => route('pages.show', $page),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Category;
|
||||
use App\Models\Page;
|
||||
use App\Models\Post;
|
||||
use App\Models\Tag;
|
||||
use App\Models\Tool;
|
||||
use Illuminate\Http\Response;
|
||||
|
||||
class SitemapController extends Controller
|
||||
{
|
||||
public function index(): Response
|
||||
{
|
||||
$posts = Post::published()->get(['slug', 'updated_at']);
|
||||
$categories = Category::all(['slug', 'updated_at']);
|
||||
$tags = Tag::all(['slug', 'updated_at']);
|
||||
$tools = Tool::approved()->get(['slug', 'updated_at']);
|
||||
$toolCategories = array_keys(config('tools.categories'));
|
||||
$pages = Page::published()->get(['slug', 'updated_at']);
|
||||
|
||||
$xml = view('sitemap', [
|
||||
'posts' => $posts,
|
||||
'categories' => $categories,
|
||||
'tags' => $tags,
|
||||
'tools' => $tools,
|
||||
'toolCategories' => $toolCategories,
|
||||
'pages' => $pages,
|
||||
])->render();
|
||||
|
||||
return response($xml, 200)->header('Content-Type', 'application/xml; charset=UTF-8');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Tool;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class ToolController extends Controller
|
||||
{
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$category = $request->string('category')->toString();
|
||||
$search = $request->string('q')->toString();
|
||||
|
||||
$tools = Tool::approved()
|
||||
->when($category, fn ($query) => $query->where('category', $category))
|
||||
->when($search, fn ($query) => $query->where('name', 'like', "%{$search}%"))
|
||||
->orderByDesc('featured')
|
||||
->orderByDesc('views')
|
||||
->paginate(12)
|
||||
->withQueryString();
|
||||
|
||||
return view('tools.index', [
|
||||
'tools' => $tools,
|
||||
'categories' => config('tools.categories'),
|
||||
'activeCategory' => $category,
|
||||
'search' => $search,
|
||||
'title' => 'AI Tools Directory — '.config('app.name'),
|
||||
'description' => 'Discover AI tools for developers, designers, marketers, and more.',
|
||||
'canonical' => route('tools.index'),
|
||||
]);
|
||||
}
|
||||
|
||||
public function category(string $category): View
|
||||
{
|
||||
abort_unless(array_key_exists($category, config('tools.categories')), 404);
|
||||
|
||||
$tools = Tool::approved()
|
||||
->where('category', $category)
|
||||
->orderByDesc('featured')
|
||||
->orderByDesc('views')
|
||||
->paginate(12);
|
||||
|
||||
$label = config("tools.categories.{$category}");
|
||||
|
||||
return view('tools.category', [
|
||||
'tools' => $tools,
|
||||
'categories' => config('tools.categories'),
|
||||
'category' => $category,
|
||||
'label' => $label,
|
||||
'title' => $label.' AI Tools — '.config('app.name'),
|
||||
'description' => "AI tools for {$label}.",
|
||||
'canonical' => route('tools.category', $category),
|
||||
]);
|
||||
}
|
||||
|
||||
public function show(Tool $tool): View
|
||||
{
|
||||
abort_unless($tool->approved, 404);
|
||||
|
||||
$tool->increment('views');
|
||||
|
||||
$related = Tool::approved()
|
||||
->where('category', $tool->category)
|
||||
->whereKeyNot($tool->id)
|
||||
->limit(4)
|
||||
->get();
|
||||
|
||||
return view('tools.show', [
|
||||
'tool' => $tool,
|
||||
'related' => $related,
|
||||
'title' => $tool->name.' — AI Tools — '.config('app.name'),
|
||||
'description' => str($tool->description)->limit(160)->toString(),
|
||||
'canonical' => route('tools.show', $tool),
|
||||
'image' => $tool->logo_url,
|
||||
]);
|
||||
}
|
||||
|
||||
public function visit(Tool $tool): RedirectResponse
|
||||
{
|
||||
abort_unless($tool->approved, 404);
|
||||
|
||||
$tool->increment('clicks');
|
||||
|
||||
return redirect()->away($tool->url);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class StoreCategoryRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'parent_id' => ['nullable', 'exists:categories,id'],
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'slug' => ['nullable', 'string', 'max:255', Rule::unique('categories', 'slug')],
|
||||
'description' => ['nullable', 'string'],
|
||||
'meta_title' => ['nullable', 'string', 'max:60'],
|
||||
'meta_description' => ['nullable', 'string', 'max:160'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use App\Enums\PostStatus;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\Rules\Enum;
|
||||
|
||||
class StorePostRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'category_id' => ['nullable', 'exists:categories,id'],
|
||||
'title' => ['required', 'string', 'max:255'],
|
||||
'slug' => ['nullable', 'string', 'max:255', Rule::unique('posts', 'slug')],
|
||||
'excerpt' => ['nullable', 'string', 'max:255'],
|
||||
'body' => ['required', 'string'],
|
||||
'featured_image' => ['nullable', 'string'],
|
||||
'status' => ['required', new Enum(PostStatus::class)],
|
||||
'published_at' => ['nullable', 'date'],
|
||||
'meta_title' => ['nullable', 'string', 'max:60'],
|
||||
'meta_description' => ['nullable', 'string', 'max:160'],
|
||||
'tags' => ['sometimes', 'array'],
|
||||
'tags.*' => ['integer', 'exists:tags,id'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class StoreTagRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'slug' => ['nullable', 'string', 'max:255', Rule::unique('tags', 'slug')],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class StoreToolRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'slug' => ['nullable', 'string', 'max:255', Rule::unique('tools', 'slug')],
|
||||
'description' => ['required', 'string'],
|
||||
'url' => ['required', 'url', 'max:255'],
|
||||
'logo_url' => ['nullable', 'url', 'max:255'],
|
||||
'category' => ['required', Rule::in(array_keys(config('tools.categories')))],
|
||||
'subcategory' => ['nullable', 'string', 'max:255'],
|
||||
'tags' => ['sometimes', 'array'],
|
||||
'tags.*' => ['string', 'max:50'],
|
||||
'pricing' => ['nullable', 'string', 'max:255'],
|
||||
'features' => ['sometimes', 'array'],
|
||||
'features.*' => ['string', 'max:255'],
|
||||
'featured' => ['sometimes', 'boolean'],
|
||||
'approved' => ['sometimes', 'boolean'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class UpdateCategoryRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'parent_id' => ['nullable', 'exists:categories,id', Rule::notIn([$this->route('category')?->id])],
|
||||
'name' => ['sometimes', 'required', 'string', 'max:255'],
|
||||
'slug' => ['sometimes', 'required', 'string', 'max:255', Rule::unique('categories', 'slug')->ignore($this->route('category'))],
|
||||
'description' => ['nullable', 'string'],
|
||||
'meta_title' => ['nullable', 'string', 'max:60'],
|
||||
'meta_description' => ['nullable', 'string', 'max:160'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use App\Enums\PostStatus;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\Rules\Enum;
|
||||
|
||||
class UpdatePostRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'category_id' => ['nullable', 'exists:categories,id'],
|
||||
'title' => ['sometimes', 'required', 'string', 'max:255'],
|
||||
'slug' => ['sometimes', 'required', 'string', 'max:255', Rule::unique('posts', 'slug')->ignore($this->route('post'))],
|
||||
'excerpt' => ['nullable', 'string', 'max:255'],
|
||||
'body' => ['sometimes', 'required', 'string'],
|
||||
'featured_image' => ['nullable', 'string'],
|
||||
'status' => ['sometimes', 'required', new Enum(PostStatus::class)],
|
||||
'published_at' => ['nullable', 'date'],
|
||||
'meta_title' => ['nullable', 'string', 'max:60'],
|
||||
'meta_description' => ['nullable', 'string', 'max:160'],
|
||||
'tags' => ['sometimes', 'array'],
|
||||
'tags.*' => ['integer', 'exists:tags,id'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class UpdateTagRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'name' => ['sometimes', 'required', 'string', 'max:255'],
|
||||
'slug' => ['sometimes', 'required', 'string', 'max:255', Rule::unique('tags', 'slug')->ignore($this->route('tag'))],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class UpdateToolRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'name' => ['sometimes', 'required', 'string', 'max:255'],
|
||||
'slug' => ['sometimes', 'required', 'string', 'max:255', Rule::unique('tools', 'slug')->ignore($this->route('tool'))],
|
||||
'description' => ['sometimes', 'required', 'string'],
|
||||
'url' => ['sometimes', 'required', 'url', 'max:255'],
|
||||
'logo_url' => ['nullable', 'url', 'max:255'],
|
||||
'category' => ['sometimes', 'required', Rule::in(array_keys(config('tools.categories')))],
|
||||
'subcategory' => ['nullable', 'string', 'max:255'],
|
||||
'tags' => ['sometimes', 'array'],
|
||||
'tags.*' => ['string', 'max:50'],
|
||||
'pricing' => ['nullable', 'string', 'max:255'],
|
||||
'features' => ['sometimes', 'array'],
|
||||
'features.*' => ['string', 'max:255'],
|
||||
'featured' => ['sometimes', 'boolean'],
|
||||
'approved' => ['sometimes', 'boolean'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Resources;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
class CategoryResource extends JsonResource
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'parent_id' => $this->parent_id,
|
||||
'name' => $this->name,
|
||||
'slug' => $this->slug,
|
||||
'description' => $this->description,
|
||||
'meta_title' => $this->meta_title,
|
||||
'meta_description' => $this->meta_description,
|
||||
'created_at' => $this->created_at,
|
||||
'updated_at' => $this->updated_at,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Resources;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
class PostResource extends JsonResource
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'title' => $this->title,
|
||||
'slug' => $this->slug,
|
||||
'excerpt' => $this->excerpt,
|
||||
'body' => $this->body,
|
||||
'featured_image' => $this->featured_image,
|
||||
'status' => $this->status,
|
||||
'published_at' => $this->published_at,
|
||||
'meta_title' => $this->meta_title,
|
||||
'meta_description' => $this->meta_description,
|
||||
'author' => new UserResource($this->whenLoaded('user')),
|
||||
'category' => new CategoryResource($this->whenLoaded('category')),
|
||||
'tags' => TagResource::collection($this->whenLoaded('tags')),
|
||||
'created_at' => $this->created_at,
|
||||
'updated_at' => $this->updated_at,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Resources;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
class TagResource extends JsonResource
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'name' => $this->name,
|
||||
'slug' => $this->slug,
|
||||
'created_at' => $this->created_at,
|
||||
'updated_at' => $this->updated_at,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Resources;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
class ToolResource extends JsonResource
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'name' => $this->name,
|
||||
'slug' => $this->slug,
|
||||
'description' => $this->description,
|
||||
'url' => $this->url,
|
||||
'logo_url' => $this->logo_url,
|
||||
'category' => $this->category,
|
||||
'category_label' => $this->category_label,
|
||||
'subcategory' => $this->subcategory,
|
||||
'tags' => $this->tags,
|
||||
'pricing' => $this->pricing,
|
||||
'features' => $this->features,
|
||||
'views' => $this->views,
|
||||
'clicks' => $this->clicks,
|
||||
'featured' => $this->featured,
|
||||
'approved' => $this->approved,
|
||||
'submitted_by' => new UserResource($this->whenLoaded('user')),
|
||||
'created_at' => $this->created_at,
|
||||
'updated_at' => $this->updated_at,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Resources;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
class UserResource extends JsonResource
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'name' => $this->name,
|
||||
'email' => $this->email,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Database\Factories\CategoryFactory;
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Attributes\RouteKey;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
#[Fillable(['parent_id', 'name', 'slug', 'description', 'meta_title', 'meta_description'])]
|
||||
#[RouteKey('slug')]
|
||||
class Category extends Model
|
||||
{
|
||||
/** @use HasFactory<CategoryFactory> */
|
||||
use HasFactory;
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::creating(function (Category $category) {
|
||||
if (empty($category->slug)) {
|
||||
$category->slug = Str::slug($category->name);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<Category, $this>
|
||||
*/
|
||||
public function parent(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Category::class, 'parent_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return HasMany<Category, $this>
|
||||
*/
|
||||
public function children(): HasMany
|
||||
{
|
||||
return $this->hasMany(Category::class, 'parent_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return HasMany<Post, $this>
|
||||
*/
|
||||
public function posts(): HasMany
|
||||
{
|
||||
return $this->hasMany(Post::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, Category>
|
||||
*/
|
||||
public function ancestors(): Collection
|
||||
{
|
||||
$ancestors = collect();
|
||||
$category = $this->parent;
|
||||
|
||||
while ($category !== null) {
|
||||
$ancestors->prepend($category);
|
||||
$category = $category->parent;
|
||||
}
|
||||
|
||||
return $ancestors;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Database\Factories\PageFactory;
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Attributes\RouteKey;
|
||||
use Illuminate\Database\Eloquent\Attributes\Scope;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
#[Fillable([
|
||||
'title', 'slug', 'body', 'meta_title', 'meta_description',
|
||||
'published', 'show_in_footer', 'order',
|
||||
])]
|
||||
#[RouteKey('slug')]
|
||||
class Page extends Model
|
||||
{
|
||||
/** @use HasFactory<PageFactory> */
|
||||
use HasFactory;
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'published' => 'boolean',
|
||||
'show_in_footer' => 'boolean',
|
||||
];
|
||||
}
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::creating(function (Page $page) {
|
||||
if (empty($page->slug)) {
|
||||
$page->slug = Str::slug($page->title);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Builder<Page> $query
|
||||
*/
|
||||
#[Scope]
|
||||
protected function published(Builder $query): void
|
||||
{
|
||||
$query->where('published', true);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Builder<Page> $query
|
||||
*/
|
||||
#[Scope]
|
||||
protected function inFooter(Builder $query): void
|
||||
{
|
||||
$query->where('show_in_footer', true)->orderBy('order')->orderBy('title');
|
||||
}
|
||||
|
||||
protected function bodyHtml(): Attribute
|
||||
{
|
||||
return Attribute::get(fn () => Str::markdown($this->body, [
|
||||
'html_input' => 'strip',
|
||||
'allow_unsafe_links' => false,
|
||||
]));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\PostStatus;
|
||||
use Database\Factories\PostFactory;
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Attributes\RouteKey;
|
||||
use Illuminate\Database\Eloquent\Attributes\Scope;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
#[Fillable([
|
||||
'user_id', 'category_id', 'title', 'slug', 'excerpt', 'body',
|
||||
'featured_image', 'status', 'published_at', 'meta_title', 'meta_description',
|
||||
])]
|
||||
#[RouteKey('slug')]
|
||||
class Post extends Model
|
||||
{
|
||||
/** @use HasFactory<PostFactory> */
|
||||
use HasFactory;
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'status' => PostStatus::class,
|
||||
'published_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::creating(function (Post $post) {
|
||||
if (empty($post->slug)) {
|
||||
$post->slug = Str::slug($post->title);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<User, $this>
|
||||
*/
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<Category, $this>
|
||||
*/
|
||||
public function category(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Category::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsToMany<Tag, $this>
|
||||
*/
|
||||
public function tags(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(Tag::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Builder<Post> $query
|
||||
*/
|
||||
#[Scope]
|
||||
protected function published(Builder $query): void
|
||||
{
|
||||
$query->where('status', PostStatus::Published)
|
||||
->whereNotNull('published_at')
|
||||
->where('published_at', '<=', now());
|
||||
}
|
||||
|
||||
protected function bodyHtml(): Attribute
|
||||
{
|
||||
return Attribute::get(fn () => Str::markdown($this->body, [
|
||||
'html_input' => 'strip',
|
||||
'allow_unsafe_links' => false,
|
||||
]));
|
||||
}
|
||||
|
||||
protected function excerptOrSummary(): Attribute
|
||||
{
|
||||
return Attribute::get(
|
||||
fn () => $this->excerpt ?: Str::limit(strip_tags(Str::markdown($this->body)), 160)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Database\Factories\TagFactory;
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Attributes\RouteKey;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
#[Fillable(['name', 'slug'])]
|
||||
#[RouteKey('slug')]
|
||||
class Tag extends Model
|
||||
{
|
||||
/** @use HasFactory<TagFactory> */
|
||||
use HasFactory;
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::creating(function (Tag $tag) {
|
||||
if (empty($tag->slug)) {
|
||||
$tag->slug = Str::slug($tag->name);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsToMany<Post, $this>
|
||||
*/
|
||||
public function posts(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(Post::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Database\Factories\ToolFactory;
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Attributes\RouteKey;
|
||||
use Illuminate\Database\Eloquent\Attributes\Scope;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
#[Fillable([
|
||||
'name', 'slug', 'description', 'url', 'logo_url', 'category', 'subcategory',
|
||||
'tags', 'pricing', 'features', 'featured', 'approved', 'user_id',
|
||||
])]
|
||||
#[RouteKey('slug')]
|
||||
class Tool extends Model
|
||||
{
|
||||
/** @use HasFactory<ToolFactory> */
|
||||
use HasFactory;
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'tags' => 'array',
|
||||
'features' => 'array',
|
||||
'featured' => 'boolean',
|
||||
'approved' => 'boolean',
|
||||
];
|
||||
}
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::creating(function (Tool $tool) {
|
||||
if (empty($tool->slug)) {
|
||||
$tool->slug = Str::slug($tool->name);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<User, $this>
|
||||
*/
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Builder<Tool> $query
|
||||
*/
|
||||
#[Scope]
|
||||
protected function approved(Builder $query): void
|
||||
{
|
||||
$query->where('approved', true);
|
||||
}
|
||||
|
||||
protected function categoryLabel(): Attribute
|
||||
{
|
||||
return Attribute::get(fn () => config("tools.categories.{$this->category}", $this->category));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
// use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||
use Database\Factories\UserFactory;
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Attributes\Hidden;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
use Laravel\Sanctum\HasApiTokens;
|
||||
|
||||
#[Fillable(['name', 'email', 'password'])]
|
||||
#[Hidden(['password', 'remember_token'])]
|
||||
class User extends Authenticatable
|
||||
{
|
||||
/** @use HasFactory<UserFactory> */
|
||||
use HasApiTokens, HasFactory, Notifiable;
|
||||
|
||||
/**
|
||||
* @return HasMany<Post>
|
||||
*/
|
||||
public function posts(): HasMany
|
||||
{
|
||||
return $this->hasMany(Post::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return HasMany<Tool, $this>
|
||||
*/
|
||||
public function tools(): HasMany
|
||||
{
|
||||
return $this->hasMany(Tool::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the attributes that should be cast.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'email_verified_at' => 'datetime',
|
||||
'password' => 'hashed',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Models\Page;
|
||||
use Illuminate\Support\Facades\View;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
class AppServiceProvider extends ServiceProvider
|
||||
{
|
||||
/**
|
||||
* Register any application services.
|
||||
*/
|
||||
public function register(): void
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* Bootstrap any application services.
|
||||
*/
|
||||
public function boot(): void
|
||||
{
|
||||
View::composer('components.layouts.app', function ($view) {
|
||||
$view->with('footerPages', Page::published()->inFooter()->get());
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
namespace App\Providers\Filament;
|
||||
|
||||
use Filament\Http\Middleware\Authenticate;
|
||||
use Filament\Http\Middleware\AuthenticateSession;
|
||||
use Filament\Http\Middleware\DisableBladeIconComponents;
|
||||
use Filament\Http\Middleware\DispatchServingFilamentEvent;
|
||||
use Filament\Pages\Dashboard;
|
||||
use Filament\Panel;
|
||||
use Filament\PanelProvider;
|
||||
use Filament\Support\Colors\Color;
|
||||
use Filament\Widgets\AccountWidget;
|
||||
use Filament\Widgets\FilamentInfoWidget;
|
||||
use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse;
|
||||
use Illuminate\Cookie\Middleware\EncryptCookies;
|
||||
use Illuminate\Foundation\Http\Middleware\PreventRequestForgery;
|
||||
use Illuminate\Routing\Middleware\SubstituteBindings;
|
||||
use Illuminate\Session\Middleware\StartSession;
|
||||
use Illuminate\View\Middleware\ShareErrorsFromSession;
|
||||
|
||||
class AdminPanelProvider extends PanelProvider
|
||||
{
|
||||
public function panel(Panel $panel): Panel
|
||||
{
|
||||
return $panel
|
||||
->default()
|
||||
->id('admin')
|
||||
->path('admin')
|
||||
->login()
|
||||
->brandLogo(asset('favicon.svg'))
|
||||
->brandLogoHeight('2rem')
|
||||
->favicon(asset('favicon.svg'))
|
||||
->colors([
|
||||
'primary' => Color::Amber,
|
||||
])
|
||||
->discoverResources(in: app_path('Filament/Resources'), for: 'App\Filament\Resources')
|
||||
->discoverPages(in: app_path('Filament/Pages'), for: 'App\Filament\Pages')
|
||||
->pages([
|
||||
Dashboard::class,
|
||||
])
|
||||
->discoverWidgets(in: app_path('Filament/Widgets'), for: 'App\Filament\Widgets')
|
||||
->widgets([
|
||||
AccountWidget::class,
|
||||
FilamentInfoWidget::class,
|
||||
])
|
||||
->middleware([
|
||||
EncryptCookies::class,
|
||||
AddQueuedCookiesToResponse::class,
|
||||
StartSession::class,
|
||||
AuthenticateSession::class,
|
||||
ShareErrorsFromSession::class,
|
||||
PreventRequestForgery::class,
|
||||
SubstituteBindings::class,
|
||||
DisableBladeIconComponents::class,
|
||||
DispatchServingFilamentEvent::class,
|
||||
])
|
||||
->authMiddleware([
|
||||
Authenticate::class,
|
||||
]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user