Compare commits

..

26 commits

Author SHA1 Message Date
jon brookes
22393b5954 feat: change order of Dusk tests
for login, image upload, and entry creation in admin panel
2026-01-05 19:04:04 +00:00
jon brookes
56ce59fc22 feat: add DuskTestCase for enhanced browser testing setup 2026-01-05 18:48:13 +00:00
jon brookes
cfb9353475 feat: extend Pest with DuskTestCase for browser testing 2026-01-05 18:44:24 +00:00
jon brookes
b0fc008530 feat: complete image upload to entry test
enhance entry creation test to select existing images and submit
2026-01-05 18:42:02 +00:00
jon brookes
33688b55be feat: add critical testing approach guidelines for Laravel Dusk 2026-01-05 18:38:05 +00:00
jon brookes
50e5fb7f3f feat: enhance media entry test to select existing images 2026-01-05 17:40:22 +00:00
jon brookes
ae662a30ef feat: custom class in entry form
and update Dusk test for featured image selection
2026-01-05 17:17:48 +00:00
jon brookes
bef3ae7f41 feat: implement user authentication traits
and tests for admin panel image uploads
2026-01-05 16:37:06 +00:00
jon brookes
4cb9d078b1 initial partically working 2026-01-05 15:05:27 +00:00
jon brookes
1e35e485ad initial partically working 2026-01-05 14:56:40 +00:00
jon brookes
ff5ab3aa58 feat: add admin email configuration to app settings
delete: remove outdated Spatie media library decision document

docs: create new decision document for Spatie media library usage

docs: add testing strategy document for Pest4 and Dusk

test: implement login tests with Dusk for user authentication

chore: add .gitignore files for console, screenshots, and source directories
2026-01-05 13:43:07 +00:00
jon brookes
aa39707e10 fix: correct PSR-4 autoload configuration in composer.json 2026-01-04 16:17:38 +00:00
jon brookes
9f8c8d43f5 added filament spatie lib back in - must be needed after all 2026-01-04 16:11:02 +00:00
jon brookes
93c977d1f5 added fixes: warning
some files likely wont be needed and wer added by ai to fix things that were no longer needed !!!
2026-01-03 17:26:18 +00:00
jon brookes
8e1650653b fix: partical images broken
public seems to be holding

still issues with picking images - not saved
2026-01-03 15:12:34 +00:00
jon brookes
6d1d88542e fix: storage to public
private was breaking things bad
2026-01-03 14:52:52 +00:00
jon brookes
a94a34ce3b fix: storage to public
this was breaking things bad
2026-01-03 14:52:04 +00:00
jon brookes
c49249ee20 fix: partical fix for featured
imaes are added, seem semi permanent

disapapar in media lib
2026-01-03 14:25:23 +00:00
jon brookes
340b466ade feat: enhance featured image upload
with gallery selection and preview
2026-01-03 13:35:38 +00:00
jon brookes
9f01d44c9d added feaured image to entry
edit form now has image upload for featured image

table view for entries shows featured image

view entry shows featured image
2026-01-03 13:22:14 +00:00
jon brookes
d24b9b0732 removed: filament/spatie-laravel-media-library-plugin 2026-01-03 12:59:56 +00:00
jon brookes
02884d4e2b feat: implement Spatie Media Library integration
with CRUD operations and media management UI
2026-01-02 18:59:24 +00:00
jon brookes
d40b87438d fix: add render hook for Vite in AdminPanelProvider 2026-01-02 16:57:30 +00:00
jon brookes
5ea0ddce23 feat: integrate Spatie Media Library and update configuration
- Added Spatie Media Library dependencies to composer.json
- Created media table migration for media management
- Added media library configuration file
- Updated Entry model to support media handling
- Updated .gitignore to exclude Vite files
- Added basic logging to app.js
2026-01-02 16:56:48 +00:00
Jon Brookes
6cf8d5dfd4 Merge pull request 'added initial entries model' (#3) from feat/add-first-model into dev
Reviewed-on: https://codeberg.org/headshed/share-lt/pulls/3
2026-01-02 16:53:40 +01:00
jon brookes
a0a1c08ece added initial entries model 2026-01-02 15:46:42 +00:00
52 changed files with 2826 additions and 16 deletions

View file

@ -15,6 +15,7 @@ This application is a Laravel application and its main Laravel ecosystems packag
- laravel/prompts (PROMPTS) - v0
- livewire/flux (FLUXUI_FREE) - v2
- livewire/livewire (LIVEWIRE) - v3
- laravel/dusk (DUSK) - v8
- laravel/mcp (MCP) - v0
- laravel/pint (PINT) - v1
- laravel/sail (SAIL) - v1
@ -43,6 +44,13 @@ This application is a Laravel application and its main Laravel ecosystems packag
## Documentation Files
- You must only create documentation files if explicitly requested by the user.
## Testing Approach - CRITICAL
- **THE APPLICATION IS 100% WORKING** - All functionality works perfectly in production.
- When writing or debugging browser tests (Laravel Dusk), focus ONLY on test syntax, selectors, and Dusk interaction methods.
- NEVER assume the application has bugs or suggest app fixes - the issue is always in the test code.
- Trust the existing functionality and work on getting the correct CSS selectors, XPath expressions, and Dusk methods.
- If manual interaction works but the test fails, the problem is the test implementation, not the app.
=== boost rules ===

3
.gitignore vendored
View file

@ -21,3 +21,6 @@ yarn-error.log
/.nova
/.vscode
/.zed
.vite
*.deleted
.env.dusk.local

View file

@ -5,3 +5,8 @@
added: laravel 12
added: AGPLv3
## 2026-01-02
added initial model and filament resource

View file

@ -15,6 +15,7 @@ This application is a Laravel application and its main Laravel ecosystems packag
- laravel/prompts (PROMPTS) - v0
- livewire/flux (FLUXUI_FREE) - v2
- livewire/livewire (LIVEWIRE) - v3
- laravel/dusk (DUSK) - v8
- laravel/mcp (MCP) - v0
- laravel/pint (PINT) - v1
- laravel/sail (SAIL) - v1

View file

@ -219,7 +219,7 @@ If you develop a new program, and you want it to be of the greatest possible use
To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found.
test
share-lt
Copyright (C) 2026 jon
This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.

View file

@ -0,0 +1,112 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Storage;
use Spatie\MediaLibrary\MediaCollections\Models\Media;
class MoveMediaToPublic extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'media:move-to-public {--dry-run : Show what would be moved without actually moving files}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Move all media files from private/local disk to public disk';
/**
* Execute the console command.
*/
public function handle(): int
{
$dryRun = $this->option('dry-run');
if ($dryRun) {
$this->warn('DRY RUN MODE - No files will actually be moved');
}
// Get all media records using local disk
$mediaRecords = Media::where('disk', 'local')->get();
if ($mediaRecords->isEmpty()) {
$this->info('No media records found using local disk.');
return self::SUCCESS;
}
$this->info("Found {$mediaRecords->count()} media records to migrate.");
$progressBar = $this->output->createProgressBar($mediaRecords->count());
$progressBar->start();
$moved = 0;
$errors = 0;
foreach ($mediaRecords as $media) {
// Use relative path: {id}/{filename}
$relativePath = $media->id . '/' . $media->file_name;
// Check if source file exists
if (!Storage::disk('local')->exists($relativePath)) {
$this->newLine();
$this->error("Source file not found: {$relativePath}");
$errors++;
$progressBar->advance();
continue;
}
try {
if (!$dryRun) {
// Copy file from local to public disk
$fileContent = Storage::disk('local')->get($relativePath);
Storage::disk('public')->put($relativePath, $fileContent);
// Verify the file was copied successfully
if (Storage::disk('public')->exists($relativePath)) {
// Update the database record
$media->update([
'disk' => 'public',
'conversions_disk' => 'public',
]);
// Delete the old file from local disk
Storage::disk('local')->delete($relativePath);
$moved++;
} else {
throw new \Exception("Failed to copy file to public disk");
}
} else {
$this->newLine();
$this->line("Would move: local:{$relativePath} -> public:{$relativePath}");
$moved++;
}
} catch (\Exception $e) {
$this->newLine();
$this->error("Error moving {$relativePath}: {$e->getMessage()}");
$errors++;
}
$progressBar->advance();
}
$progressBar->finish();
$this->newLine(2);
if ($dryRun) {
$this->info("DRY RUN: Would move {$moved} files, {$errors} errors encountered.");
$this->info("Run without --dry-run to actually perform the migration.");
} else {
$this->info("Successfully moved {$moved} files, {$errors} errors encountered.");
}
return self::SUCCESS;
}
}

View file

@ -0,0 +1,58 @@
<?php
namespace App\Filament\Resources\Entries;
use App\Filament\Resources\Entries\Pages\CreateEntry;
use App\Filament\Resources\Entries\Pages\EditEntry;
use App\Filament\Resources\Entries\Pages\ListEntries;
use App\Filament\Resources\Entries\Pages\ViewEntry;
use App\Filament\Resources\Entries\Schemas\EntryForm;
use App\Filament\Resources\Entries\Schemas\EntryInfolist;
use App\Filament\Resources\Entries\Tables\EntriesTable;
use App\Models\Entry;
use BackedEnum;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Table;
class EntryResource extends Resource
{
protected static ?string $model = Entry::class;
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedRectangleStack;
protected static ?string $recordTitleAttribute = 'title';
public static function form(Schema $schema): Schema
{
return EntryForm::configure($schema);
}
public static function infolist(Schema $schema): Schema
{
return EntryInfolist::configure($schema);
}
public static function table(Table $table): Table
{
return EntriesTable::configure($table);
}
public static function getRelations(): array
{
return [
//
];
}
public static function getPages(): array
{
return [
'index' => ListEntries::route('/'),
'create' => CreateEntry::route('/create'),
'view' => ViewEntry::route('/{record}'),
'edit' => EditEntry::route('/{record}/edit'),
];
}
}

View file

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

View file

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

View file

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

View file

@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\Entries\Pages;
use App\Filament\Resources\Entries\EntryResource;
use Filament\Actions\EditAction;
use Filament\Resources\Pages\ViewRecord;
class ViewEntry extends ViewRecord
{
protected static string $resource = EntryResource::class;
protected function getHeaderActions(): array
{
return [
EditAction::make(),
];
}
}

View file

