first commit

This commit is contained in:
Doar Mihai
2026-08-08 18:03:29 +03:00
commit df2128ebff
183 changed files with 18719 additions and 0 deletions
+19
View File
@@ -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();
}
}
+93
View File
@@ -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');
}
}
+8
View File
@@ -0,0 +1,8 @@
<?php
namespace App\Http\Controllers;
abstract class Controller
{
//
}
+21
View File
@@ -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');
}
}
+89
View File
@@ -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'],
];
}
}
+37
View File
@@ -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'],
];
}
}
+25
View File
@@ -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')],
];
}
}
+37
View File
@@ -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'],
];
}
}
+37
View File
@@ -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'],
];
}
}
+25
View File
@@ -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'))],
];
}
}
+37
View File
@@ -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'],
];
}
}
+27
View File
@@ -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,
];
}
}
+33
View File
@@ -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,
];
}
}
+23
View File
@@ -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,
];
}
}
+37
View File
@@ -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,
];
}
}
+21
View File
@@ -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,
];
}
}
+70
View File
@@ -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;
}
}
+67
View File
@@ -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,
]));
}
}
+94
View File
@@ -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)
);
}
}
+36
View File
@@ -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);
}
}
+66
View File
@@ -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));
}
}
+50
View File
@@ -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',
];
}
}
+28
View File
@@ -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,
]);
}
}