From e25b320ad2ba40b715206b5b4ec7072feb0495a3 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Fri, 5 Jun 2026 18:01:45 -0300 Subject: [PATCH 1/5] Add aspect ratio selection for Facebook posts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../Concerns/CropsImageForAspectRatio.php | 64 +++++++++ app/Services/Social/FacebookPublisher.php | 28 ++-- app/Services/Social/InstagramPublisher.php | 61 ++------- lang/en/posts.php | 7 + lang/es/posts.php | 7 + lang/pt-BR/posts.php | 7 + .../posts/editor/FacebookSettings.vue | 44 ++++++- .../components/posts/editor/ScheduleTab.vue | 2 + .../posts/previews/FacebookPreview.vue | 36 +++-- .../Services/Social/FacebookPublisherTest.php | 124 ++++++++++++++++++ 10 files changed, 310 insertions(+), 70 deletions(-) create mode 100644 app/Services/Social/Concerns/CropsImageForAspectRatio.php diff --git a/app/Services/Social/Concerns/CropsImageForAspectRatio.php b/app/Services/Social/Concerns/CropsImageForAspectRatio.php new file mode 100644 index 00000000..f006ee8b --- /dev/null +++ b/app/Services/Social/Concerns/CropsImageForAspectRatio.php @@ -0,0 +1,64 @@ +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 = "{$pathPrefix}/".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..20080e93 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, 'facebook-crops'), '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, 'facebook-crops'), '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..79540f01 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, 'instagram-crops'); // 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, 'instagram-crops'); } $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 8488c29a..3c9dd900 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 cf988ea8..4523a56c 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 6dc86360..1d33f786 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/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/tests/Feature/Services/Social/FacebookPublisherTest.php b/tests/Feature/Services/Social/FacebookPublisherTest.php index cad4cfc0..4a8ce7b3 100644 --- a/tests/Feature/Services/Social/FacebookPublisherTest.php +++ b/tests/Feature/Services/Social/FacebookPublisherTest.php @@ -13,6 +13,17 @@ use App\Models\Workspace; use App\Services\Social\FacebookPublisher; use Illuminate\Support\Facades\Http; +use Illuminate\Support\Facades\Storage; +use Intervention\Image\Drivers\Gd\Driver; +use Intervention\Image\ImageManager; + +function facebookJpegBytes(int $width = 1200, int $height = 800): string +{ + $manager = new ImageManager(Driver::class); + $image = $manager->createImage($width, $height)->fill('888888'); + + return (string) $image->encodeUsingMediaType('image/jpeg', quality: 80); +} beforeEach(function () { $this->user = User::factory()->create(); @@ -636,3 +647,116 @@ && ! array_key_exists('description', $data); }); }); + +test('facebook single image post applies the selected aspect ratio crop and uploads the crop', function (string $ratio, float $expected) { + Storage::fake(); + + $this->postPlatform->update(['meta' => ['aspect_ratio' => $ratio]]); + $this->post->update([ + 'media' => [ + ['id' => 'm1', 'path' => 'media/a.jpg', 'url' => 'https://example.com/media/a.jpg', 'mime_type' => 'image/jpeg', 'original_filename' => 'a.jpg'], + ], + ]); + + Http::fake([ + 'https://example.com/media/a.jpg' => Http::response(facebookJpegBytes(1600, 900), 200), + '*/page_123/photos' => Http::response(['id' => 'photo_1', 'post_id' => 'post_1'], 200), + ]); + + $this->publisher->publish($this->postPlatform); + + $crops = collect(Storage::allFiles())->filter(fn (string $path) => str_starts_with($path, 'facebook-crops/')); + expect($crops)->toHaveCount(1); + + $manager = new ImageManager(Driver::class); + $tempFile = tempnam(sys_get_temp_dir(), 'verify_'); + file_put_contents($tempFile, Storage::get($crops->first())); + $image = $manager->decodePath($tempFile); + expect(round($image->width() / $image->height(), 2))->toBe(round($expected, 2)); + @unlink($tempFile); + + Http::assertSent(fn ($request) => str_contains($request->url(), '/page_123/photos') + && str_contains($request['url'], 'facebook-crops/') + && ! str_contains($request['url'], 'example.com')); +})->with([ + '1:1' => ['1:1', 1.0], + '4:5' => ['4:5', 4 / 5], + '16:9' => ['16:9', 16 / 9], +]); + +test('facebook multi image post applies the aspect ratio crop to every image', function () { + Storage::fake(); + + $this->postPlatform->update(['meta' => ['aspect_ratio' => '1:1']]); + $this->post->update([ + 'media' => [ + ['id' => 'm1', 'path' => 'media/a.jpg', 'url' => 'https://example.com/media/a.jpg', 'mime_type' => 'image/jpeg', 'original_filename' => 'a.jpg'], + ['id' => 'm2', 'path' => 'media/b.jpg', 'url' => 'https://example.com/media/b.jpg', 'mime_type' => 'image/jpeg', 'original_filename' => 'b.jpg'], + ], + ]); + + Http::fake([ + 'https://example.com/media/a.jpg' => Http::response(facebookJpegBytes(1600, 900), 200), + 'https://example.com/media/b.jpg' => Http::response(facebookJpegBytes(900, 1600), 200), + '*/page_123/photos' => Http::sequence() + ->push(['id' => 'up_1'], 200) + ->push(['id' => 'up_2'], 200), + '*/page_123/feed' => Http::response(['id' => 'post_1'], 200), + ]); + + $this->publisher->publish($this->postPlatform); + + $crops = collect(Storage::allFiles())->filter(fn (string $path) => str_starts_with($path, 'facebook-crops/')); + expect($crops)->toHaveCount(2); + + $manager = new ImageManager(Driver::class); + foreach ($crops as $cropPath) { + $tempFile = tempnam(sys_get_temp_dir(), 'verify_'); + file_put_contents($tempFile, Storage::get($cropPath)); + $image = $manager->decodePath($tempFile); + expect($image->width())->toBe($image->height()); + @unlink($tempFile); + } + + Http::assertSent(fn ($request) => str_contains($request->url(), '/page_123/photos') + && str_contains($request['url'] ?? '', 'facebook-crops/')); +}); + +test('facebook image post throws when the source image cannot be downloaded for cropping', function () { + Storage::fake(); + + $this->postPlatform->update(['meta' => ['aspect_ratio' => '4:5']]); + $this->post->update([ + 'media' => [ + ['id' => 'm1', 'path' => 'media/a.jpg', 'url' => 'https://example.com/media/a.jpg', 'mime_type' => 'image/jpeg', 'original_filename' => 'a.jpg'], + ], + ]); + + Http::fake([ + 'https://example.com/media/a.jpg' => Http::response('', 404), + ]); + + expect(fn () => $this->publisher->publish($this->postPlatform)) + ->toThrow(FacebookPublishException::class, 'Failed to download image for cropping'); +}); + +test('facebook single image post without aspect ratio uploads the original image (no crop)', function () { + Storage::fake(); + + $this->post->update([ + 'media' => [ + ['id' => 'm1', 'path' => 'media/a.jpg', 'url' => 'https://example.com/media/a.jpg', 'mime_type' => 'image/jpeg', 'original_filename' => 'a.jpg'], + ], + ]); + + Http::fake([ + '*/page_123/photos' => Http::response(['id' => 'photo_1', 'post_id' => 'post_1'], 200), + ]); + + $this->publisher->publish($this->postPlatform); + + expect(collect(Storage::allFiles())->filter(fn (string $path) => str_starts_with($path, 'facebook-crops/')))->toBeEmpty(); + + Http::assertSent(fn ($request) => str_contains($request->url(), '/page_123/photos') + && $request['url'] === 'https://example.com/media/a.jpg'); +}); From 1b096f9ede2cd6ac7d6dc3536aa6158f14fc8a1e Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Fri, 5 Jun 2026 18:30:17 -0300 Subject: [PATCH 2/5] Unify crop storage folder and make IG/FB crop tests symmetric MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CropsImageForAspectRatio: drop the per-publisher path-prefix argument; crops from both platforms go to a single `social-crops/` directory (the folder has no functional meaning — the platform fetches by URL and nothing reads it back; UUID filenames prevent collisions). - Close the IG/FB test asymmetry surfaced in review: Instagram single-image crop now runs across 1:1/4:5/16:9 (dataset) and has a crop-download-failure test; Facebook gains an explicit 'original' bypass test. Both platforms now cover the same matrix (crop per ratio + uploaded-URL is the crop, original/no-meta bypass, multi-image, download-failure exception). Follow-up tracked in #83 (orphaned crops are never pruned from storage). --- .../Concerns/CropsImageForAspectRatio.php | 6 ++-- app/Services/Social/FacebookPublisher.php | 4 +-- app/Services/Social/InstagramPublisher.php | 4 +-- .../Services/Social/FacebookPublisherTest.php | 32 ++++++++++++++--- .../Social/InstagramPublisherTest.php | 36 +++++++++++++++---- 5 files changed, 64 insertions(+), 18 deletions(-) diff --git a/app/Services/Social/Concerns/CropsImageForAspectRatio.php b/app/Services/Social/Concerns/CropsImageForAspectRatio.php index f006ee8b..f807e62a 100644 --- a/app/Services/Social/Concerns/CropsImageForAspectRatio.php +++ b/app/Services/Social/Concerns/CropsImageForAspectRatio.php @@ -12,12 +12,14 @@ trait CropsImageForAspectRatio { + private const CROP_DIRECTORY = 'social-crops'; + /** * Crop the image to the user-selected aspect ratio and return a public URL * the platform can fetch. Returns the original URL untouched when no ratio * is set or 'original' is selected. */ - protected function cropImageForAspectRatio(string $imageUrl, ?string $aspectRatio, string $pathPrefix): string + protected function cropImageForAspectRatio(string $imageUrl, ?string $aspectRatio): string { if (! $aspectRatio || $aspectRatio === 'original') { return $imageUrl; @@ -36,7 +38,7 @@ protected function cropImageForAspectRatio(string $imageUrl, ?string $aspectRati $cropped = app(MediaOptimizer::class)->cropToAspectRatio($tempInput, $ratio); - $path = "{$pathPrefix}/".Str::uuid()->toString().'.jpg'; + $path = self::CROP_DIRECTORY.'/'.Str::uuid()->toString().'.jpg'; Storage::put($path, file_get_contents($cropped)); @unlink($cropped); diff --git a/app/Services/Social/FacebookPublisher.php b/app/Services/Social/FacebookPublisher.php index 20080e93..9816fc9d 100644 --- a/app/Services/Social/FacebookPublisher.php +++ b/app/Services/Social/FacebookPublisher.php @@ -117,7 +117,7 @@ private function publishTextPost(string $pageId, string $accessToken, string $co private function publishSingleImagePost(string $pageId, string $accessToken, ?string $content, $media, ?string $aspectRatio): array { $payload = [ - 'url' => $this->cropImageForAspectRatio($media->url, $aspectRatio, 'facebook-crops'), + 'url' => $this->cropImageForAspectRatio($media->url, $aspectRatio), 'access_token' => $accessToken, ]; @@ -155,7 +155,7 @@ private function publishMultiImagePost(string $pageId, string $accessToken, ?str } $uploadResponse = $this->facebookHttp()->post("{$this->baseUrl}/{$pageId}/photos", [ - 'url' => $this->cropImageForAspectRatio($media->url, $aspectRatio, 'facebook-crops'), + 'url' => $this->cropImageForAspectRatio($media->url, $aspectRatio), 'published' => 'false', 'access_token' => $accessToken, ]); diff --git a/app/Services/Social/InstagramPublisher.php b/app/Services/Social/InstagramPublisher.php index 79540f01..23e684d8 100644 --- a/app/Services/Social/InstagramPublisher.php +++ b/app/Services/Social/InstagramPublisher.php @@ -79,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->cropImageForAspectRatio($media->url, $aspectRatio, 'instagram-crops'); + $imageUrl = $this->cropImageForAspectRatio($media->url, $aspectRatio); // Step 1: Create container $containerResponse = $this->socialHttp()->post("{$this->baseUrl}/{$instagramId}/media", [ @@ -205,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->cropImageForAspectRatio($media->url, $aspectRatio, 'instagram-crops'); + $params['image_url'] = $this->cropImageForAspectRatio($media->url, $aspectRatio); } $containerResponse = $this->socialHttp()->post("{$this->baseUrl}/{$instagramId}/media", $params); diff --git a/tests/Feature/Services/Social/FacebookPublisherTest.php b/tests/Feature/Services/Social/FacebookPublisherTest.php index 4a8ce7b3..79b9c6ac 100644 --- a/tests/Feature/Services/Social/FacebookPublisherTest.php +++ b/tests/Feature/Services/Social/FacebookPublisherTest.php @@ -665,7 +665,7 @@ function facebookJpegBytes(int $width = 1200, int $height = 800): string $this->publisher->publish($this->postPlatform); - $crops = collect(Storage::allFiles())->filter(fn (string $path) => str_starts_with($path, 'facebook-crops/')); + $crops = collect(Storage::allFiles())->filter(fn (string $path) => str_starts_with($path, 'social-crops/')); expect($crops)->toHaveCount(1); $manager = new ImageManager(Driver::class); @@ -676,7 +676,7 @@ function facebookJpegBytes(int $width = 1200, int $height = 800): string @unlink($tempFile); Http::assertSent(fn ($request) => str_contains($request->url(), '/page_123/photos') - && str_contains($request['url'], 'facebook-crops/') + && str_contains($request['url'], 'social-crops/') && ! str_contains($request['url'], 'example.com')); })->with([ '1:1' => ['1:1', 1.0], @@ -706,7 +706,7 @@ function facebookJpegBytes(int $width = 1200, int $height = 800): string $this->publisher->publish($this->postPlatform); - $crops = collect(Storage::allFiles())->filter(fn (string $path) => str_starts_with($path, 'facebook-crops/')); + $crops = collect(Storage::allFiles())->filter(fn (string $path) => str_starts_with($path, 'social-crops/')); expect($crops)->toHaveCount(2); $manager = new ImageManager(Driver::class); @@ -719,7 +719,7 @@ function facebookJpegBytes(int $width = 1200, int $height = 800): string } Http::assertSent(fn ($request) => str_contains($request->url(), '/page_123/photos') - && str_contains($request['url'] ?? '', 'facebook-crops/')); + && str_contains($request['url'] ?? '', 'social-crops/')); }); test('facebook image post throws when the source image cannot be downloaded for cropping', function () { @@ -755,7 +755,29 @@ function facebookJpegBytes(int $width = 1200, int $height = 800): string $this->publisher->publish($this->postPlatform); - expect(collect(Storage::allFiles())->filter(fn (string $path) => str_starts_with($path, 'facebook-crops/')))->toBeEmpty(); + expect(collect(Storage::allFiles())->filter(fn (string $path) => str_starts_with($path, 'social-crops/')))->toBeEmpty(); + + Http::assertSent(fn ($request) => str_contains($request->url(), '/page_123/photos') + && $request['url'] === 'https://example.com/media/a.jpg'); +}); + +test('facebook single image post with original aspect ratio uploads the original image (no crop)', function () { + Storage::fake(); + + $this->postPlatform->update(['meta' => ['aspect_ratio' => 'original']]); + $this->post->update([ + 'media' => [ + ['id' => 'm1', 'path' => 'media/a.jpg', 'url' => 'https://example.com/media/a.jpg', 'mime_type' => 'image/jpeg', 'original_filename' => 'a.jpg'], + ], + ]); + + Http::fake([ + '*/page_123/photos' => Http::response(['id' => 'photo_1', 'post_id' => 'post_1'], 200), + ]); + + $this->publisher->publish($this->postPlatform); + + expect(collect(Storage::allFiles())->filter(fn (string $path) => str_starts_with($path, 'social-crops/')))->toBeEmpty(); Http::assertSent(fn ($request) => str_contains($request->url(), '/page_123/photos') && $request['url'] === 'https://example.com/media/a.jpg'); diff --git a/tests/Feature/Services/Social/InstagramPublisherTest.php b/tests/Feature/Services/Social/InstagramPublisherTest.php index fd45ee73..c32db466 100644 --- a/tests/Feature/Services/Social/InstagramPublisherTest.php +++ b/tests/Feature/Services/Social/InstagramPublisherTest.php @@ -4,6 +4,7 @@ use App\Enums\PostPlatform\ContentType; use App\Enums\SocialAccount\Platform; +use App\Exceptions\Social\InstagramPublishException; use App\Exceptions\TokenExpiredException; use App\Models\Post; use App\Models\PostPlatform; @@ -726,10 +727,10 @@ function fakeJpegBytes(int $width = 1200, int $height = 800): string ->toThrow(Exception::class); }); -test('feed image is cropped to chosen aspect ratio before publishing', function () { +test('feed image is cropped to chosen aspect ratio before publishing', function (string $aspectRatio, float $expected) { Storage::fake(); - $this->postPlatform->update(['meta' => ['aspect_ratio' => '4:5']]); + $this->postPlatform->update(['meta' => ['aspect_ratio' => $aspectRatio]]); $this->post->update([ 'media' => [ @@ -753,15 +754,14 @@ function fakeJpegBytes(int $width = 1200, int $height = 800): string $this->publisher->publish($this->postPlatform); - $cropped = collect(Storage::allFiles())->first(fn (string $path) => str_starts_with($path, 'instagram-crops/')); + $cropped = collect(Storage::allFiles())->first(fn (string $path) => str_starts_with($path, 'social-crops/')); expect($cropped)->not->toBeNull(); $manager = new ImageManager(Driver::class); $tempFile = tempnam(sys_get_temp_dir(), 'verify_'); file_put_contents($tempFile, Storage::get($cropped)); $image = $manager->decodePath($tempFile); - $ratio = $image->width() / $image->height(); - expect(abs($ratio - 0.8))->toBeLessThan(0.01); + expect(abs($image->width() / $image->height() - $expected))->toBeLessThan(0.01); @unlink($tempFile); Http::assertSent(function ($request) { @@ -770,9 +770,31 @@ function fakeJpegBytes(int $width = 1200, int $height = 800): string } $imageUrl = $request['image_url'] ?? ''; - return str_contains($imageUrl, 'instagram-crops/') + return str_contains($imageUrl, 'social-crops/') && ! str_contains($imageUrl, 'example.com/media/test.jpg'); }); +})->with([ + '1:1' => ['1:1', 1.0], + '4:5' => ['4:5', 4 / 5], + '16:9' => ['16:9', 16 / 9], +]); + +test('feed image throws when the source image cannot be downloaded for cropping', function () { + Storage::fake(); + + $this->postPlatform->update(['meta' => ['aspect_ratio' => '4:5']]); + $this->post->update([ + 'media' => [ + ['id' => 'm1', 'path' => 'media/a.jpg', 'url' => 'https://example.com/media/a.jpg', 'mime_type' => 'image/jpeg', 'original_filename' => 'a.jpg'], + ], + ]); + + Http::fake([ + 'https://example.com/media/a.jpg' => Http::response('', 404), + ]); + + expect(fn () => $this->publisher->publish($this->postPlatform)) + ->toThrow(InstagramPublishException::class, 'Failed to download image for cropping'); }); test('feed image with original aspect ratio bypasses crop', function () { @@ -875,7 +897,7 @@ function fakeJpegBytes(int $width = 1200, int $height = 800): string $this->publisher->publish($this->postPlatform); - $crops = collect(Storage::allFiles())->filter(fn (string $path) => str_starts_with($path, 'instagram-crops/')); + $crops = collect(Storage::allFiles())->filter(fn (string $path) => str_starts_with($path, 'social-crops/')); expect($crops)->toHaveCount(2); $manager = new ImageManager(Driver::class); From 5be0abfae330a38924ddd3f041914ddcd0476296 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Fri, 5 Jun 2026 18:45:48 -0300 Subject: [PATCH 3/5] Cover carousel/multi crop per-ratio and FB multi abort-on-crop-failure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Instagram carousel and Facebook multi-image crop tests now run as datasets over 1:1 and 4:5, asserting each image's real cropped ratio (previously 1:1 only) — proving the chosen ratio flows through the multi-image paths. - Add a Facebook test that a multi-image post aborts entirely (throws FacebookPublishException) when one image can't be downloaded for cropping. --- .../Services/Social/FacebookPublisherTest.php | 31 +++++++++++++++++-- .../Social/InstagramPublisherTest.php | 11 ++++--- 2 files changed, 35 insertions(+), 7 deletions(-) diff --git a/tests/Feature/Services/Social/FacebookPublisherTest.php b/tests/Feature/Services/Social/FacebookPublisherTest.php index 79b9c6ac..a5e8e37e 100644 --- a/tests/Feature/Services/Social/FacebookPublisherTest.php +++ b/tests/Feature/Services/Social/FacebookPublisherTest.php @@ -684,10 +684,10 @@ function facebookJpegBytes(int $width = 1200, int $height = 800): string '16:9' => ['16:9', 16 / 9], ]); -test('facebook multi image post applies the aspect ratio crop to every image', function () { +test('facebook multi image post applies the chosen aspect ratio crop to every image', function (string $aspectRatio, float $expected) { Storage::fake(); - $this->postPlatform->update(['meta' => ['aspect_ratio' => '1:1']]); + $this->postPlatform->update(['meta' => ['aspect_ratio' => $aspectRatio]]); $this->post->update([ 'media' => [ ['id' => 'm1', 'path' => 'media/a.jpg', 'url' => 'https://example.com/media/a.jpg', 'mime_type' => 'image/jpeg', 'original_filename' => 'a.jpg'], @@ -714,12 +714,37 @@ function facebookJpegBytes(int $width = 1200, int $height = 800): string $tempFile = tempnam(sys_get_temp_dir(), 'verify_'); file_put_contents($tempFile, Storage::get($cropPath)); $image = $manager->decodePath($tempFile); - expect($image->width())->toBe($image->height()); + expect(abs($image->width() / $image->height() - $expected))->toBeLessThan(0.01); @unlink($tempFile); } Http::assertSent(fn ($request) => str_contains($request->url(), '/page_123/photos') && str_contains($request['url'] ?? '', 'social-crops/')); +})->with([ + '1:1' => ['1:1', 1.0], + '4:5' => ['4:5', 4 / 5], +]); + +test('facebook multi image post aborts entirely when one image cannot be downloaded for cropping', function () { + Storage::fake(); + + $this->postPlatform->update(['meta' => ['aspect_ratio' => '4:5']]); + $this->post->update([ + 'media' => [ + ['id' => 'm1', 'path' => 'media/a.jpg', 'url' => 'https://example.com/media/a.jpg', 'mime_type' => 'image/jpeg', 'original_filename' => 'a.jpg'], + ['id' => 'm2', 'path' => 'media/b.jpg', 'url' => 'https://example.com/media/b.jpg', 'mime_type' => 'image/jpeg', 'original_filename' => 'b.jpg'], + ], + ]); + + Http::fake([ + 'https://example.com/media/a.jpg' => Http::response('', 404), + 'https://example.com/media/b.jpg' => Http::response(facebookJpegBytes(900, 1600), 200), + '*/page_123/photos' => Http::response(['id' => 'up'], 200), + '*/page_123/feed' => Http::response(['id' => 'post_1'], 200), + ]); + + expect(fn () => $this->publisher->publish($this->postPlatform)) + ->toThrow(FacebookPublishException::class, 'Failed to download image for cropping'); }); test('facebook image post throws when the source image cannot be downloaded for cropping', function () { diff --git a/tests/Feature/Services/Social/InstagramPublisherTest.php b/tests/Feature/Services/Social/InstagramPublisherTest.php index c32db466..30ff943b 100644 --- a/tests/Feature/Services/Social/InstagramPublisherTest.php +++ b/tests/Feature/Services/Social/InstagramPublisherTest.php @@ -869,10 +869,10 @@ function fakeJpegBytes(int $width = 1200, int $height = 800): string }); }); -test('carousel applies aspect ratio crop to every image', function () { +test('carousel applies the chosen aspect ratio crop to every image', function (string $aspectRatio, float $expected) { Storage::fake(); - $this->postPlatform->update(['meta' => ['aspect_ratio' => '1:1']]); + $this->postPlatform->update(['meta' => ['aspect_ratio' => $aspectRatio]]); $this->post->update([ 'media' => [ @@ -905,7 +905,10 @@ function fakeJpegBytes(int $width = 1200, int $height = 800): string $tempFile = tempnam(sys_get_temp_dir(), 'verify_'); file_put_contents($tempFile, Storage::get($cropPath)); $image = $manager->decodePath($tempFile); - expect($image->width())->toBe($image->height()); + expect(abs($image->width() / $image->height() - $expected))->toBeLessThan(0.01); @unlink($tempFile); } -}); +})->with([ + '1:1' => ['1:1', 1.0], + '4:5' => ['4:5', 4 / 5], +]); From 159e1a34f314c4538e84504b07f4773e034980f4 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Wed, 10 Jun 2026 13:58:20 -0300 Subject: [PATCH 4/5] feat(PostEditorTabs): add a new component for managing post editing tabs including preview, schedule, and comments functionalities --- .../posts/editor/{PostEditorSidebar.vue => PostEditorTabs.vue} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename resources/js/components/posts/editor/{PostEditorSidebar.vue => PostEditorTabs.vue} (100%) diff --git a/resources/js/components/posts/editor/PostEditorSidebar.vue b/resources/js/components/posts/editor/PostEditorTabs.vue similarity index 100% rename from resources/js/components/posts/editor/PostEditorSidebar.vue rename to resources/js/components/posts/editor/PostEditorTabs.vue From 3fe70b1402435bb50ae0bfa20a96d09c40f8c190 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Wed, 10 Jun 2026 13:58:45 -0300 Subject: [PATCH 5/5] refactor(PostEditor): update PostEditorTabs integration and enhance schedule tab visibility logic --- package-lock.json | 1 - .../js/components/posts/editor/PostEditorTabs.vue | 2 +- resources/js/components/posts/editor/PreviewTab.vue | 2 +- resources/js/pages/posts/Edit.vue | 12 ++++++------ 4 files changed, 8 insertions(+), 9 deletions(-) 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/PostEditorTabs.vue b/resources/js/components/posts/editor/PostEditorTabs.vue index 6938ecb2..e175112c 100644 --- a/resources/js/components/posts/editor/PostEditorTabs.vue +++ b/resources/js/components/posts/editor/PostEditorTabs.vue @@ -107,7 +107,7 @@ defineExpose({ /> - + {
-
+
{ 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) => {