trypost/resources/js/components/posts/create/AiPostWizard.vue

480 lines
20 KiB
Vue

<script setup lang="ts">
import { router, useHttp } from '@inertiajs/vue3';
import { echo } from '@laravel/echo-vue';
import {
IconArrowLeft,
IconCheck,
IconLoader2,
IconRefresh,
} from '@tabler/icons-vue';
import { trans } from 'laravel-vue-i18n';
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';
import { ContentType, type ContentTypeValue } from '@/enums/content-type';
interface SocialAccount {
id: string;
platform: string;
display_name: string;
username: string;
avatar_url: string | null;
}
interface Props {
socialAccounts: SocialAccount[];
}
const props = defineProps<Props>();
type WizardStep = 'configure' | 'preview';
const emit = defineEmits<{
/** Parent mirrors this in the PageHeader for context. */
'update:stepHeader': [{ title: string; description: string }];
/** Back button on the configure step asks parent to leave the AI flow. */
cancel: [];
}>();
const step = ref<WizardStep>('configure');
// Selections
const selectedFormat = ref<ContentTypeValue | null>(null);
const selectedAccountId = ref<string | null>(null);
const includeImages = ref(true);
const imageCount = ref(2);
const promptText = ref('');
// Preview state
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'] },
{ value: ContentType.InstagramStory, platforms: ['instagram', 'instagram-facebook'] },
{ value: ContentType.LinkedInPost, platforms: ['linkedin'] },
{ value: ContentType.LinkedInPagePost, platforms: ['linkedin-page'] },
{ value: ContentType.XPost, platforms: ['x'] },
{ value: ContentType.BlueskyPost, platforms: ['bluesky'] },
{ value: ContentType.ThreadsPost, platforms: ['threads'] },
{ value: ContentType.MastodonPost, platforms: ['mastodon'] },
{ value: ContentType.FacebookPost, platforms: ['facebook'] },
{ value: ContentType.FacebookStory, platforms: ['facebook'] },
{ value: ContentType.PinterestPin, platforms: ['pinterest'] },
];
const connectedPlatforms = computed(() => {
const platforms = new Set<string>();
for (const account of props.socialAccounts) {
platforms.add(account.platform);
}
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);
const isFormatConnected = (format: typeof AI_FORMATS[number]): boolean =>
format.platforms.some((p) => connectedPlatforms.value.includes(p));
const accountsForFormat = computed(() => {
if (!selectedFormat.value) return [];
const format = AI_FORMATS.find((f) => f.value === selectedFormat.value);
if (!format) return [];
return props.socialAccounts.filter((a) => format.platforms.includes(a.platform));
});
const isCarousel = computed(() => selectedFormat.value === ContentType.InstagramCarousel);
const requiresImage = computed(() =>
selectedFormat.value === ContentType.FacebookPost ||
selectedFormat.value === ContentType.PinterestPin ||
selectedFormat.value === ContentType.InstagramStory ||
selectedFormat.value === ContentType.FacebookStory,
);
const supportsOptionalImages = computed(() =>
selectedFormat.value === ContentType.InstagramFeed ||
selectedFormat.value === ContentType.LinkedInPost ||
selectedFormat.value === ContentType.LinkedInPagePost ||
selectedFormat.value === ContentType.XPost ||
selectedFormat.value === ContentType.BlueskyPost ||
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,
);
// 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(() => {
if (isCarousel.value) return imageCount.value;
if (requiresImage.value) return 1;
if (supportsOptionalImages.value && includeImages.value) return imageCount.value;
return 0;
});
const canSubmit = computed(() =>
selectedFormat.value !== null &&
selectedAccountId.value !== null &&
promptText.value.trim().length >= 3,
);
// Auto-pick the only account when format has exactly one match.
watch(accountsForFormat, (accounts) => {
if (accounts.length === 1) {
selectedAccountId.value = accounts[0].id;
} else if (accounts.length === 0) {
selectedAccountId.value = null;
} else if (accounts.length > 1 && !accounts.some((a) => a.id === selectedAccountId.value)) {
selectedAccountId.value = null;
}
});
const selectFormat = (format: ContentTypeValue) => {
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 === ContentType.InstagramCarousel) {
imageCount.value = 5;
} else if (format === ContentType.InstagramFeed) {
imageCount.value = 1;
includeImages.value = true;
} else {
imageCount.value = 2;
includeImages.value = true;
}
};
// Step header text — the parent reflects this in the PageHeader.
const stepHeaderFor = (s: WizardStep) => {
switch (s) {
case 'configure':
return {
title: trans('posts.create.ai_title'),
description: trans('posts.create.ai_configure_description'),
};
case 'preview':
return {
title: trans('posts.create.steps.preview_title'),
description: '',
};
}
};
const goToStep = (s: WizardStep) => {
step.value = s;
emit('update:stepHeader', stepHeaderFor(s));
};
emit('update:stepHeader', stepHeaderFor(step.value));
const goBack = () => {
if (step.value === 'configure') {
emit('cancel');
} else if (step.value === 'preview') {
goToStep('configure');
}
};
// Echo subscription for AI streaming
const unsubscribeEcho = () => {
if (echoChannel && subscribedChannelName) {
echo().leave(`private-${subscribedChannelName}`);
echoChannel = null;
subscribedChannelName = null;
}
};
const subscribeToCreation = (userId: string, creationId: string) => {
unsubscribeEcho();
previewCreationId.value = creationId;
const channelName = `users.${userId}.ai-creation.${creationId}`;
subscribedChannelName = channelName;
echoChannel = echo().private(channelName).listen('.PostCreationReady', (e: any) => {
if (e.error) {
previewStatus.value = 'error';
previewError.value = e.error;
} else {
previewContent.value = e.content ?? '';
previewImageTitle.value = e.image_title ?? '';
previewImageBody.value = e.image_body ?? '';
previewStatus.value = 'done';
}
unsubscribeEcho();
});
};
const startGeneration = async () => {
if (!canSubmit.value || submitting.value) return;
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 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?.response?.data?.message ?? trans('posts.create.steps.preview_error');
} finally {
submitting.value = false;
}
};
const retryGeneration = () => startGeneration();
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 data = await httpFinalize.post(finalizeRoute.url(previewCreationId.value)) as { redirect_url: string };
router.visit(data.redirect_url);
} catch {
previewStatus.value = 'error';
previewError.value = trans('posts.create.steps.preview_error');
} finally {
finalizing.value = false;
}
};
onUnmounted(() => unsubscribeEcho());
</script>
<template>
<div class="space-y-6">
<!-- Back button sticker arrow + ink label, mirrors the marketing site -->
<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"
@click="goBack"
>
<span class="inline-flex size-7 items-center justify-center rounded-md border-2 border-foreground bg-card shadow-2xs transition-transform group-hover:-translate-x-0.5">
<IconArrowLeft class="size-3.5 text-foreground" stroke-width="2.5" />
</span>
{{ $t('posts.create.steps.back') }}
</button>
<!-- ====== Step 1: Configure (everything in one screen) ====== -->
<template v-if="step === '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 (only when there's a choice to make) -->
<div v-if="selectedFormat && 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
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>
<!-- 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>
</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" @click="startGeneration">
{{ $t('posts.ai.generate.start') }}
</Button>
</div>
</template>
<!-- ====== Step 2: Preview ====== -->
<template v-else-if="step === 'preview'">
<div v-if="previewStatus === 'loading'" class="flex flex-col items-center gap-4 rounded-2xl border-2 border-foreground bg-card py-16 text-center shadow-2xs">
<div class="inline-flex size-12 -rotate-2 items-center justify-center rounded-2xl border-2 border-foreground bg-violet-200 shadow-2xs">
<IconLoader2 class="size-6 animate-spin text-foreground" stroke-width="2" />
</div>
<p class="text-sm font-semibold text-foreground/70">{{ $t('posts.create.steps.preview_loading') }}</p>
</div>
<div v-else-if="previewStatus === 'error'" class="space-y-4">
<div class="rounded-xl border-2 border-foreground bg-rose-50 p-4 shadow-2xs">
<p class="text-sm font-semibold text-rose-700">{{ previewError || $t('posts.create.steps.preview_error') }}</p>
</div>
<div class="flex justify-end">
<Button variant="outline" @click="retryGeneration">
<IconRefresh class="size-4" />
{{ $t('posts.create.steps.retry') }}
</Button>
</div>
</div>
<div v-else-if="previewStatus === 'done'" class="space-y-4">
<!-- Caption-less formats (Stories): edit title + body separately. -->
<div v-if="!supportsCaption" class="space-y-3 rounded-2xl border-2 border-foreground bg-card p-5 shadow-2xs">
<div class="space-y-1.5">
<Label class="text-[11px] font-black uppercase tracking-widest text-foreground/60">{{ $t('posts.create.preview.image_title') }}</Label>
<Input v-model="previewImageTitle" />
</div>
<div class="space-y-1.5">
<Label class="text-[11px] font-black uppercase tracking-widest text-foreground/60">{{ $t('posts.create.preview.image_body') }}</Label>
<Textarea v-model="previewImageBody" class="min-h-[120px] resize-none" />
</div>
</div>
<!-- Default: edit caption text. -->
<Textarea
v-else
v-model="previewContent"
class="min-h-[200px] resize-none p-5 text-sm leading-relaxed"
/>
<div class="flex justify-end gap-2">
<Button variant="outline" @click="retryGeneration">
<IconRefresh class="size-4" />
{{ $t('posts.create.steps.retry') }}
</Button>
<Button :disabled="finalizing" @click="createPost">
<IconLoader2 v-if="finalizing" class="size-4 animate-spin" />
{{ $t('posts.create.steps.create') }}
</Button>
</div>
</div>
</template>
</div>
</template>