feat: add image title and body fields to AI post creation and register PostTemplateSeeder in local environments
This commit is contained in:
parent
1e1519876d
commit
028fe1fefd
12 changed files with 149 additions and 66 deletions
|
|
@ -213,6 +213,18 @@ public function supportsImage(): bool
|
|||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this content type carries a text caption visible to viewers.
|
||||
* Stories are image-overlay only — viewers don't see a separate caption.
|
||||
*/
|
||||
public function supportsCaption(): bool
|
||||
{
|
||||
return match ($this) {
|
||||
self::InstagramStory, self::FacebookStory => false,
|
||||
default => true,
|
||||
};
|
||||
}
|
||||
|
||||
public function requiresMedia(): bool
|
||||
{
|
||||
return match ($this) {
|
||||
|
|
|
|||
|
|
@ -19,6 +19,8 @@ public function __construct(
|
|||
public string $creationId,
|
||||
public ?string $content,
|
||||
public ?string $error = null,
|
||||
public ?string $imageTitle = null,
|
||||
public ?string $imageBody = null,
|
||||
) {}
|
||||
|
||||
public function broadcastOn(): PrivateChannel
|
||||
|
|
@ -39,6 +41,8 @@ public function broadcastWith(): array
|
|||
return [
|
||||
'creation_id' => $this->creationId,
|
||||
'content' => $this->content,
|
||||
'image_title' => $this->imageTitle,
|
||||
'image_body' => $this->imageBody,
|
||||
'error' => $this->error,
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -69,29 +69,54 @@ public function finalize(Request $request, string $creationId): JsonResponse
|
|||
abort(Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
$media = $this->buildMediaArray($workspace, $state);
|
||||
|
||||
$post = CreatePost::execute($workspace, $request->user(), [
|
||||
'content' => data_get($state, 'content', ''),
|
||||
'media' => $media,
|
||||
]);
|
||||
|
||||
// Set the platform's aspect_ratio meta from the same enum that drives
|
||||
// image generation, so the preview matches the rendered image exactly.
|
||||
$format = data_get($state, 'format');
|
||||
$socialAccountId = data_get($state, 'social_account_id');
|
||||
$contentType = $format ? ContentType::tryFrom($format) : null;
|
||||
|
||||
// The wizard's preview is editable. Frontend sends whichever fields it
|
||||
// showed: caption for normal formats, image_title + image_body for
|
||||
// stories. We override the cached state with whatever was edited.
|
||||
if ($request->filled('content')) {
|
||||
$state['content'] = (string) $request->input('content');
|
||||
}
|
||||
if ($request->filled('image_title')) {
|
||||
$state['image_title'] = (string) $request->input('image_title');
|
||||
}
|
||||
if ($request->filled('image_body')) {
|
||||
$state['image_body'] = (string) $request->input('image_body');
|
||||
}
|
||||
|
||||
$media = $this->buildMediaArray($workspace, $state);
|
||||
|
||||
$caption = ($contentType && ! $contentType->supportsCaption())
|
||||
? ''
|
||||
: (string) data_get($state, 'content', '');
|
||||
|
||||
$post = CreatePost::execute($workspace, $request->user(), [
|
||||
'content' => $caption,
|
||||
'media' => $media,
|
||||
]);
|
||||
|
||||
// Sync the post_platform with the wizard choice: set content_type to
|
||||
// match the format (so the editor surfaces "Story" instead of "Post"
|
||||
// for instance) and aspect_ratio so the preview matches the rendered
|
||||
// image exactly. Carousel collapses to feed since Instagram doesn't
|
||||
// expose carousel as a separate content_type at the API level.
|
||||
if ($contentType && $socialAccountId) {
|
||||
$aspectRatio = $this->aspectRatioFor($contentType);
|
||||
$platformContentType = $contentType === ContentType::InstagramCarousel
|
||||
? ContentType::InstagramFeed
|
||||
: $contentType;
|
||||
|
||||
$post->postPlatforms()
|
||||
->where('social_account_id', $socialAccountId)
|
||||
->each(function ($platform) use ($aspectRatio): void {
|
||||
->each(function ($platform) use ($aspectRatio, $platformContentType): void {
|
||||
$meta = $platform->meta ?? [];
|
||||
if ($aspectRatio !== null) {
|
||||
$meta['aspect_ratio'] = $aspectRatio;
|
||||
}
|
||||
$platform->meta = $meta;
|
||||
$platform->content_type = $platformContentType->value;
|
||||
$platform->enabled = true;
|
||||
$platform->save();
|
||||
});
|
||||
|
|
@ -105,12 +130,6 @@ public function finalize(Request $request, string $creationId): JsonResponse
|
|||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the media array for post creation from the AI creation state.
|
||||
*
|
||||
* @param array<string, mixed> $state
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
/**
|
||||
* Map the AI image dimensions to the aspect_ratio string the editor's
|
||||
* preview understands. Returns null when the size doesn't match a known
|
||||
|
|
@ -129,6 +148,12 @@ private function aspectRatioFor(ContentType $type): ?string
|
|||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the media array for post creation from the AI creation state.
|
||||
*
|
||||
* @param array<string, mixed> $state
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function buildMediaArray(Workspace $workspace, array $state): array
|
||||
{
|
||||
$media = [];
|
||||
|
|
|
|||
|
|
@ -278,6 +278,15 @@ private function handleSingle(Workspace $workspace, ?SocialAccount $socialAccoun
|
|||
'created_at' => now()->toIso8601String(),
|
||||
], now()->addMinutes(30));
|
||||
|
||||
PostCreationReady::dispatch($this->userId, $this->creationId, $content);
|
||||
// Broadcast all three fields — the frontend renders caption (when the
|
||||
// format supports it) or title+body separately (for caption-less story
|
||||
// formats). No string parsing on the way back.
|
||||
PostCreationReady::dispatch(
|
||||
userId: $this->userId,
|
||||
creationId: $this->creationId,
|
||||
content: $content,
|
||||
imageTitle: $imageTitle,
|
||||
imageBody: $imageBody,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,5 +16,11 @@ public function run(): void
|
|||
$this->call([
|
||||
PlanSeeder::class,
|
||||
]);
|
||||
|
||||
if (app()->environment('local')) {
|
||||
$this->call([
|
||||
PostTemplateSeeder::class,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -471,6 +471,11 @@
|
|||
'template_description' => 'Pick from our curated templates and customize.',
|
||||
'coming_soon' => 'Coming soon',
|
||||
|
||||
'preview' => [
|
||||
'image_title' => 'Image title',
|
||||
'image_body' => 'Image body',
|
||||
],
|
||||
|
||||
'steps' => [
|
||||
'format_title' => 'Choose a format',
|
||||
'format_description' => 'Select the type of post you want to create.',
|
||||
|
|
|
|||
|
|
@ -482,6 +482,12 @@
|
|||
'ai_configure_description' => 'Elige un formato y describe el post que quieres crear.',
|
||||
'template_title' => 'Usar una plantilla',
|
||||
'template_description' => 'Elige una de nuestras plantillas y personalízala.',
|
||||
|
||||
'preview' => [
|
||||
'image_title' => 'Título de la imagen',
|
||||
'image_body' => 'Texto de la imagen',
|
||||
],
|
||||
|
||||
'coming_soon' => 'Próximamente',
|
||||
|
||||
'steps' => [
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -484,6 +484,11 @@
|
|||
'template_description' => 'Escolha um dos nossos templates e personalize.',
|
||||
'coming_soon' => 'Em breve',
|
||||
|
||||
'preview' => [
|
||||
'image_title' => 'Título da imagem',
|
||||
'image_body' => 'Texto da imagem',
|
||||
],
|
||||
|
||||
'steps' => [
|
||||
'format_title' => 'Escolha um formato',
|
||||
'format_description' => 'Selecione o tipo de post que deseja criar.',
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<script setup lang="ts">
|
||||
import { router } from '@inertiajs/vue3';
|
||||
import { router, useHttp } from '@inertiajs/vue3';
|
||||
import { echo } from '@laravel/echo-vue';
|
||||
import {
|
||||
IconArrowLeft,
|
||||
|
|
@ -12,6 +12,7 @@ import { computed, onUnmounted, ref, watch } from 'vue';
|
|||
|
||||
import { finalize as finalizeRoute, start as startRoute } from '@/actions/App/Http/Controllers/App/PostAiCreateController';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { getPlatformLogo } from '@/composables/usePlatformLogo';
|
||||
|
|
@ -54,11 +55,26 @@ const submitting = ref(false);
|
|||
const finalizing = ref(false);
|
||||
const previewStatus = ref<'loading' | 'done' | 'error'>('loading');
|
||||
const previewContent = ref('');
|
||||
const previewImageTitle = ref('');
|
||||
const previewImageBody = ref('');
|
||||
const previewError = ref('');
|
||||
const previewCreationId = ref<string | null>(null);
|
||||
let echoChannel: any = null;
|
||||
let subscribedChannelName: string | null = null;
|
||||
|
||||
const httpStart = useHttp<{
|
||||
format: string | null;
|
||||
social_account_id: string | null;
|
||||
image_count: number;
|
||||
prompt: string;
|
||||
}>({ format: null, social_account_id: null, image_count: 0, prompt: '' });
|
||||
|
||||
const httpFinalize = useHttp<{ content: string; image_title: string; image_body: string }>({
|
||||
content: '',
|
||||
image_title: '',
|
||||
image_body: '',
|
||||
});
|
||||
|
||||
const AI_FORMATS: Array<{ value: ContentTypeValue; platforms: string[] }> = [
|
||||
{ value: ContentType.InstagramFeed, platforms: ['instagram', 'instagram-facebook'] },
|
||||
{ value: ContentType.InstagramCarousel, platforms: ['instagram', 'instagram-facebook'] },
|
||||
|
|
@ -116,6 +132,11 @@ const supportsOptionalImages = computed(() =>
|
|||
const maxOptionalImages = computed(() =>
|
||||
selectedFormat.value === ContentType.InstagramFeed ? 1 : 4,
|
||||
);
|
||||
// Mirrors ContentType::supportsCaption() in PHP.
|
||||
const supportsCaption = computed(() =>
|
||||
selectedFormat.value !== ContentType.InstagramStory &&
|
||||
selectedFormat.value !== ContentType.FacebookStory,
|
||||
);
|
||||
const showsAccountPicker = computed(() => accountsForFormat.value.length > 1);
|
||||
|
||||
const submittedImageCount = computed(() => {
|
||||
|
|
@ -209,6 +230,8 @@ const subscribeToCreation = (userId: string, creationId: string) => {
|
|||
previewError.value = e.error;
|
||||
} else {
|
||||
previewContent.value = e.content ?? '';
|
||||
previewImageTitle.value = e.image_title ?? '';
|
||||
previewImageBody.value = e.image_body ?? '';
|
||||
previewStatus.value = 'done';
|
||||
}
|
||||
unsubscribeEcho();
|
||||
|
|
@ -221,41 +244,23 @@ const startGeneration = async () => {
|
|||
submitting.value = true;
|
||||
previewStatus.value = 'loading';
|
||||
previewContent.value = '';
|
||||
previewImageTitle.value = '';
|
||||
previewImageBody.value = '';
|
||||
previewError.value = '';
|
||||
goToStep('preview');
|
||||
|
||||
httpStart.format = selectedFormat.value;
|
||||
httpStart.social_account_id = selectedAccountId.value;
|
||||
httpStart.image_count = submittedImageCount.value;
|
||||
httpStart.prompt = promptText.value.trim();
|
||||
|
||||
try {
|
||||
const csrfToken = document.querySelector<HTMLMetaElement>('meta[name="csrf-token"]')?.content ?? '';
|
||||
const response = await fetch(startRoute.url(), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': csrfToken,
|
||||
Accept: 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
format: selectedFormat.value,
|
||||
social_account_id: selectedAccountId.value,
|
||||
image_count: submittedImageCount.value,
|
||||
prompt: promptText.value.trim(),
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const err = await response.json().catch(() => ({}));
|
||||
throw new Error(err?.message ?? `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const creationId: string = data.creation_id;
|
||||
const channel: string = data.channel;
|
||||
const parts = channel.split('.');
|
||||
const userId = parts[1] ?? '';
|
||||
|
||||
subscribeToCreation(userId, creationId);
|
||||
const data = await httpStart.post(startRoute.url()) as { creation_id: string; channel: string };
|
||||
const userId = data.channel.split('.')[1] ?? '';
|
||||
subscribeToCreation(userId, data.creation_id);
|
||||
} catch (err: any) {
|
||||
previewStatus.value = 'error';
|
||||
previewError.value = err?.message ?? trans('posts.create.steps.preview_error');
|
||||
previewError.value = err?.response?.data?.message ?? trans('posts.create.steps.preview_error');
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
|
|
@ -267,21 +272,12 @@ const createPost = async () => {
|
|||
if (!previewCreationId.value || finalizing.value) return;
|
||||
finalizing.value = true;
|
||||
|
||||
httpFinalize.content = previewContent.value;
|
||||
httpFinalize.image_title = previewImageTitle.value;
|
||||
httpFinalize.image_body = previewImageBody.value;
|
||||
|
||||
try {
|
||||
const csrfToken = document.querySelector<HTMLMetaElement>('meta[name="csrf-token"]')?.content ?? '';
|
||||
const response = await fetch(finalizeRoute.url(previewCreationId.value), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': csrfToken,
|
||||
Accept: 'application/json',
|
||||
},
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
|
||||
const data = await response.json();
|
||||
const data = await httpFinalize.post(finalizeRoute.url(previewCreationId.value)) as { redirect_url: string };
|
||||
router.visit(data.redirect_url);
|
||||
} catch {
|
||||
previewStatus.value = 'error';
|
||||
|
|
@ -441,10 +437,25 @@ onUnmounted(() => unsubscribeEcho());
|
|||
</div>
|
||||
|
||||
<div v-else-if="previewStatus === 'done'" class="space-y-4">
|
||||
<div class="rounded-xl border bg-muted/20 p-5">
|
||||
<p class="whitespace-pre-wrap text-sm leading-relaxed">{{ previewContent }}</p>
|
||||
<!-- Caption-less formats (Stories): edit title + body separately. -->
|
||||
<div v-if="!supportsCaption" class="space-y-3 rounded-xl border bg-muted/20 p-5">
|
||||
<div class="space-y-1">
|
||||
<Label class="text-xs font-medium text-muted-foreground">{{ $t('posts.create.preview.image_title') }}</Label>
|
||||
<Input v-model="previewImageTitle" class="bg-background" />
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<Label class="text-xs font-medium text-muted-foreground">{{ $t('posts.create.preview.image_body') }}</Label>
|
||||
<Textarea v-model="previewImageBody" class="min-h-[120px] resize-none bg-background" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Default: edit caption text. -->
|
||||
<Textarea
|
||||
v-else
|
||||
v-model="previewContent"
|
||||
class="min-h-[200px] resize-none rounded-xl border bg-muted/20 p-5 text-sm leading-relaxed"
|
||||
/>
|
||||
|
||||
<div class="flex justify-end gap-2">
|
||||
<Button variant="outline" size="sm" @click="retryGeneration">
|
||||
<IconRefresh class="mr-1 size-4" />
|
||||
|
|
|
|||
Loading…
Reference in a new issue