diff --git a/app/Services/Social/Concerns/CropsImageForAspectRatio.php b/app/Services/Social/Concerns/CropsImageForAspectRatio.php new file mode 100644 index 00000000..f807e62a --- /dev/null +++ b/app/Services/Social/Concerns/CropsImageForAspectRatio.php @@ -0,0 +1,66 @@ +aspectRatioToFloat($aspectRatio); + + $tempInput = tempnam(sys_get_temp_dir(), 'crop_in_'); + + try { + $download = Http::sink($tempInput)->timeout(120)->get($imageUrl); + + if ($download->failed()) { + throw $this->cropFailureException('Failed to download image for cropping'); + } + + $cropped = app(MediaOptimizer::class)->cropToAspectRatio($tempInput, $ratio); + + $path = self::CROP_DIRECTORY.'/'.Str::uuid()->toString().'.jpg'; + Storage::put($path, file_get_contents($cropped)); + + @unlink($cropped); + + return Storage::url($path); + } finally { + @unlink($tempInput); + } + } + + protected function aspectRatioToFloat(string $ratio): float + { + return match ($ratio) { + '4:5' => 4 / 5, + '16:9' => 16 / 9, + default => 1.0, + }; + } + + /** + * The platform-specific exception thrown when the source image cannot be + * downloaded for cropping. + */ + abstract protected function cropFailureException(string $message): SocialPublishException; +} diff --git a/app/Services/Social/FacebookPublisher.php b/app/Services/Social/FacebookPublisher.php index b7133e36..9816fc9d 100644 --- a/app/Services/Social/FacebookPublisher.php +++ b/app/Services/Social/FacebookPublisher.php @@ -7,7 +7,9 @@ use App\Enums\PostPlatform\ContentType; use App\Exceptions\Social\ErrorCategory; use App\Exceptions\Social\FacebookPublishException; +use App\Exceptions\Social\SocialPublishException; use App\Models\PostPlatform; +use App\Services\Social\Concerns\CropsImageForAspectRatio; use App\Services\Social\Concerns\HasSocialHttpClient; use Illuminate\Http\Client\PendingRequest; use Illuminate\Http\Client\Response; @@ -16,6 +18,7 @@ class FacebookPublisher { + use CropsImageForAspectRatio; use HasSocialHttpClient; private string $baseUrl; @@ -46,16 +49,17 @@ public function publish(PostPlatform $postPlatform): array $media = $postPlatform->post->mediaItems; $contentType = $postPlatform->content_type; + $aspectRatio = data_get($postPlatform->meta, 'aspect_ratio'); return match ($contentType) { ContentType::FacebookReel => $this->publishReel($pageId, $accessToken, $content, $media->first()), ContentType::FacebookStory => $this->publishStory($pageId, $accessToken, $media->first()), - ContentType::FacebookPost => $this->publishPost($pageId, $accessToken, $content, $media), + ContentType::FacebookPost => $this->publishPost($pageId, $accessToken, $content, $media, $aspectRatio), default => throw new \Exception("Unsupported Facebook content type: {$contentType?->value}"), }; } - private function publishPost(string $pageId, string $accessToken, ?string $content, $media): array + private function publishPost(string $pageId, string $accessToken, ?string $content, $media, ?string $aspectRatio): array { // Text only post if ($media->isEmpty()) { @@ -77,10 +81,10 @@ private function publishPost(string $pageId, string $accessToken, ?string $conte if ($isImage) { // Single or multiple images if ($media->count() === 1) { - return $this->publishSingleImagePost($pageId, $accessToken, $content, $firstMedia); + return $this->publishSingleImagePost($pageId, $accessToken, $content, $firstMedia, $aspectRatio); } - return $this->publishMultiImagePost($pageId, $accessToken, $content, $media); + return $this->publishMultiImagePost($pageId, $accessToken, $content, $media, $aspectRatio); } throw new \Exception('Unsupported media type for Facebook'); @@ -110,10 +114,10 @@ private function publishTextPost(string $pageId, string $accessToken, string $co ]; } - private function publishSingleImagePost(string $pageId, string $accessToken, ?string $content, $media): array + private function publishSingleImagePost(string $pageId, string $accessToken, ?string $content, $media, ?string $aspectRatio): array { $payload = [ - 'url' => $media->url, + 'url' => $this->cropImageForAspectRatio($media->url, $aspectRatio), 'access_token' => $accessToken, ]; @@ -140,7 +144,7 @@ private function publishSingleImagePost(string $pageId, string $accessToken, ?st ]; } - private function publishMultiImagePost(string $pageId, string $accessToken, ?string $content, $mediaCollection): array + private function publishMultiImagePost(string $pageId, string $accessToken, ?string $content, $mediaCollection, ?string $aspectRatio): array { // Upload each image as unpublished $attachedMedia = []; @@ -151,7 +155,7 @@ private function publishMultiImagePost(string $pageId, string $accessToken, ?str } $uploadResponse = $this->facebookHttp()->post("{$this->baseUrl}/{$pageId}/photos", [ - 'url' => $media->url, + 'url' => $this->cropImageForAspectRatio($media->url, $aspectRatio), 'published' => 'false', 'access_token' => $accessToken, ]); @@ -392,4 +396,12 @@ private function handleApiError(Response $response): never { throw FacebookPublishException::fromApiResponse($response); } + + protected function cropFailureException(string $message): SocialPublishException + { + return new FacebookPublishException( + userMessage: $message, + category: ErrorCategory::ServerError, + ); + } } diff --git a/app/Services/Social/InstagramPublisher.php b/app/Services/Social/InstagramPublisher.php index c22bef71..23e684d8 100644 --- a/app/Services/Social/InstagramPublisher.php +++ b/app/Services/Social/InstagramPublisher.php @@ -7,17 +7,16 @@ use App\Enums\PostPlatform\ContentType; use App\Exceptions\Social\ErrorCategory; use App\Exceptions\Social\InstagramPublishException; +use App\Exceptions\Social\SocialPublishException; use App\Models\PostPlatform; -use App\Services\Media\MediaOptimizer; +use App\Services\Social\Concerns\CropsImageForAspectRatio; use App\Services\Social\Concerns\HasSocialHttpClient; use Illuminate\Http\Client\Response; -use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; -use Illuminate\Support\Facades\Storage; -use Illuminate\Support\Str; class InstagramPublisher { + use CropsImageForAspectRatio; use HasSocialHttpClient; private string $baseUrl; @@ -80,7 +79,7 @@ private function publishFeed(string $instagramId, string $accessToken, ?string $ private function publishSingleImage(string $instagramId, string $accessToken, ?string $content, $media, ?string $aspectRatio): array { - $imageUrl = $this->cropForFeed($media->url, $aspectRatio); + $imageUrl = $this->cropImageForAspectRatio($media->url, $aspectRatio); // Step 1: Create container $containerResponse = $this->socialHttp()->post("{$this->baseUrl}/{$instagramId}/media", [ @@ -206,7 +205,7 @@ private function publishCarousel(string $instagramId, string $accessToken, ?stri $params['video_url'] = $media->url; $params['media_type'] = 'VIDEO'; } else { - $params['image_url'] = $this->cropForFeed($media->url, $aspectRatio); + $params['image_url'] = $this->cropImageForAspectRatio($media->url, $aspectRatio); } $containerResponse = $this->socialHttp()->post("{$this->baseUrl}/{$instagramId}/media", $params); @@ -311,52 +310,12 @@ private function publishContainer(string $instagramId, string $accessToken, stri ]; } - /** - * Crop the image to the user-selected aspect ratio and return a public URL - * Instagram can fetch. Returns the original URL untouched when no ratio is - * set or 'original' is selected (caller has already validated the original - * fits IG's 4:5 to 1.91:1 range). - */ - private function cropForFeed(string $imageUrl, ?string $aspectRatio): string + protected function cropFailureException(string $message): SocialPublishException { - if (! $aspectRatio || $aspectRatio === 'original') { - return $imageUrl; - } - - $ratio = $this->aspectRatioToFloat($aspectRatio); - - $tempInput = tempnam(sys_get_temp_dir(), 'ig_crop_in_'); - - try { - $download = Http::sink($tempInput)->timeout(120)->get($imageUrl); - - if ($download->failed()) { - throw new InstagramPublishException( - userMessage: 'Failed to download image for cropping', - category: ErrorCategory::ServerError, - ); - } - - $cropped = app(MediaOptimizer::class)->cropToAspectRatio($tempInput, $ratio); - - $path = 'instagram-crops/'.Str::uuid()->toString().'.jpg'; - Storage::put($path, file_get_contents($cropped)); - - @unlink($cropped); - - return Storage::url($path); - } finally { - @unlink($tempInput); - } - } - - private function aspectRatioToFloat(string $ratio): float - { - return match ($ratio) { - '4:5' => 4 / 5, - '16:9' => 16 / 9, - default => 1.0, - }; + return new InstagramPublishException( + userMessage: $message, + category: ErrorCategory::ServerError, + ); } private function waitForMediaProcessing(string $containerId, string $accessToken, int $maxAttempts = 30): void diff --git a/lang/en/posts.php b/lang/en/posts.php index a4dd2a69..2009a8ee 100644 --- a/lang/en/posts.php +++ b/lang/en/posts.php @@ -127,6 +127,13 @@ 'reel' => 'Reel', 'story' => 'Story', ], + 'aspect_label' => 'Aspect ratio', + 'aspect' => [ + 'square' => 'Square (1:1)', + 'portrait' => 'Portrait (4:5)', + 'landscape' => 'Landscape (16:9)', + 'original' => 'Original', + ], ], 'linkedin' => [ 'settings' => 'LinkedIn Settings', diff --git a/lang/es/posts.php b/lang/es/posts.php index 7bc4e1e9..a450a798 100644 --- a/lang/es/posts.php +++ b/lang/es/posts.php @@ -127,6 +127,13 @@ 'reel' => 'Reel', 'story' => 'Historia', ], + 'aspect_label' => 'Proporción', + 'aspect' => [ + 'square' => 'Cuadrado (1:1)', + 'portrait' => 'Vertical (4:5)', + 'landscape' => 'Horizontal (16:9)', + 'original' => 'Original', + ], ], 'linkedin' => [ 'settings' => 'Configuración de LinkedIn', diff --git a/lang/pt-BR/posts.php b/lang/pt-BR/posts.php index bba789cc..a8eb31b0 100644 --- a/lang/pt-BR/posts.php +++ b/lang/pt-BR/posts.php @@ -127,6 +127,13 @@ 'reel' => 'Reel', 'story' => 'Story', ], + 'aspect_label' => 'Proporção', + 'aspect' => [ + 'square' => 'Quadrado (1:1)', + 'portrait' => 'Retrato (4:5)', + 'landscape' => 'Paisagem (16:9)', + 'original' => 'Original', + ], ], 'linkedin' => [ 'settings' => 'Configurações do LinkedIn', diff --git a/package-lock.json b/package-lock.json index 96a8f02c..bbf85de6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4,7 +4,6 @@ "requires": true, "packages": { "": { - "name": "trypost", "dependencies": { "@inertiajs/vue3": "^3.0.0", "@tabler/icons-vue": "^3.36.1", diff --git a/resources/js/components/posts/editor/FacebookSettings.vue b/resources/js/components/posts/editor/FacebookSettings.vue index 1451dd2e..00711fcb 100644 --- a/resources/js/components/posts/editor/FacebookSettings.vue +++ b/resources/js/components/posts/editor/FacebookSettings.vue @@ -5,6 +5,7 @@ import { computed, ref } from 'vue'; import { Avatar } from '@/components/ui/avatar'; import { getMediaValidationWarning } from '@/composables/useMedia'; import { getPlatformLogo } from '@/composables/usePlatformLogo'; +import { ContentType } from '@/enums/content-type'; import type { MediaItem } from '@/types/media'; interface SocialAccount { @@ -19,30 +20,48 @@ interface Props { socialAccount: SocialAccount | null; contentType: string; media: MediaItem[]; + meta?: Record; disabled?: boolean; } const props = withDefaults(defineProps(), { disabled: false, + meta: () => ({}), }); const emit = defineEmits<{ 'update:contentType': [value: string]; + 'update:meta': [meta: Record]; }>(); const open = ref(false); const variants = [ - { value: 'facebook_post', labelKey: 'posts.form.facebook.variant.post' }, - { value: 'facebook_reel', labelKey: 'posts.form.facebook.variant.reel' }, - { value: 'facebook_story', labelKey: 'posts.form.facebook.variant.story' }, + { value: ContentType.FacebookPost, labelKey: 'posts.form.facebook.variant.post' }, + { value: ContentType.FacebookReel, labelKey: 'posts.form.facebook.variant.reel' }, + { value: ContentType.FacebookStory, labelKey: 'posts.form.facebook.variant.story' }, ]; +const aspectRatios = [ + { value: '1:1', labelKey: 'posts.form.facebook.aspect.square' }, + { value: '4:5', labelKey: 'posts.form.facebook.aspect.portrait' }, + { value: '16:9', labelKey: 'posts.form.facebook.aspect.landscape' }, + { value: 'original', labelKey: 'posts.form.facebook.aspect.original' }, +]; + +const isFeed = computed(() => props.contentType === ContentType.FacebookPost); +const selectedAspectRatio = computed(() => props.meta.aspect_ratio ?? 'original'); + const pickVariant = (value: string) => { if (props.disabled) return; emit('update:contentType', value); }; +const pickAspectRatio = (value: string) => { + if (props.disabled) return; + emit('update:meta', { ...props.meta, aspect_ratio: value }); +}; + const warning = computed(() => getMediaValidationWarning(props.contentType, props.media)); @@ -99,6 +118,25 @@ const warning = computed(() => getMediaValidationWarning(props.contentType, prop +
+

{{ $t('posts.form.facebook.aspect_label') }}

+
+ +
+
+

- + { -

+
{ :social-account="pp.social_account" :content-type="platformContentTypes[pp.id] ?? ''" :media="media ?? []" + :meta="platformMeta[pp.id] ?? {}" :disabled="isReadOnly" @update:content-type="emit('update:platformContentType', pp.id, $event)" + @update:meta="emit('update:platformMeta', pp.id, $event)" />
diff --git a/resources/js/components/posts/previews/FacebookPreview.vue b/resources/js/components/posts/previews/FacebookPreview.vue index bb5c4422..f0302ae2 100644 --- a/resources/js/components/posts/previews/FacebookPreview.vue +++ b/resources/js/components/posts/previews/FacebookPreview.vue @@ -18,6 +18,7 @@ interface Props { content: string; media: MediaItem[]; contentType?: string; + meta?: Record; charCount?: number; maxLength?: number; isValid?: boolean; @@ -31,6 +32,21 @@ 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 = { + '1:1': 100, + '4:5': 125, + '16:9': 56.25, +}; +const feedAspectPadding = computed(() => 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) { @@ -122,14 +138,18 @@ const displayName = computed(() => props.socialAccount.display_name || props.soc

- -
- + +
+
+ +
+
+
+
diff --git a/resources/js/pages/posts/Edit.vue b/resources/js/pages/posts/Edit.vue index d2c28a1b..718462c8 100644 --- a/resources/js/pages/posts/Edit.vue +++ b/resources/js/pages/posts/Edit.vue @@ -10,7 +10,7 @@ import AiRegenerateImageDialog from '@/components/posts/ai/AiRegenerateImageDial import AiReviewDialog from '@/components/posts/ai/AiReviewDialog.vue'; import PostEditorComposer from '@/components/posts/editor/PostEditorComposer.vue'; import PostEditorHeader from '@/components/posts/editor/PostEditorHeader.vue'; -import PostEditorSidebar from '@/components/posts/editor/PostEditorSidebar.vue'; +import PostEditorTabs from '@/components/posts/editor/PostEditorTabs.vue'; import { usePostEcho } from '@/composables/echo/usePostEcho'; import { firstCompatibleVariant, @@ -206,7 +206,7 @@ const initialTabFromQuery = (() => { const initialHighlightCommentId = queryParams?.get('comment') ?? null; const activeTab = ref(initialTabFromQuery); const deleteModal = ref | null>(null); -const editorSidebarRef = ref | null>(null); +const editorTabsRef = ref | null>(null); const snapToCompatibleVariant = (platformId: string) => { const pp = post.value.post_platforms.find((p) => p.id === platformId); @@ -339,9 +339,9 @@ usePostEcho(post.value.id, '.post.platform.status.updated', () => { // Echo: listen for real-time comments usePostEcho(post.value.id, '.post.comment.created', (e: any) => { if (e.mentioned_users) { - editorSidebarRef.value?.registerMentionedUsers(e.mentioned_users); + editorTabsRef.value?.registerMentionedUsers(e.mentioned_users); } - editorSidebarRef.value?.addCommentFromBroadcast(e.comment); + editorTabsRef.value?.addCommentFromBroadcast(e.comment); }); @@ -402,8 +402,8 @@ usePostEcho(post.value.id, '.post.comment.created', (e: any) => {