feat(ai-templates): template picker in the AI wizard
This commit is contained in:
parent
4656cb52c6
commit
799a151321
6 changed files with 137 additions and 9 deletions
|
|
@ -9,6 +9,7 @@
|
|||
use App\Actions\Post\DuplicatePost;
|
||||
use App\Actions\Post\SyncPostPlatforms;
|
||||
use App\Actions\Post\UpdatePost;
|
||||
use App\Ai\Templates\AiTemplateRegistry;
|
||||
use App\Enums\Post\Action as PostAction;
|
||||
use App\Enums\Post\Status as PostStatus;
|
||||
use App\Enums\SocialAccount\Platform;
|
||||
|
|
@ -142,11 +143,23 @@ public function create(Request $request): Response
|
|||
|
||||
$this->authorize('createPost', $workspace);
|
||||
|
||||
$registry = app(AiTemplateRegistry::class);
|
||||
|
||||
$templates = array_map(fn ($t) => [
|
||||
'key' => $t->key(),
|
||||
'name' => trans($t->name()),
|
||||
'description' => trans($t->description()),
|
||||
'preview' => $t->previewAsset(),
|
||||
'needs_account' => $t->needsAccount(),
|
||||
'supported_formats' => $t->supportedFormats(),
|
||||
], $registry->all());
|
||||
|
||||
return Inertia::render('posts/Create', [
|
||||
'date' => $request->query('date'),
|
||||
'socialAccounts' => SocialAccountResource::collection(
|
||||
$workspace->socialAccounts()->active()->get()
|
||||
),
|
||||
'templates' => $templates,
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -572,6 +572,7 @@
|
|||
'ai_title' => 'Generate with AI',
|
||||
'ai_description' => 'Describe what you want and AI generates the content for you.',
|
||||
'ai_configure_description' => 'Pick a format and describe the post you want to create.',
|
||||
'ai_pick_template_description' => 'Choose a style for your AI-generated post.',
|
||||
'template_title' => 'Use a template',
|
||||
'template_description' => 'Pick from our curated templates and customize.',
|
||||
'coming_soon' => 'Coming soon',
|
||||
|
|
@ -582,6 +583,7 @@
|
|||
],
|
||||
|
||||
'steps' => [
|
||||
'template_picker_title' => 'Choose a style',
|
||||
'format_title' => 'Choose a format',
|
||||
'format_description' => 'Select the type of post you want to create.',
|
||||
'account_title' => 'Choose an account',
|
||||
|
|
|
|||
|
|
@ -572,6 +572,7 @@
|
|||
'ai_title' => 'Generar con IA',
|
||||
'ai_description' => 'Describe lo que quieres y la IA genera el contenido por ti.',
|
||||
'ai_configure_description' => 'Elige un formato y describe el post que quieres crear.',
|
||||
'ai_pick_template_description' => 'Elige un estilo para tu post generado por IA.',
|
||||
'template_title' => 'Usar una plantilla',
|
||||
'template_description' => 'Elige una de nuestras plantillas y personalízala.',
|
||||
|
||||
|
|
@ -583,6 +584,7 @@
|
|||
'coming_soon' => 'Próximamente',
|
||||
|
||||
'steps' => [
|
||||
'template_picker_title' => 'Elige un estilo',
|
||||
'format_title' => 'Elige un formato',
|
||||
'format_description' => 'Selecciona el tipo de post que quieres crear.',
|
||||
'account_title' => 'Elige una cuenta',
|
||||
|
|
|
|||
|
|
@ -572,6 +572,7 @@
|
|||
'ai_title' => 'Gerar com IA',
|
||||
'ai_description' => 'Descreva o que quer e a IA gera o conteúdo pra você.',
|
||||
'ai_configure_description' => 'Escolha o formato e descreva o post que quer criar.',
|
||||
'ai_pick_template_description' => 'Escolha um estilo para o seu post gerado por IA.',
|
||||
'template_title' => 'Usar um template',
|
||||
'template_description' => 'Escolha um dos nossos templates e personalize.',
|
||||
'coming_soon' => 'Em breve',
|
||||
|
|
@ -582,6 +583,7 @@
|
|||
],
|
||||
|
||||
'steps' => [
|
||||
'template_picker_title' => 'Escolha um estilo',
|
||||
'format_title' => 'Escolha um formato',
|
||||
'format_description' => 'Selecione o tipo de post que deseja criar.',
|
||||
'account_title' => 'Escolha uma conta',
|
||||
|
|
|
|||
|
|
@ -14,8 +14,8 @@ import { Button } from '@/components/ui/button';
|
|||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { getPlatformLogo } from '@/composables/usePlatformLogo';
|
||||
import { ContentType, type ContentTypeValue } from '@/types/content-type';
|
||||
import { loading as loadingRoute } from '@/routes/app/posts/ai';
|
||||
import { ContentType, type ContentTypeValue } from '@/types/content-type';
|
||||
|
||||
interface SocialAccount {
|
||||
id: string;
|
||||
|
|
@ -25,8 +25,18 @@ interface SocialAccount {
|
|||
avatar_url: string | null;
|
||||
}
|
||||
|
||||
interface AiTemplate {
|
||||
key: string;
|
||||
name: string;
|
||||
description: string;
|
||||
preview: string;
|
||||
needs_account: boolean;
|
||||
supported_formats: string[];
|
||||
}
|
||||
|
||||
interface Props {
|
||||
socialAccounts: SocialAccount[];
|
||||
templates: AiTemplate[];
|
||||
/** ISO date (YYYY-MM-DD) carried over from the calendar's per-day "+" button. */
|
||||
date?: string | null;
|
||||
}
|
||||
|
|
@ -45,7 +55,12 @@ const emit = defineEmits<{
|
|||
const CAROUSEL_FORMAT = 'instagram_carousel' as const;
|
||||
type AiFormat = ContentTypeValue | typeof CAROUSEL_FORMAT;
|
||||
|
||||
// Wizard steps
|
||||
type WizardStep = 'template' | 'configure';
|
||||
const wizardStep = ref<WizardStep>('template');
|
||||
|
||||
// Selections
|
||||
const selectedTemplate = ref<string>(props.templates[0]?.key ?? 'image_card');
|
||||
const selectedFormat = ref<AiFormat | null>(null);
|
||||
const selectedAccountId = ref<string | null>(null);
|
||||
const includeImages = ref(true);
|
||||
|
|
@ -60,7 +75,8 @@ const httpStart = useHttp<{
|
|||
image_count: number;
|
||||
prompt: string;
|
||||
date: string | null;
|
||||
}>({ format: null, social_account_id: null, image_count: 0, prompt: '', date: null });
|
||||
template: string;
|
||||
}>({ format: null, social_account_id: null, image_count: 0, prompt: '', date: null, template: 'image_card' });
|
||||
|
||||
const AI_FORMATS: Array<{ value: AiFormat; platforms: string[] }> = [
|
||||
{ value: ContentType.InstagramFeed, platforms: ['instagram', 'instagram-facebook'] },
|
||||
|
|
@ -76,6 +92,10 @@ const AI_FORMATS: Array<{ value: AiFormat; platforms: string[] }> = [
|
|||
{ value: ContentType.PinterestPin, platforms: ['pinterest'] },
|
||||
];
|
||||
|
||||
const activeTemplate = computed(() =>
|
||||
props.templates.find((t) => t.key === selectedTemplate.value) ?? null,
|
||||
);
|
||||
|
||||
const connectedPlatforms = computed(() => {
|
||||
const platforms = new Set<string>();
|
||||
for (const account of props.socialAccounts) {
|
||||
|
|
@ -84,9 +104,12 @@ const connectedPlatforms = computed(() => {
|
|||
return Array.from(platforms);
|
||||
});
|
||||
|
||||
// Show ALL formats — disabled when the workspace has no connected account
|
||||
// for that platform. Filtering them out hides the catalog from the user.
|
||||
const availableFormats = computed(() => AI_FORMATS);
|
||||
// When the active template restricts formats, only show those; otherwise show all.
|
||||
const availableFormats = computed(() => {
|
||||
const supported = activeTemplate.value?.supported_formats ?? [];
|
||||
if (supported.length === 0) return AI_FORMATS;
|
||||
return AI_FORMATS.filter((f) => supported.includes(f.value));
|
||||
});
|
||||
|
||||
const isFormatConnected = (format: typeof AI_FORMATS[number]): boolean =>
|
||||
format.platforms.some((p) => connectedPlatforms.value.includes(p));
|
||||
|
|
@ -119,6 +142,10 @@ const maxOptionalImages = computed(() =>
|
|||
);
|
||||
const showsAccountPicker = computed(() => accountsForFormat.value.length > 1);
|
||||
|
||||
// When the template requires an account, we need one even if the format only maps
|
||||
// to a single account that gets auto-selected — the gating is purely on needsAccount.
|
||||
const templateNeedsAccount = computed(() => activeTemplate.value?.needs_account ?? false);
|
||||
|
||||
const submittedImageCount = computed(() => {
|
||||
if (isCarousel.value) return imageCount.value;
|
||||
if (requiresImage.value) return 1;
|
||||
|
|
@ -143,6 +170,16 @@ watch(accountsForFormat, (accounts) => {
|
|||
}
|
||||
});
|
||||
|
||||
// When the template changes, reset format/account selections in case the new
|
||||
// template's supported_formats set doesn't include the previously-selected one.
|
||||
watch(selectedTemplate, () => {
|
||||
const supported = activeTemplate.value?.supported_formats ?? [];
|
||||
if (supported.length > 0 && selectedFormat.value && !supported.includes(selectedFormat.value)) {
|
||||
selectedFormat.value = null;
|
||||
selectedAccountId.value = null;
|
||||
}
|
||||
});
|
||||
|
||||
const selectFormat = (format: AiFormat) => {
|
||||
selectedFormat.value = format;
|
||||
// Sensible default per format. Picking a format always pre-selects an
|
||||
|
|
@ -158,12 +195,30 @@ const selectFormat = (format: AiFormat) => {
|
|||
}
|
||||
};
|
||||
|
||||
const proceedToConfig = () => {
|
||||
wizardStep.value = 'configure';
|
||||
emit('update:stepHeader', {
|
||||
title: trans('posts.create.ai_title'),
|
||||
description: trans('posts.create.ai_configure_description'),
|
||||
});
|
||||
};
|
||||
|
||||
emit('update:stepHeader', {
|
||||
title: trans('posts.create.ai_title'),
|
||||
description: trans('posts.create.ai_configure_description'),
|
||||
description: trans('posts.create.ai_pick_template_description'),
|
||||
});
|
||||
|
||||
const goBack = () => emit('cancel');
|
||||
const goBack = () => {
|
||||
if (wizardStep.value === 'configure') {
|
||||
wizardStep.value = 'template';
|
||||
emit('update:stepHeader', {
|
||||
title: trans('posts.create.ai_title'),
|
||||
description: trans('posts.create.ai_pick_template_description'),
|
||||
});
|
||||
} else {
|
||||
emit('cancel');
|
||||
}
|
||||
};
|
||||
|
||||
const startGeneration = async () => {
|
||||
if (!canSubmit.value || submitting.value) return;
|
||||
|
|
@ -175,6 +230,7 @@ const startGeneration = async () => {
|
|||
httpStart.image_count = submittedImageCount.value;
|
||||
httpStart.prompt = promptText.value.trim();
|
||||
httpStart.date = props.date;
|
||||
httpStart.template = selectedTemplate.value;
|
||||
|
||||
try {
|
||||
const data = await httpStart.post(startRoute.url()) as { creation_id: string; channel: string };
|
||||
|
|
@ -210,6 +266,47 @@ const startGeneration = async () => {
|
|||
</span>
|
||||
{{ $t('posts.create.steps.back') }}
|
||||
</button>
|
||||
|
||||
<!-- Step 1: Template picker -->
|
||||
<template v-if="wizardStep === 'template'">
|
||||
<div class="space-y-2">
|
||||
<Label class="text-sm font-bold">{{ $t('posts.create.steps.template_picker_title') }}</Label>
|
||||
<div class="grid gap-3 sm:grid-cols-3">
|
||||
<button
|
||||
v-for="template in templates"
|
||||
:key="template.key"
|
||||
type="button"
|
||||
class="relative flex cursor-pointer flex-col overflow-hidden rounded-xl border-2 border-foreground bg-card text-left shadow-2xs transition-all hover:bg-foreground/5"
|
||||
:class="{ '!bg-violet-100 shadow-md': selectedTemplate === template.key }"
|
||||
@click="selectedTemplate = template.key"
|
||||
>
|
||||
<div class="aspect-video w-full overflow-hidden bg-muted">
|
||||
<img
|
||||
:src="template.preview"
|
||||
:alt="template.name"
|
||||
class="size-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-start gap-2 p-3">
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="truncate text-sm font-bold text-foreground">{{ template.name }}</p>
|
||||
<p class="mt-0.5 text-xs leading-snug text-foreground/60">{{ template.description }}</p>
|
||||
</div>
|
||||
<IconCheck v-if="selectedTemplate === template.key" class="mt-0.5 size-4 shrink-0 text-foreground" stroke-width="3" />
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end pt-1">
|
||||
<Button @click="proceedToConfig">
|
||||
{{ $t('posts.create.steps.next') }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Step 2: Configure (format / account / images / prompt) -->
|
||||
<template v-else-if="wizardStep === 'configure'">
|
||||
<!-- Format -->
|
||||
<div class="space-y-2">
|
||||
<Label class="text-sm font-bold">{{ $t('posts.create.steps.format_title') }}</Label>
|
||||
|
|
@ -237,8 +334,8 @@ const startGeneration = async () => {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Account (only when there's a choice to make) -->
|
||||
<div v-if="selectedFormat && showsAccountPicker" class="space-y-2">
|
||||
<!-- Account (when template needs_account OR there's a choice to make) -->
|
||||
<div v-if="selectedFormat && (templateNeedsAccount || showsAccountPicker)" class="space-y-2">
|
||||
<Label class="text-sm font-bold">{{ $t('posts.create.steps.account_title') }}</Label>
|
||||
<div class="grid grid-cols-2 gap-2 sm:grid-cols-3">
|
||||
<button
|
||||
|
|
@ -324,5 +421,6 @@ const startGeneration = async () => {
|
|||
{{ $t('posts.ai.generate.start') }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -18,11 +18,21 @@ interface SocialAccount {
|
|||
avatar_url: string | null;
|
||||
}
|
||||
|
||||
interface AiTemplate {
|
||||
key: string;
|
||||
name: string;
|
||||
description: string;
|
||||
preview: string;
|
||||
needs_account: boolean;
|
||||
supported_formats: string[];
|
||||
}
|
||||
|
||||
interface Props {
|
||||
/** ISO date (YYYY-MM-DD). When set, the manual "start from scratch" path
|
||||
* pre-schedules the new post on this date. */
|
||||
date?: string | null;
|
||||
socialAccounts: SocialAccount[];
|
||||
templates: AiTemplate[];
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
|
|
@ -131,6 +141,7 @@ const stepHeader = computed(() => {
|
|||
<AiPostWizard
|
||||
v-else-if="view === 'ai'"
|
||||
:social-accounts="socialAccounts"
|
||||
:templates="templates"
|
||||
:date="props.date"
|
||||
@update:step-header="aiHeader = $event"
|
||||
@cancel="view = 'choice'; aiHeader = null"
|
||||
|
|
|
|||
Loading…
Reference in a new issue