trypost/resources/js/components/automations/config/GenerateNodeConfig.vue
Paulo Castellano ff8fd9de40 Tidy automation frontend: extract CSS, use the Label component
- Move the automation-node and json-viewer styles out of app.css into their
  own imported stylesheets.
- Replace raw <label> elements across the node config panels and the test
  panel with the shared ui/label component.
2026-06-13 16:30:39 -03:00

333 lines
13 KiB
Vue

<script setup lang="ts">
import { usePage } from '@inertiajs/vue3';
import { computed, ref, watch } from 'vue';
import ChannelConfigurator from '@/components/ChannelConfigurator.vue';
import CodeEditor from '@/components/CodeEditor.vue';
import InputError from '@/components/InputError.vue';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import { useExpandedEditor } from '@/composables/useExpandedEditor';
import { getMediaRulesForContentType } from '@/composables/useMediaRules';
import { getMediaIncompatibilityReason, getPlatformMetaIssue } from '@/composables/usePostCompliance';
import type { PinterestBoard } from '@/types';
import type { Channel } from '@/types/channel';
import { ContentType } from '@/types/content-type';
import type { MediaItem } from '@/types/media';
import { Platform } from '@/types/platform';
interface SocialAccount {
id: string;
platform: string;
display_name: string;
username: string;
avatar_url: string | null;
}
interface TikTokCreatorInfo {
creator_nickname: string | null;
creator_username: string | null;
creator_avatar_url: string | null;
privacy_level_options: string[];
comment_disabled: boolean;
duet_disabled: boolean;
stitch_disabled: boolean;
max_video_post_duration_sec: number | null;
}
interface GenerateAccount {
social_account_id: string;
content_type: string;
meta: Record<string, any>;
}
interface GenerateConfig {
accounts: GenerateAccount[];
target_slide_count: number;
prompt_template: string;
use_brand_voice: boolean;
use_brand_visuals: boolean;
}
const props = defineProps<{
data: Record<string, unknown>;
errors?: Record<string, string>;
}>();
const emit = defineEmits<{ update: [Record<string, unknown>] }>();
const editorExpanded = useExpandedEditor();
const page = usePage();
const socialAccounts = computed<SocialAccount[]>(() => {
const raw = page.props.socialAccounts as { data?: SocialAccount[] } | SocialAccount[] | undefined;
if (!raw) return [];
return Array.isArray(raw) ? (raw as SocialAccount[]) : ((raw as { data: SocialAccount[] }).data ?? []);
});
const platformConfigs = computed<Record<string, any>>(() => {
const raw = page.props.platformConfigs as Record<string, any> | undefined;
return raw ?? {};
});
const pinterestBoards = computed<Record<string, PinterestBoard[]>>(() => {
const raw = page.props.pinterestBoards as Record<string, PinterestBoard[]> | undefined;
return raw ?? {};
});
const tiktokCreatorInfos = computed<Record<string, TikTokCreatorInfo>>(() => {
const raw = page.props.tiktokCreatorInfos as Record<string, TikTokCreatorInfo> | null | undefined;
return raw ?? {};
});
const defaultContentTypeFor = (platform: string): string => {
switch (platform) {
case Platform.Instagram:
case Platform.InstagramFacebook:
return ContentType.InstagramFeed;
case Platform.Facebook:
return ContentType.FacebookPost;
case Platform.LinkedIn:
return ContentType.LinkedInPost;
case Platform.LinkedInPage:
return ContentType.LinkedInPagePost;
case Platform.TikTok:
return ContentType.TikTokVideo;
case Platform.Pinterest:
return ContentType.PinterestPin;
case Platform.YouTube:
return ContentType.YouTubeShort;
case Platform.X:
return ContentType.XPost;
case Platform.Threads:
return ContentType.ThreadsPost;
case Platform.Bluesky:
return ContentType.BlueskyPost;
case Platform.Mastodon:
return ContentType.MastodonPost;
default:
return '';
}
};
const accountById = (id: string): SocialAccount | undefined =>
socialAccounts.value.find((a) => a.id === id);
const normalizeAccountsFromData = (): GenerateAccount[] => {
const incoming = props.data.accounts;
if (Array.isArray(incoming)) {
return (incoming as any[]).map((a) => ({
social_account_id: String(a.social_account_id ?? ''),
content_type: typeof a.content_type === 'string' && a.content_type
? a.content_type
: defaultContentTypeFor(accountById(String(a.social_account_id ?? ''))?.platform ?? ''),
meta: (a.meta as Record<string, any>) ?? {},
})).filter((a) => a.social_account_id);
}
// Backward-compat with the old shape (social_account_ids: string[]).
const legacyIds = props.data.social_account_ids;
if (Array.isArray(legacyIds)) {
return (legacyIds as string[]).map((id) => ({
social_account_id: id,
content_type: defaultContentTypeFor(accountById(id)?.platform ?? ''),
meta: {},
}));
}
return [];
};
const local = ref<GenerateConfig>({
accounts: normalizeAccountsFromData(),
target_slide_count: (props.data.target_slide_count as number | undefined) ?? 1,
prompt_template: (props.data.prompt_template as string) ?? '',
use_brand_voice: (props.data.use_brand_voice as boolean | undefined) ?? true,
use_brand_visuals: (props.data.use_brand_visuals as boolean | undefined) ?? true,
});
watch(local, (val) => emit('update', val), { deep: true });
const selectedAccountIds = computed(() => local.value.accounts.map((a) => a.social_account_id));
const onToggleAccount = (accountId: string) => {
const account = accountById(accountId);
if (account) toggleAccount(account);
};
const toggleAccount = (account: SocialAccount) => {
if (selectedAccountIds.value.includes(account.id)) {
local.value.accounts = local.value.accounts.filter((a) => a.social_account_id !== account.id);
return;
}
local.value.accounts = [
...local.value.accounts,
{
social_account_id: account.id,
content_type: defaultContentTypeFor(account.platform),
meta: {},
},
];
};
const updateContentType = (accountId: string, value: string) => {
const idx = local.value.accounts.findIndex((a) => a.social_account_id === accountId);
if (idx === -1) return;
local.value.accounts[idx] = { ...local.value.accounts[idx], content_type: value };
};
const updateMeta = (accountId: string, value: Record<string, any>) => {
const idx = local.value.accounts.findIndex((a) => a.social_account_id === accountId);
if (idx === -1) return;
local.value.accounts[idx] = { ...local.value.accounts[idx], meta: value };
};
const getPublishConfig = (account: SocialAccount): Record<string, any> | null =>
platformConfigs.value[account.id]?.publishConfig ?? null;
const getCreatorInfo = (account: SocialAccount): TikTokCreatorInfo | null =>
tiktokCreatorInfos.value[account.id] ?? null;
const getBoards = (account: SocialAccount): PinterestBoard[] =>
pinterestBoards.value[account.id] ?? [];
// Image-capability is derived from the SAME media rules the post editor uses
// (per content type), never a hardcoded list — facebook_post, tiktok_photo,
// linkedin_carousel etc. all accept multiple images. We cap AI image generation
// at MAX_GENERATED_IMAGES regardless of how many a platform technically allows.
const MAX_GENERATED_IMAGES = 10;
// Highest image count any selected account accepts (0 when none accept images,
// e.g. text-only platforms), capped at MAX_GENERATED_IMAGES.
const imageCountCap = computed(() =>
Math.min(
MAX_GENERATED_IMAGES,
local.value.accounts.reduce((max, a) => {
const rules = getMediaRulesForContentType(a.content_type);
return rules.acceptImages ? Math.max(max, rules.maxFiles) : max;
}, 0),
),
);
// Single picker: 0 = no image (text-only), 1 = single image, 2+ = carousel.
const imageCountOptions = computed(() =>
Array.from({ length: imageCountCap.value + 1 }, (_, i) => i),
);
const intendedImageCount = computed(() => local.value.target_slide_count);
const syntheticImages = (count: number): MediaItem[] =>
Array.from({ length: Math.max(0, count) }, () => ({ type: 'image' }) as MediaItem);
// Reuses the post editor's exact compliance: per-content-type media rules
// (too many / too few images, image-not-supported, media-required) AND the
// platform meta rules (TikTok privacy/disclosure, Pinterest board required).
const accountIssue = (accountId: string): string | null => {
const entry = local.value.accounts.find((a) => a.social_account_id === accountId);
if (!entry) return null;
const mediaIssue = getMediaIncompatibilityReason(entry.content_type, syntheticImages(intendedImageCount.value));
if (mediaIssue) return mediaIssue;
const account = accountById(accountId);
return account ? getPlatformMetaIssue(account.platform, entry.meta) : null;
};
// Clamp the chosen count to what the selected accounts actually allow — runs on
// mount too so legacy/over-cap values (or text-only accounts → 0) self-correct.
watch(
imageCountCap,
(cap) => {
if (local.value.target_slide_count > cap) {
local.value.target_slide_count = cap;
}
},
{ immediate: true },
);
const channels = computed<Channel[]>(() =>
socialAccounts.value.map((account) => {
const entry = local.value.accounts.find((a) => a.social_account_id === account.id);
return {
id: account.id,
platform: account.platform,
displayName: account.display_name,
username: account.username,
avatarUrl: account.avatar_url,
socialAccount: account,
contentType: entry?.content_type ?? defaultContentTypeFor(account.platform),
meta: entry?.meta ?? {},
issue: accountIssue(account.id),
publishConfig: getPublishConfig(account),
creatorInfo: getCreatorInfo(account),
boards: getBoards(account),
};
}),
);
</script>
<template>
<div class="space-y-4">
<div class="space-y-2">
<Label class="text-sm font-bold">{{ $t('automations.config.generate.social_accounts') }}</Label>
<InputError :message="errors?.accounts" />
<p v-if="socialAccounts.length === 0" class="text-xs text-foreground/60">
{{ $t('automations.config.generate.social_accounts_empty') }}
</p>
<ChannelConfigurator
v-else
:channels="channels"
:selected-ids="selectedAccountIds"
:preview-only="true"
@toggle="onToggleAccount"
@update:content-type="updateContentType"
@update:meta="updateMeta"
/>
</div>
<div v-if="imageCountCap >= 1" class="space-y-2">
<Label class="text-sm font-bold">{{ $t('automations.config.generate.image_count') }}</Label>
<p class="text-xs text-foreground/60">{{ $t('automations.config.generate.image_count_hint') }}</p>
<div class="flex flex-wrap gap-2">
<Button
v-for="n in imageCountOptions"
:key="n"
type="button"
size="icon"
:variant="local.target_slide_count === n ? 'default' : 'outline'"
@click="local.target_slide_count = n"
>
{{ n }}
</Button>
</div>
</div>
<div v-show="!editorExpanded" class="flex items-start justify-between gap-3">
<div class="space-y-0.5">
<Label class="text-sm font-bold">{{ $t('automations.config.generate.use_brand_voice') }}</Label>
<p class="text-xs text-foreground/60">{{ $t('automations.config.generate.use_brand_voice_hint') }}</p>
</div>
<Switch v-model="local.use_brand_voice" />
</div>
<div v-show="!editorExpanded && local.target_slide_count >= 1" class="flex items-start justify-between gap-3">
<div class="space-y-0.5">
<Label class="text-sm font-bold">{{ $t('automations.config.generate.use_brand_visuals') }}</Label>
<p class="text-xs text-foreground/60">{{ $t('automations.config.generate.use_brand_visuals_hint') }}</p>
</div>
<Switch v-model="local.use_brand_visuals" />
</div>
<div v-show="!editorExpanded">
<Label class="mb-1 block">{{ $t('automations.config.generate.prompt_template') }}</Label>
<div class="h-40">
<CodeEditor
v-model="local.prompt_template"
language="text"
expandable
:label="$t('automations.config.generate.prompt_template')"
placeholder="Write a social media post about {{ trigger.title }}…"
/>
</div>
<p class="mt-1 text-xs text-foreground/50">{{ $t('automations.config.generate.prompt_template_hint') }}</p>
<InputError :message="errors?.prompt_template" class="mt-1" />
</div>
</div>
</template>