diff --git a/config/backstage/media.php b/config/backstage/media.php index 584bfdbd..230e673c 100644 --- a/config/backstage/media.php +++ b/config/backstage/media.php @@ -58,7 +58,7 @@ 'navigation_icon' => 'heroicon-o-photo', 'navigation_sort' => null, 'navigation_count_badge' => false, - 'resource' => \Backstage\Media\Resources\MediaResource::class, + 'resource' => \Backstage\Resources\MediaResource::class, ], 'file_upload' => [ diff --git a/config/backstage/translations.php b/config/backstage/translations.php index 07c36c9d..c9061e85 100644 --- a/config/backstage/translations.php +++ b/config/backstage/translations.php @@ -9,4 +9,10 @@ 'navigation' => [ 'group' => 'Manage', ], + + 'eloquent' => [ + 'translatable-models' => [ + \Backstage\Models\Media::class, + ], + ], ]; diff --git a/database/migrations/2025_11_06_120044_create_translated_attributes_table.php b/database/migrations/2025_11_06_120044_create_translated_attributes_table.php new file mode 100644 index 00000000..c20965a9 --- /dev/null +++ b/database/migrations/2025_11_06_120044_create_translated_attributes_table.php @@ -0,0 +1,35 @@ +id(); + $table->string('code', 5); + + $table->foreign('code') + ->references('code') + ->on('languages') + ->onDelete('cascade'); + + $table->ulidMorphs('translatable'); + + $table->longText('attribute'); + $table->longText('translated_attribute')->nullable(); + $table->timestamp('translated_at')->nullable(); + + $table->timestamps(); + $table->softDeletes(); + }); + } + + public function down() + { + Schema::dropIfExists('translated_attributes'); + } +}; diff --git a/database/migrations/2025_11_06_120623_add_alt_column_to_media_table.php b/database/migrations/2025_11_06_120623_add_alt_column_to_media_table.php new file mode 100644 index 00000000..042e05d6 --- /dev/null +++ b/database/migrations/2025_11_06_120623_add_alt_column_to_media_table.php @@ -0,0 +1,24 @@ +getTable(), function (Blueprint $table) { + $table->text('alt')->after('height'); + }); + } + + public function down(): void + { + $model = config('backstage.media.model'); + Schema::table((new $model)->getTable(), function (Blueprint $table) { + $table->dropColumn('alt'); + }); + } +}; diff --git a/src/BackstageServiceProvider.php b/src/BackstageServiceProvider.php index b8f6f1a7..49eee7e2 100644 --- a/src/BackstageServiceProvider.php +++ b/src/BackstageServiceProvider.php @@ -9,7 +9,6 @@ use Backstage\Events\FormSubmitted; use Backstage\Http\Middleware\SetLocale; use Backstage\Listeners\ExecuteFormActions; -use Backstage\Media\Resources\MediaResource; use Backstage\Models\Block; use Backstage\Models\Media; use Backstage\Models\Menu; @@ -88,6 +87,8 @@ public function configurePackage(Package $package): void $this->writeMediaPickerConfig(); + $this->writeTranslationsConfig(); + $command->callSilently('vendor:publish', [ '--tag' => 'backstage-migrations', '--force' => true, @@ -307,7 +308,7 @@ private function generateMediaPickerConfig(): array 'navigation_icon' => 'heroicon-o-photo', 'navigation_sort' => null, 'navigation_count_badge' => false, - 'resource' => MediaResource::class, + 'resource' => \Backstage\Resources\MediaResource::class, ], ]; @@ -392,7 +393,7 @@ private function writeMediaPickerConfig(?string $path = null): void $configContent .= "use Backstage\Models\Site;\n"; $configContent .= "use Backstage\Models\User;\n"; $configContent .= "use Backstage\Models\Media;\n\n"; - $configContent .= "use Backstage\Media\Resources\MediaResource;\n\n"; + $configContent .= "use Backstage\Resources\MediaResource;\n\n"; // Custom export function to create more readable output $configContent .= 'return ' . $this->customVarExport($this->generateMediaPickerConfig()) . ";\n"; @@ -400,6 +401,90 @@ private function writeMediaPickerConfig(?string $path = null): void file_put_contents($path, $configContent); } + private function generateTranslationsConfig(): array + { + $config = [ + 'scan' => [ + 'paths' => [ + app_path(), + resource_path('views'), + base_path(''), + ], + + 'extensions' => [ + '*.php', + '*.blade.php', + '*.json', + ], + + 'functions' => [ + 'trans', + 'trans_choice', + 'Lang::transChoice', + 'Lang::trans', + 'Lang::get', + 'Lang::choice', + '@lang', + '@choice', + '__', + ], + ], + + 'eloquent' => [ + 'translatable-models' => [ + \Backstage\Models\ContentFieldValue::class, + \Backstage\Models\Tag::class, + ], + ], + + 'translators' => [ + 'default' => env('TRANSLATION_DRIVER', 'google-translate'), + + 'drivers' => [ + 'google-translate' => [ + // no options + ], + + 'ai' => [ + 'provider' => \Prism\Prism\Enums\Provider::OpenAI, + 'model' => 'gpt-5', + 'system_prompt' => 'You are an expert mathematician who explains concepts simply. The only thing you do it output what i ask. No comments, no extra information. Just the answer.', + ], + + 'deep-l' => [ + // + ], + ], + ], + ]; + + config(['translations' => $config]); + + return $config; + } + + private function writeTranslationsConfig(?string $path = null): void + { + $path ??= config_path('translations.php'); + + // Ensure directory exists + $directory = dirname($path); + if (! is_dir($directory)) { + mkdir($directory, 0755, true); + } + + // Generate the config file content + $configContent = "customVarExport($this->generateTranslationsConfig()) . ";\n"; + + file_put_contents($path, $configContent); + } + private function customVarExport($var, $indent = ''): string { switch (gettype($var)) { diff --git a/src/Models/Media.php b/src/Models/Media.php index d537ea1c..b02f0998 100644 --- a/src/Models/Media.php +++ b/src/Models/Media.php @@ -3,13 +3,16 @@ namespace Backstage\Models; use Backstage\Shared\HasPackageFactory; +use Backstage\Translations\Laravel\Contracts\TranslatesAttributes; +use Backstage\Translations\Laravel\Models\Concerns\HasTranslatableAttributes; use Illuminate\Database\Eloquent\Concerns\HasUlids; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; -class Media extends Model +class Media extends Model implements TranslatesAttributes { use HasPackageFactory; + use HasTranslatableAttributes; use HasUlids; protected $primaryKey = 'ulid'; @@ -20,7 +23,16 @@ class Media extends Model protected function casts(): array { - return []; + return [ + 'alt' => 'string', + ]; + } + + public function getTranslatableAttributes(): array + { + return [ + 'alt', + ]; } public function site(): BelongsTo diff --git a/src/Resources/MediaResource.php b/src/Resources/MediaResource.php new file mode 100644 index 00000000..55e8f998 --- /dev/null +++ b/src/Resources/MediaResource.php @@ -0,0 +1,171 @@ +first(fn ($lang) => $lang->default == true); + $others = $languages->filter(fn ($lang) => $lang->default != true)->values(); + + self::$cachedLanguages = [ + 'default' => $default, + 'others' => $others, + 'by_code' => $languages->keyBy('code'), + ]; + } + + return self::$cachedLanguages; + } + + public static function table(Table $table): Table + { + $altTagsFormSchema = self::getAltTagsFormSchema(); + + return parent::table($table) + ->headerActions([ + Action::make('upload') + ->modalHeading(__('Upload media')) + ->slideOver() + ->schema([ + FileUpload::make('media') + ->label(__('Media')) + ->disk('uploadcare') + ->multiple(), + ]) + ->action(function (array $data) { + dd($data); + // foreach ($data['media'] as $file) { + // $media = Media::create([ + // 'url' => $media['url'], + // 'alt_tags' => $media['alt_tags'], + // ]); + // } + }), + ]) + ->recordActions([ + ...parent::table($table)->getRecordActions(), + Action::make('alt-tags') + ->modalHeading(__('Manage alt tags for this media')) + ->hiddenLabel() + ->icon('heroicon-o-tag') + ->tooltip(__('Manage alt tags')) + ->slideOver() + ->fillForm(fn (Media | Model $record) => self::getAltTagsFormData($record)) + ->action(fn (array $data, Media | Model $record) => self::saveAltTags($data, $record)) + ->schema([ + // ImageEntry::make('url') + // ->label(__('Media')) + // ->formatStateUsing(fn ($state) => $state ? url($state) : null) + // ->height(200), + Grid::make(2) + ->schema([ + ...$altTagsFormSchema, + ]), + ]), + ]); + } + + private static function getAltTagsFormSchema(): array + { + $schema = []; + + $languages = self::getLanguages(); + + // Add default language first + if ($languages['default']) { + $schema[] = + Grid::make(2) + ->schema([ + TextInput::make('alt') + ->label(__('Alt Tag')) + ->prefixIcon(country_flag($languages['default']->code), true) + ->helperText(__('The alt tag for the media in the default language. We can automatically translate this to other languages using AI.')) + ->required() + ->columnSpan(1), + ])->columnSpanFull(); + } + + // Then add other languages + foreach ($languages['others'] as $language) { + $schema[] = TextInput::make('alt_tags_' . $language->code) + ->label(__('Alt Tag')) + ->suffixActions([ + Action::make('translate_from_default') + ->icon(Heroicon::OutlinedLanguage) + ->tooltip(__('Translate from default language')) + ->action(function (Get $get, Set $set) use ($language) { + $defaultAlt = $get('alt'); + + $translator = Translator::translate($defaultAlt, $language->code); + + $set('alt_tags_' . $language->code, $translator); + }), + ], true) + ->prefixIcon(country_flag($language->code), true); + } + + return $schema; + } + + private static function getAltTagsFormData(Media | Model $record): array + { + $languages = self::getLanguages(); + + $data = [ + 'alt' => $record->getTranslatedAttribute('alt', $languages['default']->code) ?? '', + ]; + + foreach ($languages['others'] as $language) { + $data['alt_tags_' . $language->code] = $record->getTranslatedAttribute('alt', $language->code) ?? ''; + } + + return $data; + } + + private static function saveAltTags(array $data, Media | Model $record): void + { + $languages = self::getLanguages(); + + $record->updateQuietly([ + 'alt' => $data['alt'], + ]); + + $record->pushTranslateAttribute('alt', $data['alt'], $languages['default']->code); + + foreach ($languages['others'] as $language) { + $key = 'alt_tags_' . $language->code; + if (isset($data[$key])) { + $record->pushTranslateAttribute('alt', $data[$key], $language->code); + } + } + + Notification::make() + ->title(__('Alt tags updated')) + ->body(__('The alt tags have been updated for the media.')) + ->send(); + + } +}