@ -0,0 +1,206 @@
<?php
namespace App\Filament\Resources\Entries\Schemas;
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\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;
class EntryForm
{
public static function configure(Schema $schema): Schema
{
return $schema
->components([
TextInput::make('title')
->required()
->live(onBlur: true)
->afterStateUpdated(function ($state, $set): void {
$set('slug', Str::slug((string) $state));
}),
TextInput::make('slug')
->required()
->dehydrated()
->readOnly(),
Textarea::make('description')
->columnSpanFull(),
SpatieMediaLibraryFileUpload::make('featured_image')
->collection('featured-image')
->image()
->imageEditor()
->disk('public')
->visibility('public')
->columnSpanFull()
->dehydrated(false)
->hintAction(
Action::make('featured_picker')
->label('Featured Image from Gallery')
->icon('heroicon-m-photo')
->extraAttributes(['id' => 'featured-picker-button'])
->schema([
Select::make('image_id')
->label('Select an existing image')
->allowHtml()
->options(function () {
return Media::where('model_type', 'temp')
->where('model_id', 0)
->where('disk', 'public')
->latest()
->limit(30)
->get(['id', 'file_name', 'name', 'disk'])
->mapWithKeys(function (Media $item) {
try {
$url = $item->getUrl();
$fileName = e($item->file_name);
$name = e($item->name ?? '');
$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' />" .
"<div class='flex flex-col'>" .
"<span class='font-medium text-sm'>{$name}</span>" .
"<span class='text-xs text-gray-500'>{$fileName}</span>" .
"</div></div>";
return [$item->id => $html];
} catch (\Exception $e) {
return [];
}
})->toArray();
})
->searchable()
->preload()
->required(),
])
->action(function (array $data, SpatieMediaLibraryFileUpload $component): void {
$record = $component->getRecord();
if (!$record) {
\Filament\Notifications\Notification::make()
->warning()
->title('Save the entry first')
->send();
return;
}
if (!$data['image_id']) {
return;
}
$sourceMedia = Media::find($data['image_id']);
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;
copy($sourceFile, $tempCopy);
try {
// Verify record has ID
if (!$record->id) {
\Filament\Notifications\Notification::make()
->danger()
->title('Entry must be saved first')
->send();
return;
}
// Add the copy to the entry's featured-image collection
$newMedia = $record->addMedia($tempCopy)
->usingName($sourceMedia->name ?: pathinfo($sourceMedia->file_name, PATHINFO_FILENAME))
->usingFileName($sourceMedia->file_name)
->toMediaCollection('featured-image', 'public');
// Dispatch event for app.js to handle
$component->getLivewire()->dispatch('featured-image-added', ['mediaId' => $newMedia->id]);
\Filament\Notifications\Notification::make()
->success()
->title('Image added to featured image')
->send();
} catch (\Exception $e) {
\Filament\Notifications\Notification::make()
->danger()
->title('Error: ' . $e->getMessage())
->send();
} finally {
if (file_exists($tempCopy)) {
unlink($tempCopy);
}
}
})
),
Toggle::make('is_published')
->required(),
Toggle::make('is_featured')
->required(),
DatePicker::make('published_at'),
RichEditor::make('content')
->columnSpanFull()
->hintAction(
Action::make('picker')
->label('Gallery Picker')
->icon('heroicon-m-photo')
->schema([
Select::make('image_url')
->label('Select an existing image')
->allowHtml()
->options(function () {
return Media::latest()
->limit(30) // Limit to 30 most recent items for performance
->get(['id', 'file_name', 'name', 'uuid', 'collection_name', 'model_type', 'model_id', 'disk'])
->filter(function (Media $item) {
// Only include media items that have a valid disk
return $item->disk !== null;
})
->mapWithKeys(function (Media $item) {
try {
$url = $item->getUrl();
} catch (\Exception $e) {
// Skip items that can't generate URLs
return [];
}
$fileName = e($item->file_name);
$name = e($item->name ?? '');
// Smaller image preview for better performance
$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' />" .
"<div class='flex flex-col'>" .
"<span class='font-medium text-sm'>{$name}</span>" .
"<span class='text-xs text-gray-500'>{$fileName}</span>" .
"</div></div>";
return [$url => $html];
})->toArray();
})
->searchable()
->preload()
->required(),
])
->action(function (array $data, RichEditor $component) {
// We dispatch the URL to the browser to be inserted into TipTap
$component->getLivewire()->dispatch('insert-editor-content', [
'statePath' => $component->getStatePath(),
'html' => "<img src='{$data['image_url']}' alt=''>",
]);
})
),
]);
}
}

View file

@ -0,0 +1,42 @@
<?php
namespace App\Filament\Resources\Entries\Schemas;
use Filament\Infolists\Components\IconEntry;
use Filament\Infolists\Components\SpatieMediaLibraryImageEntry;
use Filament\Infolists\Components\TextEntry;
use Filament\Schemas\Schema;
class EntryInfolist
{
public static function configure(Schema $schema): Schema
{
return $schema
->components([
TextEntry::make('title'),
TextEntry::make('slug'),
TextEntry::make('description')
->placeholder('-')
->columnSpanFull(),
SpatieMediaLibraryImageEntry::make('featured_image')
->collection('featured-image')
->columnSpanFull(),
IconEntry::make('is_published')
->boolean(),
IconEntry::make('is_featured')
->boolean(),
TextEntry::make('published_at')
->date()
->placeholder('-'),
TextEntry::make('content')
->placeholder('-')
->columnSpanFull(),
TextEntry::make('created_at')
->dateTime()
->placeholder('-'),
TextEntry::make('updated_at')
->dateTime()
->placeholder('-'),
]);
}
}

View file

@ -0,0 +1,58 @@
<?php
namespace App\Filament\Resources\Entries\Tables;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\DeleteBulkAction;
use Filament\Actions\EditAction;
use Filament\Actions\ViewAction;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\SpatieMediaLibraryImageColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
class EntriesTable
{
public static function configure(Table $table): Table
{
return $table
->columns([
SpatieMediaLibraryImageColumn::make('featured_image')
->collection('featured-image')
->circular()
->stacked()
->limit(3),
TextColumn::make('title')
->searchable(),
TextColumn::make('slug')
->searchable(),
IconColumn::make('is_published')
->boolean(),
IconColumn::make('is_featured')
->boolean(),
TextColumn::make('published_at')
->date()
->sortable(),
TextColumn::make('created_at')
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('updated_at')
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
])
->filters([
//
])
->recordActions([
ViewAction::make(),
EditAction::make(),
])
->toolbarActions([
BulkActionGroup::make([
DeleteBulkAction::make(),
]),
]);
}
}

View file

@ -0,0 +1,58 @@
<?php
namespace App\Filament\Resources\Media;
use App\Filament\Resources\Media\Pages\CreateMedia;
use App\Filament\Resources\Media\Pages\EditMedia;
use App\Filament\Resources\Media\Pages\ListMedia;
use App\Filament\Resources\Media\Pages\ViewMedia;
use App\Filament\Resources\Media\Schemas\MediaForm;
use App\Filament\Resources\Media\Schemas\MediaInfolist;
use App\Filament\Resources\Media\Tables\MediaTable;
use BackedEnum;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Table;
use Spatie\MediaLibrary\MediaCollections\Models\Media;
class MediaResource extends Resource
{
protected static ?string $model = Media::class;
protected static ?string $recordTitleAttribute = 'file_name';
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedRectangleStack;
public static function form(Schema $schema): Schema
{
return MediaForm::configure($schema);
}
public static function infolist(Schema $schema): Schema
{
return MediaInfolist::configure($schema);
}
public static function table(Table $table): Table
{
return MediaTable::configure($table);
}
public static function getRelations(): array
{
return [
//
];
}
public static function getPages(): array
{
return [
'index' => ListMedia::route('/'),
'create' => CreateMedia::route('/create'),
'view' => ViewMedia::route('/{record}'),
'edit' => EditMedia::route('/{record}/edit'),
];
}
}

View file

@ -0,0 +1,61 @@
<?php
namespace App\Filament\Resources\Media\Pages;
use App\Filament\Resources\Media\MediaResource;
use Filament\Resources\Pages\CreateRecord;
use Illuminate\Support\Facades\Storage;
use Spatie\MediaLibrary\MediaCollections\Models\Media;
class CreateMedia extends CreateRecord
{
protected static string $resource = MediaResource::class;
protected ?string $uploadedFile = null;
protected function mutateFormDataBeforeCreate(array $data): array
{
// Extract the file data before creating the media record
$file = $data['file'] ?? null;
unset($data['file']);
// Store file path for later
$this->uploadedFile = $file;
// Set required fields for Media model
$data['model_type'] = $data['model_type'] ?? 'temp';
$data['model_id'] = $data['model_id'] ?? 0;
$data['collection_name'] = $data['collection_name'] ?? 'default';
$data['disk'] = $data['disk'] ?? 'public';
$data['file_name'] = $file ? basename($file) : '';
$data['mime_type'] = $file && Storage::disk('public')->exists($file)
? Storage::disk('public')->mimeType($file)
: 'application/octet-stream';
$data['size'] = $file && Storage::disk('public')->exists($file)
? Storage::disk('public')->size($file)
: 0;
$data['manipulations'] = [];
$data['custom_properties'] = [];
$data['generated_conversions'] = [];
$data['responsive_images'] = [];
return $data;
}
protected function afterCreate(): void
{
if ($this->uploadedFile && $this->record) {
$disk = Storage::disk('public');
// Create the directory for this media ID (Spatie structure: {id}/{filename})
$mediaDirectory = (string) $this->record->id;
$disk->makeDirectory($mediaDirectory);
// Move file from temporary upload location to Spatie's expected location
if ($disk->exists($this->uploadedFile)) {
$newPath = $mediaDirectory.'/'.$this->record->file_name;
$disk->move($this->uploadedFile, $newPath);
}
}
}
}

View file

@ -0,0 +1,72 @@
<?php
namespace App\Filament\Resources\Media\Pages;
use App\Filament\Resources\Media\MediaResource;
use Filament\Actions\DeleteAction;
use Filament\Actions\ViewAction;
use Filament\Resources\Pages\EditRecord;
use Illuminate\Support\Facades\Storage;
class EditMedia extends EditRecord
{
protected static string $resource = MediaResource::class;
protected ?string $uploadedFile = null;
protected function getHeaderActions(): array
{
return [
ViewAction::make(),
DeleteAction::make(),
];
}
protected function mutateFormDataBeforeSave(array $data): array
{
// Extract the file data if a new file was uploaded
$file = $data['file'] ?? null;
unset($data['file']);
// Only update file-related fields if a new file was uploaded
if ($file && $file !== $this->record->getPathRelativeToRoot()) {
$this->uploadedFile = $file;
// Keep the original file_name to prevent breaking existing references
// $data['file_name'] is not updated - we preserve the original filename
$data['mime_type'] = Storage::disk('public')->exists($file)
? Storage::disk('public')->mimeType($file)
: 'application/octet-stream';
$data['size'] = Storage::disk('public')->exists($file)
? Storage::disk('public')->size($file)
: 0;
}
return $data;
}
protected function afterSave(): void
{
if ($this->uploadedFile && $this->record) {
$disk = Storage::disk('public');
$mediaDirectory = (string) $this->record->id;
// Delete old file if it exists
$oldPath = $mediaDirectory.'/'.$this->record->getOriginal('file_name');
if ($disk->exists($oldPath)) {
$disk->delete($oldPath);
}
// Move new file to Spatie's expected location using the original filename
if ($disk->exists($this->uploadedFile)) {
$disk->makeDirectory($mediaDirectory);
// Use the original file_name to preserve existing references
$newPath = $mediaDirectory.'/'.$this->record->file_name;
$disk->move($this->uploadedFile, $newPath);
}
// Redirect to the same page to refresh the form state
$this->redirect(static::getUrl(['record' => $this->record]), navigate: true);
}
}
}

