Merge pull request 'feat: add category management' (#10) from feat/categories into dev

Reviewed-on: https://codeberg.org/headshed/share-lt/pulls/10
This commit is contained in:
Jon Brookes 2026-01-09 14:21:58 +01:00
commit 6bf486e52b
22 changed files with 392 additions and 46 deletions

View file

@ -4,6 +4,8 @@
added tags to entry model added tags to entry model
added text widget and category
## 2026-01-07 ## 2026-01-07
added simple API for entries model added simple API for entries model

View file

@ -0,0 +1,50 @@
<?php
namespace App\Filament\Resources\Categroys;
use App\Filament\Resources\Categroys\Pages\CreateCategroy;
use App\Filament\Resources\Categroys\Pages\EditCategroy;
use App\Filament\Resources\Categroys\Pages\ListCategroys;
use App\Filament\Resources\Categroys\Schemas\CategroyForm;
use App\Filament\Resources\Categroys\Tables\CategroysTable;
use App\Models\Category;
use BackedEnum;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Table;
class CategroyResource extends Resource
{
protected static ?string $model = Category::class;
protected static string|BackedEnum|null $navigationIcon = Heroicon::RectangleGroup;
protected static ?string $recordTitleAttribute = 'name';
public static function form(Schema $schema): Schema
{
return CategroyForm::configure($schema);
}
public static function table(Table $table): Table
{
return CategroysTable::configure($table);
}
public static function getRelations(): array
{
return [
//
];
}
public static function getPages(): array
{
return [
'index' => ListCategroys::route('/'),
'create' => CreateCategroy::route('/create'),
'edit' => EditCategroy::route('/{record}/edit'),
];
}
}

View file

@ -0,0 +1,11 @@
<?php
namespace App\Filament\Resources\Categroys\Pages;
use App\Filament\Resources\Categroys\CategroyResource;
use Filament\Resources\Pages\CreateRecord;
class CreateCategroy extends CreateRecord
{
protected static string $resource = CategroyResource::class;
}

View file

@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\Categroys\Pages;
use App\Filament\Resources\Categroys\CategroyResource;
use Filament\Actions\DeleteAction;
use Filament\Resources\Pages\EditRecord;
class EditCategroy extends EditRecord
{
protected static string $resource = CategroyResource::class;
protected function getHeaderActions(): array
{
return [
DeleteAction::make(),
];
}
}

View file

@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\Categroys\Pages;
use App\Filament\Resources\Categroys\CategroyResource;
use Filament\Actions\CreateAction;
use Filament\Resources\Pages\ListRecords;
class ListCategroys extends ListRecords
{
protected static string $resource = CategroyResource::class;
protected function getHeaderActions(): array
{
return [
CreateAction::make(),
];
}
}

View file

@ -0,0 +1,20 @@
<?php
namespace App\Filament\Resources\Categroys\Schemas;
use Filament\Forms\Components\TextInput;
use Filament\Schemas\Schema;
class CategroyForm
{
public static function configure(Schema $schema): Schema
{
return $schema
->components([
TextInput::make('name')
->label('Category Name')
->required()
->maxLength(255),
]);
}
}

View file

@ -0,0 +1,34 @@
<?php
namespace App\Filament\Resources\Categroys\Tables;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\DeleteBulkAction;
use Filament\Actions\EditAction;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
class CategroysTable
{
public static function configure(Table $table): Table
{
return $table
->columns([
TextColumn::make('name')
->label('Category Name')
->sortable()
->searchable(),
])
->filters([
//
])
->recordActions([
EditAction::make(),
])
->toolbarActions([
BulkActionGroup::make([
DeleteBulkAction::make(),
]),
]);
}
}

View file

@ -2,19 +2,19 @@
namespace App\Filament\Resources\Entries\Schemas; namespace App\Filament\Resources\Entries\Schemas;
use App\Models\Category;
use Filament\Actions\Action; use Filament\Actions\Action;
use Filament\Forms\Components\DatePicker; use Filament\Forms\Components\DatePicker;
use Filament\Forms\Components\RichEditor; use Filament\Forms\Components\RichEditor;
use Filament\Forms\Components\Select; use Filament\Forms\Components\Select;
use Filament\Forms\Components\SpatieMediaLibraryFileUpload; use Filament\Forms\Components\SpatieMediaLibraryFileUpload;
use Filament\Forms\Components\SpatieTagsInput;
use Filament\Forms\Components\Textarea; use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput; use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle; use Filament\Forms\Components\Toggle;
use Filament\Schemas\Schema; use Filament\Schemas\Schema;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Spatie\MediaLibrary\MediaCollections\Models\Media; use Spatie\MediaLibrary\MediaCollections\Models\Media;
use Filament\Forms\Components\SpatieTagsInput;
class EntryForm class EntryForm
{ {
@ -67,12 +67,12 @@ class EntryForm
$fileName = e($item->file_name); $fileName = e($item->file_name);
$name = e($item->name ?? ''); $name = e($item->name ?? '');
$html = "<div class='flex items-center gap-3'>" . $html = "<div class='flex items-center gap-3'>".
"<img src='{$url}' class='rounded' style='width:60px;height:60px;object-fit:cover;' alt='{$fileName}' loading='lazy' />" . "<img src='{$url}' class='rounded' style='width:60px;height:60px;object-fit:cover;' alt='{$fileName}' loading='lazy' />".
"<div class='flex flex-col'>" . "<div class='flex flex-col'>".
"<span class='font-medium text-sm'>{$name}</span>" . "<span class='font-medium text-sm'>{$name}</span>".
"<span class='text-xs text-gray-500'>{$fileName}</span>" . "<span class='text-xs text-gray-500'>{$fileName}</span>".
"</div></div>"; '</div></div>';
return [$item->id => $html]; return [$item->id => $html];
} catch (\Exception $e) { } catch (\Exception $e) {
@ -87,38 +87,41 @@ class EntryForm
->action(function (array $data, SpatieMediaLibraryFileUpload $component): void { ->action(function (array $data, SpatieMediaLibraryFileUpload $component): void {
$record = $component->getRecord(); $record = $component->getRecord();
if (!$record) { if (! $record) {
\Filament\Notifications\Notification::make() \Filament\Notifications\Notification::make()
->warning() ->warning()
->title('Save the entry first') ->title('Save the entry first')
->send(); ->send();
return; return;
} }
if (!$data['image_id']) { if (! $data['image_id']) {
return; return;
} }
$sourceMedia = Media::find($data['image_id']); $sourceMedia = Media::find($data['image_id']);
if (!$sourceMedia || !file_exists($sourceMedia->getPath())) { if (! $sourceMedia || ! file_exists($sourceMedia->getPath())) {
\Filament\Notifications\Notification::make() \Filament\Notifications\Notification::make()
->danger() ->danger()
->title('Image file not found') ->title('Image file not found')
->send(); ->send();
return; return;
} }
$sourceFile = $sourceMedia->getPath(); $sourceFile = $sourceMedia->getPath();
$tempCopy = sys_get_temp_dir() . '/' . uniqid() . '_' . $sourceMedia->file_name; $tempCopy = sys_get_temp_dir().'/'.uniqid().'_'.$sourceMedia->file_name;
copy($sourceFile, $tempCopy); copy($sourceFile, $tempCopy);
try { try {
// Verify record has ID // Verify record has ID
if (!$record->id) { if (! $record->id) {
\Filament\Notifications\Notification::make() \Filament\Notifications\Notification::make()
->danger() ->danger()
->title('Entry must be saved first') ->title('Entry must be saved first')
->send(); ->send();
return; return;
} }
@ -138,7 +141,7 @@ class EntryForm
} catch (\Exception $e) { } catch (\Exception $e) {
\Filament\Notifications\Notification::make() \Filament\Notifications\Notification::make()
->danger() ->danger()
->title('Error: ' . $e->getMessage()) ->title('Error: '.$e->getMessage())
->send(); ->send();
} finally { } finally {
if (file_exists($tempCopy)) { if (file_exists($tempCopy)) {
@ -152,6 +155,14 @@ class EntryForm
Toggle::make('is_featured') Toggle::make('is_featured')
->required(), ->required(),
DatePicker::make('published_at'), DatePicker::make('published_at'),
Select::make('category_id')
->label('Category')
->options(function () {
return Category::all()
->pluck('name', 'id')
->toArray();
})
->searchable(),
RichEditor::make('content') RichEditor::make('content')
->columnSpanFull() ->columnSpanFull()
->hintAction( ->hintAction(
@ -182,12 +193,12 @@ class EntryForm
$name = e($item->name ?? ''); $name = e($item->name ?? '');
// Smaller image preview for better performance // Smaller image preview for better performance
$html = "<div class='flex items-center gap-3'>" . $html = "<div class='flex items-center gap-3'>".
"<img src='{$url}' class='rounded' style='width:60px;height:60px;object-fit:cover;' alt='{$fileName}' loading='lazy' />" . "<img src='{$url}' class='rounded' style='width:60px;height:60px;object-fit:cover;' alt='{$fileName}' loading='lazy' />".
"<div class='flex flex-col'>" . "<div class='flex flex-col'>".
"<span class='font-medium text-sm'>{$name}</span>" . "<span class='font-medium text-sm'>{$name}</span>".
"<span class='text-xs text-gray-500'>{$fileName}</span>" . "<span class='text-xs text-gray-500'>{$fileName}</span>".
"</div></div>"; '</div></div>';
return [$url => $html]; return [$url => $html];
})->toArray(); })->toArray();

View file

@ -2,6 +2,8 @@
namespace App\Filament\Resources\TextWidgets\Schemas; namespace App\Filament\Resources\TextWidgets\Schemas;
use App\Models\Category;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\Textarea; use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput; use Filament\Forms\Components\TextInput;
use Filament\Schemas\Schema; use Filament\Schemas\Schema;
@ -20,6 +22,14 @@ class TextWidgetForm
Textarea::make('content') Textarea::make('content')
->rows(5) ->rows(5)
->columnSpanFull(), ->columnSpanFull(),
Select::make('category_id')
->label('Category')
->options(function () {
return Category::all()
->pluck('name', 'id')
->toArray();
})
->searchable(),
]); ]);
} }

View file

@ -2,6 +2,7 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Http\Resources\EntryResource;
use App\Models\Entry; use App\Models\Entry;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
@ -14,7 +15,7 @@ class EntryController extends Controller
*/ */
public function index() public function index()
{ {
return Entry::all(); return EntryResource::collection(Entry::with('category')->get());
} }
/** /**
@ -24,7 +25,7 @@ class EntryController extends Controller
{ {
$user = Auth::user(); $user = Auth::user();
if (!$user || $user->email !== config('app.admin_email')) { if (! $user || $user->email !== config('app.admin_email')) {
return response()->json(['message' => 'Forbidden'], 403); return response()->json(['message' => 'Forbidden'], 403);
} }
@ -35,13 +36,13 @@ class EntryController extends Controller
$validated['slug'] = $this->generateUniqueSlug($validated['title']); $validated['slug'] = $this->generateUniqueSlug($validated['title']);
return Entry::create($validated); return new EntryResource(Entry::create($validated));
} }
private function generateUniqueSlug(string $title): string private function generateUniqueSlug(string $title): string
{ {
do { do {
$slug = Str::slug($title) . '-' . Str::random(8); $slug = Str::slug($title).'-'.Str::random(8);
} while (Entry::where('slug', $slug)->exists()); } while (Entry::where('slug', $slug)->exists());
return $slug; return $slug;
@ -54,7 +55,7 @@ class EntryController extends Controller
{ {
$this->authorize('view', $entry); $this->authorize('view', $entry);
return $entry; return new EntryResource($entry);
} }
/** /**
@ -69,7 +70,7 @@ class EntryController extends Controller
$entry->update($validated); $entry->update($validated);
return $entry; return new EntryResource($entry);
} }
/** /**

View file

@ -2,6 +2,7 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Http\Resources\TextWidgetResource;
use App\Models\TextWidget; use App\Models\TextWidget;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
@ -13,7 +14,7 @@ class TextWidgetController extends Controller
*/ */
public function index() public function index()
{ {
return TextWidget::all(); return TextWidget::with('category')->get()->map(fn ($tw) => new TextWidgetResource($tw));
} }
/** /**
@ -33,7 +34,7 @@ class TextWidgetController extends Controller
'content' => 'required|string', 'content' => 'required|string',
]); ]);
return TextWidget::create($validated); return new TextWidgetResource(TextWidget::create($validated));
} }
/** /**
@ -41,7 +42,7 @@ class TextWidgetController extends Controller
*/ */
public function show(TextWidget $textWidget) public function show(TextWidget $textWidget)
{ {
return $textWidget; return new TextWidgetResource($textWidget->load('category'));
} }
/** /**
@ -57,7 +58,7 @@ class TextWidgetController extends Controller
$textWidget->update($validated); $textWidget->update($validated);
return $textWidget; return new TextWidgetResource($textWidget->load('category'));
} }
/** /**

View file

@ -0,0 +1,32 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class EntryResource extends JsonResource
{
/**
* Transform the resource into an array.
*
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'title' => $this->title,
'slug' => $this->slug,
'description' => $this->description,
'is_published' => $this->is_published,
'is_featured' => $this->is_featured,
'published_at' => $this->published_at,
'content' => $this->content,
'category' => $this->category->name ?? null,
'featured_image_url' => $this->getFirstMediaUrl('featured-image') ?: null,
'created_at' => $this->created_at,
'updated_at' => $this->updated_at,
];
}
}

View file

@ -0,0 +1,27 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class TextWidgetResource extends JsonResource
{
/**
* Transform the resource into an array.
*
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'title' => $this->title,
'description' => $this->description,
'content' => $this->content,
'category' => $this->category->name ?? null,
'created_at' => $this->created_at,
'updated_at' => $this->updated_at,
];
}
}

10
app/Models/Category.php Normal file
View file

@ -0,0 +1,10 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Category extends Model
{
protected $fillable = ['name'];
}

View file

@ -7,6 +7,7 @@ use Filament\Forms\Components\RichEditor\Models\Concerns\InteractsWithRichConten
use Filament\Forms\Components\RichEditor\Models\Contracts\HasRichContent; use Filament\Forms\Components\RichEditor\Models\Contracts\HasRichContent;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Spatie\MediaLibrary\HasMedia; use Spatie\MediaLibrary\HasMedia;
use Spatie\MediaLibrary\InteractsWithMedia; use Spatie\MediaLibrary\InteractsWithMedia;
@ -16,11 +17,9 @@ use Spatie\Tags\HasTags;
Entry model with rich content and media library integration Entry model with rich content and media library integration
This is the main article / blog rich content model This is the main article / blog rich content model
*/ */
class Entry extends Model implements HasRichContent, HasMedia class Entry extends Model implements HasMedia, HasRichContent
{ {
use HasFactory, HasTags, InteractsWithMedia, InteractsWithRichContent;
use InteractsWithMedia, InteractsWithRichContent, HasFactory, HasTags;
protected $fillable = [ protected $fillable = [
'title', 'title',
@ -30,9 +29,9 @@ class Entry extends Model implements HasRichContent, HasMedia
'is_featured', 'is_featured',
'published_at', 'published_at',
'content', 'content',
'category_id',
]; ];
/** /**
* Set up rich content configuration for media library integration * Set up rich content configuration for media library integration
*/ */
@ -56,4 +55,9 @@ class Entry extends Model implements HasRichContent, HasMedia
} }
}); });
} }
public function category(): BelongsTo
{
return $this->belongsTo(Category::class);
}
} }

