diff --git a/CHANGELOG.md b/CHANGELOG.md
index 363ddff..6cbae33 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,8 @@
added tags to entry model
+added text widget and category
+
## 2026-01-07
added simple API for entries model
diff --git a/app/Filament/Resources/Categroys/CategroyResource.php b/app/Filament/Resources/Categroys/CategroyResource.php
new file mode 100644
index 0000000..323c762
--- /dev/null
+++ b/app/Filament/Resources/Categroys/CategroyResource.php
@@ -0,0 +1,50 @@
+ ListCategroys::route('/'),
+ 'create' => CreateCategroy::route('/create'),
+ 'edit' => EditCategroy::route('/{record}/edit'),
+ ];
+ }
+}
diff --git a/app/Filament/Resources/Categroys/Pages/CreateCategroy.php b/app/Filament/Resources/Categroys/Pages/CreateCategroy.php
new file mode 100644
index 0000000..594d595
--- /dev/null
+++ b/app/Filament/Resources/Categroys/Pages/CreateCategroy.php
@@ -0,0 +1,11 @@
+components([
+ TextInput::make('name')
+ ->label('Category Name')
+ ->required()
+ ->maxLength(255),
+ ]);
+ }
+}
diff --git a/app/Filament/Resources/Categroys/Tables/CategroysTable.php b/app/Filament/Resources/Categroys/Tables/CategroysTable.php
new file mode 100644
index 0000000..211a64a
--- /dev/null
+++ b/app/Filament/Resources/Categroys/Tables/CategroysTable.php
@@ -0,0 +1,34 @@
+columns([
+ TextColumn::make('name')
+ ->label('Category Name')
+ ->sortable()
+ ->searchable(),
+ ])
+ ->filters([
+ //
+ ])
+ ->recordActions([
+ EditAction::make(),
+ ])
+ ->toolbarActions([
+ BulkActionGroup::make([
+ DeleteBulkAction::make(),
+ ]),
+ ]);
+ }
+}
diff --git a/app/Filament/Resources/Entries/Schemas/EntryForm.php b/app/Filament/Resources/Entries/Schemas/EntryForm.php
index bd48f4d..573a47a 100644
--- a/app/Filament/Resources/Entries/Schemas/EntryForm.php
+++ b/app/Filament/Resources/Entries/Schemas/EntryForm.php
@@ -2,19 +2,19 @@
namespace App\Filament\Resources\Entries\Schemas;
+use App\Models\Category;
use Filament\Actions\Action;
use Filament\Forms\Components\DatePicker;
use Filament\Forms\Components\RichEditor;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\SpatieMediaLibraryFileUpload;
+use Filament\Forms\Components\SpatieTagsInput;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Schemas\Schema;
-use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Spatie\MediaLibrary\MediaCollections\Models\Media;
-use Filament\Forms\Components\SpatieTagsInput;
class EntryForm
{
@@ -66,13 +66,13 @@ class EntryForm
$url = $item->getUrl();
$fileName = e($item->file_name);
$name = e($item->name ?? '');
-
- $html = "
" .
- "

" .
- "
" .
- "{$name}" .
- "{$fileName}" .
- "
";
+
+ $html = "".
+ "

".
+ "
".
+ "{$name}".
+ "{$fileName}".
+ '
';
return [$item->id => $html];
} catch (\Exception $e) {
@@ -86,39 +86,42 @@ class EntryForm
])
->action(function (array $data, SpatieMediaLibraryFileUpload $component): void {
$record = $component->getRecord();
-
- if (!$record) {
+
+ if (! $record) {
\Filament\Notifications\Notification::make()
->warning()
->title('Save the entry first')
->send();
+
return;
}
- if (!$data['image_id']) {
+ if (! $data['image_id']) {
return;
}
$sourceMedia = Media::find($data['image_id']);
- if (!$sourceMedia || !file_exists($sourceMedia->getPath())) {
+ if (! $sourceMedia || ! file_exists($sourceMedia->getPath())) {
\Filament\Notifications\Notification::make()
->danger()
->title('Image file not found')
->send();
+
return;
}
$sourceFile = $sourceMedia->getPath();
- $tempCopy = sys_get_temp_dir() . '/' . uniqid() . '_' . $sourceMedia->file_name;
+ $tempCopy = sys_get_temp_dir().'/'.uniqid().'_'.$sourceMedia->file_name;
copy($sourceFile, $tempCopy);
try {
// Verify record has ID
- if (!$record->id) {
+ if (! $record->id) {
\Filament\Notifications\Notification::make()
->danger()
->title('Entry must be saved first')
->send();
+
return;
}
@@ -138,7 +141,7 @@ class EntryForm
} catch (\Exception $e) {
\Filament\Notifications\Notification::make()
->danger()
- ->title('Error: ' . $e->getMessage())
+ ->title('Error: '.$e->getMessage())
->send();
} finally {
if (file_exists($tempCopy)) {
@@ -152,6 +155,14 @@ class EntryForm
Toggle::make('is_featured')
->required(),
DatePicker::make('published_at'),
+ Select::make('category_id')
+ ->label('Category')
+ ->options(function () {
+ return Category::all()
+ ->pluck('name', 'id')
+ ->toArray();
+ })
+ ->searchable(),
RichEditor::make('content')
->columnSpanFull()
->hintAction(
@@ -177,17 +188,17 @@ class EntryForm
// Skip items that can't generate URLs
return [];
}
-
+
$fileName = e($item->file_name);
$name = e($item->name ?? '');
-
+
// Smaller image preview for better performance
- $html = "" .
- "

" .
- "
" .
- "{$name}" .
- "{$fileName}" .
- "
";
+ $html = "".
+ "

".
+ "
".
+ "{$name}".
+ "{$fileName}".
+ '
';
return [$url => $html];
})->toArray();
diff --git a/app/Filament/Resources/TextWidgets/Schemas/TextWidgetForm.php b/app/Filament/Resources/TextWidgets/Schemas/TextWidgetForm.php
index 5a0f530..53d229c 100644
--- a/app/Filament/Resources/TextWidgets/Schemas/TextWidgetForm.php
+++ b/app/Filament/Resources/TextWidgets/Schemas/TextWidgetForm.php
@@ -2,6 +2,8 @@
namespace App\Filament\Resources\TextWidgets\Schemas;
+use App\Models\Category;
+use Filament\Forms\Components\Select;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Schemas\Schema;
@@ -20,6 +22,14 @@ class TextWidgetForm
Textarea::make('content')
->rows(5)
->columnSpanFull(),
+ Select::make('category_id')
+ ->label('Category')
+ ->options(function () {
+ return Category::all()
+ ->pluck('name', 'id')
+ ->toArray();
+ })
+ ->searchable(),
]);
}
diff --git a/app/Http/Controllers/EntryController.php b/app/Http/Controllers/EntryController.php
index 21b0d6a..88f45e4 100644
--- a/app/Http/Controllers/EntryController.php
+++ b/app/Http/Controllers/EntryController.php
@@ -2,6 +2,7 @@
namespace App\Http\Controllers;
+use App\Http\Resources\EntryResource;
use App\Models\Entry;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
@@ -14,7 +15,7 @@ class EntryController extends Controller
*/
public function index()
{
- return Entry::all();
+ return EntryResource::collection(Entry::with('category')->get());
}
/**
@@ -24,7 +25,7 @@ class EntryController extends Controller
{
$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);
}
@@ -35,13 +36,13 @@ class EntryController extends Controller
$validated['slug'] = $this->generateUniqueSlug($validated['title']);
- return Entry::create($validated);
+ return new EntryResource(Entry::create($validated));
}
private function generateUniqueSlug(string $title): string
{
do {
- $slug = Str::slug($title) . '-' . Str::random(8);
+ $slug = Str::slug($title).'-'.Str::random(8);
} while (Entry::where('slug', $slug)->exists());
return $slug;
@@ -54,7 +55,7 @@ class EntryController extends Controller
{
$this->authorize('view', $entry);
- return $entry;
+ return new EntryResource($entry);
}
/**
@@ -69,7 +70,7 @@ class EntryController extends Controller
$entry->update($validated);
- return $entry;
+ return new EntryResource($entry);
}
/**
diff --git a/app/Http/Controllers/TextWidgetController.php b/app/Http/Controllers/TextWidgetController.php
index 7318464..113b9d3 100644
--- a/app/Http/Controllers/TextWidgetController.php
+++ b/app/Http/Controllers/TextWidgetController.php
@@ -2,6 +2,7 @@
namespace App\Http\Controllers;
+use App\Http\Resources\TextWidgetResource;
use App\Models\TextWidget;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
@@ -13,7 +14,7 @@ class TextWidgetController extends Controller
*/
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',
]);
- return TextWidget::create($validated);
+ return new TextWidgetResource(TextWidget::create($validated));
}
/**
@@ -41,7 +42,7 @@ class TextWidgetController extends Controller
*/
public function show(TextWidget $textWidget)
{
- return $textWidget;
+ return new TextWidgetResource($textWidget->load('category'));
}
/**
@@ -57,7 +58,7 @@ class TextWidgetController extends Controller
$textWidget->update($validated);
- return $textWidget;
+ return new TextWidgetResource($textWidget->load('category'));
}
/**
diff --git a/app/Http/Resources/EntryResource.php b/app/Http/Resources/EntryResource.php
new file mode 100644
index 0000000..e674d9a
--- /dev/null
+++ b/app/Http/Resources/EntryResource.php
@@ -0,0 +1,32 @@
+
+ */
+ 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,
+ ];
+ }
+}
diff --git a/app/Http/Resources/TextWidgetResource.php b/app/Http/Resources/TextWidgetResource.php
new file mode 100644
index 0000000..141452e
--- /dev/null
+++ b/app/Http/Resources/TextWidgetResource.php
@@ -0,0 +1,27 @@
+
+ */
+ 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,
+ ];
+ }
+}
diff --git a/app/Models/Category.php b/app/Models/Category.php
new file mode 100644
index 0000000..e82a9cc
--- /dev/null
+++ b/app/Models/Category.php
@@ -0,0 +1,10 @@
+belongsTo(Category::class);
+ }
}
diff --git a/app/Models/TextWidget.php b/app/Models/TextWidget.php
index a7e6991..73f121e 100644
--- a/app/Models/TextWidget.php
+++ b/app/Models/TextWidget.php
@@ -4,6 +4,7 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
+use Illuminate\Database\Eloquent\Relations\BelongsTo;
class TextWidget extends Model
{
@@ -13,5 +14,11 @@ class TextWidget extends Model
'title',
'description',
'content',
+ 'category_id',
];
+
+ public function category(): BelongsTo
+ {
+ return $this->belongsTo(Category::class);
+ }
}
diff --git a/database/migrations/2026_01_08_214229_create_categories_table.php b/database/migrations/2026_01_08_214229_create_categories_table.php
new file mode 100644
index 0000000..b86b7b1
--- /dev/null
+++ b/database/migrations/2026_01_08_214229_create_categories_table.php
@@ -0,0 +1,28 @@
+id();
+ $table->string('name');
+ $table->timestamps();
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::dropIfExists('categories');
+ }
+};
diff --git a/database/migrations/2026_01_08_215025_add_category_id_to_text_widgets_table.php b/database/migrations/2026_01_08_215025_add_category_id_to_text_widgets_table.php
new file mode 100644
index 0000000..72dd1ac
--- /dev/null
+++ b/database/migrations/2026_01_08_215025_add_category_id_to_text_widgets_table.php
@@ -0,0 +1,29 @@
+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');
+ });
+ }
+};
diff --git a/database/migrations/2026_01_08_215405_add_category_id_to_entries_table.php b/database/migrations/2026_01_08_215405_add_category_id_to_entries_table.php
new file mode 100644
index 0000000..96c8b89
--- /dev/null
+++ b/database/migrations/2026_01_08_215405_add_category_id_to_entries_table.php
@@ -0,0 +1,29 @@
+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');
+ });
+ }
+};
diff --git a/tests/Unit/EntryApiTest.php b/tests/Unit/EntryApiTest.php
index cbd3e33..0b8fea5 100644
--- a/tests/Unit/EntryApiTest.php
+++ b/tests/Unit/EntryApiTest.php
@@ -1,9 +1,9 @@
actingAs($user)->getJson('/api/entries');
$response->assertOk()
- ->assertJsonCount(3);
+ ->assertJsonCount(3, 'data');
});
test('can create an entry', function () {
@@ -30,7 +30,7 @@ test('can create an entry', function () {
$response = $this->postJson('/api/entries', $data);
$response->assertCreated()
- ->assertJsonFragment($data);
+ ->assertJsonFragment($data);
});
test('can show an entry', function () {
@@ -40,7 +40,7 @@ test('can show an entry', function () {
$response = $this->actingAs($user)->getJson("/api/entries/{$entry->id}");
$response->assertOk()
- ->assertJsonFragment(['id' => $entry->id]);
+ ->assertJsonFragment(['id' => $entry->id]);
});
test('can update an entry', function () {
@@ -51,7 +51,7 @@ test('can update an entry', function () {
$response = $this->actingAs($user)->putJson("/api/entries/{$entry->id}", $data);
$response->assertOk()
- ->assertJsonFragment($data);
+ ->assertJsonFragment($data);
});
test('can delete an entry', function () {
diff --git a/tests/Unit/EntryModelTest.php b/tests/Unit/EntryModelTest.php
index e339278..37fa5a6 100644
--- a/tests/Unit/EntryModelTest.php
+++ b/tests/Unit/EntryModelTest.php
@@ -11,9 +11,10 @@ it('has correct fillable attributes', function () {
'is_featured',
'published_at',
'content',
+ 'category_id',
];
- $entry = new Entry();
+ $entry = new Entry;
expect($entry->getFillable())->toEqual($expected);
});
diff --git a/tests/Unit/TextWidgetModelTest.php b/tests/Unit/TextWidgetModelTest.php
index 5dc13f6..13f405b 100644
--- a/tests/Unit/TextWidgetModelTest.php
+++ b/tests/Unit/TextWidgetModelTest.php
@@ -7,6 +7,7 @@ it('has correct fillable attributes', function () {
'title',
'description',
'content',
+ 'category_id',
];
$entry = new TextWidget;