View file

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

View file

@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\Media\Pages;
use App\Filament\Resources\Media\MediaResource;
use Filament\Actions\EditAction;
use Filament\Resources\Pages\ViewRecord;
class ViewMedia extends ViewRecord
{
protected static string $resource = MediaResource::class;
protected function getHeaderActions(): array
{
return [
EditAction::make(),
];
}
}

View file

@ -0,0 +1,59 @@
<?php
namespace App\Filament\Resources\Media\Schemas;
use Filament\Forms\Components\FileUpload;
use Filament\Forms\Components\Hidden;
use Filament\Forms\Components\TextInput;
use Filament\Schemas\Schema;
use Spatie\MediaLibrary\MediaCollections\Models\Media as SpatieMedia;
class MediaForm
{
public static function configure(Schema $schema): Schema
{
return $schema
->components([
TextInput::make('name')
->required()
->maxLength(255),
TextInput::make('collection_name')
->default('default')
->required()
->maxLength(255),
Hidden::make('disk')
->default('public'),
FileUpload::make('file')
->label('File')
->imageEditor()
->imageEditorAspectRatios([
'16:9',
'4:3',
'1:1',
])
->columnSpanFull()
->disk('public')
->directory('media')
->visibility('public')
->acceptedFileTypes(['image/*', 'application/pdf'])
->maxSize(10240)
->required(fn ($context) => $context === 'create')
->afterStateHydrated(function (FileUpload $component, $state, $record): void {
if (! $record) {
return;
}
$media = $record;
if (! $media instanceof SpatieMedia) {
return;
}
// Construct the correct path: {media_id}/{filename}
$path = $media->id.'/'.$media->file_name;
$component->state($path);
}),
]);
}
}

View file

@ -0,0 +1,36 @@
<?php
namespace App\Filament\Resources\Media\Schemas;
use Filament\Infolists\Components\ImageEntry;
use Filament\Infolists\Components\TextEntry;
use Filament\Schemas\Schema;
class MediaInfolist
{
public static function configure(Schema $schema): Schema
{
return $schema
->components([
ImageEntry::make('file_name')
->label('Preview')
->getStateUsing(fn ($record) => $record->getUrl())
->visible(fn ($record) => $record->mime_type && str_starts_with($record->mime_type, 'image/')),
TextEntry::make('name'),
TextEntry::make('file_name'),
TextEntry::make('mime_type'),
TextEntry::make('collection_name'),
TextEntry::make('size')
->formatStateUsing(fn ($state) => number_format($state / 1024, 2).' KB'),
TextEntry::make('model_type')
->label('Attached to Model'),
TextEntry::make('model_id'),
TextEntry::make('custom_properties')
->formatStateUsing(fn ($state) => json_encode($state, JSON_PRETTY_PRINT)),
TextEntry::make('created_at')
->dateTime(),
TextEntry::make('updated_at')
->dateTime(),
]);
}
}

View file

@ -0,0 +1,86 @@
<?php
namespace App\Filament\Resources\Media\Tables;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\DeleteAction;
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;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Storage;
use Spatie\MediaLibrary\MediaCollections\Models\Media;
class MediaTable
{
public static function configure(Table $table): Table
{
return $table
->modifyQueryUsing(fn ($query) => $query->where('collection_name', '!=', 'avatars'))
->columns([
ImageColumn::make('url')
->label('Preview')
->getStateUsing(fn ($record) =>
// Prefer the stored path produced by Filament's FileUpload (saved in custom_properties),
// fall back to Spatie's getUrl() when no stored_path exists.
($record->getCustomProperty('stored_path'))
? Storage::url($record->getCustomProperty('stored_path'))
: $record->getUrl()
)
->height(40)
->width(40),
TextColumn::make('name')
->searchable(),
TextColumn::make('file_name')
->searchable(),
TextColumn::make('collection_name')
->badge(),
TextColumn::make('mime_type'),
TextColumn::make('size')
->formatStateUsing(fn ($state) => number_format($state / 1024, 2).' KB'),
TextColumn::make('created_at')
->dateTime(),
])
->filters([
SelectFilter::make('collection_name')
->options([
'images' => 'Images',
'documents' => 'Documents',
]),
])
->recordActions([
EditAction::make(),
DeleteAction::make()
->action(function (Media $record) {
// Delete the actual stored file path if we saved one, otherwise fall back to the Spatie path.
$stored = $record->getCustomProperty('stored_path');
if ($stored) {
Storage::disk($record->disk)->delete($stored);
} else {
Storage::disk($record->disk)->delete($record->getPath());
}
$record->delete();
}),
])
->toolbarActions([
BulkActionGroup::make([
DeleteBulkAction::make()
->action(function (Collection $records) {
$records->each(function (Media $record) {
$stored = $record->getCustomProperty('stored_path');
if ($stored) {
Storage::disk($record->disk)->delete($stored);
} else {
Storage::disk($record->disk)->delete($record->getPath());
}
$record->delete();
});
}),
]),
]);
}
}

View file

@ -0,0 +1,108 @@
<?php
namespace App\Livewire;
use Livewire\Component;
use Spatie\MediaLibrary\MediaCollections\Models\Media;
class GalleryPicker extends Component
{
#[\Livewire\Attributes\Reactive]
public $entryId;
public $mediaItems = [];
public $selectedMediaId = null;
public $showModal = false;
#[\Livewire\Attributes\On('open-gallery-picker')]
public function openPicker($entryId): void
{
$this->entryId = $entryId;
$this->loadMediaItems();
$this->showModal = true;
}
public function loadMediaItems(): void
{
$this->mediaItems = Media::where('model_type', 'temp')
->where('model_id', 0)
->where('disk', 'public')
->latest()
->limit(30)
->get(['id', 'file_name', 'name', 'disk'])
->toArray();
}
public function selectMedia($mediaId): void
{
$this->selectedMediaId = $mediaId;
}
public function copyToEntry(): void
{
if (!$this->selectedMediaId || !$this->entryId) {
$this->dispatch('notify-error', ['message' => 'Please select an image']);
return;
}
$sourceMedia = Media::find($this->selectedMediaId);
if (!$sourceMedia) {
$this->dispatch('notify-error', ['message' => 'Media not found']);
return;
}
try {
// Get the entry
$entry = \App\Models\Entry::find($this->entryId);
if (!$entry) {
$this->dispatch('notify-error', ['message' => 'Entry not found']);
return;
}
// Get source file
$sourceFile = $sourceMedia->getPath();
if (!file_exists($sourceFile)) {
$this->dispatch('notify-error', ['message' => 'Source file not found']);
return;
}
// Create temp copy
$tempCopy = sys_get_temp_dir() . '/' . uniqid() . '_' . $sourceMedia->file_name;
copy($sourceFile, $tempCopy);
try {
// Clear existing featured image
$entry->clearMediaCollection('featured-image');
// Add to entry
$newMedia = $entry->addMedia($tempCopy)
->usingName($sourceMedia->name ?: pathinfo($sourceMedia->file_name, PATHINFO_FILENAME))
->usingFileName($sourceMedia->file_name)
->toMediaCollection('featured-image', 'public');
// Close modal and notify
$this->showModal = false;
$this->selectedMediaId = null;
$this->dispatch('media-selected', ['mediaId' => $newMedia->id, 'fileName' => $newMedia->file_name]);
$this->dispatch('notify-success', ['message' => 'Image added to entry']);
} finally {
if (file_exists($tempCopy)) {
unlink($tempCopy);
}
}
} catch (\Exception $e) {
$this->dispatch('notify-error', ['message' => 'Error: ' . $e->getMessage()]);
}
}
public function closePicker(): void
{
$this->showModal = false;
$this->selectedMediaId = null;
}
public function render()
{
return view('livewire.gallery-picker');
}
}

42
app/Models/Entry.php Normal file
View file

@ -0,0 +1,42 @@
<?php
namespace App\Models;
use Filament\Forms\Components\RichEditor\FileAttachmentProviders\SpatieMediaLibraryFileAttachmentProvider;
use Filament\Forms\Components\RichEditor\Models\Concerns\InteractsWithRichContent;
use Filament\Forms\Components\RichEditor\Models\Contracts\HasRichContent;
use Illuminate\Database\Eloquent\Model;
use Spatie\MediaLibrary\HasMedia;
use Spatie\MediaLibrary\InteractsWithMedia;
class Entry extends Model implements HasRichContent, HasMedia
{
use InteractsWithMedia, InteractsWithRichContent;
protected $fillable = [
'title',
'slug',
'description',
'is_published',
'is_featured',
'published_at',
'content',
];
/**
* Set up rich content configuration for media library integration
*/
public function setUpRichContent(): void
{
$this->registerRichContent('content')
->fileAttachmentProvider(
SpatieMediaLibraryFileAttachmentProvider::make()
->collection('content-attachments')
->preserveFilenames()
);
}
}

View file

@ -3,13 +3,18 @@
namespace App\Models;
// use Illuminate\Contracts\Auth\MustVerifyEmail;
use Filament\Models\Contracts\FilamentUser;
use Filament\Panel;
use Illuminate\Container\Attributes\Log;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Facades\Log as FacadesLog;
use Illuminate\Support\Str;
use Laravel\Fortify\TwoFactorAuthenticatable;
class User extends Authenticatable
class User extends Authenticatable implements FilamentUser
{
/** @use HasFactory<\Database\Factories\UserFactory> */
use HasFactory, Notifiable, TwoFactorAuthenticatable;
@ -61,4 +66,14 @@ class User extends Authenticatable
->map(fn ($word) => Str::substr($word, 0, 1))
->implode('');
}
/**
* Determine if the user can access Filament admin panel.
*/
public function canAccessPanel(Panel $panel): bool
{
// FacadesLog::info('Checking admin access for user: ' . $this->email . ' against admin email: ' . config('app.admin_email'));
return $this->email === config('app.admin_email');
}
}

View file

