trypost/resources/js/components/posts/previews/FacebookPreview.vue
Paulo Castellano e25b320ad2 Add aspect ratio selection for Facebook posts
Facebook feed posts now have the same aspect ratio selector as Instagram
(1:1, 4:5, 16:9, Original), letting users control how their image is cropped
before publishing instead of leaving it to Facebook's auto-crop.

The Facebook Graph API has no crop/aspect-ratio parameter for organic Page
photo posts (image crops are ads-only) — Facebook displays the bytes it
receives at their native ratio. So, exactly like Instagram, the crop is done
server-side before upload.

- Extract the Instagram crop logic into a shared CropsImageForAspectRatio
  concern (download -> crop -> store -> URL), with a per-publisher
  cropFailureException hook. InstagramPublisher refactored to use it
  (behaviour identical; existing tests unchanged).
- FacebookPublisher reads meta.aspect_ratio and crops single- and multi-image
  feed posts (facebook-crops/ prefix). Reels/Stories are unaffected.
- FacebookSettings: aspect ratio picker on the Feed variant (default Original
  -> no crop). ScheduleTab wires meta; FacebookPreview reflects the chosen
  ratio. Adds posts.form.facebook.aspect.* in en/es/pt-BR.

Closes #69
2026-06-05 18:01:45 -03:00

419 lines
No EOL
24 KiB
Vue