View file

@ -4,6 +4,7 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class TextWidget extends Model class TextWidget extends Model
{ {
@ -13,5 +14,11 @@ class TextWidget extends Model
'title', 'title',
'description', 'description',
'content', 'content',
'category_id',
]; ];
public function category(): BelongsTo
{
return $this->belongsTo(Category::class);
}
} }

View file

@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('categories', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('categories');
}
};

View file

@ -0,0 +1,29 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('text_widgets', function (Blueprint $table) {
$table->foreignId('category_id')->nullable()->constrained('categories');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('text_widgets', function (Blueprint $table) {
$table->dropForeign(['category_id']);
$table->dropColumn('category_id');
});
}
};

View file

@ -0,0 +1,29 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('entries', function (Blueprint $table) {
$table->foreignId('category_id')->nullable()->constrained('categories');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('entries', function (Blueprint $table) {
$table->dropForeign(['category_id']);
$table->dropColumn('category_id');
});
}
};

View file

@ -1,9 +1,9 @@
<?php <?php
use App\Models\Entry; use App\Models\Entry;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Config; use Illuminate\Support\Facades\Config;
use App\Models\User;
uses(RefreshDatabase::class); uses(RefreshDatabase::class);
@ -14,7 +14,7 @@ test('can list entries', function () {
$response = $this->actingAs($user)->getJson('/api/entries'); $response = $this->actingAs($user)->getJson('/api/entries');
$response->assertOk() $response->assertOk()
->assertJsonCount(3); ->assertJsonCount(3, 'data');
}); });
test('can create an entry', function () { test('can create an entry', function () {

View file

@ -11,9 +11,10 @@ it('has correct fillable attributes', function () {
'is_featured', 'is_featured',
'published_at', 'published_at',
'content', 'content',
'category_id',
]; ];
$entry = new Entry(); $entry = new Entry;
expect($entry->getFillable())->toEqual($expected); expect($entry->getFillable())->toEqual($expected);
}); });

View file

@ -7,6 +7,7 @@ it('has correct fillable attributes', function () {
'title', 'title',
'description', 'description',
'content', 'content',
'category_id',
]; ];
$entry = new TextWidget; $entry = new TextWidget;