From 560393db2a58d9748f785227001fe64d9d31b9fe Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Thu, 21 May 2026 16:27:55 -0300 Subject: [PATCH] feat: regenerate AI post images with brand palette in editor Let users adjust AI-generated slides in place via async job and Echo, while applying workspace brand, background, and text colors to image prompts. Autofill swaps site text/background colors for the image palette, and regeneration is blocked on finalized posts with safer job cleanup. Co-authored-by: Cursor --- .cursor/rules/project-context.mdc | 1 + .cursor/rules/vue-typescript.mdc | 15 + CLAUDE.md | 5 + app/Ai/Agents/PostImageRegenerator.php | 72 +++++ .../UserAiMediaRegenerationChannel.php | 15 + app/Events/Ai/PostMediaRegenerated.php | 55 ++++ .../App/PostAiRegenerateMediaController.php | 74 +++++ .../Ai/RegeneratePostMediaImageRequest.php | 34 +++ .../Requests/App/Post/UpdatePostRequest.php | 3 + app/Jobs/Ai/RegeneratePostMediaImage.php | 288 ++++++++++++++++++ app/Services/Ai/AiImageClient.php | 17 +- app/Services/Brand/BrandMetadata.php | 10 +- app/Services/Image/TemplateImageGenerator.php | 5 + app/Support/BrandImagePalette.php | 45 +++ lang/en/posts.php | 20 ++ lang/es/posts.php | 20 ++ lang/pt-BR/posts.php | 20 ++ .../posts/ai/AiRegenerateImageDialog.vue | 211 +++++++++++++ .../posts/editor/PostEditorComposer.vue | 31 +- resources/js/composables/useMedia.ts | 3 + resources/js/pages/posts/Edit.vue | 31 ++ .../prompts/post_image/generator.blade.php | 30 +- routes/app.php | 2 + routes/channels.php | 3 + tests/Feature/Ai/AutofillBrandTest.php | 17 ++ .../Feature/Ai/PostAiRegenerateMediaTest.php | 190 ++++++++++++ .../Ai/RegeneratePostMediaImageJobTest.php | 43 +++ tests/Feature/UpdatePostRequestTest.php | 33 ++ tests/Unit/Services/Ai/AiImageClientTest.php | 29 +- .../Image/TemplateImageGeneratorTest.php | 9 +- tests/Unit/Support/BrandImagePaletteTest.php | 37 +++ 31 files changed, 1335 insertions(+), 33 deletions(-) create mode 100644 app/Ai/Agents/PostImageRegenerator.php create mode 100644 app/Broadcasting/UserAiMediaRegenerationChannel.php create mode 100644 app/Events/Ai/PostMediaRegenerated.php create mode 100644 app/Http/Controllers/App/PostAiRegenerateMediaController.php create mode 100644 app/Http/Requests/App/Ai/RegeneratePostMediaImageRequest.php create mode 100644 app/Jobs/Ai/RegeneratePostMediaImage.php create mode 100644 app/Support/BrandImagePalette.php create mode 100644 resources/js/components/posts/ai/AiRegenerateImageDialog.vue create mode 100644 tests/Feature/Ai/PostAiRegenerateMediaTest.php create mode 100644 tests/Feature/Ai/RegeneratePostMediaImageJobTest.php create mode 100644 tests/Unit/Support/BrandImagePaletteTest.php diff --git a/.cursor/rules/project-context.mdc b/.cursor/rules/project-context.mdc index cb970313..55f578f9 100644 --- a/.cursor/rules/project-context.mdc +++ b/.cursor/rules/project-context.mdc @@ -28,6 +28,7 @@ This project has domain-specific skills in `.claude/skills/` (e.g. `pest-testing ## Conventions - Follow existing code conventions. Check sibling files for structure, approach and naming before creating or editing. +- In Vue ``, primary action button first in markup, then cancel/secondary (see `vue-typescript.mdc` and `CLAUDE.md`). - Use descriptive names (`isRegisteredForDiscounts`, not `discount()`). - Reuse existing components before writing new ones. - Stick to existing directory structure. Do not create new base folders without approval. diff --git a/.cursor/rules/vue-typescript.mdc b/.cursor/rules/vue-typescript.mdc index a57c9228..e657596a 100644 --- a/.cursor/rules/vue-typescript.mdc +++ b/.cursor/rules/vue-typescript.mdc @@ -42,6 +42,21 @@ This project uses `@tabler/icons-vue` for all icons. NEVER use `lucide-vue-next` - Import routes from `@/routes/...` — e.g. `import { store } from '@/routes/login'`. - Import controller actions from `@/actions/...`. +## Dialogs + +In ``, put the **primary action button first** in the markup, then secondary/cancel. + +`DialogFooter` (`resources/js/components/ui/dialog/DialogFooter.vue`) uses `flex-col-reverse` on mobile and `sm:flex-row sm:justify-start` on desktop — the first child is the leftmost action on larger screens. + +```vue + + + + +``` + +Check sibling dialogs in the same feature before inventing a new footer layout. + ## Form validation NEVER use HTML5 validation attributes (`required`, `minlength`, `pattern`, etc.) on form inputs. Always rely solely on backend validation. diff --git a/CLAUDE.md b/CLAUDE.md index f8e47ba8..82e54bc9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -230,6 +230,11 @@ ## Frontend (Vue/TypeScript) - Always use arrow functions in Vue components and TypeScript files. Never use `function` declarations. +## Dialogs + +- In ``, put the **primary action button first** in the markup, then secondary/cancel (e.g. Save → Cancel). `DialogFooter` uses `flex-col-reverse` on mobile and `sm:flex-row sm:justify-start` on desktop, so the first child is the leftmost action on larger screens. +- Match sibling dialogs in the same feature area before inventing a new footer layout. + ## Icons (@tabler/icons-vue) - This project uses `@tabler/icons-vue` for all icons. NEVER use `lucide-vue-next`. diff --git a/app/Ai/Agents/PostImageRegenerator.php b/app/Ai/Agents/PostImageRegenerator.php new file mode 100644 index 00000000..58d838bc --- /dev/null +++ b/app/Ai/Agents/PostImageRegenerator.php @@ -0,0 +1,72 @@ +workspace->content_language ?: 'en'; + + return << $schema->string() + ->description('Updated short title for the image (max ~120 chars).') + ->required(), + 'body' => $schema->string() + ->description('Updated supporting text for the image (max ~240 chars).') + ->required(), + 'keywords' => $schema->array() + ->items($schema->string()) + ->description('3-10 short keywords for image generation context.') + ->required(), + ]; + } + + public function provider(): Lab + { + return match (config('ai.default')) { + 'openai' => Lab::OpenAI, + 'anthropic' => Lab::Anthropic, + default => Lab::Gemini, + }; + } + + public function model(): string + { + return config('ai.default_text_model'); + } +} diff --git a/app/Broadcasting/UserAiMediaRegenerationChannel.php b/app/Broadcasting/UserAiMediaRegenerationChannel.php new file mode 100644 index 00000000..18da6ac3 --- /dev/null +++ b/app/Broadcasting/UserAiMediaRegenerationChannel.php @@ -0,0 +1,15 @@ +is($owner); + } +} diff --git a/app/Events/Ai/PostMediaRegenerated.php b/app/Events/Ai/PostMediaRegenerated.php new file mode 100644 index 00000000..8fb219af --- /dev/null +++ b/app/Events/Ai/PostMediaRegenerated.php @@ -0,0 +1,55 @@ +|null $media + */ + public function __construct( + public string $userId, + public string $regenerationId, + public string $postId, + public ?array $media = null, + public ?string $error = null, + ) {} + + public function broadcastAs(): string + { + return 'ai.media.regenerated'; + } + + public function broadcastOn(): PrivateChannel + { + return new PrivateChannel("user.{$this->userId}.ai-media.{$this->regenerationId}"); + } + + /** + * @return array + */ + public function broadcastWith(): array + { + return [ + 'regeneration_id' => $this->regenerationId, + 'post_id' => $this->postId, + 'media' => $this->media, + 'error' => $this->error, + ]; + } + + public function broadcastQueue(): string + { + return 'broadcasts'; + } +} diff --git a/app/Http/Controllers/App/PostAiRegenerateMediaController.php b/app/Http/Controllers/App/PostAiRegenerateMediaController.php new file mode 100644 index 00000000..36745744 --- /dev/null +++ b/app/Http/Controllers/App/PostAiRegenerateMediaController.php @@ -0,0 +1,74 @@ +authorize('update', $post); + + $workspace = $request->user()->currentWorkspace; + + $terminalStatuses = [ + PostStatus::Published, + PostStatus::PartiallyPublished, + PostStatus::Failed, + PostStatus::Publishing, + ]; + + if (in_array($post->status, $terminalStatuses, true)) { + return response()->json([ + 'message' => __('posts.cannot_edit_finalized'), + ], Response::HTTP_UNPROCESSABLE_ENTITY); + } + + $gate = Gate::inspect('useAi', $workspace->account); + if ($gate->denied()) { + return response()->json(['message' => $gate->message()], Response::HTTP_PAYMENT_REQUIRED); + } + + $mediaItem = collect($post->media ?? []) + ->first(fn ($item) => data_get($item, 'id') === $mediaId); + + if (! is_array($mediaItem)) { + return response()->json([ + 'message' => __('posts.ai.image_regenerate.errors.media_not_found'), + ], Response::HTTP_NOT_FOUND); + } + + if (data_get($mediaItem, 'source') !== Source::Ai->value) { + return response()->json([ + 'message' => __('posts.ai.image_regenerate.errors.not_ai_media'), + ], Response::HTTP_UNPROCESSABLE_ENTITY); + } + + $regenerationId = (string) Str::uuid(); + + RegeneratePostMediaImage::dispatch( + workspaceId: $workspace->id, + postId: $post->id, + userId: $request->user()->id, + mediaId: $mediaId, + regenerationId: $regenerationId, + instruction: $request->string('instruction')->toString(), + ); + + return response()->json([ + 'regeneration_id' => $regenerationId, + 'channel' => "user.{$request->user()->id}.ai-media.{$regenerationId}", + ], Response::HTTP_ACCEPTED); + } +} diff --git a/app/Http/Requests/App/Ai/RegeneratePostMediaImageRequest.php b/app/Http/Requests/App/Ai/RegeneratePostMediaImageRequest.php new file mode 100644 index 00000000..78f25659 --- /dev/null +++ b/app/Http/Requests/App/Ai/RegeneratePostMediaImageRequest.php @@ -0,0 +1,34 @@ +> + */ + public function rules(): array + { + return [ + 'instruction' => ['required', 'string', 'max:1000'], + ]; + } + + protected function prepareForValidation(): void + { + if ($this->has('instruction')) { + $this->merge([ + 'instruction' => trim((string) $this->input('instruction')), + ]); + } + } +} diff --git a/app/Http/Requests/App/Post/UpdatePostRequest.php b/app/Http/Requests/App/Post/UpdatePostRequest.php index e1f88839..eaa2d2af 100644 --- a/app/Http/Requests/App/Post/UpdatePostRequest.php +++ b/app/Http/Requests/App/Post/UpdatePostRequest.php @@ -4,6 +4,7 @@ namespace App\Http\Requests\App\Post; +use App\Enums\Media\Source; use App\Enums\Post\Status; use App\Enums\PostPlatform\ContentType; use App\Enums\SocialAccount\Platform; @@ -49,6 +50,8 @@ public function rules(): array 'media.*.original_filename' => ['sometimes', 'nullable', 'string', 'max:500'], 'media.*.size' => ['sometimes', 'nullable', 'integer'], 'media.*.meta' => ['sometimes', 'nullable', 'array'], + 'media.*.source' => ['sometimes', 'nullable', 'string', Rule::in(array_column(Source::cases(), 'value'))], + 'media.*.source_meta' => ['sometimes', 'nullable', 'array'], 'scheduled_at' => [ 'sometimes', 'nullable', diff --git a/app/Jobs/Ai/RegeneratePostMediaImage.php b/app/Jobs/Ai/RegeneratePostMediaImage.php new file mode 100644 index 00000000..23be1679 --- /dev/null +++ b/app/Jobs/Ai/RegeneratePostMediaImage.php @@ -0,0 +1,288 @@ +onQueue('ai'); + } + + public function failed(?Throwable $exception): void + { + Log::warning('RegeneratePostMediaImage failed', [ + 'post_id' => $this->postId, + 'media_id' => $this->mediaId, + 'regeneration_id' => $this->regenerationId, + 'error' => $exception?->getMessage(), + ]); + + PostMediaRegenerated::dispatch( + userId: $this->userId, + regenerationId: $this->regenerationId, + postId: $this->postId, + media: null, + error: __('posts.ai.image_regenerate.errors.unavailable'), + ); + } + + public function handle(): void + { + $workspace = Workspace::query()->findOrFail($this->workspaceId); + $post = Post::query() + ->where('workspace_id', $workspace->id) + ->with(['postPlatforms.socialAccount', 'workspace']) + ->findOrFail($this->postId); + + $mediaItems = collect($post->media ?? []); + $targetIndex = $mediaItems->search(fn ($item) => data_get($item, 'id') === $this->mediaId); + if ($targetIndex === false) { + throw new \RuntimeException('Media item no longer exists in post.'); + } + + $target = $mediaItems->get($targetIndex); + if (data_get($target, 'source') !== Source::Ai->value) { + throw new \RuntimeException('Only AI media can be regenerated.'); + } + + $sourceMeta = data_get($target, 'source_meta'); + $baseContext = $this->buildSourceContext( + sourceMeta: is_array($sourceMeta) ? $sourceMeta : [], + post: $post, + workspace: $workspace, + ); + + /** @var PostImageRegenerator $agent */ + $agent = app(PostImageRegenerator::class, ['workspace' => $workspace]); + + $response = $agent->prompt(json_encode([ + 'instruction' => $this->instruction, + 'title' => $baseContext['title'], + 'body' => $baseContext['body'], + 'keywords' => $baseContext['keywords'], + 'language' => $baseContext['language'], + ], JSON_THROW_ON_ERROR)); + + RecordAiUsage::recordText( + workspace: $workspace, + promptTokens: $response->usage?->promptTokens ?? 0, + completionTokens: $response->usage?->completionTokens ?? 0, + provider: (string) config('ai.default'), + model: (string) config('ai.default_text_model'), + userId: $this->userId, + postId: $post->id, + metadata: ['agent' => 'post_image_regenerator'], + ); + + $structured = $response->structured ?? []; + + $title = trim((string) data_get($structured, 'title', $baseContext['title'])); + $body = trim((string) data_get($structured, 'body', $baseContext['body'])); + $keywords = collect(data_get($structured, 'keywords', $baseContext['keywords'])) + ->filter(fn ($keyword) => is_string($keyword) && trim($keyword) !== '') + ->map(fn (string $keyword) => trim($keyword)) + ->values() + ->all(); + + if ($keywords === []) { + $keywords = $baseContext['keywords']; + } + + $socialAccount = $this->resolveSocialAccount($post, $workspace); + if (! $socialAccount) { + throw new \RuntimeException('No social account available for image footer rendering.'); + } + + $generator = app(TemplateImageGenerator::class); + $rendered = $generator->render( + workspace: $workspace, + socialAccount: $socialAccount, + title: $title, + body: $body, + imageKeywords: $keywords, + width: $baseContext['width'], + height: $baseContext['height'], + ); + + if (! $rendered) { + throw new \RuntimeException('Image generator failed to produce media.'); + } + + $renderedPath = (string) data_get($rendered, 'path'); + + try { + $newMediaItem = DB::transaction(function () use ($post, $rendered, $target, $workspace) { + $newMediaItem = $this->buildAiMediaItem($workspace, $rendered); + + $fresh = Post::query()->whereKey($post->id)->lockForUpdate()->firstOrFail(); + $items = collect($fresh->media ?? []); + + $currentIndex = $items->search(fn ($item) => data_get($item, 'id') === $this->mediaId); + if ($currentIndex === false) { + throw new \RuntimeException('Media item changed before regeneration completed.'); + } + + $items->put($currentIndex, $newMediaItem); + + $fresh->update(['media' => $items->values()->all()]); + + $oldMediaId = data_get($target, 'id'); + Media::query()->where('id', $oldMediaId)->first()?->delete(); + + return $newMediaItem; + }); + } catch (Throwable $exception) { + $this->discardRenderedFile($renderedPath); + + throw $exception; + } + + PostMediaRegenerated::dispatch( + userId: $this->userId, + regenerationId: $this->regenerationId, + postId: $post->id, + media: $newMediaItem, + error: null, + ); + } + + private function discardRenderedFile(string $path): void + { + if ($path !== '' && Storage::exists($path)) { + Storage::delete($path); + } + } + + /** + * @param array $sourceMeta + * @return array{ + * title: string, + * body: string, + * keywords: array, + * language: string, + * width: int, + * height: int + * } + */ + private function buildSourceContext(array $sourceMeta, Post $post, Workspace $workspace): array + { + $title = trim((string) data_get($sourceMeta, 'title', '')); + $body = trim((string) data_get($sourceMeta, 'body', '')); + $keywords = collect(data_get($sourceMeta, 'keywords', [])) + ->filter(fn ($keyword) => is_string($keyword) && trim($keyword) !== '') + ->map(fn (string $keyword) => trim($keyword)) + ->values() + ->all(); + + // Fallback path for older AI media without source metadata. + if ($title === '' && $body === '') { + $derived = trim((string) $post->content); + if ($derived !== '') { + $lines = preg_split('/\R+/', $derived) ?: []; + $title = trim((string) data_get($lines, 0, '')); + $body = trim(collect($lines)->slice(1)->implode(' ')); + } + } + + if ($title === '') { + $title = __('posts.ai.image_regenerate.fallback_title'); + } + + if ($keywords === []) { + $keywords = collect(preg_split('/\s+/', "{$title} {$body}") ?: []) + ->filter(fn ($word) => is_string($word) && mb_strlen(trim($word)) >= 4) + ->map(fn (string $word) => trim($word, ".,!?;:\"'()[]{}")) + ->filter() + ->take(8) + ->values() + ->all(); + } + + if ($keywords === []) { + $keywords = ['social media', 'marketing']; + } + + return [ + 'title' => $title, + 'body' => $body, + 'keywords' => $keywords, + 'language' => (string) data_get($sourceMeta, 'language', $workspace->content_language), + 'width' => (int) data_get($sourceMeta, 'width', TemplateImageGenerator::DEFAULT_WIDTH), + 'height' => (int) data_get($sourceMeta, 'height', TemplateImageGenerator::DEFAULT_HEIGHT), + ]; + } + + private function resolveSocialAccount(Post $post, Workspace $workspace): ?SocialAccount + { + $enabledAccount = $post->postPlatforms + ->first(fn ($platform) => $platform->enabled && $platform->socialAccount); + + if ($enabledAccount?->socialAccount) { + return $enabledAccount->socialAccount; + } + + $anyAccount = $post->postPlatforms + ->first(fn ($platform) => $platform->socialAccount); + + return $anyAccount?->socialAccount + ?? $workspace->socialAccounts()->first(); + } + + /** + * @param array{path: string, source_meta: array} $rendered + * @return array + */ + private function buildAiMediaItem(Workspace $workspace, array $rendered): array + { + $media = $workspace->media()->create([ + 'collection' => 'ai-generated', + 'type' => MediaType::Image, + 'path' => $rendered['path'], + 'original_filename' => basename($rendered['path']), + 'mime_type' => 'image/webp', + 'size' => Storage::size($rendered['path']), + 'order' => 0, + ]); + + return [ + 'id' => $media->id, + 'path' => $media->path, + 'url' => $media->url, + 'type' => 'image', + 'mime_type' => 'image/webp', + 'source' => Source::Ai->value, + 'source_meta' => $rendered['source_meta'], + ]; + } +} diff --git a/app/Services/Ai/AiImageClient.php b/app/Services/Ai/AiImageClient.php index 10cf28e8..11c2b0cb 100644 --- a/app/Services/Ai/AiImageClient.php +++ b/app/Services/Ai/AiImageClient.php @@ -5,7 +5,7 @@ namespace App\Services\Ai; use App\Enums\Workspace\ImageStyle; -use App\Support\HexColorName; +use App\Support\BrandImagePalette; use Illuminate\Support\Facades\Log; use Laravel\Ai\Image; use Throwable; @@ -28,6 +28,8 @@ public function generate( string $orientation = 'portrait', string $language = 'en', ?string $brandColor = null, + ?string $backgroundColor = null, + ?string $textColor = null, ?string $brandDescription = null, string $quality = 'low', int $timeout = 180, @@ -37,9 +39,11 @@ public function generate( return null; } - $brandColorName = $brandColor !== null - ? HexColorName::approximate($brandColor) - : null; + $palette = new BrandImagePalette( + brandColor: $brandColor, + backgroundColor: $backgroundColor, + textColor: $textColor, + ); $brandContext = null; if ($brandDescription !== null) { @@ -55,7 +59,10 @@ public function generate( 'style' => $style->value, 'scene' => implode(', ', $clean), 'language_name' => $this->languageName($language), - 'brand_color_name' => $brandColorName, + 'has_brand_palette' => $palette->isDefined(), + 'brand_color_name' => $palette->brandColorName, + 'background_color_name' => $palette->backgroundColorName, + 'text_color_name' => $palette->textColorName, 'brand_context' => $brandContext, ])->render(); diff --git a/app/Services/Brand/BrandMetadata.php b/app/Services/Brand/BrandMetadata.php index 3874d44f..112f0907 100644 --- a/app/Services/Brand/BrandMetadata.php +++ b/app/Services/Brand/BrandMetadata.php @@ -67,6 +67,12 @@ public function withBrandColor(?string $brandColor): self } /** + * Shape returned to the workspace / brand forms after autofill. + * + * Site extraction keeps the literal page background and body text colours. + * For AI image generation we swap them: page text (usually dark) becomes + * image background, and page background (usually light) becomes in-image text. + * * @return array{name: ?string, brand_description: ?string, content_language: ?string, brand_tone: ?string, brand_voice_notes: ?string, brand_color: ?string, background_color: ?string, text_color: ?string, logo_url: ?string} */ public function toArray(): array @@ -78,8 +84,8 @@ public function toArray(): array 'brand_tone' => $this->tone, 'brand_voice_notes' => $this->voiceNotes, 'brand_color' => $this->brandColor, - 'background_color' => $this->backgroundColor, - 'text_color' => $this->textColor, + 'background_color' => $this->textColor, + 'text_color' => $this->backgroundColor, 'logo_url' => $this->logoUrl, ]; } diff --git a/app/Services/Image/TemplateImageGenerator.php b/app/Services/Image/TemplateImageGenerator.php index ace9b29d..756eb8fd 100644 --- a/app/Services/Image/TemplateImageGenerator.php +++ b/app/Services/Image/TemplateImageGenerator.php @@ -69,6 +69,8 @@ public function render( orientation: $orientation, language: $language, brandColor: $workspace->brand_color, + backgroundColor: $workspace->background_color, + textColor: $workspace->text_color, brandDescription: $workspace->brand_description, ); if ($imageData === null) { @@ -114,6 +116,9 @@ public function render( 'body' => $body, 'width' => $this->width, 'height' => $this->height, + 'brand_color' => $workspace->brand_color, + 'background_color' => $workspace->background_color, + 'text_color' => $workspace->text_color, ], ]; } diff --git a/app/Support/BrandImagePalette.php b/app/Support/BrandImagePalette.php new file mode 100644 index 00000000..9dc2db2f --- /dev/null +++ b/app/Support/BrandImagePalette.php @@ -0,0 +1,45 @@ +brandColorName = self::resolveName($brandColor); + $this->backgroundColorName = self::resolveName($backgroundColor); + $this->textColorName = self::resolveName($textColor); + } + + public function isDefined(): bool + { + return $this->brandColorName !== null + || $this->backgroundColorName !== null + || $this->textColorName !== null; + } + + private static function resolveName(?string $hex): ?string + { + if ($hex === null || trim($hex) === '') { + return null; + } + + return HexColorName::approximate($hex); + } +} diff --git a/lang/en/posts.php b/lang/en/posts.php index 785c5736..26249bae 100644 --- a/lang/en/posts.php +++ b/lang/en/posts.php @@ -213,6 +213,26 @@ 'applied' => 'Applied', 'cancel' => 'Cancel', ], + 'image_regenerate' => [ + 'button' => 'Adjust', + 'title' => 'Adjust AI image', + 'description' => 'Describe the correction. The new image replaces the current one and keeps its position in the carousel.', + 'instruction_label' => 'Instruction', + 'instruction_placeholder' => 'e.g. Fix the typo in the headline and make the background lighter.', + 'processing' => 'Regenerating image... this can take a few seconds.', + 'submit' => 'Regenerate image', + 'cancel' => 'Cancel', + 'success' => 'Image updated. The new version replaced the previous one in your post.', + 'fallback_title' => 'Improve this image copy', + 'errors' => [ + 'required' => 'Instruction is required.', + 'start_failed' => 'Failed to start regeneration.', + 'unavailable' => 'Unable to regenerate this image right now.', + 'timeout' => 'Regeneration is taking longer than expected. Try again in a moment.', + 'media_not_found' => 'Media item not found.', + 'not_ai_media' => 'Only AI-generated media can be regenerated.', + ], + ], ], 'show' => [ diff --git a/lang/es/posts.php b/lang/es/posts.php index 253c11e2..389a2d1b 100644 --- a/lang/es/posts.php +++ b/lang/es/posts.php @@ -213,6 +213,26 @@ 'applied' => 'Aplicada', 'cancel' => 'Cancelar', ], + 'image_regenerate' => [ + 'button' => 'Ajustar', + 'title' => 'Ajustar imagen con IA', + 'description' => 'Describe la corrección. La nueva imagen reemplaza la actual y mantiene su posición en el carrusel.', + 'instruction_label' => 'Instrucción', + 'instruction_placeholder' => 'ej: Corregir el error en el titular y aclarar el fondo.', + 'processing' => 'Regenerando imagen... esto puede tardar unos segundos.', + 'submit' => 'Regenerar imagen', + 'cancel' => 'Cancelar', + 'success' => 'Imagen actualizada. La nueva versión reemplazó la anterior en la publicación.', + 'fallback_title' => 'Mejorar este texto de imagen', + 'errors' => [ + 'required' => 'La instrucción es obligatoria.', + 'start_failed' => 'No se pudo iniciar la regeneración.', + 'unavailable' => 'No se pudo regenerar esta imagen en este momento.', + 'timeout' => 'La regeneración está tardando más de lo esperado. Inténtalo de nuevo en un momento.', + 'media_not_found' => 'No se encontró el archivo multimedia.', + 'not_ai_media' => 'Solo se puede regenerar contenido generado por IA.', + ], + ], ], 'show' => [ diff --git a/lang/pt-BR/posts.php b/lang/pt-BR/posts.php index f62548b4..4265dca3 100644 --- a/lang/pt-BR/posts.php +++ b/lang/pt-BR/posts.php @@ -213,6 +213,26 @@ 'applied' => 'Aplicada', 'cancel' => 'Cancelar', ], + 'image_regenerate' => [ + 'button' => 'Ajustar', + 'title' => 'Ajustar imagem com IA', + 'description' => 'Descreva a correção. A nova imagem substitui a atual e mantém a posição no carrossel.', + 'instruction_label' => 'Instrução', + 'instruction_placeholder' => 'ex: Corrigir o erro no título e deixar o fundo mais claro.', + 'processing' => 'Regenerando imagem... isso pode levar alguns segundos.', + 'submit' => 'Regenerar imagem', + 'cancel' => 'Cancelar', + 'success' => 'Imagem atualizada. A nova versão substituiu a anterior no post.', + 'fallback_title' => 'Melhore esta copy da imagem', + 'errors' => [ + 'required' => 'A instrução é obrigatória.', + 'start_failed' => 'Não foi possível iniciar a regeneração.', + 'unavailable' => 'Não foi possível regenerar esta imagem agora.', + 'timeout' => 'A regeneração está demorando mais que o esperado. Tente de novo em instantes.', + 'media_not_found' => 'Mídia não encontrada.', + 'not_ai_media' => 'Só é possível regenerar mídia gerada por IA.', + ], + ], ], 'show' => [ diff --git a/resources/js/components/posts/ai/AiRegenerateImageDialog.vue b/resources/js/components/posts/ai/AiRegenerateImageDialog.vue new file mode 100644 index 00000000..1bef5bdc --- /dev/null +++ b/resources/js/components/posts/ai/AiRegenerateImageDialog.vue @@ -0,0 +1,211 @@ + + +