<script setup lang="ts">
import { IconDots, IconPhoto } from '@tabler/icons-vue';
import { computed } from 'vue';
import PostMediaPreview from '@/components/posts/previews/PostMediaPreview.vue';
import type { MediaItem } from '@/types/media';
interface SocialAccount {
id: string;
platform: string;
display_name: string;
username: string;
avatar_url: string | null;
}
interface Props {
socialAccount: SocialAccount;
content: string;
media: MediaItem[];
contentType?: string;
meta?: Record<string, any>;
charCount?: number;
maxLength?: number;
isValid?: boolean;
validationMessage?: string;
}
const props = defineProps<Props>();
// Content type helpers
const isReel = computed(() => props.contentType === 'facebook_reel');
const isStory = computed(() => props.contentType === 'facebook_story');
const isFeed = computed(() => !isReel.value && !isStory.value);
// Padding-bottom percentage = height/width. Reflects the user's chosen crop in
// the feed preview. `null` (original / unset) keeps the natural fill layout.
const ASPECT_PADDING: Record<string, number> = {
'1:1': 100,
'4:5': 125,
'16:9': 56.25,
};
const feedAspectPadding = computed<number | null>(() => ASPECT_PADDING[props.meta?.aspect_ratio ?? ''] ?? null);
const feedMediaProps = {
placeholderIcon: IconPhoto,
dotActiveClass: 'bg-[#1877f2]',
placeholderClass: 'w-full h-full flex items-center justify-center bg-[#f0f2f5] dark:bg-[#3a3b3c]',
};
// Format numbers like Facebook
const formatNumber = (num: number): string => {
if (num >= 1000000) {
return (num / 1000000).toFixed(1).replace(/\.0$/, '') + 'M';
}
if (num >= 1000) {
return (num / 1000).toFixed(1).replace(/\.0$/, '') + 'K';
}
return num.toString();
};
// Truncate content for display
const truncatedContent = computed(() => {
if (!props.content) return '';
if (props.content.length <= 100) return props.content;
return props.content.substring(0, 100) + '...';
});
const displayName = computed(() => props.socialAccount.display_name || props.socialAccount.username);
</script>
<template>
<div
class="w-full h-full bg-[#f0f2f5] dark:bg-[#18191a] text-[#050505] dark:text-[#e4e6eb] overflow-hidden flex flex-col">
<!-- ==================== FEED POST ==================== -->
<template v-if="isFeed">
<!-- Facebook Header -->
<div
class="flex-shrink-0 h-11 bg-white dark:bg-[#242526] border-b border-[#dddfe2] dark:border-[#3e4042] flex items-center px-3">
<svg class="h-6 w-auto text-[#1877f2]" viewBox="0 0 36 36" fill="currentColor">
<path
d="M20.18 35.87C28.872 34.632 35.87 27.1 35.87 18c0-9.882-8.01-17.89-17.9-17.89C8.09.11.08 8.12.08 18c0 8.06 5.33 14.87 12.66 17.11v-12.3h-3.81V18h3.81v-3.64c0-3.76 2.24-5.84 5.67-5.84 1.64 0 3.36.29 3.36.29v3.69h-1.89c-1.86 0-2.45 1.16-2.45 2.35V18h4.16l-.67 4.81h-3.49v12.3c1.08-.17 2.13-.44 3.14-.79l.56-.45z" />
</svg>
<div class="ml-auto flex items-center gap-2">
<!-- Search -->
<div class="w-8 h-8 rounded-full bg-[#e4e6eb] dark:bg-[#3a3b3c] flex items-center justify-center">
<svg class="w-4 h-4 text-[#050505] dark:text-[#e4e6eb]" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2.5">
<circle cx="11" cy="11" r="8" />
<path d="m21 21-4.35-4.35" />
</svg>
</div>
<!-- Messenger -->
<div class="w-8 h-8 rounded-full bg-[#e4e6eb] dark:bg-[#3a3b3c] flex items-center justify-center">
<svg class="w-4 h-4 text-[#050505] dark:text-[#e4e6eb]" viewBox="0 0 24 24" fill="currentColor">
<path
d="M12 2C6.48 2 2 6.04 2 11c0 2.76 1.36 5.22 3.5 6.87V22l3.75-2.06c.98.27 2.01.43 3.08.43h.34c5.52 0 10-4.04 10-9s-4.48-9-10-9h-.67zm1.13 12.13l-2.54-2.71-4.96 2.71 5.46-5.79 2.6 2.71 4.9-2.71-5.46 5.79z" />
</svg>
</div>
</div>
</div>
<!-- Post Content -->
<div class="flex-1 flex flex-col min-h-0 overflow-hidden">
<!-- Post Card -->
<div class="bg-white dark:bg-[#242526] flex-1 flex flex-col min-h-0">
<!-- Post Header -->
<div class="flex-shrink-0 flex items-center px-3 py-2">
<div class="flex items-center gap-2 flex-1">
<img v-if="socialAccount.avatar_url" :src="socialAccount.avatar_url" :alt="displayName"
class="w-10 h-10 rounded-full object-cover" />
<div v-else
class="w-10 h-10 rounded-full bg-[#1877f2] flex items-center justify-center text-white font-bold">
{{ displayName?.charAt(0).toUpperCase() }}
</div>
<div class="flex flex-col min-w-0">
<span class="text-[13px] font-semibold leading-tight">{{ displayName }}</span>
<div class="flex items-center gap-1 text-[11px] text-[#65676b] dark:text-[#b0b3b8]">
<span>Just now</span>
<span>·</span>
<!-- Globe icon -->
<svg class="w-3 h-3" viewBox="0 0 16 16" fill="currentColor">
<path
d="M8 0a8 8 0 1 0 0 16A8 8 0 0 0 8 0zm-.5 14.5v-2h1v2a6.5 6.5 0 0 1-1 0zm4.7-1.2a6.5 6.5 0 0 1-3.2.7v-1h2a1 1 0 0 0 1-1V9.5h1.5a6.5 6.5 0 0 1-1.3 3.8zM14.5 8a6.5 6.5 0 0 1-.5 2.5H12V9a1 1 0 0 0-1-1h-1V6a1 1 0 0 0-1-1H6.5V3.5h2a1 1 0 0 0 1-1V1.7a6.5 6.5 0 0 1 5 6.3zM1.5 8a6.5 6.5 0 0 1 3-5.5V4a1 1 0 0 0 1 1h1v2a1 1 0 0 0 1 1h2v3H6a1 1 0 0 0-1 1v2.5A6.5 6.5 0 0 1 1.5 8z" />
</svg>
</div>
</div>
</div>
<IconDots class="w-5 h-5 text-[#65676b] dark:text-[#b0b3b8]" />
</div>
<!-- Caption -->
<div v-if="content" class="flex-shrink-0 px-3 pb-2">
<p class="text-[14px] leading-snug">
{{ truncatedContent }}
<span v-if="content.length > 100"
class="text-[#65676b] dark:text-[#b0b3b8] cursor-pointer">See more</span>
</p>
</div>
<!-- Post Media - Aspect ratio matches user's chosen crop -->
<div
v-if="feedAspectPadding !== null"
class="relative w-full shrink-0 bg-black"
:style="{ paddingBottom: `${feedAspectPadding}%` }"
>
<div class="absolute inset-0">
<PostMediaPreview :media="media" v-bind="feedMediaProps" />
</div>
</div>
<div v-else class="flex-1 relative bg-black min-h-0">
<PostMediaPreview :media="media" v-bind="feedMediaProps" />
</div>
<!-- Reactions Bar -->
<div
class="flex-shrink-0 px-3 py-1.5 flex items-center justify-between border-b border-[#dddfe2] dark:border-[#3e4042]">
<div class="flex items-center gap-1">
<div class="flex -space-x-1">
<!-- Like reaction -->
<div
class="w-[18px] h-[18px] rounded-full bg-[#1877f2] flex items-center justify-center ring-2 ring-white dark:ring-[#242526]">
<svg class="w-2.5 h-2.5 text-white" viewBox="0 0 16 16" fill="currentColor">
<path
d="M1 7.75v6.5c0 .41.34.75.75.75h2.5a.75.75 0 0 0 .75-.75v-6.5a.75.75 0 0 0-.75-.75h-2.5a.75.75 0 0 0-.75.75zm13.47-2.58l-3.25.01-.42-1.48a3.51 3.51 0 0 0-3.38-2.56H7.2a.75.75 0 0 0-.7.47l-1.32 3.45a1.75 1.75 0 0 0-.1.56V12c0 .97.78 1.75 1.75 1.75h4.67a1.75 1.75 0 0 0 1.7-1.34l1.3-5.21a1.75 1.75 0 0 0-1.03-2.08z" />
</svg>
</div>
<!-- Love reaction -->
<div
class="w-[18px] h-[18px] rounded-full bg-[#f33e58] flex items-center justify-center ring-2 ring-white dark:ring-[#242526]">
<svg class="w-2.5 h-2.5 text-white" viewBox="0 0 16 16" fill="currentColor">
<path
d="M8 15.25s-6.75-4.47-6.75-9A3.38 3.38 0 0 1 4.62.75 3.43 3.43 0 0 1 8 2.93 3.43 3.43 0 0 1 11.38.75a3.38 3.38 0 0 1 3.37 3.5c0 4.53-6.75 9-6.75 9z" />
</svg>
</div>
</div>
<span class="text-[12px] text-[#65676b] dark:text-[#b0b3b8] ml-1">{{ formatNumber(156)
}}</span>
</div>
<span class="text-[12px] text-[#65676b] dark:text-[#b0b3b8]">23 comments · 5 shares</span>
</div>
<!-- Action Buttons -->
<div
class="flex-shrink-0 flex items-center px-2 py-1 border-b border-[#dddfe2] dark:border-[#3e4042]">
<button
class="flex-1 flex items-center justify-center gap-1.5 py-1.5 text-[#65676b] dark:text-[#b0b3b8] hover:bg-[#f0f2f5] dark:hover:bg-[#3a3b3c] rounded-lg transition-colors">
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path
d="M14 9V5a3 3 0 0 0-3-3l-4 9v11h11.28a2 2 0 0 0 2-1.7l1.38-9a2 2 0 0 0-2-2.3zM7 22H4a2 2 0 0 1-2-2v-7a2 2 0 0 1 2-2h3" />
</svg>
<span class="text-[12px] font-semibold">Like</span>
</button>
<button
class="flex-1 flex items-center justify-center gap-1.5 py-1.5 text-[#65676b] dark:text-[#b0b3b8] hover:bg-[#f0f2f5] dark:hover:bg-[#3a3b3c] rounded-lg transition-colors">
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
</svg>
<span class="text-[12px] font-semibold">Comment</span>
</button>
<button
class="flex-1 flex items-center justify-center gap-1.5 py-1.5 text-[#65676b] dark:text-[#b0b3b8] hover:bg-[#f0f2f5] dark:hover:bg-[#3a3b3c] rounded-lg transition-colors">
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8" />
<polyline points="16 6 12 2 8 6" />
<line x1="12" y1="2" x2="12" y2="15" />
</svg>
<span class="text-[12px] font-semibold">Share</span>
</button>
</div>
</div>
</div>
</template>
<!-- ==================== REELS ==================== -->
<template v-else-if="isReel">
<div class="relative flex-1 bg-black overflow-hidden">
<!-- Video/Media - Full screen -->
<div class="absolute inset-0">
<PostMediaPreview
:media="media"
:placeholder-icon="IconPhoto"
:show-arrows="false"
:show-dots="false"
placeholder-class="w-full h-full flex items-center justify-center bg-[#18191a]"
/>
</div>
<!-- Gradient overlay -->
<div v-if="media.length > 0"
class="absolute inset-x-0 bottom-0 h-48 bg-gradient-to-t from-black/70 via-black/30 to-transparent pointer-events-none z-[5]" />
<!-- Top Bar -->
<div v-if="media.length > 0"
class="absolute top-1 left-0 right-0 px-3 flex items-center justify-between z-10">
<span class="text-white text-[14px] font-semibold drop-shadow-lg">Reels</span>
<svg class="w-5 h-5 text-white drop-shadow-lg" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2">
<circle cx="12" cy="12" r="3" />
<path d="M16.5 7.5L21 3M21 3v4M21 3h-4" />
</svg>
</div>
<!-- Right Sidebar Actions -->
<div v-if="media.length > 0"
class="absolute right-2 bottom-[68px] flex flex-col items-center gap-4 z-10">
<!-- Like -->
<div class="flex flex-col items-center">
<div class="w-10 h-10 flex items-center justify-center">
<svg class="w-7 h-7 text-white drop-shadow-lg" viewBox="0 0 24 24" fill="currentColor">
<path
d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z" />
</svg>
</div>
<span class="text-white text-[10px] font-semibold drop-shadow">{{ formatNumber(12453) }}</span>
</div>
<!-- Comment -->
<div class="flex flex-col items-center">
<div class="w-10 h-10 flex items-center justify-center">
<svg class="w-7 h-7 text-white drop-shadow-lg" viewBox="0 0 24 24" fill="currentColor">
<path d="M20 2H4c-1.1 0-2 .9-2 2v18l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2z" />
</svg>
</div>
<span class="text-white text-[10px] font-semibold drop-shadow">{{ formatNumber(892) }}</span>
</div>
<!-- Share -->
<div class="flex flex-col items-center">
<div class="w-10 h-10 flex items-center justify-center">
<svg class="w-7 h-7 text-white drop-shadow-lg" viewBox="0 0 24 24" fill="currentColor">
<path
d="M12 2L15.09 8.26L22 9.27L17 14.14L18.18 21.02L12 17.77L5.82 21.02L7 14.14L2 9.27L8.91 8.26L12 2Z"
transform="rotate(90 12 12)" />
</svg>
</div>
</div>
<!-- More -->
<div class="w-10 h-10 flex items-center justify-center">
<IconDots class="w-6 h-6 text-white drop-shadow-lg" />
</div>
<!-- Audio spinning icon -->
<div
class="w-9 h-9 rounded-lg bg-gradient-to-br from-neutral-600 to-neutral-900 border border-white/30 overflow-hidden animate-[spin_3s_linear_infinite]">
<img v-if="socialAccount.avatar_url" :src="socialAccount.avatar_url"
class="w-full h-full object-cover" />
<div v-else
class="w-full h-full bg-[#1877f2] flex items-center justify-center text-white font-bold text-xs">
{{ displayName?.charAt(0).toUpperCase() }}
</div>
</div>
</div>
<!-- Bottom Info -->
<div v-if="media.length > 0" class="absolute left-0 right-14 bottom-[68px] px-3 z-10">
<div class="flex items-center gap-2 mb-1.5">
<img v-if="socialAccount.avatar_url" :src="socialAccount.avatar_url"
class="w-8 h-8 rounded-full object-cover border border-white/30" />
<div v-else
class="w-8 h-8 rounded-full bg-[#1877f2] flex items-center justify-center text-white font-bold text-[12px] border border-white/30">
{{ displayName?.charAt(0).toUpperCase() }}
</div>
<span class="text-white text-[13px] font-semibold drop-shadow-lg">{{ displayName }}</span>
<button class="px-3 py-1 bg-[#1877f2] text-white text-[11px] font-semibold rounded-md">
Follow
</button>
</div>
<p v-if="content" class="text-white text-[12px] drop-shadow-lg line-clamp-2 mb-1.5">
{{ content }}
</p>
<div class="flex items-center">
<div class="flex items-center gap-1 bg-white/20 backdrop-blur-sm rounded-full px-2 py-0.5">
<svg class="w-3 h-3 text-white" viewBox="0 0 24 24" fill="currentColor">
<path
d="M12 3v10.55c-.59-.34-1.27-.55-2-.55-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4V7h4V3h-6z" />
</svg>
<span class="text-white text-[10px] truncate max-w-[120px]">Original audio</span>
</div>
</div>
</div>
<!-- Bottom Navigation Bar -->
<div
class="absolute bottom-0 left-0 right-0 h-[50px] bg-black/80 backdrop-blur-sm flex items-center justify-around px-4 z-20">
<!-- Home -->
<svg class="w-6 h-6 text-white/70" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2">
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
</svg>
<!-- Friends -->
<svg class="w-6 h-6 text-white/70" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2">
<circle cx="9" cy="7" r="4" />
<path d="M2 21v-2a4 4 0 0 1 4-4h6" />
</svg>
<!-- Reels - Active -->
<svg class="w-6 h-6 text-white" viewBox="0 0 24 24" fill="currentColor">
<rect x="2" y="2" width="20" height="20" rx="4" />
<polygon points="10,7 10,17 17,12" fill="black" />
</svg>
<!-- Marketplace -->
<svg class="w-6 h-6 text-white/70" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2">
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
</svg>
<!-- Profile -->
<div class="w-6 h-6 rounded-full overflow-hidden ring-[1.5px] ring-white/50">
<img v-if="socialAccount.avatar_url" :src="socialAccount.avatar_url"
class="w-full h-full object-cover" />
<div v-else class="w-full h-full bg-neutral-600" />
</div>
</div>
</div>
</template>
<!-- ==================== STORIES ==================== -->
<template v-else-if="isStory">
<div class="relative flex-1 bg-black overflow-hidden">
<!-- Media - Full screen -->
<div class="absolute inset-0">
<PostMediaPreview
:media="media"
:placeholder-icon="IconPhoto"
:show-arrows="false"
:show-dots="false"
placeholder-class="w-full h-full flex items-center justify-center bg-gradient-to-b from-[#1877f2]/50 to-[#833ab4]/50"
/>
</div>
<!-- Progress Bars -->
<div v-if="media.length > 0" class="absolute top-1 left-2 right-2 flex gap-0.5 z-10">
<div class="flex-1 h-[2px] bg-white/30 rounded-full overflow-hidden">
<div class="h-full w-1/3 bg-white rounded-full" />
</div>
</div>
<!-- User Info -->
<div v-if="media.length > 0"
class="absolute top-3 left-0 right-0 px-3 flex items-center justify-between z-10">
<div class="flex items-center gap-2">
<div class="p-[2px] bg-[#1877f2] rounded-full">
<img v-if="socialAccount.avatar_url" :src="socialAccount.avatar_url"
class="w-8 h-8 rounded-full object-cover border-2 border-black" />
<div v-else
class="w-8 h-8 rounded-full bg-[#1877f2] flex items-center justify-center text-white font-bold text-[11px] border-2 border-black">
{{ displayName?.charAt(0).toUpperCase() }}
</div>
</div>
<span class="text-white text-[13px] font-semibold drop-shadow-lg">{{ displayName }}</span>
<span class="text-white/70 text-[11px] drop-shadow">2h</span>
</div>
<div class="flex items-center gap-3">
<IconDots class="w-5 h-5 text-white drop-shadow-lg" />
</div>
</div>
<!-- Caption overlay (if content exists and media) -->
<div v-if="media.length > 0 && content" class="absolute bottom-20 left-3 right-3 z-10">
<p
class="text-white text-[13px] text-center drop-shadow-lg bg-black/20 backdrop-blur-sm rounded-xl px-3 py-2 line-clamp-2">
{{ content }}
</p>
</div>
<!-- Bottom Reply Bar -->
<div v-if="media.length > 0" class="absolute bottom-3 left-3 right-3 flex items-center gap-2 z-10">
<div class="flex-1 bg-white/20 backdrop-blur-md rounded-full px-4 py-2.5 border border-white/20">
<span class="text-white/80 text-[13px]">Reply to {{ displayName }}...</span>
</div>
<svg class="w-7 h-7 text-white drop-shadow-lg" viewBox="0 0 24 24" fill="currentColor">
<path
d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z" />
</svg>
<svg class="w-7 h-7 text-white drop-shadow-lg" viewBox="0 0 24 24" fill="currentColor">
<path d="M2 21l21-9L2 3v7l15 2-15 2v7z" />
</svg>
</div>
</div>
</template>
</div>
</template>