feat(ai-templates): choose visual style after the format, on the same screen

This commit is contained in:
Paulo Castellano 2026-06-17 18:45:03 -03:00
parent 5184ff3013
commit e886db5621

View file

@ -55,13 +55,9 @@ 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 selectedStyle = ref<string>('image_card');
const selectedAccountId = ref<string | null>(null);
const includeImages = ref(true);
const imageCount = ref(2);
@ -92,8 +88,23 @@ 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,
/** Templates with no format restriction — pure visual styles (image_card, tweet_card). */
const styleTemplates = computed(() => props.templates.filter((t) => t.supported_formats.length === 0));
/** The template whose supported_formats includes the currently selected format (e.g. carousel). */
const formatBoundTemplate = computed(() =>
selectedFormat.value
? props.templates.find((t) => t.supported_formats.includes(selectedFormat.value as string)) ?? null
: null,
);
/** The template key that will be sent to the backend. */
const resolvedTemplate = computed(() =>
formatBoundTemplate.value ? formatBoundTemplate.value.key : selectedStyle.value,
);
const resolvedTemplateRecord = computed(() =>
props.templates.find((t) => t.key === resolvedTemplate.value) ?? null,
);
const connectedPlatforms = computed(() => {
@ -104,12 +115,7 @@ const connectedPlatforms = computed(() => {
return Array.from(platforms);
});
// 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 availableFormats = computed(() => AI_FORMATS);
const isFormatConnected = (format: typeof AI_FORMATS[number]): boolean =>
format.platforms.some((p) => connectedPlatforms.value.includes(p));
@ -136,15 +142,12 @@ const supportsOptionalImages = computed(() =>
selectedFormat.value === ContentType.ThreadsPost ||
selectedFormat.value === ContentType.MastodonPost,
);
// Instagram Feed accepts only 1 image (single-image post). Others accept up to 4.
const maxOptionalImages = computed(() =>
selectedFormat.value === ContentType.InstagramFeed ? 1 : 4,
);
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 templateNeedsAccount = computed(() => resolvedTemplateRecord.value?.needs_account ?? false);
const submittedImageCount = computed(() => {
if (isCarousel.value) return imageCount.value;
@ -170,20 +173,8 @@ 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
// image option so the user sees a chip highlighted on arrival.
if (format === CAROUSEL_FORMAT) {
imageCount.value = 5;
} else if (format === ContentType.InstagramFeed) {
@ -195,29 +186,13 @@ 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_pick_template_description'),
description: trans('posts.create.ai_configure_description'),
});
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');
}
emit('cancel');
};
const startGeneration = async () => {
@ -230,7 +205,7 @@ const startGeneration = async () => {
httpStart.image_count = submittedImageCount.value;
httpStart.prompt = promptText.value.trim();
httpStart.date = props.date;
httpStart.template = selectedTemplate.value;
httpStart.template = resolvedTemplate.value;
try {
const data = await httpStart.post(startRoute.url()) as { creation_id: string; channel: string };
@ -255,7 +230,7 @@ const startGeneration = async () => {
<template>
<div class="space-y-6">
<!-- Back button — sticker arrow + ink label, mirrors the marketing site -->
<!-- Back button -->
<button
type="button"
class="group inline-flex cursor-pointer items-center gap-1.5 text-sm font-semibold text-foreground/70 transition-colors hover:text-foreground"
@ -267,163 +242,152 @@ const startGeneration = async () => {
{{ $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>
<!-- Format -->
<div class="space-y-2">
<Label class="text-sm font-bold">{{ $t('posts.create.steps.format_title') }}</Label>
<div class="grid gap-2 sm:grid-cols-2">
<button
v-for="format in availableFormats"
:key="format.value"
type="button"
class="flex cursor-pointer items-center gap-3 rounded-xl border-2 border-foreground bg-card p-3.5 text-left text-sm shadow-2xs transition-all hover:bg-foreground/5 disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:bg-card"
:class="{ '!bg-violet-100 shadow-md': selectedFormat === format.value }"
:disabled="!isFormatConnected(format)"
:title="!isFormatConnected(format) ? $t('posts.create.steps.connect_first') : ''"
@click="selectFormat(format.value)"
>
<span class="inline-flex size-7 items-center justify-center overflow-hidden rounded-full border-2 border-foreground bg-card shadow-2xs">
<img
:src="getPlatformLogo(format.platforms[0])"
:alt="format.platforms[0]"
class="size-full object-cover"
/>
</span>
<span class="flex-1 font-semibold text-foreground">{{ $t(`posts.create.steps.format.${format.value}`) }}</span>
<IconCheck v-if="selectedFormat === format.value" class="size-4 text-foreground" stroke-width="3" />
</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>
<div class="grid gap-2 sm:grid-cols-2">
<button
v-for="format in availableFormats"
:key="format.value"
type="button"
class="flex cursor-pointer items-center gap-3 rounded-xl border-2 border-foreground bg-card p-3.5 text-left text-sm shadow-2xs transition-all hover:bg-foreground/5 disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:bg-card"
:class="{ '!bg-violet-100 shadow-md': selectedFormat === format.value }"
:disabled="!isFormatConnected(format)"
:title="!isFormatConnected(format) ? $t('posts.create.steps.connect_first') : ''"
@click="selectFormat(format.value)"
>
<span class="inline-flex size-7 items-center justify-center overflow-hidden rounded-full border-2 border-foreground bg-card shadow-2xs">
<img
:src="getPlatformLogo(format.platforms[0])"
:alt="format.platforms[0]"
class="size-full object-cover"
/>
</span>
<span class="flex-1 font-semibold text-foreground">{{ $t(`posts.create.steps.format.${format.value}`) }}</span>
<IconCheck v-if="selectedFormat === format.value" class="size-4 text-foreground" stroke-width="3" />
</button>
</div>
</div>
<!-- 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>
<p v-if="templateNeedsAccount && accountsForFormat.length === 0" class="text-sm text-foreground/60">
{{ $t('posts.create.steps.no_account_for_template') }}
</p>
<div v-else class="grid grid-cols-2 gap-2 sm:grid-cols-3">
<button
v-for="account in accountsForFormat"
:key="account.id"
type="button"
class="relative flex cursor-pointer items-center gap-2 rounded-xl border-2 border-foreground bg-card p-2.5 text-left text-sm shadow-2xs transition-all hover:bg-foreground/5"
:class="{ '!bg-violet-100 shadow-md': selectedAccountId === account.id }"
@click="selectedAccountId = account.id"
>
<span class="inline-flex size-8 shrink-0 items-center justify-center overflow-hidden rounded-full border-2 border-foreground bg-card shadow-2xs">
<img
v-if="account.avatar_url"
:src="account.avatar_url"
:alt="account.display_name"
class="size-full object-cover"
/>
<img v-else :src="getPlatformLogo(account.platform)" :alt="account.platform" class="size-4" />
</span>
<!-- Visual style — shown only for single-image formats (not carousel) -->
<div v-if="selectedFormat && !formatBoundTemplate" 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 styleTemplates"
: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': selectedStyle === template.key }"
@click="selectedStyle = 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-xs font-bold leading-tight text-foreground">{{ account.display_name }}</p>
<p v-if="account.username" class="truncate text-xs font-medium text-foreground/60">@{{ account.username }}</p>
<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="selectedAccountId === account.id" class="absolute right-2 top-2 size-3.5 text-foreground" stroke-width="3" />
</button>
</div>
<IconCheck v-if="selectedStyle === template.key" class="mt-0.5 size-4 shrink-0 text-foreground" stroke-width="3" />
</div>
</button>
</div>
</div>
<!-- Media — inline, only when format actually has options -->
<div v-if="selectedFormat && isCarousel" class="space-y-2">
<Label class="text-sm font-bold">{{ $t('posts.create.steps.media_carousel') }}</Label>
<div class="flex flex-wrap gap-2">
<Button
v-for="n in [2, 3, 4, 5, 6, 7, 8, 9, 10]"
:key="n"
type="button"
size="icon"
:variant="imageCount === n ? 'default' : 'outline'"
@click="imageCount = n"
>
{{ n }}
</Button>
</div>
<!-- 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>
<p v-if="templateNeedsAccount && accountsForFormat.length === 0" class="text-sm text-foreground/60">
{{ $t('posts.create.steps.no_account_for_template') }}
</p>
<div v-else class="grid grid-cols-2 gap-2 sm:grid-cols-3">
<button
v-for="account in accountsForFormat"
:key="account.id"
type="button"
class="relative flex cursor-pointer items-center gap-2 rounded-xl border-2 border-foreground bg-card p-2.5 text-left text-sm shadow-2xs transition-all hover:bg-foreground/5"
:class="{ '!bg-violet-100 shadow-md': selectedAccountId === account.id }"
@click="selectedAccountId = account.id"
>
<span class="inline-flex size-8 shrink-0 items-center justify-center overflow-hidden rounded-full border-2 border-foreground bg-card shadow-2xs">
<img
v-if="account.avatar_url"
:src="account.avatar_url"
:alt="account.display_name"
class="size-full object-cover"
/>
<img v-else :src="getPlatformLogo(account.platform)" :alt="account.platform" class="size-4" />
</span>
<div class="min-w-0 flex-1">
<p class="truncate text-xs font-bold leading-tight text-foreground">{{ account.display_name }}</p>
<p v-if="account.username" class="truncate text-xs font-medium text-foreground/60">@{{ account.username }}</p>
</div>
<IconCheck v-if="selectedAccountId === account.id" class="absolute right-2 top-2 size-3.5 text-foreground" stroke-width="3" />
</button>
</div>
</div>
<div v-if="selectedFormat && supportsOptionalImages" class="space-y-2">
<Label class="text-sm font-bold">{{ $t('posts.create.steps.media_optional_label') }}</Label>
<div class="flex flex-wrap gap-2">
<Button
type="button"
:variant="!includeImages ? 'default' : 'outline'"
@click="includeImages = false"
>
{{ $t('posts.create.steps.media_none') }}
</Button>
<Button
v-for="n in maxOptionalImages"
:key="n"
type="button"
size="icon"
:variant="includeImages && imageCount === n ? 'default' : 'outline'"
@click="includeImages = true; imageCount = n"
>
{{ n }}
</Button>
</div>
</div>
<!-- Prompt -->
<div v-if="selectedFormat" class="space-y-2">
<Label for="ai-prompt" class="text-sm font-bold">{{ $t('posts.create.steps.prompt_label') }}</Label>
<Textarea
id="ai-prompt"
v-model="promptText"
:placeholder="$t('posts.create.steps.prompt_placeholder')"
class="min-h-[140px] resize-none"
/>
</div>
<!-- Generate -->
<div v-if="selectedFormat" class="flex justify-end pt-1">
<Button :disabled="!canSubmit || submitting" @click="startGeneration">
{{ $t('posts.ai.generate.start') }}
<!-- Media — inline, only when format actually has options -->
<div v-if="selectedFormat && isCarousel" class="space-y-2">
<Label class="text-sm font-bold">{{ $t('posts.create.steps.media_carousel') }}</Label>
<div class="flex flex-wrap gap-2">
<Button
v-for="n in [2, 3, 4, 5, 6, 7, 8, 9, 10]"
:key="n"
type="button"
size="icon"
:variant="imageCount === n ? 'default' : 'outline'"
@click="imageCount = n"
>
{{ n }}
</Button>
</div>
</template>
</div>
<div v-if="selectedFormat && supportsOptionalImages" class="space-y-2">
<Label class="text-sm font-bold">{{ $t('posts.create.steps.media_optional_label') }}</Label>
<div class="flex flex-wrap gap-2">
<Button
type="button"
:variant="!includeImages ? 'default' : 'outline'"
@click="includeImages = false"
>
{{ $t('posts.create.steps.media_none') }}
</Button>
<Button
v-for="n in maxOptionalImages"
:key="n"
type="button"
size="icon"
:variant="includeImages && imageCount === n ? 'default' : 'outline'"
@click="includeImages = true; imageCount = n"
>
{{ n }}
</Button>
</div>
</div>
<!-- Prompt -->
<div v-if="selectedFormat" class="space-y-2">
<Label for="ai-prompt" class="text-sm font-bold">{{ $t('posts.create.steps.prompt_label') }}</Label>
<Textarea
id="ai-prompt"
v-model="promptText"
:placeholder="$t('posts.create.steps.prompt_placeholder')"
class="min-h-[140px] resize-none"
/>
</div>
<!-- Generate -->
<div v-if="selectedFormat" class="flex justify-end pt-1">
<Button :disabled="!canSubmit || submitting" @click="startGeneration">
{{ $t('posts.ai.generate.start') }}
</Button>
</div>
</div>
</template>