Co-authored-by: jon brookes <marshyon@gmail.com> Reviewed-on: https://codeberg.org/headshed/share-lt/pulls/18
544 lines
29 KiB
PHP
544 lines
29 KiB
PHP
<?php
|
|
|
|
namespace App\Filament\Resources\Entries\Schemas;
|
|
|
|
use App\Models\Category;
|
|
use Filament\Actions\Action;
|
|
use Filament\Forms\Components\DatePicker;
|
|
use Filament\Forms\Components\RichEditor;
|
|
use Filament\Forms\Components\Select;
|
|
use Filament\Forms\Components\SpatieMediaLibraryFileUpload;
|
|
use Filament\Forms\Components\SpatieTagsInput;
|
|
use Filament\Forms\Components\Textarea;
|
|
use Filament\Forms\Components\TextInput;
|
|
use Filament\Forms\Components\Toggle;
|
|
use Filament\Schemas\Schema;
|
|
use Illuminate\Support\Facades\Log;
|
|
use Illuminate\Support\Str;
|
|
use Spatie\MediaLibrary\MediaCollections\Models\Media;
|
|
|
|
class EntryForm
|
|
{
|
|
public static function configure(Schema $schema): Schema
|
|
{
|
|
return $schema
|
|
->components([
|
|
Select::make('type')
|
|
->options([
|
|
'article' => 'Article',
|
|
'card' => 'Card',
|
|
'text' => 'Text',
|
|
'image' => 'Image',
|
|
])
|
|
->default('article')
|
|
->required()
|
|
->live(),
|
|
TextInput::make('title')
|
|
->required()
|
|
->live(onBlur: true)
|
|
->afterStateUpdated(function ($state, $set): void {
|
|
$set('slug', Str::slug((string) $state));
|
|
}),
|
|
TextInput::make('slug')
|
|
->required()
|
|
->visible(fn($get) => $get('type') === 'article')
|
|
->dehydrated()
|
|
->readOnly(),
|
|
Textarea::make('description')
|
|
->visible(fn($get) => $get('type') === 'article')
|
|
->columnSpanFull(),
|
|
SpatieTagsInput::make('tags')
|
|
->type('entry-tags')
|
|
->visible(fn($get) => $get('type') === 'article')
|
|
->columnSpanFull(),
|
|
SpatieMediaLibraryFileUpload::make('featured_image')
|
|
->multiple() // <- force array handling for Filament v4 bug
|
|
->visible(
|
|
fn($get) =>
|
|
$get('type') === 'article' || $get('type') === 'image'
|
|
)
|
|
->collection('featured-image')
|
|
->image()
|
|
->imageEditor()
|
|
->disk(config('media-library.disk_name', 'public'))
|
|
->visibility('public')
|
|
->columnSpanFull()
|
|
->dehydrated(false)
|
|
->saveUploadedFileUsing(function ($file, $record) {
|
|
if (is_array($file)) {
|
|
$file = reset($file);
|
|
}
|
|
|
|
// Validate upload object early
|
|
if (
|
|
! is_object($file) ||
|
|
! (method_exists($file, 'getRealPath') || method_exists($file, 'getPathname') || method_exists($file, 'getStream') || method_exists($file, 'store'))
|
|
) {
|
|
Log::error('Invalid upload object', ['type' => gettype($file)]);
|
|
throw new \Exception('Invalid upload object provided to saveUploadedFileUsing');
|
|
}
|
|
|
|
// Use safe variables for further calls
|
|
$realPath = method_exists($file, 'getRealPath') ? $file->getRealPath() : null;
|
|
$exists = $realPath ? file_exists($realPath) : false;
|
|
$name = method_exists($file, 'getClientOriginalName') ? $file->getClientOriginalName() : null;
|
|
|
|
|
|
|
|
|
|
Log::info('TemporaryUploadedFile Debug', [
|
|
'path' => $file->getRealPath(),
|
|
'exists' => file_exists($file->getRealPath()),
|
|
'name' => $file->getClientOriginalName(),
|
|
'temp_dir' => sys_get_temp_dir(),
|
|
'disk_root' => config('filesystems.disks.local.root'),
|
|
'is_readable' => is_readable($file->getRealPath()),
|
|
'is_writable' => is_writable($file->getRealPath()),
|
|
]);
|
|
|
|
// Additional debug: Check if the file is being moved to livewire-tmp
|
|
$livewireTmpPath = storage_path('framework/livewire-tmp');
|
|
Log::info('Livewire Temp Directory Debug', [
|
|
'livewire_tmp_path' => $livewireTmpPath,
|
|
'exists' => file_exists($livewireTmpPath),
|
|
'is_writable' => is_writable($livewireTmpPath),
|
|
]);
|
|
|
|
// Check if the file is being moved
|
|
$tempFilePath = $file->getRealPath();
|
|
$newFilePath = $livewireTmpPath . '/' . $file->getClientOriginalName();
|
|
if (file_exists($tempFilePath)) {
|
|
Log::info('File exists in temp directory', ['temp_file_path' => $tempFilePath]);
|
|
} else {
|
|
Log::error('File does not exist in temp directory', ['temp_file_path' => $tempFilePath]);
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// $diskName = config('media-library.disk_name', 'public');
|
|
$diskName = config('media-library.disk_name');
|
|
|
|
if (config('logging.channels.' . config('logging.default') . '.level') === 'debug') {
|
|
Log::info('Featured Image Upload Debug', [
|
|
'disk' => $diskName,
|
|
'file_name' => $file->getClientOriginalName(),
|
|
'file_size' => $file->getSize(),
|
|
'file_mime' => $file->getMimeType(),
|
|
'file_path' => $file->getRealPath(),
|
|
'record_id' => $record?->id,
|
|
'aws_config' => [
|
|
'bucket' => config('filesystems.disks.s3.bucket'),
|
|
'region' => config('filesystems.disks.s3.region'),
|
|
'key_exists' => !empty(config('filesystems.disks.s3.key')),
|
|
'secret_exists' => !empty(config('filesystems.disks.s3.secret')),
|
|
]
|
|
]);
|
|
}
|
|
|
|
try {
|
|
if (!$record) {
|
|
throw new \Exception('Record not found during upload');
|
|
}
|
|
|
|
// Test S3 connection if using S3
|
|
if ($diskName === 's3') {
|
|
$disk = \Storage::disk('s3');
|
|
|
|
// Test basic S3 connectivity
|
|
$testFile = 'test-' . time() . '.txt';
|
|
$disk->put($testFile, 'test content');
|
|
$disk->delete($testFile);
|
|
|
|
if (config('logging.channels.' . config('logging.default') . '.level') === 'debug') {
|
|
Log::info('S3 connectivity test passed');
|
|
}
|
|
}
|
|
|
|
// Use addMedia with the file directly, not addMediaFromRequest
|
|
// Generate secure filename similar to Livewire temp files
|
|
$originalName = $file->getClientOriginalName();
|
|
$extension = pathinfo($originalName, PATHINFO_EXTENSION);
|
|
$baseName = pathinfo($originalName, PATHINFO_FILENAME);
|
|
|
|
// Generate secure filename with encoded original name
|
|
$encodedName = base64_encode($originalName);
|
|
$secureFileName = Str::random(32) . '-meta' . $encodedName . '-.' . $extension;
|
|
|
|
|
|
|
|
|
|
|
|
// Resolve possibly-relative Livewire temp path + safe fallbacks
|
|
$realPath = $realPath ?: (method_exists($file, 'getRealPath') ? $file->getRealPath() : null);
|
|
$candidates = [];
|
|
|
|
if ($realPath && str_starts_with($realPath, '/')) {
|
|
$candidates[] = $realPath;
|
|
} else {
|
|
$candidates[] = sys_get_temp_dir() . '/' . ltrim((string)$realPath, '/');
|
|
$candidates[] = storage_path('framework/' . ltrim((string)$realPath, '/'));
|
|
$candidates[] = storage_path(ltrim((string)$realPath, '/'));
|
|
$candidates[] = base_path(ltrim((string)$realPath, '/'));
|
|
}
|
|
|
|
if ($realPath) {
|
|
$candidates[] = storage_path('framework/livewire-tmp/' . basename($realPath));
|
|
$candidates[] = sys_get_temp_dir() . '/' . basename($realPath);
|
|
}
|
|
|
|
// 1) Try storing to local disk (creates an absolute path we control)
|
|
$stored = null;
|
|
if (method_exists($file, 'store')) {
|
|
try {
|
|
$stored = $file->store('livewire-temp', 'local'); // storage/app/livewire-temp/...
|
|
if ($stored && \Storage::disk('local')->exists($stored)) {
|
|
$resolved = \Storage::disk('local')->path($stored);
|
|
}
|
|
} catch (\Throwable $e) {
|
|
Log::debug('store() fallback failed', ['err' => $e->getMessage()]);
|
|
}
|
|
}
|
|
|
|
// 2) If not resolved, check candidates
|
|
if (! isset($resolved)) {
|
|
foreach ($candidates as $p) {
|
|
if ($p && file_exists($p)) {
|
|
$resolved = $p;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
// 3) If still not resolved, try stream -> temp file copy
|
|
$is_tmp_copy = false;
|
|
if (! isset($resolved)) {
|
|
try {
|
|
$stream = null;
|
|
if (method_exists($file, 'getStream')) {
|
|
$stream = $file->getStream();
|
|
} elseif (method_exists($file, 'getRealPath') && is_readable($file->getRealPath())) {
|
|
$stream = fopen($file->getRealPath(), 'r');
|
|
}
|
|
|
|
if ($stream) {
|
|
$tmpPath = tempnam(sys_get_temp_dir(), 'filament-upload-');
|
|
$out = fopen($tmpPath, 'w');
|
|
stream_copy_to_stream($stream, $out);
|
|
fclose($out);
|
|
if (is_resource($stream)) {
|
|
@fclose($stream);
|
|
}
|
|
$resolved = $tmpPath;
|
|
$is_tmp_copy = true;
|
|
}
|
|
} catch (\Throwable $e) {
|
|
Log::debug('stream fallback failed', ['err' => $e->getMessage()]);
|
|
}
|
|
}
|
|
|
|
// 4) Still nothing -> error
|
|
if (empty($resolved)) {
|
|
Log::error('Featured Image Upload: could not resolve temp path', [
|
|
'original' => $realPath,
|
|
'checked_candidates' => $candidates,
|
|
'stored' => $stored,
|
|
]);
|
|
throw new \Exception("File `{$realPath}` does not exist");
|
|
}
|
|
|
|
// 5) Use resolved absolute path
|
|
$media = $record->addMedia($resolved)
|
|
->usingName($baseName)
|
|
->usingFileName($secureFileName)
|
|
->toMediaCollection('featured-image', $diskName);
|
|
|
|
// 6) Cleanup short-lived artifacts
|
|
if (! empty($is_tmp_copy) && file_exists($resolved)) {
|
|
@unlink($resolved);
|
|
}
|
|
if (! empty($stored) && \Storage::disk('local')->exists($stored)) {
|
|
\Storage::disk('local')->delete($stored);
|
|
}
|
|
|
|
Log::info('Featured image resolved', ['resolved' => $resolved, 'media_id' => $media->id ?? null]);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if (config('logging.channels.' . config('logging.default') . '.level') === 'debug') {
|
|
Log::info('Featured Image Upload Success', [
|
|
'media_id' => $media->id,
|
|
'media_url' => $media->getUrl(),
|
|
'media_path' => $media->getPathRelativeToRoot(),
|
|
'disk' => $media->disk
|
|
]);
|
|
}
|
|
|
|
return $media->getUrl();
|
|
} catch (\Exception $e) {
|
|
Log::error('Featured Image Upload Failed', [
|
|
'error' => $e->getMessage(),
|
|
'trace' => $e->getTraceAsString(),
|
|
'disk' => $diskName,
|
|
'file_name' => $file->getClientOriginalName()
|
|
]);
|
|
|
|
|
|
// Also show error to user
|
|
\Filament\Notifications\Notification::make()
|
|
->danger()
|
|
->title('Upload Failed')
|
|
->body($e->getMessage())
|
|
->persistent()
|
|
->send();
|
|
|
|
throw $e;
|
|
}
|
|
})
|
|
->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 () {
|
|
$currentDisk = config('media-library.disk_name', 'public');
|
|
return Media::where('disk', $currentDisk)
|
|
->latest()
|
|
->limit(50)
|
|
->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();
|
|
$diskName = config('media-library.disk_name', 'public');
|
|
|
|
if (config('logging.channels.' . config('logging.default') . '.level') === 'debug') {
|
|
Log::info('Featured Image Picker Action Debug', [
|
|
'disk' => $diskName,
|
|
'record_id' => $record?->id,
|
|
'image_id' => $data['image_id'] ?? null
|
|
]);
|
|
}
|
|
|
|
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) {
|
|
Log::error('Source media not found', ['image_id' => $data['image_id']]);
|
|
\Filament\Notifications\Notification::make()
|
|
->danger()
|
|
->title('Source image not found in database')
|
|
->send();
|
|
return;
|
|
}
|
|
|
|
try {
|
|
// For S3, we need to handle file copying differently
|
|
if ($sourceMedia->disk === 's3') {
|
|
if (config('logging.channels.' . config('logging.default') . '.level') === 'debug') {
|
|
Log::info('Copying S3 media to new collection', [
|
|
'source_disk' => $sourceMedia->disk,
|
|
'source_path' => $sourceMedia->getPathRelativeToRoot(),
|
|
'target_disk' => $diskName
|
|
]);
|
|
}
|
|
|
|
// Copy from S3 to S3 or download temporarily
|
|
$sourceDisk = \Storage::disk($sourceMedia->disk);
|
|
$sourceContent = $sourceDisk->get($sourceMedia->getPathRelativeToRoot());
|
|
|
|
if (!$sourceContent) {
|
|
throw new \Exception('Could not read source file from S3');
|
|
}
|
|
|
|
$tempCopy = sys_get_temp_dir() . '/' . uniqid() . '_' . $sourceMedia->file_name;
|
|
file_put_contents($tempCopy, $sourceContent);
|
|
} else {
|
|
// Local file handling
|
|
$sourceFile = $sourceMedia->getPath();
|
|
if (! file_exists($sourceFile)) {
|
|
throw new \Exception('Source file not found on disk');
|
|
}
|
|
|
|
$tempCopy = sys_get_temp_dir() . '/' . uniqid() . '_' . $sourceMedia->file_name;
|
|
copy($sourceFile, $tempCopy);
|
|
}
|
|
|
|
// 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', $diskName);
|
|
|
|
if (config('logging.channels.' . config('logging.default') . '.level') === 'debug') {
|
|
Log::info('Featured Image Picker Success', [
|
|
'new_media_id' => $newMedia->id,
|
|
'new_media_disk' => $newMedia->disk,
|
|
'new_media_url' => $newMedia->getUrl()
|
|
]);
|
|
}
|
|
|
|
// 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) {
|
|
Log::error('Featured Image Picker Failed', [
|
|
'error' => $e->getMessage(),
|
|
'trace' => $e->getTraceAsString(),
|
|
'source_media_id' => $data['image_id'],
|
|
'disk' => $diskName
|
|
]);
|
|
|
|
\Filament\Notifications\Notification::make()
|
|
->danger()
|
|
->title('Error: ' . $e->getMessage())
|
|
->persistent()
|
|
->send();
|
|
} finally {
|
|
if (isset($tempCopy) && file_exists($tempCopy)) {
|
|
unlink($tempCopy);
|
|
}
|
|
}
|
|
})
|
|
),
|
|
Toggle::make('is_published')
|
|
->required(),
|
|
Toggle::make('is_featured')
|
|
->required(),
|
|
TextInput::make('priority')
|
|
->label('Priority')
|
|
->numeric()
|
|
->default(0)
|
|
->required(),
|
|
DatePicker::make('published_at')
|
|
->visible(fn($get) => $get('type') === 'article'),
|
|
Select::make('category_id')
|
|
->label('Category')
|
|
->options(function () {
|
|
return Category::all()
|
|
->pluck('name', 'id')
|
|
->toArray();
|
|
})
|
|
->searchable(),
|
|
TextInput::make('call_to_action_text')
|
|
->label('Call to Action Text')
|
|
->visible(fn($get) => $get('type') !== 'article'),
|
|
TextInput::make('call_to_action_link')
|
|
->label('Call to Action URL')
|
|
->visible(fn($get) => $get('type') !== 'article'),
|
|
|
|
RichEditor::make('content')
|
|
->visible(fn($get) => $get('type') !== 'image')
|
|
->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=''>",
|
|
]);
|
|
})
|
|
),
|
|
|
|
]);
|
|
}
|
|
}
|