feat: implement Spatie Media Library integration
with CRUD operations and media management UI
This commit is contained in:
parent
d40b87438d
commit
02884d4e2b
11 changed files with 499 additions and 4 deletions
|
|
@ -2,13 +2,16 @@
|
|||
|
||||
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\Textarea;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Components\Toggle;
|
||||
use Filament\Schemas\Schema;
|
||||
use Illuminate\Support\Str;
|
||||
use Spatie\MediaLibrary\MediaCollections\Models\Media;
|
||||
|
||||
class EntryForm
|
||||
{
|
||||
|
|
@ -18,13 +21,14 @@ 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(),
|
||||
Toggle::make('is_published')
|
||||
|
|
@ -33,7 +37,45 @@ class EntryForm
|
|||
->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 () {
|
||||
// We must 'get' the collection first so we can call getUrl()
|
||||
// because 'url' is not a column in the Spatie database table.
|
||||
return Media::latest()
|
||||
->get()
|
||||
->mapWithKeys(function (Media $item) {
|
||||
$url = $item->getUrl();
|
||||
|
||||
$fileName = e($item->file_name);
|
||||
$name = e($item->name ?? '');
|
||||
|
||||
$html = "<div class='flex items-center gap-2 w-full'>".
|
||||
"<img src=\"{$url}\" class=\"rounded\" style=\"max-width:200px;max-height:200px;object-fit:cover;width:auto;height:auto;\" alt=\"{$fileName}\"/>".
|
||||
"<span class=\"ml-2 truncate\">{$name} — {$fileName}</span></div>";
|
||||
|
||||
return [$url => $html];
|
||||
})->toArray();
|
||||
})
|
||||
->searchable()
|
||||
->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=''>",
|
||||
]);
|
||||
})
|
||||
),
|
||||
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
->circular()
|
||||
->stacked()
|
||||
->limit(3),
|
||||
TextColumn::make('title')
|
||||
->searchable(),
|
||||
TextColumn::make('slug')
|
||||
|
|
|
|||
58
app/Filament/Resources/Media/MediaResource.php
Normal file
58
app/Filament/Resources/Media/MediaResource.php
Normal 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'),
|
||||
];
|
||||
}
|
||||
}
|
||||
61
app/Filament/Resources/Media/Pages/CreateMedia.php
Normal file
61
app/Filament/Resources/Media/Pages/CreateMedia.php
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
72
app/Filament/Resources/Media/Pages/EditMedia.php
Normal file
72
app/Filament/Resources/Media/Pages/EditMedia.php
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
19
app/Filament/Resources/Media/Pages/ListMedia.php
Normal file
19
app/Filament/Resources/Media/Pages/ListMedia.php
Normal 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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
19
app/Filament/Resources/Media/Pages/ViewMedia.php
Normal file
19
app/Filament/Resources/Media/Pages/ViewMedia.php
Normal 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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
59
app/Filament/Resources/Media/Schemas/MediaForm.php
Normal file
59
app/Filament/Resources/Media/Schemas/MediaForm.php
Normal 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);
|
||||
}),
|
||||
]);
|
||||
}
|
||||
}
|
||||
36
app/Filament/Resources/Media/Schemas/MediaInfolist.php
Normal file
36
app/Filament/Resources/Media/Schemas/MediaInfolist.php
Normal 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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
86
app/Filament/Resources/Media/Tables/MediaTable.php
Normal file
86
app/Filament/Resources/Media/Tables/MediaTable.php
Normal 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();
|
||||
});
|
||||
}),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -1 +1,38 @@
|
|||
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);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
console.log('Testing if app.js is still running');
|
||||
Loading…
Add table
Add a link
Reference in a new issue