Merge pull request #57 from trypostit/feat/ai-image-regenerate
feat: regenerate AI post images with brand palette
This commit is contained in:
commit
7a598615c3
67 changed files with 1848 additions and 171 deletions
|
|
@ -61,3 +61,9 @@ If you hit `Illuminate\Foundation\ViteException: Unable to locate file in Vite m
|
|||
## Deployment
|
||||
|
||||
Laravel can be deployed using [Laravel Cloud](https://cloud.laravel.com/) — the fastest path to production for Laravel apps.
|
||||
|
||||
## AI agents (`app/Ai/Agents`)
|
||||
|
||||
- Do not write prompts inside agent classes (no heredocs, no long strings in `instructions()`).
|
||||
- Store prompt copy in `resources/views/prompts/**/*.blade.php` and render with `view('prompts....', $vars)->render()`.
|
||||
- Follow `PostContentStreamer`, `PostContentReviewer`, or `BrandAnalyzer` for structure.
|
||||
|
|
|
|||
|
|
@ -28,6 +28,8 @@ 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 `<DialogFooter>`, primary action button first in markup, then cancel/secondary (see `vue-typescript.mdc` and `CLAUDE.md`).
|
||||
- AI agents: prompts live in `resources/views/prompts/`; `instructions()` uses `view(...)->render()` only — never heredocs or inline prompt strings in `app/Ai/Agents/` (see `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.
|
||||
|
|
|
|||
|
|
@ -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 `<DialogFooter>`, 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
|
||||
<DialogFooter>
|
||||
<Button @click="submit">{{ $t('...submit') }}</Button>
|
||||
<Button variant="outline" @click="open = false">{{ $t('common.cancel') }}</Button>
|
||||
</DialogFooter>
|
||||
```
|
||||
|
||||
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.
|
||||
|
|
|
|||
11
CLAUDE.md
11
CLAUDE.md
|
|
@ -230,6 +230,17 @@ ## Frontend (Vue/TypeScript)
|
|||
|
||||
- Always use arrow functions in Vue components and TypeScript files. Never use `function` declarations.
|
||||
|
||||
## Dialogs
|
||||
|
||||
- In `<DialogFooter>`, 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.
|
||||
|
||||
## AI agents (`app/Ai/Agents`)
|
||||
|
||||
- **Never** embed prompts in PHP (`<<<PROMPT`, heredocs, or long string literals in `instructions()`).
|
||||
- Put system/instruction text in Blade under `resources/views/prompts/` (e.g. `prompts.post_content.generator`, `prompts.post_image.regenerator`).
|
||||
- In `instructions()`, return `view('prompts....', [...])->render()` and pass only the variables the Blade file needs — same pattern as `PostContentStreamer`, `PostContentReviewer`, and `BrandAnalyzer`.
|
||||
|
||||
## Icons (@tabler/icons-vue)
|
||||
|
||||
- This project uses `@tabler/icons-vue` for all icons. NEVER use `lucide-vue-next`.
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@
|
|||
use App\Jobs\PublishPost;
|
||||
use App\Models\Post;
|
||||
use App\Models\Workspace;
|
||||
use App\Support\PostStatusRules;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
|
@ -20,9 +21,7 @@ class UpdatePost
|
|||
*/
|
||||
public static function execute(Workspace $workspace, Post $post, array $data): array
|
||||
{
|
||||
$terminalStatuses = [PostStatus::Published, PostStatus::PartiallyPublished, PostStatus::Failed, PostStatus::Publishing];
|
||||
|
||||
if (in_array($post->status, $terminalStatuses, true)) {
|
||||
if (PostStatusRules::blocksEditing($post)) {
|
||||
return ['post' => $post, 'action' => PostAction::Finalized];
|
||||
}
|
||||
|
||||
|
|
|
|||
64
app/Ai/Agents/PostImageRegenerator.php
Normal file
64
app/Ai/Agents/PostImageRegenerator.php
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Ai\Agents;
|
||||
|
||||
use App\Models\Workspace;
|
||||
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
||||
use Laravel\Ai\Attributes\Temperature;
|
||||
use Laravel\Ai\Contracts\Agent;
|
||||
use Laravel\Ai\Contracts\HasStructuredOutput;
|
||||
use Laravel\Ai\Enums\Lab;
|
||||
use Laravel\Ai\Promptable;
|
||||
|
||||
#[Temperature(0.25)]
|
||||
class PostImageRegenerator implements Agent, HasStructuredOutput
|
||||
{
|
||||
use Promptable;
|
||||
|
||||
public function __construct(
|
||||
public Workspace $workspace,
|
||||
) {}
|
||||
|
||||
public function instructions(): string
|
||||
{
|
||||
return view('prompts.post_image.regenerator', [
|
||||
'content_language' => $this->workspace->content_language ?: 'en',
|
||||
])->render();
|
||||
}
|
||||
|
||||
public function schema(JsonSchema $schema): array
|
||||
{
|
||||
return [
|
||||
'title' => $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(),
|
||||
'change_mode' => $schema->string()
|
||||
->enum(['image_only', 'text_only', 'both'])
|
||||
->description('Set to image_only (change visual only), text_only (change text only), or both (change visual and text).')
|
||||
->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');
|
||||
}
|
||||
}
|
||||
15
app/Broadcasting/UserAiMediaRegenerationChannel.php
Normal file
15
app/Broadcasting/UserAiMediaRegenerationChannel.php
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Broadcasting;
|
||||
|
||||
use App\Models\User;
|
||||
|
||||
class UserAiMediaRegenerationChannel
|
||||
{
|
||||
public function join(User $user, User $owner, string $regenerationId): bool
|
||||
{
|
||||
return $user->is($owner);
|
||||
}
|
||||
}
|
||||
55
app/Events/Ai/PostMediaRegenerated.php
Normal file
55
app/Events/Ai/PostMediaRegenerated.php
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Events\Ai;
|
||||
|
||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||
use Illuminate\Broadcasting\PrivateChannel;
|
||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class PostMediaRegenerated implements ShouldBroadcast
|
||||
{
|
||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||
|
||||
/**
|
||||
* @param array<string, mixed>|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<string, mixed>
|
||||
*/
|
||||
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';
|
||||
}
|
||||
}
|
||||
|
|
@ -19,6 +19,7 @@
|
|||
use App\Http\Resources\Api\PostResource;
|
||||
use App\Models\Post;
|
||||
use App\Services\Post\MediaAttacher;
|
||||
use App\Support\PostStatusRules;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||
|
|
@ -69,7 +70,7 @@ public function update(UpdatePostRequest $request, Post $post): PostResource|Jso
|
|||
|
||||
if (data_get($result, 'action') === PostAction::Finalized) {
|
||||
return response()->json(
|
||||
['message' => 'Cannot edit a post in a terminal state.'],
|
||||
['message' => PostStatusRules::editBlockedMessage()],
|
||||
Response::HTTP_UNPROCESSABLE_ENTITY
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,9 +18,7 @@ public function generate(GeneratePostContentRequest $request, Post $post): JsonR
|
|||
{
|
||||
$workspace = $request->user()->currentWorkspace;
|
||||
|
||||
if ($post->workspace_id !== $workspace->id) {
|
||||
abort(Response::HTTP_FORBIDDEN);
|
||||
}
|
||||
$this->authorize('update', $post);
|
||||
|
||||
$gate = Gate::inspect('useAi', $workspace->account);
|
||||
if ($gate->denied()) {
|
||||
|
|
|
|||
67
app/Http/Controllers/App/PostAiRegenerateMediaController.php
Normal file
67
app/Http/Controllers/App/PostAiRegenerateMediaController.php
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\App;
|
||||
|
||||
use App\Enums\Media\Source;
|
||||
use App\Http\Requests\App\Ai\RegeneratePostMediaImageRequest;
|
||||
use App\Jobs\Ai\RegeneratePostMediaImage;
|
||||
use App\Models\Post;
|
||||
use App\Support\PostStatusRules;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Support\Str;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class PostAiRegenerateMediaController extends Controller
|
||||
{
|
||||
public function regenerate(RegeneratePostMediaImageRequest $request, Post $post, string $mediaId): JsonResponse
|
||||
{
|
||||
$this->authorize('update', $post);
|
||||
|
||||
$workspace = $request->user()->currentWorkspace;
|
||||
|
||||
if (PostStatusRules::blocksEditing($post)) {
|
||||
return response()->json([
|
||||
'message' => PostStatusRules::editBlockedMessage(),
|
||||
], 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -18,9 +18,7 @@ public function review(ReviewPostContentRequest $request, Post $post): JsonRespo
|
|||
{
|
||||
$workspace = $request->user()->currentWorkspace;
|
||||
|
||||
if ($post->workspace_id !== $workspace->id) {
|
||||
abort(Response::HTTP_FORBIDDEN);
|
||||
}
|
||||
$this->authorize('update', $post);
|
||||
|
||||
$gate = Gate::inspect('useAi', $workspace->account);
|
||||
if ($gate->denied()) {
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@
|
|||
use App\Services\Post\PostMetricsFetcher;
|
||||
use App\Services\Social\PinterestPublisher;
|
||||
use App\Services\Social\TikTokCreatorInfo;
|
||||
use App\Support\PostStatusRules;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
|
|
@ -219,7 +220,7 @@ public function edit(Request $request, Post $post): Response|RedirectResponse
|
|||
|
||||
$this->authorize('update', $post);
|
||||
|
||||
if (in_array($post->status, [PostStatus::Publishing, PostStatus::Published, PostStatus::PartiallyPublished, PostStatus::Failed], true)) {
|
||||
if (PostStatusRules::blocksEditing($post)) {
|
||||
return redirect()->route('app.posts.show', $post);
|
||||
}
|
||||
|
||||
|
|
@ -313,7 +314,7 @@ public function destroy(Request $request, Post $post): RedirectResponse
|
|||
|
||||
$this->authorize('delete', $post);
|
||||
|
||||
if (in_array($post->status, [PostStatus::Publishing, PostStatus::Published, PostStatus::PartiallyPublished], true)) {
|
||||
if (PostStatusRules::blocksDeletion($post)) {
|
||||
session()->flash('flash.banner', __('posts.flash.cannot_delete_published'));
|
||||
session()->flash('flash.bannerStyle', 'danger');
|
||||
|
||||
|
|
|
|||
34
app/Http/Requests/App/Ai/RegeneratePostMediaImageRequest.php
Normal file
34
app/Http/Requests/App/Ai/RegeneratePostMediaImageRequest.php
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\App\Ai;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class RegeneratePostMediaImageRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array<int, mixed>>
|
||||
*/
|
||||
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')),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
463
app/Jobs/Ai/RegeneratePostMediaImage.php
Normal file
463
app/Jobs/Ai/RegeneratePostMediaImage.php
Normal file
|
|
@ -0,0 +1,463 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Jobs\Ai;
|
||||
|
||||
use App\Ai\Agents\PostImageRegenerator;
|
||||
use App\Enums\Media\Source;
|
||||
use App\Enums\Media\Type as MediaType;
|
||||
use App\Events\Ai\PostMediaRegenerated;
|
||||
use App\Models\Media;
|
||||
use App\Models\Post;
|
||||
use App\Models\SocialAccount;
|
||||
use App\Models\Workspace;
|
||||
use App\Services\Ai\RecordAiUsage;
|
||||
use App\Services\Image\TemplateImageGenerator;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
class RegeneratePostMediaImage implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public string $workspaceId,
|
||||
public string $postId,
|
||||
public string $userId,
|
||||
public string $mediaId,
|
||||
public string $regenerationId,
|
||||
public string $instruction,
|
||||
) {
|
||||
$this->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 = $this->loadPost($workspace);
|
||||
$target = $this->resolveAiMediaTarget($post);
|
||||
|
||||
$baseContext = $this->buildSourceContext(
|
||||
sourceMeta: is_array(data_get($target, 'source_meta')) ? data_get($target, 'source_meta') : [],
|
||||
post: $post,
|
||||
workspace: $workspace,
|
||||
);
|
||||
|
||||
$copy = $this->regenerateSlideCopy($workspace, $post, $baseContext);
|
||||
$rendered = $this->renderRegeneratedImage($workspace, $post, $copy, $baseContext);
|
||||
$newMediaItem = $this->replaceMediaOnPost($post, $target, $workspace, $rendered);
|
||||
|
||||
PostMediaRegenerated::dispatch(
|
||||
userId: $this->userId,
|
||||
regenerationId: $this->regenerationId,
|
||||
postId: $post->id,
|
||||
media: $newMediaItem,
|
||||
error: null,
|
||||
);
|
||||
}
|
||||
|
||||
private function loadPost(Workspace $workspace): Post
|
||||
{
|
||||
return Post::query()
|
||||
->where('workspace_id', $workspace->id)
|
||||
->with(['postPlatforms.socialAccount', 'workspace'])
|
||||
->findOrFail($this->postId);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function resolveAiMediaTarget(Post $post): array
|
||||
{
|
||||
$target = collect($post->media ?? [])
|
||||
->first(fn ($item) => data_get($item, 'id') === $this->mediaId);
|
||||
|
||||
if (! is_array($target)) {
|
||||
throw new RuntimeException('Media item no longer exists in post.');
|
||||
}
|
||||
|
||||
if (data_get($target, 'source') !== Source::Ai->value) {
|
||||
throw new RuntimeException('Only AI media can be regenerated.');
|
||||
}
|
||||
|
||||
return $target;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{
|
||||
* title: string,
|
||||
* body: string,
|
||||
* keywords: array<int, string>,
|
||||
* background_path: string,
|
||||
* language: string,
|
||||
* width: int,
|
||||
* height: int
|
||||
* } $baseContext
|
||||
* @return array{
|
||||
* title: string,
|
||||
* body: string,
|
||||
* keywords: array<int, string>,
|
||||
* regenerate_image: bool,
|
||||
* regenerate_text: bool,
|
||||
* change_mode: 'image_only'|'text_only'|'both'
|
||||
* }
|
||||
*/
|
||||
private function regenerateSlideCopy(Workspace $workspace, Post $post, array $baseContext): array
|
||||
{
|
||||
/** @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'],
|
||||
);
|
||||
|
||||
return $this->mergeStructuredCopy($baseContext, $response->structured ?? []);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{
|
||||
* title: string,
|
||||
* body: string,
|
||||
* keywords: array<int, string>,
|
||||
* background_path: string,
|
||||
* language: string,
|
||||
* width: int,
|
||||
* height: int
|
||||
* } $baseContext
|
||||
* @param array<string, mixed> $structured
|
||||
* @return array{
|
||||
* title: string,
|
||||
* body: string,
|
||||
* keywords: array<int, string>,
|
||||
* regenerate_image: bool,
|
||||
* regenerate_text: bool,
|
||||
* change_mode: 'image_only'|'text_only'|'both'
|
||||
* }
|
||||
*/
|
||||
private function mergeStructuredCopy(array $baseContext, array $structured): array
|
||||
{
|
||||
$changeMode = $this->resolveChangeMode((string) data_get($structured, 'change_mode', 'both'));
|
||||
$regenerateImage = in_array($changeMode, ['image_only', 'both'], true);
|
||||
$regenerateText = in_array($changeMode, ['text_only', 'both'], true);
|
||||
$keywords = $this->normalizeKeywords(data_get($structured, 'keywords', $baseContext['keywords']));
|
||||
|
||||
return [
|
||||
'title' => $regenerateText
|
||||
? trim((string) data_get($structured, 'title', $baseContext['title']))
|
||||
: $baseContext['title'],
|
||||
'body' => $regenerateText
|
||||
? trim((string) data_get($structured, 'body', $baseContext['body']))
|
||||
: $baseContext['body'],
|
||||
'keywords' => $regenerateImage && $keywords !== [] ? $keywords : $baseContext['keywords'],
|
||||
'regenerate_image' => $regenerateImage,
|
||||
'regenerate_text' => $regenerateText,
|
||||
'change_mode' => $changeMode,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{
|
||||
* title: string,
|
||||
* body: string,
|
||||
* keywords: array<int, string>,
|
||||
* regenerate_image: bool,
|
||||
* regenerate_text: bool,
|
||||
* change_mode: 'image_only'|'text_only'|'both'
|
||||
* } $copy
|
||||
* @param array{
|
||||
* title: string,
|
||||
* body: string,
|
||||
* keywords: array<int, string>,
|
||||
* language: string,
|
||||
* width: int,
|
||||
* height: int
|
||||
* } $baseContext
|
||||
* @return array{path: string, source_meta: array<string, mixed>}
|
||||
*/
|
||||
private function renderRegeneratedImage(
|
||||
Workspace $workspace,
|
||||
Post $post,
|
||||
array $copy,
|
||||
array $baseContext,
|
||||
): array {
|
||||
$socialAccount = $this->resolveSocialAccount($post, $workspace);
|
||||
|
||||
if (! $socialAccount) {
|
||||
throw new RuntimeException('No social account available for image footer rendering.');
|
||||
}
|
||||
|
||||
$reusedBackgroundPath = null;
|
||||
if (! $copy['regenerate_image']) {
|
||||
$reusedBackgroundPath = (string) data_get($baseContext, 'background_path', '');
|
||||
if ($reusedBackgroundPath === '') {
|
||||
$reusedBackgroundPath = null;
|
||||
}
|
||||
}
|
||||
|
||||
$rendered = app(TemplateImageGenerator::class)->render(
|
||||
workspace: $workspace,
|
||||
socialAccount: $socialAccount,
|
||||
title: $copy['title'],
|
||||
body: $copy['body'],
|
||||
imageKeywords: $copy['keywords'],
|
||||
width: $baseContext['width'],
|
||||
height: $baseContext['height'],
|
||||
backgroundPath: $reusedBackgroundPath,
|
||||
);
|
||||
|
||||
if (! $rendered) {
|
||||
throw new RuntimeException('Image generator failed to produce media.');
|
||||
}
|
||||
|
||||
return $rendered;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $target
|
||||
* @param array{path: string, source_meta: array<string, mixed>} $rendered
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function replaceMediaOnPost(
|
||||
Post $post,
|
||||
array $target,
|
||||
Workspace $workspace,
|
||||
array $rendered,
|
||||
): array {
|
||||
$renderedPath = $rendered['path'];
|
||||
$newBackgroundPath = (string) data_get($rendered, 'source_meta.background_path', '');
|
||||
$oldBackgroundPath = (string) data_get($target, 'source_meta.background_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()]);
|
||||
|
||||
Media::query()->where('id', data_get($target, 'id'))->first()?->delete();
|
||||
|
||||
return $newMediaItem;
|
||||
});
|
||||
|
||||
if ($oldBackgroundPath !== '' && $oldBackgroundPath !== $newBackgroundPath && Storage::exists($oldBackgroundPath)) {
|
||||
Storage::delete($oldBackgroundPath);
|
||||
}
|
||||
|
||||
return $newMediaItem;
|
||||
} catch (Throwable $exception) {
|
||||
$this->discardRenderedFile($renderedPath);
|
||||
if ($newBackgroundPath !== '' && $newBackgroundPath !== $oldBackgroundPath && Storage::exists($newBackgroundPath)) {
|
||||
Storage::delete($newBackgroundPath);
|
||||
}
|
||||
|
||||
throw $exception;
|
||||
}
|
||||
}
|
||||
|
||||
private function discardRenderedFile(string $path): void
|
||||
{
|
||||
if ($path !== '' && Storage::exists($path)) {
|
||||
Storage::delete($path);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $sourceMeta
|
||||
* @return array{
|
||||
* title: string,
|
||||
* body: string,
|
||||
* keywords: array<int, string>,
|
||||
* background_path: string,
|
||||
* 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 = $this->normalizeKeywords(data_get($sourceMeta, 'keywords', []));
|
||||
|
||||
if ($title === '' && $body === '') {
|
||||
[$title, $body] = $this->titleAndBodyFromPostContent($post);
|
||||
}
|
||||
|
||||
if ($title === '') {
|
||||
$title = __('posts.ai.image_regenerate.fallback_title');
|
||||
}
|
||||
|
||||
if ($keywords === []) {
|
||||
$keywords = $this->keywordsFromCopy($title, $body);
|
||||
}
|
||||
|
||||
if ($keywords === []) {
|
||||
$keywords = ['social media', 'marketing'];
|
||||
}
|
||||
|
||||
return [
|
||||
'title' => $title,
|
||||
'body' => $body,
|
||||
'keywords' => $keywords,
|
||||
'background_path' => (string) data_get($sourceMeta, 'background_path', ''),
|
||||
'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),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0: string, 1: string}
|
||||
*/
|
||||
private function titleAndBodyFromPostContent(Post $post): array
|
||||
{
|
||||
$lines = Str::of((string) $post->content)
|
||||
->replace(["\r\n", "\r"], "\n")
|
||||
->trim()
|
||||
->explode("\n")
|
||||
->map(fn (string $line) => trim($line))
|
||||
->filter()
|
||||
->values();
|
||||
|
||||
if ($lines->isEmpty()) {
|
||||
return ['', ''];
|
||||
}
|
||||
|
||||
return [
|
||||
(string) $lines->first(),
|
||||
$lines->slice(1)->implode(' '),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function keywordsFromCopy(string $title, string $body): array
|
||||
{
|
||||
return Str::of("{$title} {$body}")
|
||||
->squish()
|
||||
->explode(' ')
|
||||
->map(fn (string $word) => (string) Str::of($word)->trim(".,!?;:\"'()[]{}"))
|
||||
->filter(fn (string $word) => mb_strlen($word) >= 4)
|
||||
->take(8)
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function normalizeKeywords(mixed $keywords): array
|
||||
{
|
||||
return collect($keywords)
|
||||
->filter(fn ($keyword) => is_string($keyword) && trim($keyword) !== '')
|
||||
->map(fn (string $keyword) => trim($keyword))
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return 'image_only'|'text_only'|'both'
|
||||
*/
|
||||
private function resolveChangeMode(string $value): string
|
||||
{
|
||||
return match ($value) {
|
||||
'image_only', 'text_only', 'both' => $value,
|
||||
default => 'both',
|
||||
};
|
||||
}
|
||||
|
||||
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<string, mixed>} $rendered
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -9,6 +9,7 @@
|
|||
use App\Enums\Post\Status;
|
||||
use App\Http\Resources\Api\PostResource;
|
||||
use App\Models\Post;
|
||||
use App\Support\PostStatusRules;
|
||||
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
||||
use Laravel\Mcp\Request;
|
||||
use Laravel\Mcp\Response;
|
||||
|
|
@ -48,7 +49,7 @@ public function handle(Request $request): Response|ResponseFactory
|
|||
]);
|
||||
|
||||
if (data_get($result, 'action') === PostAction::Finalized) {
|
||||
return Response::error('Post is already published or in a terminal state.');
|
||||
return Response::error(PostStatusRules::editBlockedMessage());
|
||||
}
|
||||
|
||||
/** @var Post $updated */
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@
|
|||
use App\Http\Resources\Api\PostResource;
|
||||
use App\Models\Post;
|
||||
use App\Rules\ContentTypeMatchesPostPlatform;
|
||||
use App\Support\PostStatusRules;
|
||||
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Laravel\Mcp\Request;
|
||||
|
|
@ -55,7 +56,7 @@ public function handle(Request $request): Response|ResponseFactory
|
|||
$result = UpdatePost::execute($workspace, $post, $payload);
|
||||
|
||||
if (data_get($result, 'action') === PostAction::Finalized) {
|
||||
return Response::error('Cannot edit a post in a terminal state.');
|
||||
return Response::error(PostStatusRules::editBlockedMessage());
|
||||
}
|
||||
|
||||
/** @var Post $updated */
|
||||
|
|
|
|||
|
|
@ -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,7 @@ public function generate(
|
|||
return null;
|
||||
}
|
||||
|
||||
$brandColorName = $brandColor !== null
|
||||
? HexColorName::approximate($brandColor)
|
||||
: null;
|
||||
$palette = $this->buildPaletteContext($brandColor, $backgroundColor, $textColor);
|
||||
|
||||
$brandContext = null;
|
||||
if ($brandDescription !== null) {
|
||||
|
|
@ -55,7 +55,10 @@ public function generate(
|
|||
'style' => $style->value,
|
||||
'scene' => implode(', ', $clean),
|
||||
'language_name' => $this->languageName($language),
|
||||
'brand_color_name' => $brandColorName,
|
||||
'has_brand_palette' => data_get($palette, 'is_defined', false),
|
||||
'brand_color_name' => data_get($palette, 'brand_color_name'),
|
||||
'background_color_name' => data_get($palette, 'background_color_name'),
|
||||
'text_color_name' => data_get($palette, 'text_color_name'),
|
||||
'brand_context' => $brandContext,
|
||||
])->render();
|
||||
|
||||
|
|
@ -92,4 +95,38 @@ private function languageName(string $code): string
|
|||
default => 'English',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* is_defined: bool,
|
||||
* brand_color_name: ?string,
|
||||
* background_color_name: ?string,
|
||||
* text_color_name: ?string
|
||||
* }
|
||||
*/
|
||||
private function buildPaletteContext(
|
||||
?string $brandColor,
|
||||
?string $backgroundColor,
|
||||
?string $textColor,
|
||||
): array {
|
||||
$brandColorName = $this->resolveColorName($brandColor);
|
||||
$backgroundColorName = $this->resolveColorName($backgroundColor);
|
||||
$textColorName = $this->resolveColorName($textColor);
|
||||
|
||||
return [
|
||||
'is_defined' => $brandColorName !== null || $backgroundColorName !== null || $textColorName !== null,
|
||||
'brand_color_name' => $brandColorName,
|
||||
'background_color_name' => $backgroundColorName,
|
||||
'text_color_name' => $textColorName,
|
||||
];
|
||||
}
|
||||
|
||||
private function resolveColorName(?string $hex): ?string
|
||||
{
|
||||
if ($hex === null || trim($hex) === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return HexColorName::approximate($hex);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@ public function render(
|
|||
array $imageKeywords,
|
||||
int $width = self::DEFAULT_WIDTH,
|
||||
int $height = self::DEFAULT_HEIGHT,
|
||||
?string $backgroundPath = null,
|
||||
): ?array {
|
||||
$this->width = $width;
|
||||
$this->height = $height;
|
||||
|
|
@ -63,16 +64,33 @@ public function render(
|
|||
};
|
||||
$language = $workspace->content_language;
|
||||
|
||||
$imageData = $this->aiImage->generate(
|
||||
keywords: $imageKeywords,
|
||||
style: $imageStyle,
|
||||
orientation: $orientation,
|
||||
language: $language,
|
||||
brandColor: $workspace->brand_color,
|
||||
brandDescription: $workspace->brand_description,
|
||||
);
|
||||
if ($imageData === null) {
|
||||
return null;
|
||||
$generatedNewBackground = false;
|
||||
$resolvedBackgroundPath = null;
|
||||
$imageData = null;
|
||||
|
||||
if (is_string($backgroundPath) && trim($backgroundPath) !== '' && Storage::exists($backgroundPath)) {
|
||||
$resolvedBackgroundPath = $backgroundPath;
|
||||
$imageData = Storage::get($backgroundPath);
|
||||
}
|
||||
|
||||
if (! is_string($imageData) || $imageData === '') {
|
||||
$imageData = $this->aiImage->generate(
|
||||
keywords: $imageKeywords,
|
||||
style: $imageStyle,
|
||||
orientation: $orientation,
|
||||
language: $language,
|
||||
brandColor: $workspace->brand_color,
|
||||
backgroundColor: $workspace->background_color,
|
||||
textColor: $workspace->text_color,
|
||||
brandDescription: $workspace->brand_description,
|
||||
);
|
||||
|
||||
if ($imageData === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$generatedNewBackground = true;
|
||||
$resolvedBackgroundPath = $this->storeBackgroundImage($imageData);
|
||||
}
|
||||
|
||||
$manager = new ImageManager(Driver::class);
|
||||
|
|
@ -83,16 +101,18 @@ public function render(
|
|||
$filename = 'ai-images/'.uniqid('slide_', true).'.webp';
|
||||
Storage::put($filename, (string) $canvas->encode(new WebpEncoder(quality: 85)));
|
||||
|
||||
RecordAiUsage::recordImage(
|
||||
workspace: $workspace,
|
||||
provider: 'openai',
|
||||
model: AiImageClient::MODEL,
|
||||
metadata: [
|
||||
'image_style' => $imageStyle->value,
|
||||
'width' => $this->width,
|
||||
'height' => $this->height,
|
||||
],
|
||||
);
|
||||
if ($generatedNewBackground) {
|
||||
RecordAiUsage::recordImage(
|
||||
workspace: $workspace,
|
||||
provider: 'openai',
|
||||
model: AiImageClient::MODEL,
|
||||
metadata: [
|
||||
'image_style' => $imageStyle->value,
|
||||
'width' => $this->width,
|
||||
'height' => $this->height,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
RecordAiUsage::recordTemplate(
|
||||
workspace: $workspace,
|
||||
|
|
@ -114,10 +134,25 @@ 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,
|
||||
'background_path' => $resolvedBackgroundPath,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
private function storeBackgroundImage(string $imageData): string
|
||||
{
|
||||
$manager = new ImageManager(Driver::class);
|
||||
$background = $manager->decodeBinary($imageData)->cover($this->width, $this->height);
|
||||
$backgroundPath = 'ai-images/'.uniqid('bg_', true).'.webp';
|
||||
|
||||
Storage::put($backgroundPath, (string) $background->encode(new WebpEncoder(quality: 85)));
|
||||
|
||||
return $backgroundPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick the closest aspect ratio for the active canvas so the AI image
|
||||
* generator returns a photo that doesn't need heavy cropping.
|
||||
|
|
|
|||
51
app/Support/PostStatusRules.php
Normal file
51
app/Support/PostStatusRules.php
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use App\Enums\Post\Status as PostStatus;
|
||||
use App\Models\Post;
|
||||
|
||||
class PostStatusRules
|
||||
{
|
||||
private const EDIT_BLOCKED_MESSAGE_KEY = 'posts.cannot_edit_finalized';
|
||||
|
||||
/**
|
||||
* Statuses where the post can no longer be edited.
|
||||
*
|
||||
* @var array<int, PostStatus>
|
||||
*/
|
||||
private const EDIT_BLOCKED_STATUSES = [
|
||||
PostStatus::Published,
|
||||
PostStatus::PartiallyPublished,
|
||||
PostStatus::Failed,
|
||||
PostStatus::Publishing,
|
||||
];
|
||||
|
||||
/**
|
||||
* Statuses where the post can no longer be deleted.
|
||||
*
|
||||
* @var array<int, PostStatus>
|
||||
*/
|
||||
private const DELETE_BLOCKED_STATUSES = [
|
||||
PostStatus::Publishing,
|
||||
PostStatus::Published,
|
||||
PostStatus::PartiallyPublished,
|
||||
];
|
||||
|
||||
public static function blocksEditing(Post $post): bool
|
||||
{
|
||||
return in_array($post->status, self::EDIT_BLOCKED_STATUSES, true);
|
||||
}
|
||||
|
||||
public static function blocksDeletion(Post $post): bool
|
||||
{
|
||||
return in_array($post->status, self::DELETE_BLOCKED_STATUSES, true);
|
||||
}
|
||||
|
||||
public static function editBlockedMessage(): string
|
||||
{
|
||||
return __(self::EDIT_BLOCKED_MESSAGE_KEY);
|
||||
}
|
||||
}
|
||||
|
|
@ -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' => [
|
||||
|
|
|
|||
|
|
@ -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' => [
|
||||
|
|
|
|||
|
|
@ -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' => [
|
||||
|
|
|
|||
|
|
@ -2,12 +2,7 @@
|
|||
import { IconCloudUpload, IconPhoto, IconVideo, IconX, IconLoader2, IconPlus } from '@tabler/icons-vue';
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
interface MediaItem {
|
||||
id: string;
|
||||
url: string;
|
||||
type: string;
|
||||
original_filename: string;
|
||||
}
|
||||
import type { MediaItem } from '@/types/media';
|
||||
|
||||
interface Props {
|
||||
media: MediaItem[];
|
||||
|
|
|
|||
87
resources/js/components/posts/ai/AiRegenerateImageDialog.vue
Normal file
87
resources/js/components/posts/ai/AiRegenerateImageDialog.vue
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
<script setup lang="ts">
|
||||
import { watch } from 'vue';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { useAiMediaRegeneration, type RegenerationPayload } from '@/composables/useAiMediaRegeneration';
|
||||
import type { MediaItem } from '@/types/media';
|
||||
|
||||
const props = defineProps<{
|
||||
postId: string;
|
||||
mediaItem: MediaItem | null;
|
||||
}>();
|
||||
|
||||
const open = defineModel<boolean>('open', { required: true });
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'regenerated', payload: RegenerationPayload): void;
|
||||
}>();
|
||||
|
||||
const { instruction, errorMessage, status, isBusy, isProcessing, canSubmit, submit, resetState, blockDismissWhileBusy } = useAiMediaRegeneration({
|
||||
postId: props.postId,
|
||||
getMediaItem: () => props.mediaItem,
|
||||
onRegenerated: (payload) => emit('regenerated', payload),
|
||||
onCompleted: () => {
|
||||
open.value = false;
|
||||
},
|
||||
});
|
||||
|
||||
watch(open, (isOpen) => {
|
||||
if (!isOpen) {
|
||||
if (isProcessing.value) {
|
||||
open.value = true;
|
||||
return;
|
||||
}
|
||||
resetState();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog v-model:open="open">
|
||||
<DialogContent
|
||||
class="sm:max-w-xl"
|
||||
:show-close-button="!isBusy"
|
||||
@pointer-down-outside="blockDismissWhileBusy"
|
||||
@escape-key-down="blockDismissWhileBusy"
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{{ $t('posts.ai.image_regenerate.title') }}</DialogTitle>
|
||||
<DialogDescription>{{ $t('posts.ai.image_regenerate.description') }}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="ai-image-instruction">{{ $t('posts.ai.image_regenerate.instruction_label') }}</Label>
|
||||
<Textarea
|
||||
id="ai-image-instruction"
|
||||
v-model="instruction"
|
||||
:disabled="isBusy"
|
||||
:placeholder="$t('posts.ai.image_regenerate.instruction_placeholder')"
|
||||
rows="4"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p v-if="status === 'processing'" class="text-sm text-foreground/70">
|
||||
{{ $t('posts.ai.image_regenerate.processing') }}
|
||||
</p>
|
||||
<p v-if="errorMessage" class="text-sm font-semibold text-rose-700">{{ errorMessage }}</p>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
:loading="isBusy"
|
||||
:disabled="!canSubmit"
|
||||
@click="submit"
|
||||
>
|
||||
{{ $t('posts.ai.image_regenerate.submit') }}
|
||||
</Button>
|
||||
<Button variant="outline" :disabled="isBusy" @click="open = false">
|
||||
{{ $t('posts.ai.image_regenerate.cancel') }}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
|
@ -3,8 +3,9 @@ import { IconAlertTriangle, IconChevronDown, IconChevronUp } from '@tabler/icons
|
|||
import { computed, ref } from 'vue';
|
||||
|
||||
import { Avatar } from '@/components/ui/avatar';
|
||||
import { getMediaValidationWarning, type MediaItem } from '@/composables/useMedia';
|
||||
import { getMediaValidationWarning } from '@/composables/useMedia';
|
||||
import { getPlatformLogo } from '@/composables/usePlatformLogo';
|
||||
import type { MediaItem } from '@/types/media';
|
||||
|
||||
interface SocialAccount {
|
||||
id: string;
|
||||
|
|
|
|||
|
|
@ -3,9 +3,10 @@ import { IconAlertTriangle, IconChevronDown, IconChevronUp } from '@tabler/icons
|
|||
import { computed, ref } from 'vue';
|
||||
|
||||
import { Avatar } from '@/components/ui/avatar';
|
||||
import { getMediaValidationWarning, type MediaItem } from '@/composables/useMedia';
|
||||
import { getMediaValidationWarning } from '@/composables/useMedia';
|
||||
import { getPlatformLogo } from '@/composables/usePlatformLogo';
|
||||
import { ContentType } from '@/enums/content-type';
|
||||
import type { MediaItem } from '@/types/media';
|
||||
|
||||
interface SocialAccount {
|
||||
id: string;
|
||||
|
|
|
|||
|
|
@ -3,9 +3,10 @@ import { IconAlertTriangle, IconChevronDown, IconChevronUp } from '@tabler/icons
|
|||
import { computed, ref } from 'vue';
|
||||
|
||||
import { Avatar } from '@/components/ui/avatar';
|
||||
import { getMediaValidationWarning, type MediaItem } from '@/composables/useMedia';
|
||||
import { getMediaValidationWarning } from '@/composables/useMedia';
|
||||
import { getPlatformLogo } from '@/composables/usePlatformLogo';
|
||||
import { ContentType } from '@/enums/content-type';
|
||||
import type { MediaItem } from '@/types/media';
|
||||
|
||||
interface SocialAccount {
|
||||
id: string;
|
||||
|
|
|
|||
|
|
@ -14,11 +14,12 @@ import {
|
|||
ComboboxList,
|
||||
ComboboxTrigger,
|
||||
} from '@/components/ui/combobox';
|
||||
import { getMediaValidationWarning, type MediaItem } from '@/composables/useMedia';
|
||||
import { getMediaValidationWarning } from '@/composables/useMedia';
|
||||
import { usePageErrors } from '@/composables/usePageErrors';
|
||||
import { getPlatformLogo } from '@/composables/usePlatformLogo';
|
||||
import { ContentType } from '@/enums/content-type';
|
||||
import type { PinterestBoard } from '@/types';
|
||||
import type { MediaItem } from '@/types/media';
|
||||
|
||||
interface SocialAccount {
|
||||
id: string;
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import {
|
|||
IconHash,
|
||||
IconLibraryPhoto,
|
||||
IconMoodSmile,
|
||||
IconRefresh,
|
||||
IconSparkles,
|
||||
IconTrash,
|
||||
IconVideo,
|
||||
|
|
@ -21,17 +22,7 @@ import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover'
|
|||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { formatBytes } from '@/composables/useMedia';
|
||||
import { getPlatformLabel, getPlatformLogo } from '@/composables/usePlatformLogo';
|
||||
|
||||
interface MediaItem {
|
||||
id: string;
|
||||
path: string;
|
||||
url: string;
|
||||
type?: string;
|
||||
mime_type?: string;
|
||||
original_filename?: string;
|
||||
size?: number;
|
||||
meta?: { width?: number; height?: number; duration?: number };
|
||||
}
|
||||
import type { MediaItem } from '@/types/media';
|
||||
|
||||
interface Signature {
|
||||
id: string;
|
||||
|
|
@ -49,11 +40,17 @@ interface MediaIssue {
|
|||
reason: string;
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
signatures: Signature[];
|
||||
platformLimits: PlatformLimit[];
|
||||
mediaIssues: Record<string, MediaIssue[]>;
|
||||
}>();
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
signatures: Signature[];
|
||||
platformLimits: PlatformLimit[];
|
||||
mediaIssues: Record<string, MediaIssue[]>;
|
||||
allowAiRegenerate?: boolean;
|
||||
}>(),
|
||||
{
|
||||
allowAiRegenerate: true,
|
||||
},
|
||||
);
|
||||
|
||||
const content = defineModel<string>('content', { required: true });
|
||||
const media = defineModel<MediaItem[]>('media', { required: true });
|
||||
|
|
@ -61,6 +58,7 @@ const media = defineModel<MediaItem[]>('media', { required: true });
|
|||
const emit = defineEmits<{
|
||||
(e: 'open-ai-generate'): void;
|
||||
(e: 'open-ai-review'): void;
|
||||
(e: 'open-ai-regenerate-image', mediaId: string): void;
|
||||
}>();
|
||||
|
||||
const emojiOpen = ref(false);
|
||||
|
|
@ -210,6 +208,7 @@ const onMediaKeydown = async (event: KeyboardEvent, index: number) => {
|
|||
};
|
||||
|
||||
const issueLabel = (reason: string): string => trans(`posts.form.warnings.${reason}`);
|
||||
const canRegenerateWithAi = (item: MediaItem): boolean => props.allowAiRegenerate && item.source === 'ai';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
@ -296,6 +295,16 @@ const issueLabel = (reason: string): string => trans(`posts.form.warnings.${reas
|
|||
<IconGripVertical class="size-3.5" />
|
||||
</span>
|
||||
|
||||
<button
|
||||
v-if="canRegenerateWithAi(item)"
|
||||
type="button"
|
||||
class="absolute bottom-1.5 left-1.5 inline-flex h-6 cursor-pointer items-center gap-1 rounded-md border-2 border-foreground bg-card px-1.5 text-[10px] font-semibold text-foreground opacity-0 shadow-2xs transition-all hover:bg-violet-100 group-hover:opacity-100 group-focus:opacity-100"
|
||||
@click.stop="emit('open-ai-regenerate-image', item.id)"
|
||||
>
|
||||
<IconRefresh class="size-3" />
|
||||
{{ $t('posts.ai.image_regenerate.button') }}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="absolute right-1.5 top-1.5 inline-flex size-6 cursor-pointer items-center justify-center rounded-md border-2 border-foreground bg-card text-foreground opacity-0 shadow-2xs transition-all hover:bg-rose-100 hover:text-rose-700 group-hover:opacity-100 group-focus:opacity-100"
|
||||
|
|
|
|||
|
|
@ -6,17 +6,7 @@ import PreviewTab from '@/components/posts/editor/PreviewTab.vue';
|
|||
import ScheduleTab from '@/components/posts/editor/ScheduleTab.vue';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import type { PinterestBoard } from '@/types';
|
||||
|
||||
interface MediaItem {
|
||||
id: string;
|
||||
path: string;
|
||||
url: string;
|
||||
type?: string;
|
||||
mime_type?: string;
|
||||
original_filename?: string;
|
||||
size?: number;
|
||||
meta?: { width?: number; height?: number; duration?: number };
|
||||
}
|
||||
import type { MediaItem } from '@/types/media';
|
||||
|
||||
interface SocialAccount {
|
||||
id: string;
|
||||
|
|
|
|||
|
|
@ -6,8 +6,8 @@ import PhoneMockup from '@/components/PhoneMockup.vue';
|
|||
import { PlatformPreview } from '@/components/posts/previews';
|
||||
import { Avatar } from '@/components/ui/avatar';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import type { MediaItem } from '@/composables/useMedia';
|
||||
import { getPlatformLabel, getPlatformLogo } from '@/composables/usePlatformLogo';
|
||||
import type { MediaItem } from '@/types/media';
|
||||
|
||||
interface SocialAccount {
|
||||
id: string;
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import { usePageErrors } from '@/composables/usePageErrors';
|
|||
import { getPlatformLabel, getPlatformLogo } from '@/composables/usePlatformLogo';
|
||||
import { Platform } from '@/enums/platform';
|
||||
import type { PinterestBoard } from '@/types';
|
||||
import type { MediaItem } from '@/types/media';
|
||||
import { PostPlatformStatus } from '@/types/post';
|
||||
|
||||
interface SocialAccount {
|
||||
|
|
@ -70,14 +71,6 @@ interface TikTokCreatorInfo {
|
|||
max_video_post_duration_sec: number | null;
|
||||
}
|
||||
|
||||
interface MediaItem {
|
||||
id: string;
|
||||
url: string;
|
||||
type?: string;
|
||||
mime_type?: string;
|
||||
meta?: Record<string, any>;
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
postPlatforms: PostPlatform[];
|
||||
selectedPlatformIds: string[];
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
<script setup lang="ts">
|
||||
import VideoPreview from "@/components/posts/previews/VideoPreview.vue";
|
||||
import { isVideoMedia, type MediaItem } from '@/composables/useMedia';
|
||||
import { isVideoMedia } from '@/composables/useMedia';
|
||||
import type { MediaItem } from '@/types/media';
|
||||
|
||||
interface SocialAccount {
|
||||
id: string;
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { IconDots, IconPhoto } from '@tabler/icons-vue';
|
|||
import { computed } from 'vue';
|
||||
|
||||
import PostMediaPreview from '@/components/posts/previews/PostMediaPreview.vue';
|
||||
import type { MediaItem } from '@/composables/useMedia';
|
||||
import type { MediaItem } from '@/types/media';
|
||||
|
||||
interface SocialAccount {
|
||||
id: string;
|
||||
|
|
|
|||
|
|
@ -14,8 +14,8 @@ import {
|
|||
import { computed } from 'vue';
|
||||
|
||||
import PostMediaPreview from '@/components/posts/previews/PostMediaPreview.vue';
|
||||
import type { MediaItem } from '@/composables/useMedia';
|
||||
import { ContentType } from '@/enums/content-type';
|
||||
import type { MediaItem } from '@/types/media';
|
||||
|
||||
interface SocialAccount {
|
||||
id: string;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
<script setup lang="ts">
|
||||
import VideoPreview from "@/components/posts/previews/VideoPreview.vue";
|
||||
import { isVideoMedia, type MediaItem } from '@/composables/useMedia';
|
||||
import { isVideoMedia } from '@/composables/useMedia';
|
||||
import type { MediaItem } from '@/types/media';
|
||||
|
||||
interface SocialAccount {
|
||||
id: string;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
<script setup lang="ts">
|
||||
import VideoPreview from "@/components/posts/previews/VideoPreview.vue";
|
||||
import { isVideoMedia, type MediaItem } from '@/composables/useMedia';
|
||||
import { isVideoMedia } from '@/composables/useMedia';
|
||||
import type { MediaItem } from '@/types/media';
|
||||
|
||||
interface SocialAccount {
|
||||
id: string;
|
||||
|
|
|
|||
|
|
@ -3,7 +3,8 @@ import { IconPhoto, IconStack2 } from '@tabler/icons-vue';
|
|||
import { computed } from 'vue';
|
||||
|
||||
import VideoPreview from "@/components/posts/previews/VideoPreview.vue";
|
||||
import { isVideoMedia, type MediaItem } from '@/composables/useMedia';
|
||||
import { isVideoMedia } from '@/composables/useMedia';
|
||||
import type { MediaItem } from '@/types/media';
|
||||
|
||||
interface SocialAccount {
|
||||
id: string;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
|
||||
import type { MediaItem } from '@/types/media';
|
||||
|
||||
import BlueskyPreview from './BlueskyPreview.vue';
|
||||
import FacebookPreview from './FacebookPreview.vue';
|
||||
import InstagramPreview from './InstagramPreview.vue';
|
||||
|
|
@ -20,14 +22,6 @@ interface SocialAccount {
|
|||
avatar_url: string | null;
|
||||
}
|
||||
|
||||
interface MediaItem {
|
||||
id: string;
|
||||
url: string;
|
||||
type?: string;
|
||||
mime_type?: string;
|
||||
original_filename?: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
platform: string;
|
||||
socialAccount: SocialAccount | null | undefined;
|
||||
|
|
|
|||
|
|
@ -3,7 +3,8 @@ import { IconChevronLeft, IconChevronRight, IconPhoto } from '@tabler/icons-vue'
|
|||
import { computed, ref, watch, type Component } from 'vue';
|
||||
|
||||
import VideoPreview from '@/components/posts/previews/VideoPreview.vue';
|
||||
import { isVideoMedia, type MediaItem } from '@/composables/useMedia';
|
||||
import { isVideoMedia } from '@/composables/useMedia';
|
||||
import type { MediaItem } from '@/types/media';
|
||||
|
||||
interface Props {
|
||||
media: MediaItem[];
|
||||
|
|
|
|||
|
|
@ -2,7 +2,8 @@
|
|||
import { computed } from 'vue';
|
||||
|
||||
import VideoPreview from "@/components/posts/previews/VideoPreview.vue";
|
||||
import { isVideoMedia, type MediaItem } from '@/composables/useMedia';
|
||||
import { isVideoMedia } from '@/composables/useMedia';
|
||||
import type { MediaItem } from '@/types/media';
|
||||
|
||||
interface SocialAccount {
|
||||
id: string;
|
||||
|
|
|
|||
|
|
@ -3,7 +3,8 @@ import { IconPlus } from '@tabler/icons-vue';
|
|||
import { computed } from 'vue';
|
||||
|
||||
import VideoPreview from "@/components/posts/previews/VideoPreview.vue";
|
||||
import { isVideoMedia, type MediaItem } from '@/composables/useMedia';
|
||||
import { isVideoMedia } from '@/composables/useMedia';
|
||||
import type { MediaItem } from '@/types/media';
|
||||
|
||||
interface SocialAccount {
|
||||
id: string;
|
||||
|
|
|
|||
|
|
@ -2,7 +2,8 @@
|
|||
import { computed } from 'vue';
|
||||
|
||||
import VideoPreview from "@/components/posts/previews/VideoPreview.vue";
|
||||
import { isVideoMedia, type MediaItem } from '@/composables/useMedia';
|
||||
import { isVideoMedia } from '@/composables/useMedia';
|
||||
import type { MediaItem } from '@/types/media';
|
||||
|
||||
interface SocialAccount {
|
||||
id: string;
|
||||
|
|
|
|||
|
|
@ -2,7 +2,8 @@
|
|||
import { computed } from 'vue';
|
||||
|
||||
import VideoPreview from "@/components/posts/previews/VideoPreview.vue";
|
||||
import { isVideoMedia, type MediaItem } from '@/composables/useMedia';
|
||||
import { isVideoMedia } from '@/composables/useMedia';
|
||||
import type { MediaItem } from '@/types/media';
|
||||
|
||||
interface SocialAccount {
|
||||
id: string;
|
||||
|
|
|
|||
172
resources/js/composables/useAiMediaRegeneration.ts
Normal file
172
resources/js/composables/useAiMediaRegeneration.ts
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
import { useHttp } from '@inertiajs/vue3';
|
||||
import { echo } from '@laravel/echo-vue';
|
||||
import { trans } from 'laravel-vue-i18n';
|
||||
import { computed, onUnmounted, ref } from 'vue';
|
||||
import { toast } from 'vue-sonner';
|
||||
|
||||
import { regenerateMedia as regeneratePostAiMedia } from '@/routes/app/posts/ai';
|
||||
import type { MediaItem } from '@/types/media';
|
||||
|
||||
export interface RegenerationPayload {
|
||||
media: MediaItem;
|
||||
targetMediaId: string;
|
||||
}
|
||||
|
||||
type RegenerationStatus = 'idle' | 'starting' | 'processing';
|
||||
|
||||
interface RegenerationStartResponse {
|
||||
channel?: string;
|
||||
}
|
||||
|
||||
interface RegenerationEvent {
|
||||
media: MediaItem | null;
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
interface UseAiMediaRegenerationOptions {
|
||||
postId: string;
|
||||
getMediaItem: () => MediaItem | null;
|
||||
onRegenerated: (payload: RegenerationPayload) => void;
|
||||
onCompleted?: () => void;
|
||||
}
|
||||
|
||||
const REGENERATION_TIMEOUT_MS = 180_000;
|
||||
|
||||
export const useAiMediaRegeneration = (options: UseAiMediaRegenerationOptions) => {
|
||||
const instruction = ref('');
|
||||
const errorMessage = ref<string | null>(null);
|
||||
const status = ref<RegenerationStatus>('idle');
|
||||
|
||||
const httpRegenerate = useHttp<{ instruction: string }>({
|
||||
instruction: '',
|
||||
});
|
||||
|
||||
let subscribedChannel: string | null = null;
|
||||
let regenerationTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const isBusy = computed(() => status.value !== 'idle');
|
||||
const isProcessing = computed(() => status.value === 'processing');
|
||||
const normalizedInstruction = computed(() => instruction.value.trim());
|
||||
const canSubmit = computed(() => normalizedInstruction.value.length > 0 && !isBusy.value);
|
||||
|
||||
const unsubscribe = () => {
|
||||
if (subscribedChannel) {
|
||||
echo().leave(`private-${subscribedChannel}`);
|
||||
subscribedChannel = null;
|
||||
}
|
||||
};
|
||||
|
||||
const clearRegenerationTimeout = () => {
|
||||
if (regenerationTimeout !== null) {
|
||||
clearTimeout(regenerationTimeout);
|
||||
regenerationTimeout = null;
|
||||
}
|
||||
};
|
||||
|
||||
const setIdleWithError = (message: string) => {
|
||||
errorMessage.value = message;
|
||||
status.value = 'idle';
|
||||
};
|
||||
|
||||
const resetState = () => {
|
||||
instruction.value = '';
|
||||
errorMessage.value = null;
|
||||
status.value = 'idle';
|
||||
clearRegenerationTimeout();
|
||||
unsubscribe();
|
||||
};
|
||||
|
||||
const blockDismissWhileBusy = (event: Event) => {
|
||||
if (isBusy.value) {
|
||||
event.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
const handleRegenerationResult = (event: RegenerationEvent) => {
|
||||
clearRegenerationTimeout();
|
||||
|
||||
const mediaItem = options.getMediaItem();
|
||||
if (event.error || !event.media || !mediaItem) {
|
||||
setIdleWithError(event.error ?? trans('posts.ai.image_regenerate.errors.unavailable'));
|
||||
unsubscribe();
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success(trans('posts.ai.image_regenerate.success'));
|
||||
|
||||
options.onRegenerated({
|
||||
media: event.media,
|
||||
targetMediaId: mediaItem.id,
|
||||
});
|
||||
|
||||
resetState();
|
||||
options.onCompleted?.();
|
||||
};
|
||||
|
||||
const subscribe = (channel: string) => {
|
||||
subscribedChannel = channel;
|
||||
status.value = 'processing';
|
||||
|
||||
clearRegenerationTimeout();
|
||||
regenerationTimeout = setTimeout(() => {
|
||||
setIdleWithError(trans('posts.ai.image_regenerate.errors.timeout'));
|
||||
unsubscribe();
|
||||
}, REGENERATION_TIMEOUT_MS);
|
||||
|
||||
echo()
|
||||
.private(channel)
|
||||
.listen('.ai.media.regenerated', (event: RegenerationEvent) => handleRegenerationResult(event));
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
const mediaItem = options.getMediaItem();
|
||||
const instructionValue = normalizedInstruction.value;
|
||||
|
||||
if (!mediaItem) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!instructionValue) {
|
||||
setIdleWithError(trans('posts.ai.image_regenerate.errors.required'));
|
||||
return;
|
||||
}
|
||||
|
||||
errorMessage.value = null;
|
||||
status.value = 'starting';
|
||||
httpRegenerate.instruction = instructionValue;
|
||||
|
||||
try {
|
||||
const response = (await httpRegenerate.post(
|
||||
regeneratePostAiMedia.url({ post: options.postId, mediaId: mediaItem.id }),
|
||||
)) as RegenerationStartResponse;
|
||||
const channel = String(response.channel ?? '');
|
||||
|
||||
if (!channel) {
|
||||
throw new Error('Missing channel in regeneration response.');
|
||||
}
|
||||
|
||||
subscribe(channel);
|
||||
} catch (error: unknown) {
|
||||
const responseMessage = (error as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
||||
setIdleWithError(responseMessage ?? trans('posts.ai.image_regenerate.errors.start_failed'));
|
||||
}
|
||||
};
|
||||
|
||||
onUnmounted(() => {
|
||||
clearRegenerationTimeout();
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
return {
|
||||
instruction,
|
||||
errorMessage,
|
||||
status,
|
||||
isBusy,
|
||||
isProcessing,
|
||||
canSubmit,
|
||||
submit,
|
||||
resetState,
|
||||
blockDismissWhileBusy,
|
||||
};
|
||||
};
|
||||
|
||||
|
|
@ -1,18 +1,7 @@
|
|||
import { getMediaRulesForContentType } from '@/composables/useMediaRules';
|
||||
import type { MediaItem } from '@/types/media';
|
||||
|
||||
export interface MediaItem {
|
||||
id: string;
|
||||
url: string;
|
||||
type?: string;
|
||||
mime_type?: string;
|
||||
original_filename?: string;
|
||||
size?: number;
|
||||
meta?: {
|
||||
width?: number;
|
||||
height?: number;
|
||||
duration?: number;
|
||||
};
|
||||
}
|
||||
export type { MediaItem } from '@/types/media';
|
||||
|
||||
export interface MediaValidationWarning {
|
||||
key: string; // short key, e.g. 'gif_not_allowed'
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
import { trans } from 'laravel-vue-i18n';
|
||||
import { computed, type ComputedRef, type Ref } from 'vue';
|
||||
|
||||
import { getMediaItemIssue, type MediaItem } from '@/composables/useMedia';
|
||||
import { getMediaItemIssue } from '@/composables/useMedia';
|
||||
import { getMediaRulesForContentType } from '@/composables/useMediaRules';
|
||||
import { getPlatformLabel } from '@/composables/usePlatformLogo';
|
||||
import { ContentType } from '@/enums/content-type';
|
||||
import { Platform } from '@/enums/platform';
|
||||
import type { MediaItem } from '@/types/media';
|
||||
|
||||
export interface CompliancePostPlatform {
|
||||
id: string;
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { computed, onUnmounted, ref, watch } from 'vue';
|
|||
|
||||
import ConfirmDeleteModal from '@/components/ConfirmDeleteModal.vue';
|
||||
import AiGenerateDialog from '@/components/posts/ai/AiGenerateDialog.vue';
|
||||
import AiRegenerateImageDialog from '@/components/posts/ai/AiRegenerateImageDialog.vue';
|
||||
import AiReviewDialog from '@/components/posts/ai/AiReviewDialog.vue';
|
||||
import PostEditorComposer from '@/components/posts/editor/PostEditorComposer.vue';
|
||||
import PostEditorHeader from '@/components/posts/editor/PostEditorHeader.vue';
|
||||
|
|
@ -22,19 +23,9 @@ import debounce from '@/debounce';
|
|||
import AppLayout from '@/layouts/AppLayout.vue';
|
||||
import { destroy as destroyPost, update as updatePost } from '@/routes/app/posts';
|
||||
import type { PinterestBoard } from '@/types';
|
||||
import type { MediaItem } from '@/types/media';
|
||||
import { PostStatus } from '@/types/post';
|
||||
|
||||
interface MediaItem {
|
||||
id: string;
|
||||
path: string;
|
||||
url: string;
|
||||
type?: string;
|
||||
mime_type?: string;
|
||||
original_filename?: string;
|
||||
size?: number;
|
||||
meta?: { width?: number; height?: number; duration?: number };
|
||||
}
|
||||
|
||||
interface SocialAccount {
|
||||
id: string;
|
||||
platform: string;
|
||||
|
|
@ -176,6 +167,8 @@ const isSaving = ref(false);
|
|||
const showSaved = ref(false);
|
||||
const isAiGenerateOpen = ref(false);
|
||||
const isAiReviewOpen = ref(false);
|
||||
const isAiRegenerateImageOpen = ref(false);
|
||||
const selectedAiMediaId = ref<string | null>(null);
|
||||
|
||||
const onAiGenerateApply = (newContent: string) => {
|
||||
content.value = newContent;
|
||||
|
|
@ -185,6 +178,23 @@ const onAiReviewApply = (original: string, suggestion: string) => {
|
|||
content.value = content.value.replace(original, suggestion);
|
||||
};
|
||||
|
||||
const onOpenAiRegenerateImage = (mediaId: string) => {
|
||||
selectedAiMediaId.value = mediaId;
|
||||
isAiRegenerateImageOpen.value = true;
|
||||
};
|
||||
|
||||
const selectedAiMediaItem = computed(() => (
|
||||
selectedAiMediaId.value
|
||||
? (media.value.find((item) => item.id === selectedAiMediaId.value) ?? null)
|
||||
: null
|
||||
));
|
||||
|
||||
const onAiMediaRegenerated = (payload: { media: MediaItem; targetMediaId: string }) => {
|
||||
media.value = media.value.map((item) => (
|
||||
item.id === payload.targetMediaId ? payload.media : item
|
||||
));
|
||||
};
|
||||
|
||||
const isPostActionDisabled = computed(
|
||||
() => isSubmitting.value || selectedPlatformIds.value.length === 0 || !canSchedule.value,
|
||||
);
|
||||
|
|
@ -384,8 +394,10 @@ usePostEcho(post.value.id, '.post.comment.created', (e: any) => {
|
|||
:signatures="signatures"
|
||||
:platform-limits="platformLimits"
|
||||
:media-issues="mediaIssues"
|
||||
:allow-ai-regenerate="!isLocked"
|
||||
@open-ai-generate="isAiGenerateOpen = true"
|
||||
@open-ai-review="isAiReviewOpen = true"
|
||||
@open-ai-regenerate-image="onOpenAiRegenerateImage"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -441,4 +453,11 @@ usePostEcho(post.value.id, '.post.comment.created', (e: any) => {
|
|||
:content="content"
|
||||
@apply="onAiReviewApply"
|
||||
/>
|
||||
|
||||
<AiRegenerateImageDialog
|
||||
v-model:open="isAiRegenerateImageOpen"
|
||||
:post-id="post.id"
|
||||
:media-item="selectedAiMediaItem"
|
||||
@regenerated="onAiMediaRegenerated"
|
||||
/>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -18,16 +18,9 @@ import { getPlatformStatusConfig, getPostStatusConfig } from '@/composables/useP
|
|||
import dayjs from '@/dayjs';
|
||||
import AppLayout from '@/layouts/AppLayout.vue';
|
||||
import { index as postsIndex } from '@/routes/app/posts';
|
||||
import type { MediaItem } from '@/types/media';
|
||||
import { PostPlatformStatus, PostStatus } from '@/types/post';
|
||||
|
||||
interface MediaItem {
|
||||
id: string;
|
||||
url: string;
|
||||
type?: string;
|
||||
mime_type?: string;
|
||||
original_filename?: string;
|
||||
}
|
||||
|
||||
interface SocialAccount {
|
||||
id: string;
|
||||
platform: string;
|
||||
|
|
|
|||
18
resources/js/types/media.ts
Normal file
18
resources/js/types/media.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
export type MediaSource = 'ai' | 'unsplash' | 'giphy';
|
||||
|
||||
export interface MediaItem {
|
||||
id: string;
|
||||
url: string;
|
||||
path?: string;
|
||||
type?: string;
|
||||
mime_type?: string;
|
||||
original_filename?: string;
|
||||
size?: number;
|
||||
source?: MediaSource;
|
||||
source_meta?: Record<string, unknown>;
|
||||
meta?: {
|
||||
width?: number;
|
||||
height?: number;
|
||||
duration?: number;
|
||||
};
|
||||
}
|
||||
|
|
@ -1,27 +1,27 @@
|
|||
@switch($style)
|
||||
@case('cinematic')
|
||||
Cinematic photograph of {{ $scene }}, soft natural lighting, shallow depth of field, color graded with cinematic tones, professional cinematography, magazine editorial quality, 35mm film aesthetic.
|
||||
Cinematic photograph of {{ $scene }}, soft natural lighting, shallow depth of field, professional cinematography, magazine editorial quality, 35mm film aesthetic.
|
||||
@break
|
||||
@case('illustration')
|
||||
Modern flat vector illustration of {{ $scene }}, soft pastel color palette of muted tones, clean geometric shapes, no gradients, contemporary editorial illustration style, calm and uncluttered composition.
|
||||
Modern flat vector illustration of {{ $scene }}, clean geometric shapes, no gradients, contemporary editorial illustration style, calm and uncluttered composition.
|
||||
@break
|
||||
@case('isometric_3d')
|
||||
3D isometric illustration of {{ $scene }}, isometric viewing angle, vibrant but tasteful colors with soft ambient occlusion shadows, clean octane-style rendering, modern SaaS landing page aesthetic, polished and inviting.
|
||||
3D isometric illustration of {{ $scene }}, isometric viewing angle, soft ambient occlusion shadows, clean octane-style rendering, modern SaaS landing page aesthetic, polished and inviting.
|
||||
@break
|
||||
@case('cartoon')
|
||||
Friendly cartoon illustration of {{ $scene }}, hand-drawn line art style, bright cheerful palette of soft tones, slightly chunky line weight, Notion or Linear-style illustration, warm and approachable mood.
|
||||
Friendly cartoon illustration of {{ $scene }}, hand-drawn line art style, slightly chunky line weight, Notion or Linear-style illustration, warm and approachable mood.
|
||||
@break
|
||||
@case('typographic')
|
||||
Abstract typographic editorial composition inspired by {{ $scene }}, modern Swiss design aesthetic, large decorative letterform shapes used purely as visual elements, gallery-quality print poster, deep contrasting background. The letterforms must be ABSTRACT and DECORATIVE — do NOT spell out any actual word, name, headline, caption, or readable phrase. Do NOT include any sentence, label, watermark, or numerical text.
|
||||
Abstract typographic editorial composition inspired by {{ $scene }}, modern Swiss design aesthetic, large decorative letterform shapes used purely as visual elements, gallery-quality print poster. The letterforms must be ABSTRACT and DECORATIVE — do NOT spell out any actual word, name, headline, caption, or readable phrase. Do NOT include any sentence, label, watermark, or numerical text.
|
||||
@break
|
||||
@case('infographic')
|
||||
Modern flat infographic design illustrating {{ $scene }}, clean dashboard tile style, soft pastel palette, sleek minimal data visualization aesthetic, no people, focus on shapes and graphics.
|
||||
Modern flat infographic design illustrating {{ $scene }}, clean dashboard tile style, sleek minimal data visualization aesthetic, no people, focus on shapes and graphics.
|
||||
@break
|
||||
@case('minimalist')
|
||||
Minimalist still-life photograph of {{ $scene }}, lots of empty negative space, soft diffused window light, monochromatic muted palette, calm zen aesthetic, fine art editorial photography style.
|
||||
Minimalist still-life photograph of {{ $scene }}, lots of empty negative space, soft diffused window light, calm zen aesthetic, fine art editorial photography style.
|
||||
@break
|
||||
@case('mockup')
|
||||
Clean product mockup photograph of {{ $scene }}, polished commercial product photography lighting, soft long shadow, minimal styling, editorial Apple-style aesthetic, neutral pastel background.
|
||||
Clean product mockup photograph of {{ $scene }}, polished commercial product photography lighting, soft long shadow, minimal styling, editorial Apple-style aesthetic.
|
||||
@break
|
||||
@endswitch
|
||||
|
||||
|
|
@ -30,7 +30,7 @@
|
|||
@endif
|
||||
|
||||
@if($style === 'infographic')
|
||||
Charts and bars are fine but do not include axis labels, numbers, percentages, or any written legend.
|
||||
Charts, bars, lines, and data highlights are encouraged; do not include axis labels, numbers, percentages, or any written legend.
|
||||
@endif
|
||||
|
||||
@if($style === 'mockup')
|
||||
|
|
@ -39,9 +39,19 @@
|
|||
|
||||
Any diegetic text that appears within the scene (text on screens, packaging, signage, speech bubbles, magazine covers, captions inside a comic frame, decorative letterforms) MUST be written in {{ $language_name }}.
|
||||
|
||||
@if($has_brand_palette)
|
||||
BRAND COLOR PALETTE (mandatory — applies to every style above; overrides generic stock palettes such as default blue dashboards):
|
||||
@isset($brand_color_name)
|
||||
Include a small accent of {{ $brand_color_name }} (the brand colour) on a single physical object or graphic element in the scene — a mug, plant pot, jacket, accessory, neon highlight, or UI bar. Keep this accent to roughly 5% of the image; do not flood the scene with this colour or use it as the dominant palette.
|
||||
- Brand / primary accent ({{ $brand_color_name }}): charts, bars, graph lines, highlights, CTAs, icons, key shapes, accents, and primary UI elements.
|
||||
@endisset
|
||||
@isset($background_color_name)
|
||||
- Background / surfaces ({{ $background_color_name }}): canvas, skies, walls, cards, tiles, negative space, and large neutral areas.
|
||||
@endisset
|
||||
@isset($text_color_name)
|
||||
- Text / in-scene typography ({{ $text_color_name }}): diegetic UI labels, signage lettering, and any readable text inside the scene.
|
||||
@endisset
|
||||
Harmonize the three colours with tasteful lighter and darker variations for depth. Keep the image polished and cohesive; do not introduce unrelated hues that clash with this palette.
|
||||
@endif
|
||||
|
||||
@isset($brand_context)
|
||||
Brand context (use only to inform tasteful detail choices in the scene, not to spell anything out): {{ $brand_context }}
|
||||
|
|
|
|||
16
resources/views/prompts/post_image/regenerator.blade.php
Normal file
16
resources/views/prompts/post_image/regenerator.blade.php
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
You are editing text that will be printed inside a social media image.
|
||||
|
||||
Your job:
|
||||
- Apply the user's instruction to the current title/body/keywords.
|
||||
- Keep the same language as the input unless the instruction explicitly asks to change it.
|
||||
- Keep output concise and suitable for image overlays.
|
||||
- Preserve intent and topic; only change what is needed.
|
||||
- Return one `change_mode` value:
|
||||
- `text_only`: user asked to change only wording/typos/capitalization/punctuation. Keep visual unchanged.
|
||||
- `image_only`: user asked to change visual only. Keep title/body exactly as provided (no spelling/casing normalization).
|
||||
- `both`: user asked to change both visual and text.
|
||||
- In `image_only`, do not rewrite title/body and do not "fix" grammar/casing automatically.
|
||||
|
||||
Return JSON only, following the schema.
|
||||
|
||||
Language preference: {{ $content_language ?? 'en' }}.
|
||||
|
|
@ -10,6 +10,7 @@
|
|||
use App\Http\Controllers\App\NotificationController;
|
||||
use App\Http\Controllers\App\PostAiCreateController;
|
||||
use App\Http\Controllers\App\PostAiGenerateController;
|
||||
use App\Http\Controllers\App\PostAiRegenerateMediaController;
|
||||
use App\Http\Controllers\App\PostAiReviewController;
|
||||
use App\Http\Controllers\App\PostCommentController;
|
||||
use App\Http\Controllers\App\PostController;
|
||||
|
|
@ -162,6 +163,7 @@
|
|||
|
||||
// Post AI
|
||||
Route::post('posts/{post}/ai/generate', [PostAiGenerateController::class, 'generate'])->name('app.posts.ai.generate');
|
||||
Route::post('posts/{post}/media/{mediaId}/ai/regenerate', [PostAiRegenerateMediaController::class, 'regenerate'])->name('app.posts.ai.regenerate-media');
|
||||
Route::post('posts/{post}/ai/review', [PostAiReviewController::class, 'review'])->name('app.posts.ai.review');
|
||||
Route::post('posts/ai/create', [PostAiCreateController::class, 'start'])->name('app.posts.ai.create');
|
||||
Route::get('posts/ai/{creationId}/loading', [PostAiCreateController::class, 'loading'])->name('app.posts.ai.loading')->whereUuid('creationId');
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
use App\Broadcasting\PostChannel;
|
||||
use App\Broadcasting\UserAiCreationChannel;
|
||||
use App\Broadcasting\UserAiGenerationChannel;
|
||||
use App\Broadcasting\UserAiMediaRegenerationChannel;
|
||||
use App\Broadcasting\WorkspaceChannel;
|
||||
use App\Broadcasting\WorkspaceUserChannel;
|
||||
use Illuminate\Support\Facades\Broadcast;
|
||||
|
|
@ -18,3 +19,5 @@
|
|||
Broadcast::channel('user.{owner}.ai-gen.{generationId}', UserAiGenerationChannel::class);
|
||||
|
||||
Broadcast::channel('user.{owner}.ai-creation.{creationId}', UserAiCreationChannel::class);
|
||||
|
||||
Broadcast::channel('user.{owner}.ai-media.{regenerationId}', UserAiMediaRegenerationChannel::class);
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
|
||||
use App\Actions\Ai\AutofillBrand;
|
||||
use App\Ai\Agents\BrandAnalyzer;
|
||||
use App\Services\Brand\BrandMetadata;
|
||||
use Illuminate\Http\Client\Request as HttpRequest;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
|
|
@ -425,6 +426,22 @@
|
|||
expect($result->voiceNotes)->toBeNull();
|
||||
});
|
||||
|
||||
test('toArray swaps site background and text colours for the image palette fields', function () {
|
||||
$metadata = new BrandMetadata(
|
||||
brandColor: '#eab308',
|
||||
backgroundColor: '#ffffff',
|
||||
textColor: '#1f2937',
|
||||
);
|
||||
|
||||
$array = $metadata->toArray();
|
||||
|
||||
expect($array['background_color'])->toBe('#1f2937')
|
||||
->and($array['text_color'])->toBe('#ffffff')
|
||||
->and($array['brand_color'])->toBe('#eab308')
|
||||
->and($metadata->backgroundColor)->toBe('#ffffff')
|
||||
->and($metadata->textColor)->toBe('#1f2937');
|
||||
});
|
||||
|
||||
test('BrandMetadata toArray exposes the shape the controller expects', function () {
|
||||
Http::fake([
|
||||
'example.com' => Http::response('<html lang="en"><head><title>Foo</title></head></html>', 200),
|
||||
|
|
|
|||
|
|
@ -22,14 +22,14 @@
|
|||
});
|
||||
|
||||
test('endpoint requires authentication', function () {
|
||||
$this->postJson("/posts/{$this->post->id}/ai/generate", ['prompt' => 'hi'])
|
||||
$this->postJson(route('app.posts.ai.generate', $this->post), ['prompt' => 'hi'])
|
||||
->assertStatus(Response::HTTP_UNAUTHORIZED);
|
||||
});
|
||||
|
||||
test('endpoint validates prompt is required', function () {
|
||||
Bus::fake();
|
||||
$this->actingAs($this->user)
|
||||
->postJson("/posts/{$this->post->id}/ai/generate", [])
|
||||
->postJson(route('app.posts.ai.generate', $this->post), [])
|
||||
->assertStatus(Response::HTTP_UNPROCESSABLE_ENTITY)
|
||||
->assertJsonValidationErrors(['prompt']);
|
||||
});
|
||||
|
|
@ -40,15 +40,15 @@
|
|||
$foreignPost = Post::factory()->create(['workspace_id' => $otherWorkspace->id]);
|
||||
|
||||
$this->actingAs($this->user)
|
||||
->postJson("/posts/{$foreignPost->id}/ai/generate", ['prompt' => 'hi'])
|
||||
->assertStatus(Response::HTTP_FORBIDDEN);
|
||||
->postJson(route('app.posts.ai.generate', $foreignPost), ['prompt' => 'hi'])
|
||||
->assertStatus(Response::HTTP_NOT_FOUND);
|
||||
});
|
||||
|
||||
test('endpoint dispatches StreamPostContent and returns generation id and channel', function () {
|
||||
Bus::fake();
|
||||
|
||||
$response = $this->actingAs($this->user)
|
||||
->postJson("/posts/{$this->post->id}/ai/generate", [
|
||||
->postJson(route('app.posts.ai.generate', $this->post), [
|
||||
'prompt' => 'Write a post about Mondays',
|
||||
'current_content' => 'Old content',
|
||||
])
|
||||
|
|
|
|||
190
tests/Feature/Ai/PostAiRegenerateMediaTest.php
Normal file
190
tests/Feature/Ai/PostAiRegenerateMediaTest.php
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Enums\Post\Status as PostStatus;
|
||||
use App\Enums\SocialAccount\Platform;
|
||||
use App\Enums\UserWorkspace\Role;
|
||||
use App\Jobs\Ai\RegeneratePostMediaImage;
|
||||
use App\Models\Post;
|
||||
use App\Models\PostPlatform;
|
||||
use App\Models\SocialAccount;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use Illuminate\Support\Facades\Bus;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
beforeEach(function () {
|
||||
config(['trypost.self_hosted' => true]);
|
||||
|
||||
$this->user = User::factory()->create();
|
||||
$this->workspace = Workspace::factory()->create([
|
||||
'user_id' => $this->user->id,
|
||||
'account_id' => $this->user->account_id,
|
||||
]);
|
||||
$this->workspace->members()->attach($this->user->id, ['role' => Role::Admin->value]);
|
||||
$this->user->update(['current_workspace_id' => $this->workspace->id]);
|
||||
|
||||
$this->socialAccount = SocialAccount::factory()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'platform' => Platform::Instagram,
|
||||
]);
|
||||
|
||||
$this->post = Post::factory()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'user_id' => $this->user->id,
|
||||
'media' => [[
|
||||
'id' => 'media-ai-1',
|
||||
'path' => 'ai-images/old.webp',
|
||||
'url' => 'https://example.com/old.webp',
|
||||
'type' => 'image',
|
||||
'mime_type' => 'image/webp',
|
||||
'source' => 'ai',
|
||||
'source_meta' => [
|
||||
'title' => 'Old image headline',
|
||||
'body' => 'Old image body',
|
||||
'keywords' => ['marketing', 'automation'],
|
||||
'width' => 1080,
|
||||
'height' => 1350,
|
||||
],
|
||||
]],
|
||||
]);
|
||||
|
||||
PostPlatform::factory()->instagram()->create([
|
||||
'post_id' => $this->post->id,
|
||||
'social_account_id' => $this->socialAccount->id,
|
||||
'enabled' => true,
|
||||
]);
|
||||
});
|
||||
|
||||
test('regenerate media dispatches async job and returns channel payload', function () {
|
||||
Bus::fake();
|
||||
|
||||
$response = $this->actingAs($this->user)
|
||||
->postJson(route('app.posts.ai.regenerate-media', [
|
||||
'post' => $this->post->id,
|
||||
'mediaId' => 'media-ai-1',
|
||||
]), [
|
||||
'instruction' => 'Replace ECP with ICP and keep the same visual style.',
|
||||
])
|
||||
->assertStatus(Response::HTTP_ACCEPTED);
|
||||
|
||||
$regenerationId = $response->json('regeneration_id');
|
||||
expect($regenerationId)->toBeString()->not->toBeEmpty();
|
||||
expect($response->json('channel'))->toBe("user.{$this->user->id}.ai-media.{$regenerationId}");
|
||||
|
||||
Bus::assertDispatched(RegeneratePostMediaImage::class, function (RegeneratePostMediaImage $job) use ($regenerationId) {
|
||||
return $job->workspaceId === $this->workspace->id
|
||||
&& $job->postId === $this->post->id
|
||||
&& $job->mediaId === 'media-ai-1'
|
||||
&& $job->instruction === 'Replace ECP with ICP and keep the same visual style.'
|
||||
&& $job->regenerationId === $regenerationId;
|
||||
});
|
||||
});
|
||||
|
||||
test('regenerate media rejects non ai media items', function () {
|
||||
Bus::fake();
|
||||
|
||||
$this->post->update([
|
||||
'media' => [[
|
||||
'id' => 'media-static-1',
|
||||
'path' => 'uploads/static.png',
|
||||
'url' => 'https://example.com/static.png',
|
||||
'type' => 'image',
|
||||
'mime_type' => 'image/png',
|
||||
'source' => 'unsplash',
|
||||
]],
|
||||
]);
|
||||
|
||||
$this->actingAs($this->user)
|
||||
->postJson(route('app.posts.ai.regenerate-media', [
|
||||
'post' => $this->post->id,
|
||||
'mediaId' => 'media-static-1',
|
||||
]), [
|
||||
'instruction' => 'Change the title text',
|
||||
])
|
||||
->assertStatus(Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||
|
||||
Bus::assertNotDispatched(RegeneratePostMediaImage::class);
|
||||
});
|
||||
|
||||
test('regenerate media returns not found when media id is missing from post', function () {
|
||||
Bus::fake();
|
||||
|
||||
$this->actingAs($this->user)
|
||||
->postJson(route('app.posts.ai.regenerate-media', [
|
||||
'post' => $this->post->id,
|
||||
'mediaId' => 'missing-media-id',
|
||||
]), [
|
||||
'instruction' => 'Fix typo',
|
||||
])
|
||||
->assertStatus(Response::HTTP_NOT_FOUND);
|
||||
|
||||
Bus::assertNotDispatched(RegeneratePostMediaImage::class);
|
||||
});
|
||||
|
||||
test('regenerate media rejects finalized posts', function () {
|
||||
Bus::fake();
|
||||
|
||||
$this->post->update(['status' => PostStatus::Published->value]);
|
||||
|
||||
$this->actingAs($this->user)
|
||||
->postJson(route('app.posts.ai.regenerate-media', [
|
||||
'post' => $this->post->id,
|
||||
'mediaId' => 'media-ai-1',
|
||||
]), [
|
||||
'instruction' => 'Fix typo',
|
||||
])
|
||||
->assertStatus(Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||
|
||||
Bus::assertNotDispatched(RegeneratePostMediaImage::class);
|
||||
});
|
||||
|
||||
test('regenerate media validates instruction is required', function () {
|
||||
Bus::fake();
|
||||
|
||||
$this->actingAs($this->user)
|
||||
->postJson(route('app.posts.ai.regenerate-media', [
|
||||
'post' => $this->post->id,
|
||||
'mediaId' => 'media-ai-1',
|
||||
]), [
|
||||
'instruction' => ' ',
|
||||
])
|
||||
->assertStatus(Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||
|
||||
Bus::assertNotDispatched(RegeneratePostMediaImage::class);
|
||||
});
|
||||
|
||||
test('regenerate media denies access when post is from another workspace', function () {
|
||||
Bus::fake();
|
||||
|
||||
$otherUser = User::factory()->create();
|
||||
$otherWorkspace = Workspace::factory()->create([
|
||||
'user_id' => $otherUser->id,
|
||||
'account_id' => $otherUser->account_id,
|
||||
]);
|
||||
|
||||
$otherPost = Post::factory()->create([
|
||||
'workspace_id' => $otherWorkspace->id,
|
||||
'user_id' => $otherUser->id,
|
||||
'media' => [[
|
||||
'id' => 'other-ai-media',
|
||||
'path' => 'ai-images/other.webp',
|
||||
'url' => 'https://example.com/other.webp',
|
||||
'type' => 'image',
|
||||
'mime_type' => 'image/webp',
|
||||
'source' => 'ai',
|
||||
]],
|
||||
]);
|
||||
|
||||
$this->actingAs($this->user)
|
||||
->postJson(route('app.posts.ai.regenerate-media', [
|
||||
'post' => $otherPost->id,
|
||||
'mediaId' => 'other-ai-media',
|
||||
]), [
|
||||
'instruction' => 'Fix typo',
|
||||
])
|
||||
->assertNotFound();
|
||||
|
||||
Bus::assertNotDispatched(RegeneratePostMediaImage::class);
|
||||
});
|
||||
|
|
@ -21,13 +21,13 @@
|
|||
});
|
||||
|
||||
test('endpoint requires authentication', function () {
|
||||
$this->postJson("/posts/{$this->post->id}/ai/review", ['content' => 'hi'])
|
||||
$this->postJson(route('app.posts.ai.review', $this->post), ['content' => 'hi'])
|
||||
->assertStatus(Response::HTTP_UNAUTHORIZED);
|
||||
});
|
||||
|
||||
test('endpoint validates content required', function () {
|
||||
$this->actingAs($this->user)
|
||||
->postJson("/posts/{$this->post->id}/ai/review", [])
|
||||
->postJson(route('app.posts.ai.review', $this->post), [])
|
||||
->assertStatus(Response::HTTP_UNPROCESSABLE_ENTITY)
|
||||
->assertJsonValidationErrors(['content']);
|
||||
});
|
||||
|
|
@ -37,8 +37,8 @@
|
|||
$foreignPost = Post::factory()->create(['workspace_id' => $otherWorkspace->id]);
|
||||
|
||||
$this->actingAs($this->user)
|
||||
->postJson("/posts/{$foreignPost->id}/ai/review", ['content' => 'hi'])
|
||||
->assertStatus(Response::HTTP_FORBIDDEN);
|
||||
->postJson(route('app.posts.ai.review', $foreignPost), ['content' => 'hi'])
|
||||
->assertStatus(Response::HTTP_NOT_FOUND);
|
||||
});
|
||||
|
||||
test('endpoint returns suggestions array', function () {
|
||||
|
|
@ -49,7 +49,7 @@
|
|||
]);
|
||||
|
||||
$response = $this->actingAs($this->user)
|
||||
->postJson("/posts/{$this->post->id}/ai/review", ['content' => 'i was here'])
|
||||
->postJson(route('app.posts.ai.review', $this->post), ['content' => 'i was here'])
|
||||
->assertOk();
|
||||
|
||||
expect($response->json('suggestions'))->toBeArray()->toHaveCount(1);
|
||||
|
|
|
|||
123
tests/Feature/Ai/RegeneratePostMediaImageJobTest.php
Normal file
123
tests/Feature/Ai/RegeneratePostMediaImageJobTest.php
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Jobs\Ai\RegeneratePostMediaImage;
|
||||
use App\Models\Post;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
|
||||
test('job fallback source context uses post content when source_meta is missing', function () {
|
||||
$user = User::factory()->create();
|
||||
$workspace = Workspace::factory()->create([
|
||||
'user_id' => $user->id,
|
||||
'account_id' => $user->account_id,
|
||||
]);
|
||||
|
||||
$post = Post::factory()->create([
|
||||
'workspace_id' => $workspace->id,
|
||||
'user_id' => $user->id,
|
||||
'content' => "Headline with typo ECP\nBody line for fallback context.",
|
||||
'media' => [],
|
||||
]);
|
||||
|
||||
$job = new RegeneratePostMediaImage(
|
||||
workspaceId: $workspace->id,
|
||||
postId: $post->id,
|
||||
userId: $user->id,
|
||||
mediaId: 'media-ai-1',
|
||||
regenerationId: '0196f5ca-bf2e-7d15-9a22-5709ab10d6c9',
|
||||
instruction: 'Fix typo from ECP to ICP.',
|
||||
);
|
||||
|
||||
$method = new ReflectionMethod(RegeneratePostMediaImage::class, 'buildSourceContext');
|
||||
$method->setAccessible(true);
|
||||
|
||||
$context = $method->invoke($job, [], $post, $workspace);
|
||||
|
||||
expect(data_get($context, 'title'))->toBe('Headline with typo ECP');
|
||||
expect((string) data_get($context, 'body'))->toContain('Body line for fallback context.');
|
||||
expect(data_get($context, 'keywords'))->toBeArray()->not->toBeEmpty();
|
||||
expect(data_get($context, 'width'))->toBe(1080);
|
||||
expect(data_get($context, 'height'))->toBe(1350);
|
||||
});
|
||||
|
||||
test('merge structured copy keeps text unchanged for image_only mode', function () {
|
||||
$job = new RegeneratePostMediaImage(
|
||||
workspaceId: 'workspace-id',
|
||||
postId: 'post-id',
|
||||
userId: 'user-id',
|
||||
mediaId: 'media-id',
|
||||
regenerationId: 'regen-id',
|
||||
instruction: 'change image only',
|
||||
);
|
||||
|
||||
$method = new ReflectionMethod(RegeneratePostMediaImage::class, 'mergeStructuredCopy');
|
||||
$method->setAccessible(true);
|
||||
|
||||
$baseContext = [
|
||||
'title' => 'Keep THIS Text',
|
||||
'body' => 'Body should remain exactly the same.',
|
||||
'keywords' => ['saas', 'dashboard'],
|
||||
'background_path' => 'ai-images/bg.webp',
|
||||
'language' => 'en',
|
||||
'width' => 1080,
|
||||
'height' => 1350,
|
||||
];
|
||||
|
||||
$structured = [
|
||||
'change_mode' => 'image_only',
|
||||
'title' => 'Model tried to rewrite',
|
||||
'body' => 'Model tried to rewrite body',
|
||||
'keywords' => ['modern office', 'team'],
|
||||
];
|
||||
|
||||
$copy = $method->invoke($job, $baseContext, $structured);
|
||||
|
||||
expect(data_get($copy, 'title'))->toBe($baseContext['title'])
|
||||
->and(data_get($copy, 'body'))->toBe($baseContext['body'])
|
||||
->and(data_get($copy, 'keywords'))->toBe(['modern office', 'team'])
|
||||
->and(data_get($copy, 'regenerate_image'))->toBeTrue()
|
||||
->and(data_get($copy, 'regenerate_text'))->toBeFalse()
|
||||
->and(data_get($copy, 'change_mode'))->toBe('image_only');
|
||||
});
|
||||
|
||||
test('merge structured copy keeps keywords unchanged for text_only mode', function () {
|
||||
$job = new RegeneratePostMediaImage(
|
||||
workspaceId: 'workspace-id',
|
||||
postId: 'post-id',
|
||||
userId: 'user-id',
|
||||
mediaId: 'media-id',
|
||||
regenerationId: 'regen-id',
|
||||
instruction: 'change text only',
|
||||
);
|
||||
|
||||
$method = new ReflectionMethod(RegeneratePostMediaImage::class, 'mergeStructuredCopy');
|
||||
$method->setAccessible(true);
|
||||
|
||||
$baseContext = [
|
||||
'title' => 'Old title',
|
||||
'body' => 'Old body',
|
||||
'keywords' => ['saas', 'dashboard'],
|
||||
'background_path' => 'ai-images/bg.webp',
|
||||
'language' => 'en',
|
||||
'width' => 1080,
|
||||
'height' => 1350,
|
||||
];
|
||||
|
||||
$structured = [
|
||||
'change_mode' => 'text_only',
|
||||
'title' => 'New title',
|
||||
'body' => 'New body',
|
||||
'keywords' => ['ignored', 'for', 'text-only'],
|
||||
];
|
||||
|
||||
$copy = $method->invoke($job, $baseContext, $structured);
|
||||
|
||||
expect(data_get($copy, 'title'))->toBe('New title')
|
||||
->and(data_get($copy, 'body'))->toBe('New body')
|
||||
->and(data_get($copy, 'keywords'))->toBe($baseContext['keywords'])
|
||||
->and(data_get($copy, 'regenerate_image'))->toBeFalse()
|
||||
->and(data_get($copy, 'regenerate_text'))->toBeTrue()
|
||||
->and(data_get($copy, 'change_mode'))->toBe('text_only');
|
||||
});
|
||||
|
|
@ -116,7 +116,7 @@
|
|||
$response = TryPostServer::actingAs($this->user)
|
||||
->tool(UpdatePostTool::class, ['post_id' => $post->id, 'content' => 'x']);
|
||||
|
||||
$response->assertHasErrors(['Cannot edit a post in a terminal state.']);
|
||||
$response->assertHasErrors([__('posts.cannot_edit_finalized')]);
|
||||
})->with([
|
||||
PostStatus::Published,
|
||||
PostStatus::PartiallyPublished,
|
||||
|
|
@ -277,7 +277,7 @@
|
|||
$response = TryPostServer::actingAs($this->user)
|
||||
->tool(PublishPostTool::class, ['post_id' => $post->id]);
|
||||
|
||||
$response->assertHasErrors(['Post is already published or in a terminal state.']);
|
||||
$response->assertHasErrors([__('posts.cannot_edit_finalized')]);
|
||||
})->with([
|
||||
PostStatus::Published,
|
||||
PostStatus::PartiallyPublished,
|
||||
|
|
|
|||
|
|
@ -492,3 +492,36 @@
|
|||
$response->assertSessionHasErrors('content');
|
||||
expect(session('errors')->get('content')[0])->toContain('Threads');
|
||||
});
|
||||
|
||||
test('draft save accepts media source metadata for ai regeneration', function () {
|
||||
$payload = [
|
||||
[
|
||||
'id' => 'media-ai-keep-meta',
|
||||
'path' => 'ai-images/generated.webp',
|
||||
'url' => 'https://example.com/ai-images/generated.webp',
|
||||
'type' => 'image',
|
||||
'mime_type' => 'image/webp',
|
||||
'source' => 'ai',
|
||||
'source_meta' => [
|
||||
'title' => 'Fix ECP typo',
|
||||
'body' => 'Body copy',
|
||||
'keywords' => ['marketing', 'automation'],
|
||||
'width' => 1080,
|
||||
'height' => 1350,
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$response = $this->actingAs($this->user)
|
||||
->put(route('app.posts.update', $this->post), [
|
||||
'status' => Status::Draft->value,
|
||||
'media' => $payload,
|
||||
'platforms' => [],
|
||||
]);
|
||||
|
||||
$response->assertSessionDoesntHaveErrors();
|
||||
|
||||
$this->post->refresh();
|
||||
expect(data_get($this->post->media, '0.source'))->toBe('ai');
|
||||
expect(data_get($this->post->media, '0.source_meta.title'))->toBe('Fix ECP typo');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -90,32 +90,43 @@
|
|||
Image::assertGenerated(fn (ImagePrompt $prompt) => $prompt->contains('English'));
|
||||
});
|
||||
|
||||
test('generate appends brand color accent when brandColor is provided', function () {
|
||||
test('generate appends brand palette when workspace colours are provided', function () {
|
||||
Image::fake();
|
||||
|
||||
$client = new AiImageClient;
|
||||
$client->generate(['x'], ImageStyle::Cinematic, brandColor: '#f47b20');
|
||||
$client->generate(
|
||||
['x'],
|
||||
ImageStyle::Infographic,
|
||||
brandColor: '#facc15',
|
||||
backgroundColor: '#ffffff',
|
||||
textColor: '#0f172a',
|
||||
);
|
||||
|
||||
Image::assertGenerated(fn (ImagePrompt $prompt) => $prompt->contains('warm orange')
|
||||
&& $prompt->contains('small accent'));
|
||||
Image::assertGenerated(fn (ImagePrompt $prompt) => $prompt->contains('BRAND COLOR PALETTE')
|
||||
&& $prompt->contains('golden yellow')
|
||||
&& $prompt->contains('charts, bars')
|
||||
&& $prompt->contains('off-white')
|
||||
&& $prompt->contains('in-scene typography'));
|
||||
});
|
||||
|
||||
test('generate omits brand color accent when brandColor is null', function () {
|
||||
test('generate omits brand palette when no workspace colours are set', function () {
|
||||
Image::fake();
|
||||
|
||||
$client = new AiImageClient;
|
||||
$client->generate(['x'], ImageStyle::Cinematic);
|
||||
|
||||
Image::assertGenerated(fn (ImagePrompt $prompt) => ! $prompt->contains('small accent'));
|
||||
Image::assertGenerated(fn (ImagePrompt $prompt) => ! $prompt->contains('BRAND COLOR PALETTE'));
|
||||
});
|
||||
|
||||
test('generate skips accent when brandColor hex is malformed', function () {
|
||||
test('generate includes only valid colours in the palette', function () {
|
||||
Image::fake();
|
||||
|
||||
$client = new AiImageClient;
|
||||
$client->generate(['x'], ImageStyle::Cinematic, brandColor: 'not-a-hex');
|
||||
$client->generate(['x'], ImageStyle::Cinematic, brandColor: 'not-a-hex', backgroundColor: '#ffffff');
|
||||
|
||||
Image::assertGenerated(fn (ImagePrompt $prompt) => ! $prompt->contains('small accent'));
|
||||
Image::assertGenerated(fn (ImagePrompt $prompt) => $prompt->contains('BRAND COLOR PALETTE')
|
||||
&& $prompt->contains('off-white')
|
||||
&& ! $prompt->contains('Brand / primary accent'));
|
||||
});
|
||||
|
||||
test('generate appends brand context when brandDescription is provided', function () {
|
||||
|
|
|
|||
|
|
@ -78,8 +78,37 @@
|
|||
->toHaveKey('keywords')
|
||||
->toHaveKey('style', 'illustration')
|
||||
->toHaveKey('model', 'gpt-image-2')
|
||||
->toHaveKey('title', 'Hello World');
|
||||
->toHaveKey('title', 'Hello World')
|
||||
->toHaveKey('brand_color', '#0000ff')
|
||||
->toHaveKey('background_color', '#ffffff')
|
||||
->toHaveKey('text_color', '#000000')
|
||||
->toHaveKey('background_path');
|
||||
}
|
||||
|
||||
Image::assertGenerated(fn ($prompt) => $prompt->contains('kitchen'));
|
||||
Image::assertGenerated(fn ($prompt) => $prompt->contains('kitchen')
|
||||
&& $prompt->contains('BRAND COLOR PALETTE')
|
||||
&& $prompt->contains('blue'));
|
||||
})->skip(fn () => ! extension_loaded('gd'), 'GD extension required');
|
||||
|
||||
test('reuses existing background path when provided', function () use ($minimalPng) {
|
||||
if (! file_exists(base_path('resources/fonts/Inter-Bold.ttf'))) {
|
||||
$this->markTestSkipped('Inter fonts not available — skipping render-dependent test.');
|
||||
}
|
||||
|
||||
$backgroundPath = 'ai-images/reuse-bg.webp';
|
||||
Storage::put($backgroundPath, $minimalPng());
|
||||
|
||||
$service = new TemplateImageGenerator(new BrandColorMapper, new AiImageClient);
|
||||
$result = $service->render(
|
||||
workspace: Workspace::factory()->make(),
|
||||
socialAccount: SocialAccount::factory()->make(['username' => 'testuser', 'display_name' => 'Test User']),
|
||||
title: 'Updated title',
|
||||
body: 'Updated body',
|
||||
imageKeywords: [],
|
||||
backgroundPath: $backgroundPath,
|
||||
);
|
||||
|
||||
expect($result)->not->toBeNull();
|
||||
expect(data_get($result, 'source_meta.background_path'))->toBe($backgroundPath);
|
||||
Image::assertNothingGenerated();
|
||||
})->skip(fn () => ! extension_loaded('gd'), 'GD extension required');
|
||||
|
|
|
|||
47
tests/Unit/Support/PostStatusRulesTest.php
Normal file
47
tests/Unit/Support/PostStatusRulesTest.php
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Enums\Post\Status as PostStatus;
|
||||
use App\Models\Post;
|
||||
use App\Support\PostStatusRules;
|
||||
|
||||
test('blocks editing for terminal statuses', function (PostStatus $status) {
|
||||
$post = Post::factory()->make(['status' => $status]);
|
||||
|
||||
expect(PostStatusRules::blocksEditing($post))->toBeTrue();
|
||||
})->with([
|
||||
PostStatus::Publishing,
|
||||
PostStatus::Published,
|
||||
PostStatus::PartiallyPublished,
|
||||
PostStatus::Failed,
|
||||
]);
|
||||
|
||||
test('allows editing for non terminal statuses', function (PostStatus $status) {
|
||||
$post = Post::factory()->make(['status' => $status]);
|
||||
|
||||
expect(PostStatusRules::blocksEditing($post))->toBeFalse();
|
||||
})->with([
|
||||
PostStatus::Draft,
|
||||
PostStatus::Scheduled,
|
||||
]);
|
||||
|
||||
test('blocks deletion for published statuses', function (PostStatus $status) {
|
||||
$post = Post::factory()->make(['status' => $status]);
|
||||
|
||||
expect(PostStatusRules::blocksDeletion($post))->toBeTrue();
|
||||
})->with([
|
||||
PostStatus::Publishing,
|
||||
PostStatus::Published,
|
||||
PostStatus::PartiallyPublished,
|
||||
]);
|
||||
|
||||
test('allows deletion for draft, scheduled and failed statuses', function (PostStatus $status) {
|
||||
$post = Post::factory()->make(['status' => $status]);
|
||||
|
||||
expect(PostStatusRules::blocksDeletion($post))->toBeFalse();
|
||||
})->with([
|
||||
PostStatus::Draft,
|
||||
PostStatus::Scheduled,
|
||||
PostStatus::Failed,
|
||||
]);
|
||||
Loading…
Reference in a new issue