@ -10,6 +10,8 @@ use Filament\Pages\Dashboard;
use Filament\Panel;
use Filament\PanelProvider;
use Filament\Support\Colors\Color;
use Filament\Support\Facades\FilamentView;
use Filament\View\PanelsRenderHook;
use Filament\Widgets\AccountWidget;
use Filament\Widgets\FilamentInfoWidget;
use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse;
@ -29,7 +31,7 @@ class AdminPanelProvider extends PanelProvider
->path('admin')
->login()
->colors([
'primary' => Color::Amber,
'primary' => Color::Blue,
])
->discoverResources(in: app_path('Filament/Resources'), for: 'App\Filament\Resources')
->discoverPages(in: app_path('Filament/Pages'), for: 'App\Filament\Pages')
@ -56,4 +58,12 @@ class AdminPanelProvider extends PanelProvider
Authenticate::class,
]);
}
public function boot(): void
{
FilamentView::registerRenderHook(
PanelsRenderHook::BODY_END,
fn (): string => \Illuminate\Support\Facades\Blade::render('@vite("resources/js/app.js")'),
);
}
}

View file

@ -8,14 +8,17 @@
"require": {
"php": "^8.2",
"filament/filament": "^4.0",
"filament/spatie-laravel-media-library-plugin": "^4.4",
"laravel/fortify": "^1.30",
"laravel/framework": "^12.0",
"laravel/tinker": "^2.10.1",
"livewire/flux": "^2.9.0"
"livewire/flux": "^2.9.0",
"spatie/laravel-medialibrary": "^11.17"
},
"require-dev": {
"fakerphp/faker": "^1.23",
"laravel/boost": "^1.8",
"laravel/dusk": "^8.3",
"laravel/pail": "^1.2.2",
"laravel/pint": "^1.24",
"laravel/sail": "^1.41",

647
composer.lock generated
View file

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "ed91eaf8381afba35eea6bfdd94e4e18",
"content-hash": "8074d7e5af3ede59ab12a880d70b7989",
"packages": [
{
"name": "anourvalar/eloquent-serialize",
@ -565,6 +565,83 @@
],
"time": "2024-07-16T11:13:48+00:00"
},
{
"name": "composer/semver",
"version": "3.4.4",
"source": {
"type": "git",
"url": "https://github.com/composer/semver.git",
"reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/composer/semver/zipball/198166618906cb2de69b95d7d47e5fa8aa1b2b95",
"reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95",
"shasum": ""
},
"require": {
"php": "^5.3.2 || ^7.0 || ^8.0"
},
"require-dev": {
"phpstan/phpstan": "^1.11",
"symfony/phpunit-bridge": "^3 || ^7"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-main": "3.x-dev"
}
},
"autoload": {
"psr-4": {
"Composer\\Semver\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Nils Adermann",
"email": "naderman@naderman.de",
"homepage": "http://www.naderman.de"
},
{
"name": "Jordi Boggiano",
"email": "j.boggiano@seld.be",
"homepage": "http://seld.be"
},
{
"name": "Rob Bast",
"email": "rob.bast@gmail.com",
"homepage": "http://robbast.nl"
}
],
"description": "Semver library that offers utilities, version constraint parsing and validation.",
"keywords": [
"semantic",
"semver",
"validation",
"versioning"
],
"support": {
"irc": "ircs://irc.libera.chat:6697/composer",
"issues": "https://github.com/composer/semver/issues",
"source": "https://github.com/composer/semver/tree/3.4.4"
},
"funding": [
{
"url": "https://packagist.com",
"type": "custom"
},
{
"url": "https://github.com/composer",
"type": "github"
}
],
"time": "2025-08-20T19:15:30+00:00"
},
{
"name": "danharrin/date-format-converter",
"version": "v0.3.1",
@ -1432,6 +1509,43 @@
},
"time": "2025-12-30T13:02:44+00:00"
},
{
"name": "filament/spatie-laravel-media-library-plugin",
"version": "v4.4.0",
"source": {
"type": "git",
"url": "https://github.com/filamentphp/spatie-laravel-media-library-plugin.git",
"reference": "73748df28a9c2e8c34d2c02f9314c330602e1830"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/filamentphp/spatie-laravel-media-library-plugin/zipball/73748df28a9c2e8c34d2c02f9314c330602e1830",
"reference": "73748df28a9c2e8c34d2c02f9314c330602e1830",
"shasum": ""
},
"require": {
"filament/support": "self.version",
"php": "^8.2",
"spatie/laravel-medialibrary": "^11.0"
},
"type": "library",
"autoload": {
"psr-4": {
"Filament\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"description": "Filament support for `spatie/laravel-medialibrary`.",
"homepage": "https://github.com/filamentphp/filament",
"support": {
"issues": "https://github.com/filamentphp/filament/issues",
"source": "https://github.com/filamentphp/filament"
},
"time": "2025-12-09T09:54:02+00:00"
},
{
"name": "filament/support",
"version": "v4.4.0",
@ -3535,6 +3649,84 @@
],
"time": "2025-12-19T02:00:29+00:00"
},
{
"name": "maennchen/zipstream-php",
"version": "3.2.1",
"source": {
"type": "git",
"url": "https://github.com/maennchen/ZipStream-PHP.git",
"reference": "682f1098a8fddbaf43edac2306a691c7ad508ec5"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/maennchen/ZipStream-PHP/zipball/682f1098a8fddbaf43edac2306a691c7ad508ec5",
"reference": "682f1098a8fddbaf43edac2306a691c7ad508ec5",
"shasum": ""
},
"require": {
"ext-mbstring": "*",
"ext-zlib": "*",
"php-64bit": "^8.3"
},
"require-dev": {
"brianium/paratest": "^7.7",
"ext-zip": "*",
"friendsofphp/php-cs-fixer": "^3.86",
"guzzlehttp/guzzle": "^7.5",
"mikey179/vfsstream": "^1.6",
"php-coveralls/php-coveralls": "^2.5",
"phpunit/phpunit": "^12.0",
"vimeo/psalm": "^6.0"
},
"suggest": {
"guzzlehttp/psr7": "^2.4",
"psr/http-message": "^2.0"
},
"type": "library",
"autoload": {
"psr-4": {
"ZipStream\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Paul Duncan",
"email": "pabs@pablotron.org"
},
{
"name": "Jonatan Männchen",
"email": "jonatan@maennchen.ch"
},
{
"name": "Jesse Donat",
"email": "donatj@gmail.com"
},
{
"name": "András Kolesár",
"email": "kolesar@kolesar.hu"
}
],
"description": "ZipStream is a library for dynamically streaming dynamic zip files from PHP without writing to the disk at all on the server.",
"keywords": [
"stream",
"zip"
],
"support": {
"issues": "https://github.com/maennchen/ZipStream-PHP/issues",
"source": "https://github.com/maennchen/ZipStream-PHP/tree/3.2.1"
},
"funding": [
{
"url": "https://github.com/maennchen",
"type": "github"
}
],
"time": "2025-12-10T09:58:31+00:00"
},
{
"name": "masterminds/html5",
"version": "2.10.0",
@ -3604,16 +3796,16 @@
},
{
"name": "monolog/monolog",
"version": "3.9.0",
"version": "3.10.0",
"source": {
"type": "git",
"url": "https://github.com/Seldaek/monolog.git",
"reference": "10d85740180ecba7896c87e06a166e0c95a0e3b6"
"reference": "b321dd6749f0bf7189444158a3ce785cc16d69b0"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/Seldaek/monolog/zipball/10d85740180ecba7896c87e06a166e0c95a0e3b6",
"reference": "10d85740180ecba7896c87e06a166e0c95a0e3b6",
"url": "https://api.github.com/repos/Seldaek/monolog/zipball/b321dd6749f0bf7189444158a3ce785cc16d69b0",
"reference": "b321dd6749f0bf7189444158a3ce785cc16d69b0",
"shasum": ""
},
"require": {
@ -3631,7 +3823,7 @@
"graylog2/gelf-php": "^1.4.2 || ^2.0",
"guzzlehttp/guzzle": "^7.4.5",
"guzzlehttp/psr7": "^2.2",
"mongodb/mongodb": "^1.8",
"mongodb/mongodb": "^1.8 || ^2.0",
"php-amqplib/php-amqplib": "~2.4 || ^3",
"php-console/php-console": "^3.1.8",
"phpstan/phpstan": "^2",
@ -3691,7 +3883,7 @@
],
"support": {
"issues": "https://github.com/Seldaek/monolog/issues",
"source": "https://github.com/Seldaek/monolog/tree/3.9.0"
"source": "https://github.com/Seldaek/monolog/tree/3.10.0"
},
"funding": [
{
@ -3703,7 +3895,7 @@
"type": "tidelift"
}
],
"time": "2025-03-24T10:02:05+00:00"
"time": "2026-01-02T08:56:05+00:00"
},
{
"name": "nesbot/carbon",
@ -5382,6 +5574,134 @@
],
"time": "2022-12-17T21:53:22+00:00"
},
{
"name": "spatie/image",
"version": "3.8.7",
"source": {
"type": "git",
"url": "https://github.com/spatie/image.git",
"reference": "4d35db207c4b317bc221d02ab7ba94aa78b44c24"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/spatie/image/zipball/4d35db207c4b317bc221d02ab7ba94aa78b44c24",
"reference": "4d35db207c4b317bc221d02ab7ba94aa78b44c24",
"shasum": ""
},
"require": {
"ext-exif": "*",
"ext-json": "*",
"ext-mbstring": "*",
"php": "^8.2",
"spatie/image-optimizer": "^1.7.5",
"spatie/temporary-directory": "^2.2",
"symfony/process": "^6.4|^7.0|^8.0"
},
"require-dev": {
"ext-gd": "*",
"ext-imagick": "*",
"laravel/sail": "^1.34",
"pestphp/pest": "^3.0|^4.0",
"phpstan/phpstan": "^1.10.50",
"spatie/pest-plugin-snapshots": "^2.1",
"spatie/pixelmatch-php": "^1.0",
"spatie/ray": "^1.40.1",
"symfony/var-dumper": "^6.4|^7.0|^8.0"
},
"type": "library",
"autoload": {
"psr-4": {
"Spatie\\Image\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Freek Van der Herten",
"email": "freek@spatie.be",
"homepage": "https://spatie.be",
"role": "Developer"
}
],
"description": "Manipulate images with an expressive API",
"homepage": "https://github.com/spatie/image",
"keywords": [
"image",
"spatie"
],
"support": {
"source": "https://github.com/spatie/image/tree/3.8.7"
},
"funding": [
{
"url": "https://spatie.be/open-source/support-us",
"type": "custom"
},
{
"url": "https://github.com/spatie",
"type": "github"
}
],
"time": "2025-11-24T15:10:50+00:00"
},
{
"name": "spatie/image-optimizer",
"version": "1.8.1",
"source": {
"type": "git",
"url": "https://github.com/spatie/image-optimizer.git",
"reference": "2ad9ac7c19501739183359ae64ea6c15869c23d9"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/spatie/image-optimizer/zipball/2ad9ac7c19501739183359ae64ea6c15869c23d9",
"reference": "2ad9ac7c19501739183359ae64ea6c15869c23d9",
"shasum": ""
},
"require": {
"ext-fileinfo": "*",
"php": "^7.3|^8.0",
"psr/log": "^1.0 | ^2.0 | ^3.0",
"symfony/process": "^4.2|^5.0|^6.0|^7.0|^8.0"
},
"require-dev": {
"pestphp/pest": "^1.21|^2.0|^3.0|^4.0",
"phpunit/phpunit": "^8.5.21|^9.4.4|^10.0|^11.0|^12.0",
"symfony/var-dumper": "^4.2|^5.0|^6.0|^7.0|^8.0"
},
"type": "library",
"autoload": {
"psr-4": {
"Spatie\\ImageOptimizer\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Freek Van der Herten",
"email": "freek@spatie.be",
"homepage": "https://spatie.be",
"role": "Developer"
}
],
"description": "Easily optimize images using PHP",
"homepage": "https://github.com/spatie/image-optimizer",
"keywords": [
"image-optimizer",
"spatie"
],
"support": {
"issues": "https://github.com/spatie/image-optimizer/issues",
"source": "https://github.com/spatie/image-optimizer/tree/1.8.1"
},
"time": "2025-11-26T10:57:19+00:00"
},
{
"name": "spatie/invade",
"version": "2.1.0",
@ -5441,6 +5761,116 @@
],
"time": "2024-05-17T09:06:10+00:00"
},
{
"name": "spatie/laravel-medialibrary",
"version": "11.17.7",
"source": {
"type": "git",
"url": "https://github.com/spatie/laravel-medialibrary.git",
"reference": "237f34f70ae97523c1a99cad7176e229b8d6f0b6"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/spatie/laravel-medialibrary/zipball/237f34f70ae97523c1a99cad7176e229b8d6f0b6",
"reference": "237f34f70ae97523c1a99cad7176e229b8d6f0b6",
"shasum": ""
},
"require": {
"composer/semver": "^3.4",
"ext-exif": "*",
"ext-fileinfo": "*",
"ext-json": "*",
"illuminate/bus": "^10.2|^11.0|^12.0",
"illuminate/conditionable": "^10.2|^11.0|^12.0",
"illuminate/console": "^10.2|^11.0|^12.0",
"illuminate/database": "^10.2|^11.0|^12.0",
"illuminate/pipeline": "^10.2|^11.0|^12.0",
"illuminate/support": "^10.2|^11.0|^12.0",
"maennchen/zipstream-php": "^3.1",
"php": "^8.2",
"spatie/image": "^3.3.2",
"spatie/laravel-package-tools": "^1.16.1",
"spatie/temporary-directory": "^2.2",
"symfony/console": "^6.4.1|^7.0|^8.0"
},
"conflict": {
"php-ffmpeg/php-ffmpeg": "<0.6.1"
},
"require-dev": {
"aws/aws-sdk-php": "^3.293.10",
"ext-imagick": "*",
"ext-pdo_sqlite": "*",
"ext-zip": "*",
"guzzlehttp/guzzle": "^7.8.1",
"larastan/larastan": "^2.7|^3.0",
"league/flysystem-aws-s3-v3": "^3.22",
"mockery/mockery": "^1.6.7",
"orchestra/testbench": "^8.36|^9.15|^10.8",
"pestphp/pest": "^2.36|^3.0|^4.0",
"phpstan/extension-installer": "^1.3.1",
"spatie/laravel-ray": "^1.33",
"spatie/pdf-to-image": "^2.2|^3.0",
"spatie/pest-expectations": "^1.13",
"spatie/pest-plugin-snapshots": "^2.1"
},
"suggest": {
"league/flysystem-aws-s3-v3": "Required to use AWS S3 file storage",
"php-ffmpeg/php-ffmpeg": "Required for generating video thumbnails",
"spatie/pdf-to-image": "Required for generating thumbnails of PDFs and SVGs"
},
"type": "library",
"extra": {
"laravel": {
"providers": [
"Spatie\\MediaLibrary\\MediaLibraryServiceProvider"
]
}
},
"autoload": {
"psr-4": {
"Spatie\\MediaLibrary\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Freek Van der Herten",
"email": "freek@spatie.be",
"homepage": "https://spatie.be",
"role": "Developer"
}
],
"description": "Associate files with Eloquent models",
"homepage": "https://github.com/spatie/laravel-medialibrary",
"keywords": [
"cms",
"conversion",
"downloads",
"images",
"laravel",
"laravel-medialibrary",
"media",
"spatie"
],
"support": {
"issues": "https://github.com/spatie/laravel-medialibrary/issues",
"source": "https://github.com/spatie/laravel-medialibrary/tree/11.17.7"
},
"funding": [
{
"url": "https://spatie.be/open-source/support-us",
"type": "custom"
},
{
"url": "https://github.com/spatie",
"type": "github"
}
],
"time": "2025-12-15T08:51:55+00:00"
},
{
"name": "spatie/laravel-package-tools",
"version": "1.92.7",
@ -5567,6 +5997,67 @@
],
"time": "2025-02-21T14:16:57+00:00"
},
{
"name": "spatie/temporary-directory",
"version": "2.3.0",
"source": {
"type": "git",
"url": "https://github.com/spatie/temporary-directory.git",
"reference": "580eddfe9a0a41a902cac6eeb8f066b42e65a32b"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/spatie/temporary-directory/zipball/580eddfe9a0a41a902cac6eeb8f066b42e65a32b",
"reference": "580eddfe9a0a41a902cac6eeb8f066b42e65a32b",
"shasum": ""
},
"require": {
"php": "^8.0"
},
"require-dev": {
"phpunit/phpunit": "^9.5"
},
"type": "library",
"autoload": {
"psr-4": {
"Spatie\\TemporaryDirectory\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Alex Vanderbist",
"email": "alex@spatie.be",
"homepage": "https://spatie.be",
"role": "Developer"
}
],
"description": "Easily create, use and destroy temporary directories",
"homepage": "https://github.com/spatie/temporary-directory",
"keywords": [
"php",
"spatie",
"temporary-directory"
],
"support": {
"issues": "https://github.com/spatie/temporary-directory/issues",
"source": "https://github.com/spatie/temporary-directory/tree/2.3.0"
},
"funding": [
{
"url": "https://spatie.be/open-source/support-us",
"type": "custom"
},
{
"url": "https://github.com/spatie",
"type": "github"
}
],
"time": "2025-01-13T13:04:43+00:00"
},
{
"name": "symfony/clock",
"version": "v7.4.0",
@ -8939,6 +9430,80 @@
},
"time": "2025-12-19T15:04:12+00:00"
},
{
"name": "laravel/dusk",
"version": "v8.3.4",
"source": {
"type": "git",
"url": "https://github.com/laravel/dusk.git",
"reference": "33a4211c7b63ffe430bf30ec3c014012dcb6dfa6"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/laravel/dusk/zipball/33a4211c7b63ffe430bf30ec3c014012dcb6dfa6",
"reference": "33a4211c7b63ffe430bf30ec3c014012dcb6dfa6",
"shasum": ""
},
"require": {
"ext-json": "*",
"ext-zip": "*",
"guzzlehttp/guzzle": "^7.5",
"illuminate/console": "^10.0|^11.0|^12.0",
"illuminate/support": "^10.0|^11.0|^12.0",
"php": "^8.1",
"php-webdriver/webdriver": "^1.15.2",
"symfony/console": "^6.2|^7.0",
"symfony/finder": "^6.2|^7.0",
"symfony/process": "^6.2|^7.0",
"vlucas/phpdotenv": "^5.2"
},
"require-dev": {
"laravel/framework": "^10.0|^11.0|^12.0",
"mockery/mockery": "^1.6",
"orchestra/testbench-core": "^8.19|^9.17|^10.8",
"phpstan/phpstan": "^1.10",
"phpunit/phpunit": "^10.1|^11.0|^12.0.1",
"psy/psysh": "^0.11.12|^0.12",
"symfony/yaml": "^6.2|^7.0"
},
"suggest": {
"ext-pcntl": "Used to gracefully terminate Dusk when tests are running."
},
"type": "library",
"extra": {
"laravel": {
"providers": [
"Laravel\\Dusk\\DuskServiceProvider"
]
}
},
"autoload": {
"psr-4": {
"Laravel\\Dusk\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Taylor Otwell",
"email": "taylor@laravel.com"
}
],
"description": "Laravel Dusk provides simple end-to-end testing and browser automation.",
"keywords": [
"laravel",
"testing",
"webdriver"
],
"support": {
"issues": "https://github.com/laravel/dusk/issues",
"source": "https://github.com/laravel/dusk/tree/v8.3.4"
},
"time": "2025-11-20T16:26:16+00:00"
},
{
"name": "laravel/mcp",
"version": "v0.5.1",
@ -10104,6 +10669,72 @@
},
"time": "2022-02-21T01:04:05+00:00"
},
{
"name": "php-webdriver/webdriver",
"version": "1.16.0",
"source": {
"type": "git",
"url": "https://github.com/php-webdriver/php-webdriver.git",
"reference": "ac0662863aa120b4f645869f584013e4c4dba46a"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/php-webdriver/php-webdriver/zipball/ac0662863aa120b4f645869f584013e4c4dba46a",
"reference": "ac0662863aa120b4f645869f584013e4c4dba46a",
"shasum": ""
},
"require": {
"ext-curl": "*",
"ext-json": "*",
"ext-zip": "*",
"php": "^7.3 || ^8.0",
"symfony/polyfill-mbstring": "^1.12",
"symfony/process": "^5.0 || ^6.0 || ^7.0 || ^8.0"
},
"replace": {
"facebook/webdriver": "*"
},
"require-dev": {
"ergebnis/composer-normalize": "^2.20.0",
"ondram/ci-detector": "^4.0",
"php-coveralls/php-coveralls": "^2.4",
"php-mock/php-mock-phpunit": "^2.0",
"php-parallel-lint/php-parallel-lint": "^1.2",
"phpunit/phpunit": "^9.3",
"squizlabs/php_codesniffer": "^3.5",
"symfony/var-dumper": "^5.0 || ^6.0 || ^7.0 || ^8.0"
},
"suggest": {
"ext-simplexml": "For Firefox profile creation"
},
"type": "library",
"autoload": {
"files": [
"lib/Exception/TimeoutException.php"
],
"psr-4": {
"Facebook\\WebDriver\\": "lib/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"description": "A PHP client for Selenium WebDriver. Previously facebook/webdriver.",
"homepage": "https://github.com/php-webdriver/php-webdriver",
"keywords": [
"Chromedriver",
"geckodriver",
"php",
"selenium",
"webdriver"
],
"support": {
"issues": "https://github.com/php-webdriver/php-webdriver/issues",
"source": "https://github.com/php-webdriver/php-webdriver/tree/1.16.0"
},
"time": "2025-12-28T23:57:40+00:00"
},
{
"name": "phpdocumentor/reflection-common",
"version": "2.2.0",

View file

@ -123,4 +123,6 @@ return [
'store' => env('APP_MAINTENANCE_STORE', 'database'),
],
'admin_email' => env('ADMIN_EMAIL', ''),
];

303
config/media-library.php Normal file
View file

@ -0,0 +1,303 @@
<?php
return [
/*
* The disk on which to store added files and derived images by default. Choose
* one or more of the disks you've configured in config/filesystems.php.
*/
'disk_name' => env('MEDIA_DISK', 'public'),
/*
* The maximum file size of an item in bytes.
* Adding a larger file will result in an exception.
*/
'max_file_size' => 1024 * 1024 * 10, // 10MB
/*
* This queue connection will be used to generate derived and responsive images.
* Leave empty to use the default queue connection.
*/
'queue_connection_name' => env('QUEUE_CONNECTION', 'sync'),
/*
* This queue will be used to generate derived and responsive images.
* Leave empty to use the default queue.
*/
'queue_name' => env('MEDIA_QUEUE', ''),
/*
* By default all conversions will be performed on a queue.
*/
'queue_conversions_by_default' => env('QUEUE_CONVERSIONS_BY_DEFAULT', true),
/*
* Should database transactions be run after database commits?
*/
'queue_conversions_after_database_commit' => env('QUEUE_CONVERSIONS_AFTER_DB_COMMIT', true),
/*
* The fully qualified class name of the media model.
*/
'media_model' => Spatie\MediaLibrary\MediaCollections\Models\Media::class,
/*
* The fully qualified class name of the media observer.
*/
'media_observer' => Spatie\MediaLibrary\MediaCollections\Models\Observers\MediaObserver::class,
/*
* When enabled, media collections will be serialised using the default
* laravel model serialization behaviour.
*
* Keep this option disabled if using Media Library Pro components (https://medialibrary.pro)
*/
'use_default_collection_serialization' => false,
/*
* The fully qualified class name of the model used for temporary uploads.
*
* This model is only used in Media Library Pro (https://medialibrary.pro)
*/
'temporary_upload_model' => Spatie\MediaLibraryPro\Models\TemporaryUpload::class,
/*
* When enabled, Media Library Pro will only process temporary uploads that were uploaded
* in the same session. You can opt to disable this for stateless usage of
* the pro components.
*/
'enable_temporary_uploads_session_affinity' => true,
/*
* When enabled, Media Library pro will generate thumbnails for uploaded file.
*/
'generate_thumbnails_for_temporary_uploads' => true,
/*
* This is the class that is responsible for naming generated files.
*/
'file_namer' => Spatie\MediaLibrary\Support\FileNamer\DefaultFileNamer::class,
/*
* The class that contains the strategy for determining a media file's path.
*/
'path_generator' => Spatie\MediaLibrary\Support\PathGenerator\DefaultPathGenerator::class,
/*
* The class that contains the strategy for determining how to remove files.
*/
'file_remover_class' => Spatie\MediaLibrary\Support\FileRemover\DefaultFileRemover::class,
/*
* Here you can specify which path generator should be used for the given class.
*/
'custom_path_generators' => [
// Model::class => PathGenerator::class
// or
// 'model_morph_alias' => PathGenerator::class
],
/*
* When urls to files get generated, this class will be called. Use the default
* if your files are stored locally above the site root or on s3.
*/
'url_generator' => Spatie\MediaLibrary\Support\UrlGenerator\DefaultUrlGenerator::class,
/*
* Moves media on updating to keep path consistent. Enable it only with a custom
* PathGenerator that uses, for example, the media UUID.
*/
'moves_media_on_update' => false,
/*
* Whether to activate versioning when urls to files get generated.
* When activated, this attaches a ?v=xx query string to the URL.
*/
'version_urls' => false,
/*
* The media library will try to optimize all converted images by removing
* metadata and applying a little bit of compression. These are
* the optimizers that will be used by default.
*/
'image_optimizers' => [
Spatie\ImageOptimizer\Optimizers\Jpegoptim::class => [
'-m85', // set maximum quality to 85%
'--force', // ensure that progressive generation is always done also if a little bigger
'--strip-all', // this strips out all text information such as comments and EXIF data
'--all-progressive', // this will make sure the resulting image is a progressive one
],
Spatie\ImageOptimizer\Optimizers\Pngquant::class => [
'--force', // required parameter for this package
],
Spatie\ImageOptimizer\Optimizers\Optipng::class => [
'-i0', // this will result in a non-interlaced, progressive scanned image
'-o2', // this set the optimization level to two (multiple IDAT compression trials)
'-quiet', // required parameter for this package
],
Spatie\ImageOptimizer\Optimizers\Svgo::class => [
'--disable=cleanupIDs', // disabling because it is known to cause troubles
],
Spatie\ImageOptimizer\Optimizers\Gifsicle::class => [
'-b', // required parameter for this package
'-O3', // this produces the slowest but best results
],
Spatie\ImageOptimizer\Optimizers\Cwebp::class => [
'-m 6', // for the slowest compression method in order to get the best compression.
'-pass 10', // for maximizing the amount of analysis pass.
'-mt', // multithreading for some speed improvements.
'-q 90', // quality factor that brings the least noticeable changes.
],
Spatie\ImageOptimizer\Optimizers\Avifenc::class => [
'-a cq-level=23', // constant quality level, lower values mean better quality and greater file size (0-63).
'-j all', // number of jobs (worker threads, "all" uses all available cores).
'--min 0', // min quantizer for color (0-63).
'--max 63', // max quantizer for color (0-63).
'--minalpha 0', // min quantizer for alpha (0-63).
'--maxalpha 63', // max quantizer for alpha (0-63).
'-a end-usage=q', // rate control mode set to Constant Quality mode.
'-a tune=ssim', // SSIM as tune the encoder for distortion metric.
],
],
/*
* These generators will be used to create an image of media files.
*/
'image_generators' => [
Spatie\MediaLibrary\Conversions\ImageGenerators\Image::class,
Spatie\MediaLibrary\Conversions\ImageGenerators\Webp::class,
Spatie\MediaLibrary\Conversions\ImageGenerators\Avif::class,
Spatie\MediaLibrary\Conversions\ImageGenerators\Pdf::class,
Spatie\MediaLibrary\Conversions\ImageGenerators\Svg::class,
Spatie\MediaLibrary\Conversions\ImageGenerators\Video::class,
],
/*
* The path where to store temporary files while performing image conversions.
* If set to null, storage_path('media-library/temp') will be used.
*/
'temporary_directory_path' => null,
/*
* The engine that should perform the image conversions.
* Should be either `gd` or `imagick`.
*/
'image_driver' => env('IMAGE_DRIVER', 'gd'),
/*
* FFMPEG & FFProbe binaries paths, only used if you try to generate video
* thumbnails and have installed the php-ffmpeg/php-ffmpeg composer
* dependency.
*/
'ffmpeg_path' => env('FFMPEG_PATH', '/usr/bin/ffmpeg'),
'ffprobe_path' => env('FFPROBE_PATH', '/usr/bin/ffprobe'),
/*
* The timeout (in seconds) that will be used when generating video
* thumbnails via FFMPEG.
*/
'ffmpeg_timeout' => env('FFMPEG_TIMEOUT', 900),
/*
* The number of threads that FFMPEG should use. 0 means that FFMPEG
* may decide itself.
*/
'ffmpeg_threads' => env('FFMPEG_THREADS', 0),
/*
* Here you can override the class names of the jobs used by this package. Make sure
* your custom jobs extend the ones provided by the package.
*/
'jobs' => [
'perform_conversions' => Spatie\MediaLibrary\Conversions\Jobs\PerformConversionsJob::class,
'generate_responsive_images' => Spatie\MediaLibrary\ResponsiveImages\Jobs\GenerateResponsiveImagesJob::class,
],
/*
* When using the addMediaFromUrl method you may want to replace the default downloader.
* This is particularly useful when the url of the image is behind a firewall and
* need to add additional flags, possibly using curl.
*/
'media_downloader' => Spatie\MediaLibrary\Downloaders\DefaultDownloader::class,
/*
* When using the addMediaFromUrl method the SSL is verified by default.
* This is option disables SSL verification when downloading remote media.
* Please note that this is a security risk and should only be false in a local environment.
*/
'media_downloader_ssl' => env('MEDIA_DOWNLOADER_SSL', true),
/*
* The default lifetime in minutes for temporary urls.
* This is used when you call the `getLastTemporaryUrl` or `getLastTemporaryUrl` method on a media item.
*/
'temporary_url_default_lifetime' => env('MEDIA_TEMPORARY_URL_DEFAULT_LIFETIME', 5),
'remote' => [
/*
* Any extra headers that should be included when uploading media to
* a remote disk. Even though supported headers may vary between
* different drivers, a sensible default has been provided.
*
* Supported by S3: CacheControl, Expires, StorageClass,
* ServerSideEncryption, Metadata, ACL, ContentEncoding
*/
'extra_headers' => [
'CacheControl' => 'max-age=604800',
],
],
'responsive_images' => [
/*
* This class is responsible for calculating the target widths of the responsive
* images. By default we optimize for filesize and create variations that each are 30%
* smaller than the previous one. More info in the documentation.
*
* https://docs.spatie.be/laravel-medialibrary/v9/advanced-usage/generating-responsive-images
*/
'width_calculator' => Spatie\MediaLibrary\ResponsiveImages\WidthCalculator\FileSizeOptimizedWidthCalculator::class,
/*
* By default rendering media to a responsive image will add some javascript and a tiny placeholder.
* This ensures that the browser can already determine the correct layout.
* When disabled, no tiny placeholder is generated.
*/
'use_tiny_placeholders' => true,
/*
* This class will generate the tiny placeholder used for progressive image loading. By default
* the media library will use a tiny blurred jpg image.
*/
'tiny_placeholder_generator' => Spatie\MediaLibrary\ResponsiveImages\TinyPlaceholderGenerator\Blurred::class,
],
/*
* When enabling this option, a route will be registered that will enable
* the Media Library Pro Vue and React components to move uploaded files
* in a S3 bucket to their right place.
*/
'enable_vapor_uploads' => env('ENABLE_MEDIA_LIBRARY_VAPOR_UPLOADS', false),
/*
* When converting Media instances to response the media library will add
* a `loading` attribute to the `img` tag. Here you can set the default
* value of that attribute.
*
* Possible values: 'lazy', 'eager', 'auto' or null if you don't want to set any loading instruction.
*
* More info: https://css-tricks.com/native-lazy-loading/
*/
'default_loading_attribute_value' => null,
/*
* You can specify a prefix for that is used for storing all media.
* If you set this to `/my-subdir`, all your media will be stored in a `/my-subdir` directory.
*/
'prefix' => env('MEDIA_PREFIX', ''),
/*
* When forcing lazy loading, media will be loaded even if you don't eager load media and you have
* disabled lazy loading globally in the service provider.
*/
'force_lazy_loading' => env('FORCE_MEDIA_LIBRARY_LAZY_LOADING', true),
];

View file

@ -0,0 +1,34 @@
<?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('entries', function (Blueprint $table) {
$table->id();
$table->string('title');
$table->string('slug')->unique();
$table->text('description')->nullable();
$table->boolean('is_published')->default(false);
$table->boolean('is_featured')->default(false);
$table->date('published_at')->nullable();
$table->mediumText('content')->nullable();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('entries');
}
};

View file

@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('media', function (Blueprint $table) {
$table->id();
$table->morphs('model');
$table->uuid()->nullable()->unique();
$table->string('collection_name');
$table->string('name');
$table->string('file_name');
$table->string('mime_type')->nullable();
$table->string('disk');
$table->string('conversions_disk')->nullable();
$table->unsignedBigInteger('size');
$table->json('manipulations');
$table->json('custom_properties');
$table->json('generated_conversions');
$table->json('responsive_images');
$table->unsignedInteger('order_column')->nullable()->index();
$table->nullableTimestamps();
});
}
};

