Merge branch 'main' into fix/linkedin-drop-r_basicprofile
This commit is contained in:
commit
4987a31e8a
15 changed files with 403 additions and 90 deletions
66
app/Services/Social/Concerns/CropsImageForAspectRatio.php
Normal file
66
app/Services/Social/Concerns/CropsImageForAspectRatio.php
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Social\Concerns;
|
||||
|
||||
use App\Exceptions\Social\SocialPublishException;
|
||||
use App\Services\Media\MediaOptimizer;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
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
|
||||
{
|
||||
if (! $aspectRatio || $aspectRatio === 'original') {
|
||||
return $imageUrl;
|
||||
}
|
||||
|
||||
$ratio = $this->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;
|
||||
}
|
||||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
1
package-lock.json
generated
1
package-lock.json
generated
|
|
@ -4,7 +4,6 @@
|
|||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "trypost",
|
||||
"dependencies": {
|
||||
"@inertiajs/vue3": "^3.0.0",
|
||||
"@tabler/icons-vue": "^3.36.1",
|
||||
|
|
|
|||
|
|
@ -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<string, any>;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
disabled: false,
|
||||
meta: () => ({}),
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:contentType': [value: string];
|
||||
'update:meta': [meta: Record<string, any>];
|
||||
}>();
|
||||
|
||||
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));
|
||||
</script>
|
||||
|
||||
|
|
@ -99,6 +118,25 @@ const warning = computed(() => getMediaValidationWarning(props.contentType, prop
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="isFeed" class="space-y-2">
|
||||
<p class="text-[11px] font-black uppercase tracking-widest text-foreground/60">{{ $t('posts.form.facebook.aspect_label') }}</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
v-for="ratio in aspectRatios"
|
||||
:key="ratio.value"
|
||||
type="button"
|
||||
class="cursor-pointer rounded-full border-2 px-3 py-1 text-xs font-bold uppercase tracking-widest transition-colors disabled:cursor-not-allowed disabled:opacity-50"
|
||||
:class="selectedAspectRatio === ratio.value
|
||||
? 'border-foreground bg-violet-100 text-foreground shadow-2xs'
|
||||
: 'border-foreground/30 text-foreground/70 hover:border-foreground hover:text-foreground'"
|
||||
:disabled="disabled"
|
||||
@click="pickAspectRatio(ratio.value)"
|
||||
>
|
||||
{{ $t(ratio.labelKey) }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p
|
||||
v-if="warning"
|
||||
class="flex items-start gap-2 rounded-lg border-2 border-foreground bg-rose-50 p-2 text-xs font-semibold text-rose-700"
|
||||
|
|
|
|||
|
|
@ -107,7 +107,7 @@ defineExpose({
|
|||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="schedule" class="flex-1 overflow-y-auto p-4">
|
||||
<TabsContent value="schedule" force-mount :class="['flex-1 overflow-y-auto p-4', { hidden: activeTab !== 'schedule' }]">
|
||||
<ScheduleTab
|
||||
:post-platforms="post.post_platforms"
|
||||
:selected-platform-ids="selectedPlatformIds"
|
||||
|
|
@ -94,7 +94,7 @@ const activeContentType = computed((): string | undefined => {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-1 items-start justify-center bg-foreground/5 px-4 pb-8 pt-6">
|
||||
<div class="flex flex-1 items-start justify-center px-4 pb-8 pt-6">
|
||||
<PhoneMockup v-if="activePlatform">
|
||||
<PlatformPreview
|
||||
:platform="activePlatform.platform"
|
||||
|
|
|
|||
|
|
@ -315,8 +315,10 @@ const contentTypeErrorFor = (pp: PostPlatform): string | undefined => {
|
|||
: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)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ interface Props {
|
|||
content: string;
|
||||
media: MediaItem[];
|
||||
contentType?: string;
|
||||
meta?: Record<string, any>;
|
||||
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<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) {
|
||||
|
|
@ -122,14 +138,18 @@ const displayName = computed(() => props.socialAccount.display_name || props.soc
|
|||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Post Media -->
|
||||
<div class="flex-1 relative bg-black min-h-0">
|
||||
<PostMediaPreview
|
||||
:media="media"
|
||||
:placeholder-icon="IconPhoto"
|
||||
dot-active-class="bg-[#1877f2]"
|
||||
placeholder-class="w-full h-full flex items-center justify-center bg-[#f0f2f5] dark:bg-[#3a3b3c]"
|
||||
/>
|
||||
<!-- 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 -->
|
||||
|
|
|
|||
|
|
@ -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<InstanceType<typeof ConfirmDeleteModal> | null>(null);
|
||||
const editorSidebarRef = ref<InstanceType<typeof PostEditorSidebar> | null>(null);
|
||||
const editorTabsRef = ref<InstanceType<typeof PostEditorTabs> | 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);
|
||||
});
|
||||
</script>
|
||||
|
||||
|
|
@ -402,8 +402,8 @@ usePostEcho(post.value.id, '.post.comment.created', (e: any) => {
|
|||
</div>
|
||||
|
||||
<div class="hidden lg:block lg:w-1/3 overflow-hidden">
|
||||
<PostEditorSidebar
|
||||
ref="editorSidebarRef"
|
||||
<PostEditorTabs
|
||||
ref="editorTabsRef"
|
||||
v-model:active-tab="activeTab"
|
||||
:post="post"
|
||||
:workspace-id="workspace.id"
|
||||
|
|
|
|||
|
|
@ -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,163 @@
|
|||
&& ! 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, 'social-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'], 'social-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 chosen aspect ratio crop to every image', function (string $aspectRatio, float $expected) {
|
||||
Storage::fake();
|
||||
|
||||
$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'],
|
||||
['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, 'social-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(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 () {
|
||||
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, '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');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 () {
|
||||
|
|
@ -847,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' => [
|
||||
|
|
@ -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);
|
||||
|
|
@ -883,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],
|
||||
]);
|
||||
|
|
|
|||
Loading…
Reference in a new issue