diff --git a/app/Filament/Pages/GlobalSettings.php b/app/Filament/Pages/GlobalSettings.php index 81ff412..382c44f 100644 --- a/app/Filament/Pages/GlobalSettings.php +++ b/app/Filament/Pages/GlobalSettings.php @@ -26,6 +26,16 @@ class GlobalSettings extends Page implements HasForms protected static string|UnitEnum|null $navigationGroup = 'Admin'; + public static function getNavigationLabel(): string + { + return __('filament.pages.global_settings'); + } + + public function getTitle(): string + { + return __('filament.pages.global_settings'); + } + public ?array $data = []; public function mount(GeneralSettings $settings): void diff --git a/app/Filament/Resources/AiModels/AiModelResource.php b/app/Filament/Resources/AiModels/AiModelResource.php index 778740c..b89147c 100644 --- a/app/Filament/Resources/AiModels/AiModelResource.php +++ b/app/Filament/Resources/AiModels/AiModelResource.php @@ -6,6 +6,7 @@ use App\Api\Plugins\ApiPluginInterface; use App\Api\Plugins\PluginLoader; use App\Models\AiModel; use App\Models\ApiProvider; +use BackedEnum; use Filament\Actions\Action; use Filament\Actions\BulkActionGroup; use Filament\Actions\CreateAction; @@ -27,7 +28,7 @@ class AiModelResource extends Resource { protected static ?string $model = AiModel::class; - // protected static ?string $navigationIcon = 'heroicon-o-rectangle-stack'; + protected static BackedEnum|string|null $navigationIcon = 'heroicon-o-cpu-chip'; protected static ?int $navigationSort = -100; @@ -36,30 +37,71 @@ class AiModelResource extends Resource return __('filament.navigation.groups.ai_models'); } + public static function getNavigationLabel(): string + { + return __('filament.navigation.ai_models'); + } + public static function form(Schema $schema): Schema { return $schema ->components([ - Section::make('Hinweise zu Model-Parametern') - ->description('Parameter werden je nach Plugin unterschiedlich verwendet: Runware/ComfyUI als JSON für Workflow/Model-Settings, Leonardo v2 akzeptiert z. B. width/height/style_ids/prompt_enhance. Prüfe die Plugin-Doku; Felder, die nicht zum Plugin passen, werden ignoriert.') + Section::make(__('filament.resource.ai_model.parameters_help_title')) + ->description(__('filament.resource.ai_model.parameters_help_text')) ->columns(1) ->collapsible() ->schema([]), TextInput::make('name') + ->label(__('filament.resource.ai_model.form.name')) ->required(), - TextInput::make('model_id') - ->required(), + Select::make('model_id') + ->label(__('filament.resource.ai_model.form.model_id')) + ->searchable() + ->required() + ->getSearchResultsUsing(function (string $search, callable $get): array { + $providerId = $get('api_provider_id'); + if (! $providerId) { + return []; + } + + $provider = ApiProvider::find($providerId); + if (! $provider || ! $provider->plugin) { + return []; + } + + $plugin = PluginLoader::getPlugin($provider->plugin, $provider); + if (! method_exists($plugin, 'searchModels')) { + return []; + } + + $results = $plugin->searchModels($search); + + $options = []; + foreach ($results as $result) { + $id = $result['id'] ?? null; + $name = $result['name'] ?? $id; + if ($id) { + $options[$id] = $name ? $name.' ('.$id.')' : $id; + } + } + + return $options; + }) + ->getOptionLabelUsing(fn ($value): ?string => $value), TextInput::make('model_type') + ->label(__('filament.resource.ai_model.form.model_type')) ->nullable(), Select::make('api_provider_id') - ->label('API Provider') + ->label(__('filament.resource.ai_model.form.api_provider')) ->relationship('primaryApiProvider', 'name') ->searchable() ->preload() ->required(), Toggle::make('enabled') + ->label(__('filament.resource.ai_model.form.enabled')) ->default(true), Textarea::make('parameters') + ->label(__('filament.resource.ai_model.form.parameters')) ->nullable() ->rows(15), ]); @@ -143,7 +185,7 @@ class AiModelResource extends Resource ->actions([ EditAction::make(), Action::make('duplicate') - ->label('Duplicate') + ->label(__('filament.resource.ai_model.action.duplicate')) ->icon('heroicon-o-document-duplicate') ->action(function (Model $record, $livewire) { $livewire->redirect(static::getUrl('create', ['sourceRecord' => $record->id])); diff --git a/app/Filament/Resources/ApiProviders/ApiProviderResource.php b/app/Filament/Resources/ApiProviders/ApiProviderResource.php index e6cadb5..acd2b12 100644 --- a/app/Filament/Resources/ApiProviders/ApiProviderResource.php +++ b/app/Filament/Resources/ApiProviders/ApiProviderResource.php @@ -33,6 +33,11 @@ class ApiProviderResource extends Resource return __('filament.navigation.groups.ai_models'); } + public static function getNavigationLabel(): string + { + return __('filament.navigation.api_providers'); + } + public static function form(Schema $schema): Schema { $plugins = self::getAvailablePlugins(); @@ -40,31 +45,38 @@ class ApiProviderResource extends Resource return $schema ->components([ Placeholder::make('provider_dashboard') - ->label('Provider Dashboard') + ->label(__('filament.resource.api_provider.dashboard')) ->content(fn (?ApiProvider $record) => $record?->getDashboardUrl() ? ''.$record->getDashboardUrl().'' : '—') ->disableLabel(false) ->columnSpanFull() ->visible(fn (?ApiProvider $record) => (bool) $record?->getDashboardUrl()), TextInput::make('name') + ->label(__('filament.resource.api_provider.form.name')) ->required() ->maxLength(255), Toggle::make('enabled') + ->label(__('filament.resource.api_provider.form.enabled')) ->default(true), TextInput::make('api_url') + ->label(__('filament.resource.api_provider.form.api_url')) ->required() ->url() ->maxLength(255), TextInput::make('username') + ->label(__('filament.resource.api_provider.form.username')) ->nullable() ->maxLength(255), TextInput::make('password') + ->label(__('filament.resource.api_provider.form.password')) ->password() ->nullable() ->maxLength(255), TextInput::make('token') + ->label(__('filament.resource.api_provider.form.token')) ->nullable() ->maxLength(255), Select::make('plugin') + ->label(__('filament.resource.api_provider.form.plugin')) ->options($plugins) ->nullable(), ]); @@ -74,11 +86,12 @@ class ApiProviderResource extends Resource { return $table ->columns([ - TextColumn::make('name')->searchable()->sortable(), + TextColumn::make('name')->label(__('filament.resource.api_provider.table.name'))->searchable()->sortable(), IconColumn::make('enabled') + ->label(__('filament.resource.api_provider.table.enabled')) ->boolean(), - TextColumn::make('api_url')->searchable(), - TextColumn::make('plugin')->searchable()->sortable(), + TextColumn::make('api_url')->label(__('filament.resource.api_provider.table.api_url'))->searchable(), + TextColumn::make('plugin')->label(__('filament.resource.api_provider.table.plugin'))->searchable()->sortable(), ]) ->filters([ // diff --git a/app/Filament/Resources/Images/ImageResource.php b/app/Filament/Resources/Images/ImageResource.php index 5ba0273..dbd2004 100644 --- a/app/Filament/Resources/Images/ImageResource.php +++ b/app/Filament/Resources/Images/ImageResource.php @@ -27,6 +27,11 @@ class ImageResource extends Resource return __('filament.navigation.groups.content'); } + public static function getNavigationLabel(): string + { + return __('filament.navigation.images'); + } + public static function form(Schema $schema): Schema { return $schema @@ -45,8 +50,9 @@ class ImageResource extends Resource { return $table ->columns([ - TextColumn::make('path')->searchable()->sortable(), + TextColumn::make('path')->label(__('filament.resource.image.table.path'))->searchable()->sortable(), ImageColumn::make('path') + ->label(__('filament.resource.image.table.image')) ->disk('public'), ]) ->filters([ diff --git a/app/Filament/Resources/Roles/RoleResource.php b/app/Filament/Resources/Roles/RoleResource.php index 3f8c84c..4fb4a9f 100644 --- a/app/Filament/Resources/Roles/RoleResource.php +++ b/app/Filament/Resources/Roles/RoleResource.php @@ -3,6 +3,7 @@ namespace App\Filament\Resources\Roles; use App\Models\Role; +use BackedEnum; use Filament\Actions\BulkActionGroup; use Filament\Actions\CreateAction; use Filament\Actions\DeleteBulkAction; @@ -20,9 +21,17 @@ class RoleResource extends Resource protected static UnitEnum|string|null $navigationGroup = 'User Management'; - protected static ?string $navigationLabel = 'User Roles'; + protected static BackedEnum|string|null $navigationIcon = 'heroicon-o-key'; - protected static bool $shouldRegisterNavigation = false; + public static function getNavigationLabel(): string + { + return __('filament.navigation.user_roles'); + } + + public static function getNavigationGroup(): ?string + { + return __('filament.navigation.groups.user_management'); + } public static function form(Schema $schema): Schema { diff --git a/app/Filament/Resources/Styles/Pages/CreateStyle.php b/app/Filament/Resources/Styles/Pages/CreateStyle.php index 5be1ade..5274036 100644 --- a/app/Filament/Resources/Styles/Pages/CreateStyle.php +++ b/app/Filament/Resources/Styles/Pages/CreateStyle.php @@ -2,16 +2,110 @@ namespace App\Filament\Resources\Styles\Pages; +use App\Api\Plugins\PluginLoader; use App\Filament\Resources\Styles\StyleResource; -use Filament\Actions; +use App\Models\AiModel; +use App\Models\Image; +use App\Models\Style; +use Filament\Actions\Action; +use Filament\Notifications\Notification; use Filament\Resources\Pages\CreateRecord; +use Illuminate\Support\Facades\Http; +use Illuminate\Support\Facades\Storage; +use Illuminate\Support\Str; class CreateStyle extends CreateRecord { protected static string $resource = StyleResource::class; + protected function getHeaderActions(): array + { + return [ + Action::make('generate_preview') + ->label(__('filament.resource.style.preview.generate')) + ->icon('heroicon-o-sparkles') + ->color('gray') + ->action(function (): void { + try { + $state = $this->form->getState(); + $path = $state['preview_source'] ?? null; + $aiModelId = $state['ai_model_id'] ?? null; + $prompt = $state['prompt'] ?? null; + + if (! $path) { + throw new \RuntimeException(__('filament.resource.style.preview.missing_image')); + } + if (! $aiModelId) { + throw new \RuntimeException(__('filament.resource.style.preview.missing_model')); + } + if (! $prompt) { + throw new \RuntimeException(__('filament.resource.style.preview.missing_prompt')); + } + + $aiModel = AiModel::with('primaryApiProvider')->findOrFail($aiModelId); + $provider = $aiModel->primaryApiProvider; + if (! $provider || ! $provider->enabled || ! $provider->plugin || ! $provider->token) { + throw new \RuntimeException(__('filament.resource.style.preview.provider_invalid')); + } + + $style = new Style([ + 'title' => $state['title'] ?? 'Preview', + 'prompt' => $prompt, + 'description' => $state['description'] ?? '', + 'parameters' => $state['parameters'] ?? [], + ]); + $style->setRelation('aiModel', $aiModel); + + $image = new Image([ + 'path' => $path, + ]); + + $plugin = PluginLoader::getPlugin($provider->plugin, $provider); + $result = $plugin->processImageStyleChange($image, $style); + + $imageData = null; + if (isset($result['base64Data'])) { + $imageData = base64_decode($result['base64Data']); + } elseif (isset($result['prompt_id'])) { + $fetched = $plugin->getStyledImage($result['prompt_id']); + if (str_starts_with($fetched, 'http')) { + $resp = Http::timeout(30)->get($fetched); + $resp->throw(); + $imageData = $resp->body(); + } else { + $imageData = base64_decode($fetched); + } + } + + if (! $imageData) { + throw new \RuntimeException(__('filament.resource.style.preview.no_image')); + } + + $fileName = 'style_previews/generated_'.Str::uuid().'.png'; + Storage::disk('public')->put($fileName, $imageData); + + $this->form->fill([ + 'preview_image' => $fileName, + ]); + + Notification::make() + ->title(__('filament.resource.style.preview.done')) + ->body(__('filament.resource.style.preview.saved_hint')) + ->success() + ->send(); + } catch (\Throwable $e) { + Notification::make() + ->title(__('filament.resource.style.preview.failed')) + ->body($e->getMessage()) + ->danger() + ->send(); + } + }), + ]; + } + protected function getRedirectUrl(): string { return $this->getResource()::getUrl('index'); } -} \ No newline at end of file +} diff --git a/app/Filament/Resources/Styles/Pages/EditStyle.php b/app/Filament/Resources/Styles/Pages/EditStyle.php index c17c4e1..c0ce202 100644 --- a/app/Filament/Resources/Styles/Pages/EditStyle.php +++ b/app/Filament/Resources/Styles/Pages/EditStyle.php @@ -2,9 +2,18 @@ namespace App\Filament\Resources\Styles\Pages; +use App\Api\Plugins\PluginLoader; use App\Filament\Resources\Styles\StyleResource; +use App\Models\AiModel; +use App\Models\Image; +use App\Models\Style; use Filament\Actions; +use Filament\Actions\Action; +use Filament\Notifications\Notification; use Filament\Resources\Pages\EditRecord; +use Illuminate\Support\Facades\Http; +use Illuminate\Support\Facades\Storage; +use Illuminate\Support\Str; class EditStyle extends EditRecord { @@ -13,6 +22,88 @@ class EditStyle extends EditRecord protected function getHeaderActions(): array { return [ + Action::make('generate_preview') + ->label(__('filament.resource.style.preview.generate')) + ->icon('heroicon-o-sparkles') + ->color('gray') + ->action(function (): void { + try { + $state = $this->form->getState(); + $path = $state['preview_source'] ?? null; + $aiModelId = $state['ai_model_id'] ?? null; + $prompt = $state['prompt'] ?? null; + + if (! $path) { + throw new \RuntimeException(__('filament.resource.style.preview.missing_image')); + } + if (! $aiModelId) { + throw new \RuntimeException(__('filament.resource.style.preview.missing_model')); + } + if (! $prompt) { + throw new \RuntimeException(__('filament.resource.style.preview.missing_prompt')); + } + + $aiModel = AiModel::with('primaryApiProvider')->findOrFail($aiModelId); + $provider = $aiModel->primaryApiProvider; + if (! $provider || ! $provider->enabled || ! $provider->plugin || ! $provider->token) { + throw new \RuntimeException(__('filament.resource.style.preview.provider_invalid')); + } + + // Temporary style + image instances + $style = new Style([ + 'title' => $state['title'] ?? 'Preview', + 'prompt' => $prompt, + 'description' => $state['description'] ?? '', + 'parameters' => $state['parameters'] ?? [], + ]); + $style->setRelation('aiModel', $aiModel); + + $image = new Image([ + 'path' => $path, + ]); + + $plugin = PluginLoader::getPlugin($provider->plugin, $provider); + $result = $plugin->processImageStyleChange($image, $style); + + $imageData = null; + if (isset($result['base64Data'])) { + $imageData = base64_decode($result['base64Data']); + } elseif (isset($result['prompt_id'])) { + $fetched = $plugin->getStyledImage($result['prompt_id']); + if (str_starts_with($fetched, 'http')) { + $resp = Http::timeout(30)->get($fetched); + $resp->throw(); + $imageData = $resp->body(); + } else { + $imageData = base64_decode($fetched); + } + } + + if (! $imageData) { + throw new \RuntimeException(__('filament.resource.style.preview.no_image')); + } + + $fileName = 'style_previews/generated_'.Str::uuid().'.png'; + Storage::disk('public')->put($fileName, $imageData); + + // Update form state + $this->form->fill([ + 'preview_image' => $fileName, + ]); + + Notification::make() + ->title(__('filament.resource.style.preview.done')) + ->body(__('filament.resource.style.preview.saved_hint')) + ->success() + ->send(); + } catch (\Throwable $e) { + Notification::make() + ->title(__('filament.resource.style.preview.failed')) + ->body($e->getMessage()) + ->danger() + ->send(); + } + }), Actions\DeleteAction::make(), ]; } @@ -21,4 +112,4 @@ class EditStyle extends EditRecord { return $this->getResource()::getUrl('index'); } -} \ No newline at end of file +} diff --git a/app/Filament/Resources/Styles/StyleResource.php b/app/Filament/Resources/Styles/StyleResource.php index b9bab28..338e8cd 100644 --- a/app/Filament/Resources/Styles/StyleResource.php +++ b/app/Filament/Resources/Styles/StyleResource.php @@ -5,7 +5,6 @@ namespace App\Filament\Resources\Styles; use App\Models\Style; use BackedEnum; use Filament\Actions\Action; -use Filament\Actions\Action\Step; use Filament\Actions\BulkAction; use Filament\Actions\BulkActionGroup; use Filament\Actions\CreateAction; @@ -41,6 +40,11 @@ class StyleResource extends Resource return __('filament.navigation.groups.ai_models'); } + public static function getNavigationLabel(): string + { + return __('filament.navigation.styles'); + } + public static function form(Schema $schema): Schema { return $schema @@ -68,9 +72,17 @@ class StyleResource extends Resource Textarea::make('description') ->required() ->rows(5), + FileUpload::make('preview_source') + ->label('Referenzbild für Vorschau') + ->helperText('Lade ein Bild hoch, um eine Vorschau mit dem aktuellen Prompt zu generieren.') + ->disk('public') + ->directory('style_previews/reference') + ->image() + ->dehydrated(false), ]), Select::make('ai_model_id') ->relationship('aiModel', 'name') + ->label(__('filament.resource.style.form.ai_model')) ->required(), FileUpload::make('preview_image') ->disk('public') @@ -82,9 +94,11 @@ class StyleResource extends Resource Tab::make('Details') ->components([ TextInput::make('sort_order') + ->label(__('filament.resource.style.form.sort_order')) ->numeric() ->default(0), Textarea::make('parameters') + ->label(__('filament.resource.style.form.parameters')) ->nullable() ->rows(15), ]), @@ -97,12 +111,13 @@ class StyleResource extends Resource return $table ->defaultSort('sort_order') ->columns([ - TextColumn::make('title')->searchable()->sortable(), + TextColumn::make('title')->label(__('filament.resource.style.table.title'))->searchable()->sortable(), IconColumn::make('enabled') + ->label(__('filament.resource.style.table.enabled')) ->boolean(), - TextColumn::make('aiModel.name')->searchable()->sortable(), - ImageColumn::make('preview_image')->disk('public'), - TextColumn::make('sort_order')->sortable(), + TextColumn::make('aiModel.name')->label(__('filament.resource.style.table.ai_model'))->searchable()->sortable(), + ImageColumn::make('preview_image')->label(__('filament.resource.style.table.preview_image'))->disk('public'), + TextColumn::make('sort_order')->label(__('filament.resource.style.table.sort_order'))->sortable(), ]) ->filters([ SelectFilter::make('ai_model') @@ -111,30 +126,24 @@ class StyleResource extends Resource ->deferFilters(false) ->headerActions([ Action::make('import_styles') - ->label('Import Styles (CSV)') + ->label(__('filament.resource.style.import.title')) ->icon('heroicon-o-arrow-up-on-square-stack') - ->steps([ - Step::make('Datei') - ->schema([ - FileUpload::make('import_file') - ->label('CSV-Datei (Titel,Prompt,[Beschreibung])') - ->acceptedFileTypes([ - 'text/csv', - 'text/plain', - 'application/vnd.ms-excel', - 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - ]) - ->directory('imports/styles') - ->visibility('private') - ->required(), - ]), - Step::make('Zuordnung') - ->schema([ - Select::make('ai_model_id') - ->label('AI Modell') - ->relationship('aiModel', 'name') - ->required(), - ]), + ->form([ + FileUpload::make('import_file') + ->label(__('filament.resource.style.import.file_label')) + ->acceptedFileTypes([ + 'text/csv', + 'text/plain', + 'application/vnd.ms-excel', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + ]) + ->directory('imports/styles') + ->visibility('private') + ->required(), + Select::make('ai_model_id') + ->label(__('filament.resource.style.import.model_label')) + ->relationship('aiModel', 'name') + ->required(), ]) ->action(function (array $data): void { $path = storage_path('app/'.$data['import_file']); @@ -146,7 +155,7 @@ class StyleResource extends Resource $rows = self::parseImportFile($path); if (empty($rows)) { - throw new \RuntimeException('Keine gültigen Zeilen gefunden.'); + throw new \RuntimeException(__('filament.resource.style.import.empty')); } $created = 0; @@ -176,8 +185,8 @@ class StyleResource extends Resource } \Filament\Notifications\Notification::make() - ->title('Import abgeschlossen') - ->body($created.' Styles importiert. Bitte Vorschau-Bilder ergänzen.') + ->title(__('filament.resource.style.import.title')) + ->body($created.' '.__('filament.resource.style.import.success')) ->success() ->send(); }), @@ -185,7 +194,7 @@ class StyleResource extends Resource ->actions([ EditAction::make(), Action::make('duplicate') - ->label('Duplicate') + ->label(__('filament.resource.style.action.duplicate')) ->icon('heroicon-o-document-duplicate') ->action(function (\App\Models\Style $record) { $newStyle = $record->replicate(); @@ -199,17 +208,64 @@ class StyleResource extends Resource BulkActionGroup::make([ DeleteBulkAction::make(), BulkAction::make('enable') - ->label('Enable Selected') + ->label(__('filament.resource.style.action.enable_selected')) ->icon('heroicon-o-check-circle') ->action(function (\Illuminate\Support\Collection $records) { $records->each->update(['enabled' => true]); }), BulkAction::make('disable') - ->label('Disable Selected') + ->label(__('filament.resource.style.action.disable_selected')) ->icon('heroicon-o-x-circle') ->action(function (\Illuminate\Support\Collection $records) { $records->each->update(['enabled' => false]); }), + BulkAction::make('reassignModel') + ->label(__('filament.resource.style.action.reassign_model')) + ->icon('heroicon-o-arrow-path') + ->form([ + Select::make('ai_model_id') + ->label('Zielmodell') + ->relationship('aiModel', 'name') + ->required(), + ]) + ->action(function (\Illuminate\Support\Collection $records, array $data) { + $count = 0; + foreach ($records as $record) { + $record->update(['ai_model_id' => $data['ai_model_id']]); + $count++; + } + + \Filament\Notifications\Notification::make() + ->title(__('filament.resource.style.action.reassign_model')) + ->body("{$count} ".__('filament.resource.style.table.title')) + ->success() + ->send(); + }), + BulkAction::make('duplicateToModel') + ->label(__('filament.resource.style.action.duplicate_to_model')) + ->icon('heroicon-o-document-duplicate') + ->form([ + Select::make('ai_model_id') + ->label('Zielmodell') + ->relationship('aiModel', 'name') + ->required(), + ]) + ->action(function (\Illuminate\Support\Collection $records, array $data) { + $created = 0; + foreach ($records as $record) { + $copy = $record->replicate(); + $copy->title = $record->title.' (Copy)'; + $copy->ai_model_id = $data['ai_model_id']; + $copy->save(); + $created++; + } + + \Filament\Notifications\Notification::make() + ->title(__('filament.resource.style.action.duplicate')) + ->body("{$created} Kopien erstellt.") + ->success() + ->send(); + }), ]), ]) ->emptyStateActions([ diff --git a/app/Filament/Resources/Users/UserResource.php b/app/Filament/Resources/Users/UserResource.php index ec31f7c..7994520 100644 --- a/app/Filament/Resources/Users/UserResource.php +++ b/app/Filament/Resources/Users/UserResource.php @@ -16,14 +16,17 @@ use Filament\Schemas\Schema; use Filament\Tables\Columns\TextColumn; use Filament\Tables\Table; use UnitEnum; +use BackedEnum; class UserResource extends Resource { protected static ?string $model = User::class; + protected static BackedEnum|string|null $navigationIcon = 'heroicon-o-user-group'; + protected static string|UnitEnum|null $navigationGroup = 'User Management'; - protected static ?string $navigationLabel = 'Users'; + protected static ?string $navigationLabel = null; public static function form(Schema $schema): Schema { @@ -32,19 +35,23 @@ class UserResource extends Resource Section::make('User Details') ->components([ TextInput::make('name') + ->label(__('filament.resource.user.form.name')) ->required() ->maxLength(255), TextInput::make('email') + ->label(__('filament.resource.user.form.email')) ->email() ->required() ->maxLength(255), TextInput::make('password') + ->label(__('filament.resource.user.form.password')) ->password() ->dehydrateStateUsing(fn (string $state): string => bcrypt($state)) ->dehydrated(fn (?string $state): bool => filled($state)) ->required(fn (string $operation): bool => $operation === 'create') ->maxLength(255), Select::make('role_id') + ->label(__('filament.resource.user.form.role')) ->relationship('role', 'name') ->required(), ])->columns(2) @@ -53,14 +60,17 @@ class UserResource extends Resource Section::make('Preferences') ->components([ Toggle::make('email_notifications_enabled') + ->label(__('filament.resource.user.form.email_notifications_enabled')) ->default(true), Select::make('theme_preference') + ->label(__('filament.resource.user.form.theme_preference')) ->options([ - 'light' => 'Light', - 'dark' => 'Dark', + 'light' => __('filament.resource.user.form.theme_light'), + 'dark' => __('filament.resource.user.form.theme_dark'), ]) ->default('light'), Select::make('locale') + ->label(__('filament.resource.user.form.locale')) ->options([ 'en' => 'English', 'de' => 'Deutsch', diff --git a/app/Filament/Widgets/AppStatsOverview.php b/app/Filament/Widgets/AppStatsOverview.php index 776285d..840d12c 100644 --- a/app/Filament/Widgets/AppStatsOverview.php +++ b/app/Filament/Widgets/AppStatsOverview.php @@ -2,24 +2,24 @@ namespace App\Filament\Widgets; -use Filament\Widgets\StatsOverviewWidget as BaseWidget; -use Filament\Widgets\StatsOverviewWidget\Stat; use App\Models\AiModel; use App\Models\ApiProvider; use App\Models\Style; +use Filament\Widgets\StatsOverviewWidget as BaseWidget; +use Filament\Widgets\StatsOverviewWidget\Stat; class AppStatsOverview extends BaseWidget { protected function getCards(): array { return [ - Stat::make('Anzahl AI Modelle', AiModel::count()) + Stat::make(__('filament.widgets.app_stats.ai_models'), AiModel::count()) ->icon('heroicon-o-server') ->url(route('filament.admin.resources.ai-models.index')), - Stat::make('Anzahl API-Provider', ApiProvider::count()) + Stat::make(__('filament.widgets.app_stats.api_providers'), ApiProvider::count()) ->icon('heroicon-o-cube') ->url(route('filament.admin.resources.api-providers.index')), - Stat::make('Anzahl Styles', Style::count()) + Stat::make(__('filament.widgets.app_stats.styles'), Style::count()) ->icon('heroicon-o-sparkles') ->url(route('filament.admin.resources.styles.index')), ]; diff --git a/app/Http/Middleware/SetLocale.php b/app/Http/Middleware/SetLocale.php index 927f0ff..ab09fa7 100644 --- a/app/Http/Middleware/SetLocale.php +++ b/app/Http/Middleware/SetLocale.php @@ -15,18 +15,24 @@ class SetLocale */ public function handle(Request $request, Closure $next): Response { - if (auth()->check() && auth()->user()->locale) { - app()->setLocale(auth()->user()->locale); - } else { - $locale = substr($request->server('HTTP_ACCEPT_LANGUAGE'), 0, 2); + $locale = null; - if (in_array($locale, ['de'])) { - app()->setLocale($locale); - } else { - app()->setLocale('en'); + if ($request->has('locale')) { + $locale = $request->get('locale'); + session(['locale' => $locale]); + } elseif (session()->has('locale')) { + $locale = session('locale'); + } elseif (auth()->check() && auth()->user()->locale) { + $locale = auth()->user()->locale; + } else { + $headerLocale = substr($request->server('HTTP_ACCEPT_LANGUAGE'), 0, 2); + if (in_array($headerLocale, ['de'])) { + $locale = $headerLocale; } } + app()->setLocale($locale ?: 'en'); + return $next($request); } -} \ No newline at end of file +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 2ac0167..eacacfa 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -21,15 +21,8 @@ class AppServiceProvider extends ServiceProvider public function boot(): void { FilamentAsset::register([ - \Filament\Support\Assets\Js::make('custom-navigation-state', __DIR__ . '/../../resources/js/custom/navigation-state.js'), + \Filament\Support\Assets\Js::make('custom-navigation-state', __DIR__.'/../../resources/js/custom/navigation-state.js'), ]); - $locale = substr(request()->server('HTTP_ACCEPT_LANGUAGE'), 0, 2); - - if (in_array($locale, ['de'])) { - app()->setLocale($locale); - } else { - app()->setLocale('en'); - } } } diff --git a/app/Providers/Filament/AdminPanelProvider.php b/app/Providers/Filament/AdminPanelProvider.php index d2a3871..b5a2da8 100644 --- a/app/Providers/Filament/AdminPanelProvider.php +++ b/app/Providers/Filament/AdminPanelProvider.php @@ -3,9 +3,11 @@ namespace App\Providers\Filament; use App\Filament\Resources\PluginResource; +use App\Http\Middleware\SetLocale; use Filament\Http\Middleware\Authenticate; use Filament\Http\Middleware\DisableBladeIconComponents; use Filament\Http\Middleware\DispatchServingFilamentEvent; +use Filament\Navigation\MenuItem; use Filament\Pages; use Filament\Panel; use Filament\PanelProvider; @@ -17,6 +19,7 @@ use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken; use Illuminate\Routing\Middleware\SubstituteBindings; use Illuminate\Session\Middleware\AuthenticateSession; use Illuminate\Session\Middleware\StartSession; +use Illuminate\Support\Facades\App; use Illuminate\Support\Facades\Auth; use Illuminate\Support\HtmlString; use Illuminate\View\Middleware\ShareErrorsFromSession; @@ -59,6 +62,7 @@ class AdminPanelProvider extends PanelProvider AddQueuedCookiesToResponse::class, StartSession::class, AuthenticateSession::class, + SetLocale::class, ShareErrorsFromSession::class, VerifyCsrfToken::class, SubstituteBindings::class, @@ -71,7 +75,19 @@ class AdminPanelProvider extends PanelProvider ->plugins([ ]) - ->profile(); + ->profile(isSimple: false) + ->userMenuItems([ + MenuItem::make() + ->label(fn () => 'English') + ->color(fn () => App::getLocale() === 'en' ? 'primary' : null) + ->icon('heroicon-o-language') + ->url(fn () => request()->fullUrlWithQuery(['locale' => 'en'])), + MenuItem::make() + ->label(fn () => 'Deutsch') + ->color(fn () => App::getLocale() === 'de' ? 'primary' : null) + ->icon('heroicon-o-language') + ->url(fn () => request()->fullUrlWithQuery(['locale' => 'de'])), + ]); if (Auth::check()) { $user = Auth::user(); diff --git a/resources/lang/de/filament.php b/resources/lang/de/filament.php index a0831bb..90dcc4d 100644 --- a/resources/lang/de/filament.php +++ b/resources/lang/de/filament.php @@ -8,6 +8,8 @@ return [ 'model_id' => 'Modell ID', 'model_type' => 'Modell Typ', 'enabled' => 'Aktiviert', + 'parameters_help_title' => 'Model-Parameter', + 'parameters_help_text' => 'Parameter sind plugin-spezifisch: Runware/ComfyUI erwarten Workflow-JSON; Leonardo v2 kann width/height/style_ids/prompt_enhance nutzen. Nicht unterstützte Felder werden ignoriert.', 'api_providers' => 'API Provider', 'api_provider' => 'API Provider', 'search_model' => 'Modell suchen', @@ -21,6 +23,9 @@ return [ 'enabled' => 'Aktiviert', 'api_providers' => 'API Anbieter', ], + 'action' => [ + 'duplicate' => 'Duplizieren', + ], ], 'api_provider' => [ 'form' => [ @@ -78,6 +83,7 @@ return [ 'prompt' => 'Prompt', 'description' => 'Beschreibung', 'preview_image' => 'Vorschaubild', + 'preview_source' => 'Referenzbild für Vorschau', 'parameters' => 'Parameter', 'parameters_help' => 'Für ComfyUI, fügen Sie hier das Workflow-JSON ein. Verwenden Sie __PROMPT__, __FILENAME__ und __MODEL_ID__ als Platzhalter.', 'api_provider' => 'API Anbieter', @@ -96,6 +102,26 @@ return [ 'duplicate' => 'Duplizieren', 'enable_selected' => 'Ausgewählte aktivieren', 'disable_selected' => 'Ausgewählte deaktivieren', + 'reassign_model' => 'AI-Modell zuweisen', + 'duplicate_to_model' => 'Duplizieren auf anderes Modell', + ], + 'import' => [ + 'title' => 'Styles importieren', + 'file_label' => 'CSV/Excel (Titel,Prompt,[Beschreibung])', + 'model_label' => 'AI Modell', + 'success' => 'Styles importiert. Bitte Vorschau-Bilder ergänzen.', + 'empty' => 'Keine gültigen Zeilen gefunden.', + ], + 'preview' => [ + 'generate' => 'Vorschau generieren', + 'missing_image' => 'Bitte ein Referenzbild hochladen.', + 'missing_model' => 'Bitte ein AI Modell auswählen.', + 'missing_prompt' => 'Prompt darf nicht leer sein.', + 'provider_invalid' => 'API Provider ist nicht korrekt konfiguriert oder deaktiviert.', + 'no_image' => 'Keine Bilddaten erhalten.', + 'done' => 'Vorschau erstellt', + 'saved_hint' => 'Das Vorschaubild wurde gesetzt. Speichere den Style, um es zu behalten.', + 'failed' => 'Vorschau fehlgeschlagen', ], ], 'setting' => [ @@ -136,6 +162,11 @@ return [ 'email' => 'E-Mail', 'password' => 'Passwort', 'role' => 'Rolle', + 'locale' => 'Sprache', + 'email_notifications_enabled' => 'E-Mail-Benachrichtigungen', + 'theme_preference' => 'Theme-Einstellung', + 'theme_light' => 'Hell', + 'theme_dark' => 'Dunkel', ], 'table' => [ 'name' => 'Name', @@ -144,6 +175,9 @@ return [ ], ], ], + 'pages' => [ + 'global_settings' => 'Globale Einstellungen', + ], 'navigation' => [ 'groups' => [ 'ai_models' => 'AI-Modelle', @@ -155,6 +189,18 @@ return [ 'install_plugin' => 'Plugin installieren', 'plugin_list' => 'Plugin-Liste', 'users' => 'Benutzer', + 'user_roles' => 'Benutzerrollen', + 'ai_models' => 'AI-Modelle', + 'api_providers' => 'API Provider', + 'styles' => 'Styles', + 'images' => 'Bilder', + ], + 'widgets' => [ + 'app_stats' => [ + 'ai_models' => 'AI-Modelle', + 'api_providers' => 'API-Provider', + 'styles' => 'Styles', + ], ], 'styled_image_display' => [ 'title' => 'Neu gestyltes Bild', diff --git a/resources/lang/en/filament.php b/resources/lang/en/filament.php index f710005..3246e98 100644 --- a/resources/lang/en/filament.php +++ b/resources/lang/en/filament.php @@ -8,6 +8,8 @@ return [ 'model_id' => 'Model ID', 'model_type' => 'Model Type', 'enabled' => 'Enabled', + 'parameters_help_title' => 'Model parameters', + 'parameters_help_text' => 'Parameters are plugin-specific: Runware/ComfyUI expect workflow JSON; Leonardo v2 can use width/height/style_ids/prompt_enhance. Values not supported by the plugin are ignored.', 'api_providers' => 'API Providers', 'api_provider' => 'API Provider', 'search_model' => 'Search Model', @@ -21,6 +23,9 @@ return [ 'enabled' => 'Enabled', 'api_providers' => 'API Providers', ], + 'action' => [ + 'duplicate' => 'Duplicate', + ], ], 'api_provider' => [ 'form' => [ @@ -30,6 +35,7 @@ return [ 'password' => 'Password', 'token' => 'Token', 'plugin' => 'Plugin', + 'enabled' => 'Enabled', ], 'table' => [ 'name' => 'Name', @@ -77,6 +83,7 @@ return [ 'prompt' => 'Prompt', 'description' => 'Description', 'preview_image' => 'Preview Image', + 'preview_source' => 'Reference Image for Preview', 'parameters' => 'Parameters', 'parameters_help' => 'For ComfyUI, paste the workflow JSON here. Use __PROMPT__, __FILENAME__, and __MODEL_ID__ as placeholders.', 'api_provider' => 'API Provider', @@ -95,6 +102,26 @@ return [ 'duplicate' => 'Duplicate', 'enable_selected' => 'Enable Selected', 'disable_selected' => 'Disable Selected', + 'reassign_model' => 'Assign AI Model', + 'duplicate_to_model' => 'Duplicate to another model', + ], + 'import' => [ + 'title' => 'Import Styles', + 'file_label' => 'CSV/Excel (Title,Prompt,[Description])', + 'model_label' => 'AI Model', + 'success' => 'Styles imported. Please add preview images.', + 'empty' => 'No valid rows found.', + ], + 'preview' => [ + 'generate' => 'Generate preview', + 'missing_image' => 'Please upload a reference image.', + 'missing_model' => 'Please select an AI model.', + 'missing_prompt' => 'Prompt must not be empty.', + 'provider_invalid' => 'API provider is not configured or disabled.', + 'no_image' => 'No image data received.', + 'done' => 'Preview generated', + 'saved_hint' => 'Preview image set. Save the style to keep it.', + 'failed' => 'Preview failed', ], ], 'setting' => [ @@ -115,6 +142,11 @@ return [ 'email' => 'Email', 'password' => 'Password', 'role' => 'Role', + 'locale' => 'Language', + 'email_notifications_enabled' => 'Email notifications', + 'theme_preference' => 'Theme preference', + 'theme_light' => 'Light', + 'theme_dark' => 'Dark', ], 'table' => [ 'name' => 'Name', @@ -123,6 +155,9 @@ return [ ], ], ], + 'pages' => [ + 'global_settings' => 'Global Settings', + ], 'navigation' => [ 'groups' => [ 'ai_models' => 'AI models', @@ -134,6 +169,18 @@ return [ 'install_plugin' => 'Install Plugin', 'plugin_list' => 'Plugin List', 'users' => 'Users', + 'user_roles' => 'User Roles', + 'ai_models' => 'AI Models', + 'api_providers' => 'API Providers', + 'styles' => 'Styles', + 'images' => 'Images', + ], + 'widgets' => [ + 'app_stats' => [ + 'ai_models' => 'AI models', + 'api_providers' => 'API providers', + 'styles' => 'Styles', + ], ], 'loading_spinner' => [ 'processing_image' => 'Processing image...',