View file

@ -12,4 +12,3 @@ It is a way to create apps that have all the features of an advanced app with ma
There are so many things to do, we don't have the time to re-write or re-invent what is already achieved by filament and likely, better than we could create ourselves or likely dream of.

View file

@ -0,0 +1,86 @@
# 2026-01-02
## Entry model
```bash
php artisan make:model -m Entry
```
creates skeleton files for a model and migration
we edit the migration to hold the fields we desire to be present in each record
just the minimal are added for now, paying attention to the amount of data that these may need to hold
* string (varchar): ~255 chars default.
* text: ~65 KB.
* mediumText: ~16 MB.
```bash
php artisan migrate
```
runs the migration to create the table
## Entry filament resource
```bash
php artisan filament:resource Entry
The "title attribute" is used to label each record in the UI.
You can leave this blank if records do not have a title.
┌ What is the title attribute for this model? ─────────────────┐
│ title │
└──────────────────────────────────────────────────────────────┘
┌ Would you like to generate a read-only view page for the resource? ┐
│ Yes │
└────────────────────────────────────────────────────────────────────┘
┌ Should the configuration be generated from the current database columns? ┐
│ Yes │
└──────────────────────────────────────────────────────────────────────────┘
INFO Filament resource [App\Filament\Resources\Entries\EntryResource] created successfully.
```
There is more work to do on this model but for now, a new resource is added to the admin panel by which records can be added, edited, listed.
Full CRUD is already possible. This is amazing if you think how long this would have taken to create all this manually.
### slugify
```php
TextInput::make('title')
->required()
->reactive()
->afterStateUpdated(function ($state, $set): void {
$set('slug', Str::slug((string) $state));
}),
TextInput::make('slug')
->required(),
```
adding reactive and afterStateUpdted to title automatically creates a safe slug
I class things like this creature comforts of the framework and it shows how simple it can be to make fields active and updated by previous entries
For the user, this reduces error and confusion over what a url should be
### rich editor
just changing the TextInput for content
```php
RichEditor::make('content')
->columnSpanFull(),
```
to `RichEditr` gives us a 'tiptap' rich text editor
already, this is feeling more like a mini CMS, with relatively little effort

