diff --git a/.gitignore b/.gitignore
index c7cf1fa..3a324ad 100644
--- a/.gitignore
+++ b/.gitignore
@@ -21,3 +21,4 @@ yarn-error.log
/.nova
/.vscode
/.zed
+.vite
diff --git a/app/Console/Commands/MoveMediaToPublic.php b/app/Console/Commands/MoveMediaToPublic.php
new file mode 100644
index 0000000..bce2727
--- /dev/null
+++ b/app/Console/Commands/MoveMediaToPublic.php
@@ -0,0 +1,112 @@
+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;
+ }
+}
diff --git a/app/Filament/Resources/Entries/Schemas/EntryForm.php b/app/Filament/Resources/Entries/Schemas/EntryForm.php
index 82e163d..6c029f6 100644
--- a/app/Filament/Resources/Entries/Schemas/EntryForm.php
+++ b/app/Filament/Resources/Entries/Schemas/EntryForm.php
@@ -2,13 +2,18 @@
namespace App\Filament\Resources\Entries\Schemas;
+use Filament\Actions\Action;
use Filament\Forms\Components\DatePicker;
use Filament\Forms\Components\RichEditor;
-use Filament\Forms\Components\TextInput;
+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
{
@@ -18,22 +23,183 @@ class EntryForm
->components([
TextInput::make('title')
->required()
- ->reactive()
+ ->live(onBlur: true)
->afterStateUpdated(function ($state, $set): void {
$set('slug', Str::slug((string) $state));
}),
TextInput::make('slug')
->required()
- ->disabled(),
+ ->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('Pick from Gallery')
+ ->icon('heroicon-m-photo')
+ ->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 = "
" .
+ "

" .
+ "
" .
+ "{$name}" .
+ "{$fileName}" .
+ "
";
+
+ 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(),
+ ->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 = "" .
+ "

" .
+ "
" .
+ "{$name}" .
+ "{$fileName}" .
+ "
";
+
+ 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' => "
",
+ ]);
+ })
+ ),
+
]);
}
}
diff --git a/app/Filament/Resources/Entries/Schemas/EntryInfolist.php b/app/Filament/Resources/Entries/Schemas/EntryInfolist.php
index 6a5d87d..be741bc 100644
--- a/app/Filament/Resources/Entries/Schemas/EntryInfolist.php
+++ b/app/Filament/Resources/Entries/Schemas/EntryInfolist.php
@@ -3,6 +3,7 @@
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;
@@ -17,6 +18,9 @@ class EntryInfolist
TextEntry::make('description')
->placeholder('-')
->columnSpanFull(),
+ SpatieMediaLibraryImageEntry::make('featured_image')
+ ->collection('featured-image')
+ ->columnSpanFull(),
IconEntry::make('is_published')
->boolean(),
IconEntry::make('is_featured')
diff --git a/app/Filament/Resources/Entries/Tables/EntriesTable.php b/app/Filament/Resources/Entries/Tables/EntriesTable.php
index 57035c0..eb89fd4 100644
--- a/app/Filament/Resources/Entries/Tables/EntriesTable.php
+++ b/app/Filament/Resources/Entries/Tables/EntriesTable.php
@@ -7,6 +7,7 @@ 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;
@@ -16,6 +17,11 @@ class EntriesTable
{
return $table
->columns([
+ SpatieMediaLibraryImageColumn::make('featured_image')
+ ->collection('featured-image')
+ ->circular()
+ ->stacked()
+ ->limit(3),
TextColumn::make('title')
->searchable(),
TextColumn::make('slug')
diff --git a/app/Filament/Resources/Media/MediaResource.php b/app/Filament/Resources/Media/MediaResource.php
new file mode 100644
index 0000000..2ac19fe
--- /dev/null
+++ b/app/Filament/Resources/Media/MediaResource.php
@@ -0,0 +1,58 @@
+ ListMedia::route('/'),
+ 'create' => CreateMedia::route('/create'),
+ 'view' => ViewMedia::route('/{record}'),
+ 'edit' => EditMedia::route('/{record}/edit'),
+ ];
+ }
+}
diff --git a/app/Filament/Resources/Media/Pages/CreateMedia.php b/app/Filament/Resources/Media/Pages/CreateMedia.php
new file mode 100644
index 0000000..c652a96
--- /dev/null
+++ b/app/Filament/Resources/Media/Pages/CreateMedia.php
@@ -0,0 +1,61 @@
+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);
+ }
+ }
+ }
+}
diff --git a/app/Filament/Resources/Media/Pages/EditMedia.php b/app/Filament/Resources/Media/Pages/EditMedia.php
new file mode 100644
index 0000000..b6f4cdb
--- /dev/null
+++ b/app/Filament/Resources/Media/Pages/EditMedia.php
@@ -0,0 +1,72 @@
+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);
+ }
+ }
+}
diff --git a/app/Filament/Resources/Media/Pages/ListMedia.php b/app/Filament/Resources/Media/Pages/ListMedia.php
new file mode 100644
index 0000000..2e6b63d
--- /dev/null
+++ b/app/Filament/Resources/Media/Pages/ListMedia.php
@@ -0,0 +1,19 @@
+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);
+ }),
+ ]);
+ }
+}
diff --git a/app/Filament/Resources/Media/Schemas/MediaInfolist.php b/app/Filament/Resources/Media/Schemas/MediaInfolist.php
new file mode 100644
index 0000000..dfe4801
--- /dev/null
+++ b/app/Filament/Resources/Media/Schemas/MediaInfolist.php
@@ -0,0 +1,36 @@
+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(),
+ ]);
+ }
+}
diff --git a/app/Filament/Resources/Media/Tables/MediaTable.php b/app/Filament/Resources/Media/Tables/MediaTable.php
new file mode 100644
index 0000000..dba76b0
--- /dev/null
+++ b/app/Filament/Resources/Media/Tables/MediaTable.php
@@ -0,0 +1,86 @@
+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();
+ });
+ }),
+ ]),
+ ]);
+ }
+}
diff --git a/app/Livewire/GalleryPicker.php b/app/Livewire/GalleryPicker.php
new file mode 100644
index 0000000..c1da485
--- /dev/null
+++ b/app/Livewire/GalleryPicker.php
@@ -0,0 +1,108 @@
+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');
+ }
+}
diff --git a/app/Models/Entry.php b/app/Models/Entry.php
index f9b7c1d..d0036af 100644
--- a/app/Models/Entry.php
+++ b/app/Models/Entry.php
@@ -2,10 +2,19 @@
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
-class Entry extends Model
{
+
+ use InteractsWithMedia, InteractsWithRichContent;
+
protected $fillable = [
'title',
'slug',
@@ -15,4 +24,19 @@ class Entry extends Model
'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()
+ );
+ }
+
}
diff --git a/app/Providers/Filament/AdminPanelProvider.php b/app/Providers/Filament/AdminPanelProvider.php
index 373a4be..81aa43d 100644
--- a/app/Providers/Filament/AdminPanelProvider.php
+++ b/app/Providers/Filament/AdminPanelProvider.php
@@ -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;
@@ -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")'),
+ );
+ }
}
diff --git a/composer.json b/composer.json
index f49e35f..c67e0d1 100644
--- a/composer.json
+++ b/composer.json
@@ -11,7 +11,8 @@
"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",
diff --git a/composer.lock b/composer.lock
index 1b34af6..010c9b8 100644
--- a/composer.lock
+++ b/composer.lock
@@ -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": "bec347dcc3a450fc682920a766cbe019",
"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",
diff --git a/config/media-library.php b/config/media-library.php
new file mode 100644
index 0000000..33d558a
--- /dev/null
+++ b/config/media-library.php
@@ -0,0 +1,303 @@
+ 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),
+];
diff --git a/database/migrations/2026_01_02_160151_create_media_table.php b/database/migrations/2026_01_02_160151_create_media_table.php
new file mode 100644
index 0000000..47a4be9
--- /dev/null
+++ b/database/migrations/2026_01_02_160151_create_media_table.php
@@ -0,0 +1,32 @@
+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();
+ });
+ }
+};
diff --git a/docs/decisions/004-spatie-media-library b/docs/decisions/004-spatie-media-library
new file mode 100644
index 0000000..e1dc122
--- /dev/null
+++ b/docs/decisions/004-spatie-media-library
@@ -0,0 +1,10 @@
+# 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
+```
+
diff --git a/resources/js/app.js b/resources/js/app.js
index e69de29..22e1c9c 100644
--- a/resources/js/app.js
+++ b/resources/js/app.js
@@ -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
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');
\ No newline at end of file
diff --git a/resources/views/components/featured-image-display.blade.php b/resources/views/components/featured-image-display.blade.php
new file mode 100644
index 0000000..437552f
--- /dev/null
+++ b/resources/views/components/featured-image-display.blade.php
@@ -0,0 +1,32 @@
+
+ @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
+
+
+
+ @if(count($media) > 0)
+
+ @foreach($media as $item)
+
+
+
 }})
+
+
{{ $item->name }}
+
{{ $item->file_name }}
+
+
+
+ @endforeach
+
+ @else
+
No featured image selected
+ @endif
+
+
diff --git a/resources/views/livewire/gallery-picker.blade.php b/resources/views/livewire/gallery-picker.blade.php
new file mode 100644
index 0000000..103b66b
--- /dev/null
+++ b/resources/views/livewire/gallery-picker.blade.php
@@ -0,0 +1,72 @@
+
+ @if($showModal ?? false)
+
+
+
+
+
+
+
+
+
+
+ Select Image from Gallery
+
+
+
+
+ @if(empty($mediaItems ?? []))
+
No images in gallery
+ @else
+
+ @foreach($mediaItems ?? [] as $item)
+
+ @endforeach
+
+ @endif
+
+
+
+
+
+
+
+
+
+ @endif
+