View file

@ -0,0 +1,24 @@
# 2026-01-02
```bash
php artisan storage:link
composer require "spatie/laravel-medialibrary"
composer require filament/spatie-laravel-media-library-plugin:"^4.0" -W
php artisan vendor:publish --provider="Spatie\MediaLibrary\MediaLibraryServiceProvider" --tag="medialibrary-config"
php artisan migrate
```
I had a lot of fun and silly games working out if I even needed both Spatie media library and the filament plugin they have as it states itself a sub directory of filament itself.
I managed to remove the spatie fulament plugin and for the app to work for a while but eventually found a part of it not implemented in filament core. It took a while but there seems to be an overlap somewhere. So I re-installed the spatie filament module to find all the errors went away, when previously they did not
PHP and I guess composer would seem to aggressively cache things very very hard and I just didnt understand how very hard that is sometimes.
Suffice to say, spatie media library seems to be 'the way' as far as media is concerned for filament.
I looked at curator, also by the author of the tiptap filament integration to find it seems dependant on tailwind3, limiting me to Laravel 11 I believe, which was not what I wanted.
I used filament 'actions' in order to augment the form for 'entries' so as to add 'pick from gallery' functionality together with custom javascript to insert records found in media library for both rich content and featured images
There may be a better way of doing this but for now, I believe this is a solution I have craved for a while now with filament and have at least gained a bit better understanding of so called 'actions' and will re-visit this whole section in the docs.

View file

@ -0,0 +1,38 @@
# 2026-01-05
I would very much like to start using Pest4 as it has a lot of nice things in it like browser testing with some of the latest screen shot visual testing and more
for now I know Dusk and have been able to use this in previous projects before Pest4
so initially at least I intend to use Dusk and build on to migrate to Pest4 where I can
# test isolation and setup
in the past I now realize, I was introducing brittle tests by first doing setup to then rely on a configuration for further tests
I think this is a pattern that some follow but as a generalist, devops and hybrid person I can and do make mistakes. I suppose we all do.
Mine was to not fully embrace test isolation
Each test therefor needs to be set up from scratch so to speak
I then have each subsequent test re-setting up say, a user, a database table, whatever is required but each time from a blank slate
That way, when a test fails, it should be obvious what has been broken without having to traverse through a bunch of setup and dependencies to find I broke a setup step, not a test.
Let us hope so anyway. That is the approach I am going with with Dusk for now. Lets see how I get on and if I get the setup code as non repeated by abstraction as I can
I think testing and particulary automated testing is essential for success.
It has been disappointing to find in my time working with others in the past that there seems resistance to testing and to automation of testing for reasons I believe not technical but that which time does not allow for me to digress
## confusing config
In config files: Just use the key name without any prefix
In your code: Use config('filename.keyname')
The filename becomes the first part automatically - you never type it inside the config file itself.

View file

@ -0,0 +1,48 @@
console.log('App.js is loaded');
document.addEventListener('livewire:init', () => {
Livewire.on('insert-editor-content', (data) => {
console.log('Received insert-editor-content data:', data);
// Handle if data is an array
const payload = Array.isArray(data) ? data[0] : data;
const statePath = payload.statePath;
const html = payload.html;
console.log('Extracted statePath:', statePath, 'html:', html);
// 1. Find the editor by its statePath
const container = document.querySelector(`[wire\\:model="${statePath}"]`) || document.querySelector(`[statepath="${statePath}"]`);
console.log('Container found:', container);
const editorElement = container ? container.querySelector('.tiptap') : null;
console.log('Editor element found:', editorElement);
if (editorElement && editorElement.editor) {
console.log('Inserting content:', html);
// 2. Insert the HTML (the <img> tag) into the editor
setTimeout(() => {
editorElement.editor.chain().focus().insertContent(html).run();
}, 500);
} else {
console.log('Editor not found or not initialized');
// Fallback: try to find any .tiptap on the page
const anyTiptap = document.querySelector('.tiptap');
console.log('Any tiptap found:', anyTiptap);
if (anyTiptap && anyTiptap.editor) {
console.log('Inserting to any tiptap');
setTimeout(() => {
anyTiptap.editor.chain().focus().insertContent(html).run();
}, 500);
}
}
});
Livewire.on('featured-image-added', (data) => {
console.log('Received featured-image-added event:', data);
const payload = Array.isArray(data) ? data[0] : data;
// Reload the page to show the updated featured image
setTimeout(() => {
window.location.reload();
}, 500);
});
});
console.log('Testing if app.js is still running');

View file

@ -0,0 +1,32 @@
<div>
@php
$record = $this->data;
if ($record && isset($record['id'])) {
$entry = \App\Models\Entry::find($record['id']);
$media = $entry ? $entry->getMedia('featured-image') : [];
} else {
$media = [];
}
@endphp
<div class="space-y-2">
<label class="text-sm font-medium text-gray-700">Featured Image</label>
@if(count($media) > 0)
<div class="space-y-2">
@foreach($media as $item)
<div class="flex items-center justify-between p-3 bg-gray-50 rounded-lg border border-gray-200">
<div class="flex items-center gap-3">
<img src="{{ $item->getUrl() }}" alt="" class="w-16 h-16 object-cover rounded">
<div>
<p class="text-sm font-medium">{{ $item->name }}</p>
<p class="text-xs text-gray-500">{{ $item->file_name }}</p>
</div>
</div>
</div>
@endforeach
</div>
@else
<p class="text-sm text-gray-500">No featured image selected</p>
@endif
</div>
</div>

View file

@ -0,0 +1,72 @@
<div>
@if($showModal ?? false)
<div class="fixed inset-0 z-50 overflow-y-auto">
<div class="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
<!-- Background overlay -->
<div class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" wire:click="closePicker()"></div>
<!-- Modal panel -->
<div class="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full">
<div class="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
<div class="flex justify-between items-center mb-4">
<h3 class="text-lg font-medium text-gray-900">
Select Image from Gallery
</h3>
<button wire:click="closePicker()" class="text-gray-400 hover:text-gray-500">
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
</div>
@if(empty($mediaItems ?? []))
<p class="text-center text-gray-500 py-8">No images in gallery</p>
@else
<div class="grid grid-cols-3 gap-4 max-h-96 overflow-y-auto">
@foreach($mediaItems ?? [] as $item)
<button
wire:click="selectMedia({{ $item['id'] }})"
class="relative group overflow-hidden rounded-lg border-2 transition-colors {{ ($selectedMediaId ?? null) === $item['id'] ? 'border-blue-500 bg-blue-50' : 'border-gray-200 hover:border-gray-300' }}"
>
<img
src="{{ asset('storage/' . $item['disk'] . '/' . $item['id'] . '/' . $item['file_name']) }}"
alt="{{ $item['name'] ?? $item['file_name'] }}"
class="w-full h-32 object-cover"
loading="lazy"
/>
@if(($selectedMediaId ?? null) === $item['id'])
<div class="absolute inset-0 bg-blue-500 bg-opacity-20 flex items-center justify-center">
<svg class="h-8 w-8 text-blue-600" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"></path>
</svg>
</div>
@endif
<div class="absolute bottom-0 left-0 right-0 bg-black bg-opacity-50 text-white text-xs px-2 py-1 opacity-0 group-hover:opacity-100 transition-opacity truncate">
{{ $item['name'] ?? $item['file_name'] }}
</div>
</button>
@endforeach
</div>
@endif
</div>
<div class="bg-gray-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse gap-3">
<button
wire:click="copyToEntry"
:disabled="!($selectedMediaId ?? null)"
class="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-blue-600 text-base font-medium text-white hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed sm:w-auto sm:text-sm"
>
Add Image
</button>
<button
wire:click="closePicker()"
class="w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 sm:w-auto sm:text-sm"
>
Cancel
</button>
</div>
</div>
</div>
</div>
@endif
</div>

View file

@ -0,0 +1,60 @@
<?php
namespace Tests\Browser;
use Illuminate\Foundation\Testing\DatabaseTruncation;
use Laravel\Dusk\Browser;
use Tests\Browser\Concerns\AuthenticatesUsers;
use Tests\DuskTestCase;
class LoginTest extends DuskTestCase
{
use DatabaseTruncation;
use AuthenticatesUsers;
public function test_login(): void
{
$user = $this->createTestUser("login-test@example.com");
$this->browse(function (Browser $browser) use ($user) {
$this->loginUser($browser, $user);
$this->assertWithDebugPause($browser, fn($b) =>
$b->assertPathIs('/dashboard'),
1000 // Custom pause time for this test
);
});
}
public function test_invalid_login(): void
{
$user = $this->createTestUser("invalid-email@example.com");
$this->browse(function (Browser $browser) use ($user) {
$this->loginUser($browser, $user);
$this->assertWithDebugPause($browser, fn($b) =>
$b->visit('/admin')
->waitForLocation('/admin')
->assertPathIs('/admin')
->assertSee('FORBIDDEN'),
1000 // Custom pause time for this test
);
});
}
public function test_access_admin_panel(): void
{
$user = $this->createTestUser("login-test@example.com");
$this->browse(function (Browser $browser) use ($user) {
$this->loginUser($browser, $user);
$this->assertWithDebugPause($browser, fn($b) =>
$b->visit('/admin')
->waitForLocation('/admin')
->assertPathIs('/admin')
->assertTitleContains('Dashboard')
->assertDontSee('FORBIDDEN'),
1000 // Custom pause time for this test
);
});
}
}

View file

@ -0,0 +1,46 @@
<?php
namespace Tests\Browser;
use Illuminate\Foundation\Testing\DatabaseTruncation;
use Laravel\Dusk\Browser;
use Tests\Browser\Concerns\AuthenticatesUsers;
use Tests\DuskTestCase;
class UploadImageAdminTest extends DuskTestCase
{
use DatabaseTruncation;
use AuthenticatesUsers;
public function test_image_upload_admin_panel(): void
{
$user = $this->createTestUser("login-test@example.com");
$filePath = base_path('tests/Browser/fixtures/robot.webp');
$this->browse(function (Browser $browser ) use ($user, $filePath) {
$this->loginUser($browser, $user);
$this->assertWithDebugPause(
$browser,
fn($b) =>
$b->visit('/admin/media')
->waitForLocation('/admin/media')
->assertPathIs('/admin/media')
->assertTitleContains('Media')
->clickLink('New media')
->waitForText('Create Media')
->type('#form\\.name', 'test image')
->assertVisible('.filepond--drop-label')
->attach('.filepond--browser', $filePath)
->pause(7000)
->waitForText('Create')
->waitFor('#key-bindings-1:not([disabled])')
->click('#key-bindings-1')
->assertSee('Collection name'),
1000 // Custom pause time for this test
);
});
}
}

View file

@ -0,0 +1,74 @@
<?php
namespace Tests\Browser;
use Illuminate\Foundation\Testing\DatabaseTruncation;
use Laravel\Dusk\Browser;
use Tests\Browser\Concerns\AuthenticatesUsers;
use Tests\DuskTestCase;
class CreateEntryAdminTest extends DuskTestCase
{
use DatabaseTruncation;
use AuthenticatesUsers;
public function test_create_entry_admin_panel(): void
{
$user = $this->createTestUser("login-test@example.com");
$filePath = base_path('tests/Browser/fixtures/robot.webp');
$this->browse(function (Browser $browser) use ($user, $filePath) {
$this->loginUser($browser, $user);
$this->assertWithDebugPause(
$browser,
fn($b) =>
$b->visit('/admin/media')
->waitForLocation('/admin/media')
->assertPathIs('/admin/media')
->assertTitleContains('Media')
->clickLink('New media')
->waitForText('Create Media')
->type('#form\\.name', 'test image')
->assertVisible('.filepond--drop-label')
->attach('.filepond--browser', $filePath)
->pause(7000)
->waitForText('Create')
->waitFor('#key-bindings-1:not([disabled])')
->click('#key-bindings-1')
->assertSee('Collection name')
->pause(5000)
->visit('/admin/entries')
->waitForLocation('/admin/entries')
->assertPathIs('/admin/entries')
->assertTitleContains('Entries')
->clickLink('New entry')
->waitForText('Create Entry')
->type('#form\\.title', 'TEST ENTRY')
->keys('#form\\.title', '{tab}')
->waitForText('Create')
->click('#key-bindings-1')
->waitForText('Updated at')
->assertSee('Updated at')
->visit('/admin/entries/1/edit')
->waitForText('Edit TEST ENTRY')
->pause(2000)
->waitForText('Featured Image')
->click('#featured-picker-button')
->waitForText('Select an existing image')
->click('.fi-select-input-btn')
->pause(2000)
->click('li:first-child')
->waitForText('Submit')
->clickAtXPath('//button[contains(., "Submit")]')
->waitForText('Edit TEST ENTRY')
->click('#key-bindings-1'),
// ->pause(20000),
1000 // Custom pause time for this test
);
});
}
}

View file

@ -0,0 +1,38 @@
<?php
namespace Tests\Browser\Concerns;
use App\Models\User;
use Laravel\Dusk\Browser;
trait AuthenticatesUsers
{
private function createTestUser(string $email): User
{
return User::factory()->create([
'email' => $email,
'password' => bcrypt('password'),
'two_factor_secret' => null,
'two_factor_recovery_codes' => null,
]);
}
private function loginUser(Browser $browser, User $user): void
{
$browser->visit('/login')
->type('email', $user->email)
->type('password', 'password')
->press('Log in')
->waitForLocation('/dashboard');
}
private function assertWithDebugPause(Browser $browser, callable $assertions, int $pauseMs = 10000): void
{
try {
$assertions($browser);
} catch (\Exception $e) {
$browser->pause($pauseMs);
throw $e;
}
}
}

2
tests/Browser/console/.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
*
!.gitignore

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

2
tests/Browser/screenshots/.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
*
!.gitignore

2
tests/Browser/source/.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
*
!.gitignore

48
tests/DuskTestCase.php Normal file
View file

@ -0,0 +1,48 @@
<?php
namespace Tests;
use Facebook\WebDriver\Chrome\ChromeOptions;
use Facebook\WebDriver\Remote\DesiredCapabilities;
use Facebook\WebDriver\Remote\RemoteWebDriver;
use Illuminate\Support\Collection;
use Laravel\Dusk\TestCase as BaseTestCase;
use PHPUnit\Framework\Attributes\BeforeClass;
abstract class DuskTestCase extends BaseTestCase
{
/**
* Prepare for Dusk test execution.
*/
#[BeforeClass]
public static function prepare(): void
{
if (! static::runningInSail()) {
static::startChromeDriver(['--port=9515']);
}
}
/**
* Create the RemoteWebDriver instance.
*/
protected function driver(): RemoteWebDriver
{
$options = (new ChromeOptions)->addArguments(collect([
$this->shouldStartMaximized() ? '--start-maximized' : '--window-size=1920,1080',
'--disable-search-engine-choice-screen',
'--disable-smooth-scrolling',
])->unless($this->hasHeadlessDisabled(), function (Collection $items) {
return $items->merge([
'--disable-gpu',
'--headless=new',
]);
})->all());
return RemoteWebDriver::create(
$_ENV['DUSK_DRIVER_URL'] ?? env('DUSK_DRIVER_URL') ?? 'http://localhost:9515',
DesiredCapabilities::chrome()->setCapability(
ChromeOptions::CAPABILITY, $options
)
);
}
}

View file

@ -1,5 +1,9 @@
<?php
pest()->extend(Tests\DuskTestCase::class)
// ->use(Illuminate\Foundation\Testing\DatabaseMigrations::class)
->in('Browser');
/*
|--------------------------------------------------------------------------
| Test Case
@ -13,7 +17,7 @@
pest()->extend(Tests\TestCase::class)
->use(Illuminate\Foundation\Testing\RefreshDatabase::class)
->in('Feature');
->in('Feature', 'Unit');
/*
|--------------------------------------------------------------------------

View file

@ -6,5 +6,15 @@ use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
abstract class TestCase extends BaseTestCase
{
//
/**
* Creates the application.
*/
public function createApplication(): \Illuminate\Foundation\Application
{
$app = require __DIR__ . '/../bootstrap/app.php';
$app->make(\Illuminate\Contracts\Console\Kernel::class)->bootstrap();
return $app;
}
}

View file

@ -0,0 +1,19 @@
<?php
use App\Models\Entry;
it('has correct fillable attributes', function () {
$expected = [
'title',
'slug',
'description',
'is_published',
'is_featured',
'published_at',
'content',
];
$entry = new Entry();
expect($entry->getFillable())->toEqual($expected);
});

View file

@ -0,0 +1,53 @@
<?php
use App\Filament\Resources\Entries\EntryResource;
use App\Models\Entry;
it('references the correct model and record title attribute', function () {
$defaults = (new ReflectionClass(EntryResource::class))->getDefaultProperties();
expect($defaults['model'] ?? null)->toBe(Entry::class);
expect($defaults['recordTitleAttribute'] ?? null)->toBe('title');
});
it('defines the expected pages', function () {
$pages = EntryResource::getPages();
expect(array_keys($pages))->toEqual(['index', 'create', 'view', 'edit']);
});
it('accepts new record when entered via the form schema', function () {
$data = [
'title' => 'Test Entry',
'slug' => 'test-entry',
'description' => 'This is a test entry.',
'is_published' => true,
'is_featured' => false,
'published_at' => now()->toDateString(),
'content' => '<p>This is the content of the test entry.</p>',
];
$entry = new Entry();
$entry->fill($data);
expect($entry->slug)->toBe('test-entry');
});
it('deletes a record correctly', function () {
$entry = Entry::create([
'title' => 'Test Entry to Delete',
'slug' => 'test-entry-to-delete',
'description' => 'This is a test entry.',
'is_published' => false,
'is_featured' => false,
'published_at' => null,
'content' => '<p>This is the content of the test entry.</p>',
]);
$entryId = $entry->id;
$entry->delete();
$deletedEntry = Entry::find($entryId);
expect($deletedEntry)->toBeNull();
});