Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 10 additions & 7 deletions resources/js/components/posts/editor/InstagramSettings.vue
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { computed, ref } from 'vue';
import MediaRulesWarning from '@/components/posts/editor/MediaRulesWarning.vue';
import { Avatar } from '@/components/ui/avatar';
import { getPlatformLogo } from '@/composables/usePlatformLogo';
import { AspectRatio, type AspectRatioValue } from '@/types/aspect-ratio';
import { ContentType } from '@/types/content-type';
import type { MediaItem } from '@/types/media';
import { Platform } from '@/types/platform';
Expand Down Expand Up @@ -46,21 +47,21 @@ const variants = [


const aspectRatios = [
{ value: '1:1', labelKey: 'posts.form.instagram.aspect.square' },
{ value: '4:5', labelKey: 'posts.form.instagram.aspect.portrait' },
{ value: '16:9', labelKey: 'posts.form.instagram.aspect.landscape' },
{ value: 'original', labelKey: 'posts.form.instagram.aspect.original' },
];
{ value: AspectRatio.Square, labelKey: 'posts.form.instagram.aspect.square' },
{ value: AspectRatio.Portrait, labelKey: 'posts.form.instagram.aspect.portrait' },
{ value: AspectRatio.Landscape, labelKey: 'posts.form.instagram.aspect.landscape' },
{ value: AspectRatio.Original, labelKey: 'posts.form.instagram.aspect.original' },
] as const;

const isFeed = computed(() => props.contentType === ContentType.InstagramFeed);
const selectedAspectRatio = computed(() => props.meta.aspect_ratio ?? '1:1');
const selectedAspectRatio = computed(() => props.meta.aspect_ratio ?? AspectRatio.Original);

const pickVariant = (value: string) => {
if (props.disabled) return;
emit('update:contentType', value);
};

const pickAspectRatio = (value: string) => {
const pickAspectRatio = (value: AspectRatioValue) => {
if (props.disabled) return;
emit('update:meta', { ...props.meta, aspect_ratio: value });
};
Expand All @@ -71,6 +72,7 @@ const pickAspectRatio = (value: string) => {
<button
type="button"
class="flex w-full cursor-pointer items-center justify-between gap-3 p-4 text-sm"
data-testid="instagram-settings-toggle"
@click="open = !open"
>
<span class="flex min-w-0 items-center gap-2">
Expand Down Expand Up @@ -126,6 +128,7 @@ const pickAspectRatio = (value: string) => {
v-for="ratio in aspectRatios"
:key="ratio.value"
type="button"
:data-testid="`instagram-aspect-${ratio.value.replace(':', '-')}`"
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'
Expand Down
1 change: 1 addition & 0 deletions resources/js/components/posts/editor/PostEditorActions.vue
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ const scheduledAtError = computed(() => errors.value.scheduled_at);
<Button
type="button"
class="w-full lg:w-auto"
data-testid="post-submit"
:disabled="isPostActionDisabled"
@click="emit('submit', hasPickedTime ? PostStatus.Scheduled : PostStatus.Publishing)"
>
Expand Down
2 changes: 1 addition & 1 deletion resources/js/components/posts/editor/PostEditorTabs.vue
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ defineExpose({
<TabsTrigger value="preview" data-testid="editor-tab-preview">{{
$t('posts.edit.tabs.preview')
}}</TabsTrigger>
<TabsTrigger value="schedule">{{
<TabsTrigger value="schedule" data-testid="editor-tab-channels">{{
$t('posts.edit.tabs.channels')
}}</TabsTrigger>
<TabsTrigger value="comments">{{
Expand Down
29 changes: 18 additions & 11 deletions resources/js/components/posts/previews/InstagramPreview.vue
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { computed } from 'vue';
import PostMediaPreview from '@/components/posts/previews/PostMediaPreview.vue';
import VerticalMediaCanvas from '@/components/posts/previews/VerticalMediaCanvas.vue';
import { getInitials } from '@/composables/useInitials';
import { AspectRatio } from '@/types/aspect-ratio';
import { ContentType } from '@/types/content-type';
import type { MediaItem } from '@/types/media';

Expand Down Expand Up @@ -57,17 +58,23 @@ const isFeed = computed(() => !isReel.value && !isStory.value);

// Padding-bottom percentage = height/width. Used instead of CSS `aspect-ratio`
// because inside this flex column some rendering paths ignored `aspect-ratio`
// and the frame stuck to a stale height. `null` = use original media height.
const ASPECT_PADDING: Record<string, number | null> = {
'1:1': 100,
'4:5': 125,
'16:9': 56.25,
'original': null,
// and the frame stuck to a stale height.
const ASPECT_PADDING: Record<string, number> = {
[AspectRatio.Square]: 100,
[AspectRatio.Portrait]: 125,
[AspectRatio.Landscape]: 56.25,
};

const feedAspectStyle = computed(() => {
const fraction = ASPECT_PADDING[props.meta?.aspect_ratio ?? '1:1'] ?? 100;
return fraction === null ? { aspectRatio: 'auto' } : { paddingBottom: `${fraction}%` };
const feedAspectPadding = computed(() => {
const selectedRatio = props.meta?.aspect_ratio ?? AspectRatio.Original;

if (selectedRatio !== AspectRatio.Original) {
return ASPECT_PADDING[selectedRatio] ?? 100;
}

const { width = 0, height = 0 } = props.media[0]?.meta ?? {};

return width > 0 && height > 0 ? (height / width) * 100 : 100;
});

// Format numbers like Instagram
Expand Down Expand Up @@ -127,7 +134,7 @@ const truncatedCaption = computed(() => {
</div>

<!-- Post Media - Aspect ratio matches user's chosen crop -->
<div class="relative w-full shrink-0 bg-black" :style="feedAspectStyle">
<div class="relative w-full shrink-0 bg-black" :style="{ paddingBottom: `${feedAspectPadding}%` }" data-testid="instagram-feed-media">
<div class="absolute inset-0">
<PostMediaPreview
:media="media"
Expand Down Expand Up @@ -297,4 +304,4 @@ const truncatedCaption = computed(() => {
</div>
</template>
</div>
</template>
</template>
16 changes: 11 additions & 5 deletions resources/js/composables/useMedia.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { getMediaRulesForContentType } from '@/composables/useMediaRules';
import date from '@/date';
import type { MediaRules } from '@/lib/contentTypeMediaRules';
import { isDocument, isGif, isImage, isMov, isVideo } from '@/lib/mediaType';
import { ContentType } from '@/types/content-type';
import type { MediaItem } from '@/types/media';

export type { MediaItem } from '@/types/media';
Expand Down Expand Up @@ -40,7 +41,7 @@ const warning = (key: string, params: Record<string, string> = {}): MediaValidat
const firstWarning = (...candidates: Array<MediaValidationWarning | false | null | undefined>): MediaValidationWarning | null =>
candidates.find((candidate): candidate is MediaValidationWarning => Boolean(candidate)) ?? null;

const itemConstraintWarning = (item: MediaItem, rules: MediaRules): MediaValidationWarning | null => {
const itemConstraintWarning = (item: MediaItem, rules: MediaRules, contentType: string): MediaValidationWarning | null => {
const size = item.size ?? 0;
const duration = item.meta?.duration ?? 0;
const width = item.meta?.width ?? 0;
Expand Down Expand Up @@ -68,7 +69,12 @@ const itemConstraintWarning = (item: MediaItem, rules: MediaRules): MediaValidat
return warning('image_too_large', sizeParams(rules.maxImageBytes, size));
}

if (width > 0 && height > 0 && ! (rules.autoFitsImage && isImage(item))) {
const skipSourceAspectRatioCheck = isImage(item) && (
rules.autoFitsImage
|| contentType === ContentType.InstagramFeed
);

if (width > 0 && height > 0 && ! skipSourceAspectRatioCheck) {
const ratio = width / height;

if (rules.aspectRatioMin && ratio < rules.aspectRatioMin) {
Expand Down Expand Up @@ -111,7 +117,7 @@ export const getMediaValidationWarning = (
rules.acceptDocuments && documents.length > 0 && total > 1 && warning('document_not_alone'),
! rules.acceptsGif && media.some(isGif) && warning('gif_not_allowed'),
! rules.acceptsMov && media.some(isMov) && warning('mov_not_allowed'),
...media.map((item) => itemConstraintWarning(item, rules)),
...media.map((item) => itemConstraintWarning(item, rules, contentType)),
);
};

Expand All @@ -129,7 +135,7 @@ export const getMediaItemIssue = (item: MediaItem, contentType: string): string
if (isDocument(item)) {
return firstWarning(
! rules.acceptDocuments && warning('no_document_allowed'),
itemConstraintWarning(item, rules),
itemConstraintWarning(item, rules, contentType),
)?.key ?? null;
}

Expand All @@ -138,6 +144,6 @@ export const getMediaItemIssue = (item: MediaItem, contentType: string): string
! isVideo(item) && ! rules.acceptImages && warning('no_image_allowed'),
isGif(item) && ! rules.acceptsGif && warning('gif_not_allowed'),
isMov(item) && ! rules.acceptsMov && warning('mov_not_allowed'),
itemConstraintWarning(item, rules),
itemConstraintWarning(item, rules, contentType),
)?.key ?? null;
};
8 changes: 8 additions & 0 deletions resources/js/types/aspect-ratio.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export const AspectRatio = {
Square: '1:1',
Portrait: '4:5',
Landscape: '16:9',
Original: 'original',
} as const;

export type AspectRatioValue = (typeof AspectRatio)[keyof typeof AspectRatio];
116 changes: 116 additions & 0 deletions tests/Browser/ChannelMediaIssueTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -111,3 +111,119 @@ function waitForChannelIssueCondition(mixed $page, string $testId, string $condi
'https://docs.trypost.it/knowledge-base/media#x-twitter',
)->assertNoJavaScriptErrors();
});

/**
* @param array<string, string> $platformMeta
* @param array<string, int> $imageMeta
* @return array{Post, PostPlatform}
*/
function seedInstagramFeedImagePost(array $platformMeta = ['aspect_ratio' => 'original'], array $imageMeta = ['width' => 1080, 'height' => 1440], int $size = 1024): array
{
$user = User::factory()->create();
$workspace = Workspace::factory()->create(['user_id' => $user->id]);
$workspace->members()->attach($user->id, ['role' => Role::Member->value]);
$user->update(['current_workspace_id' => $workspace->id]);

$account = SocialAccount::factory()->instagram()->create(['workspace_id' => $workspace->id]);
$post = Post::factory()->create([
'workspace_id' => $workspace->id,
'user_id' => $user->id,
'content' => 'A five-image carousel',
'media' => array_map(fn (int $index): array => [
'id' => "image-{$index}",
'type' => 'image',
'mime_type' => 'image/jpeg',
'path' => "uploads/image-{$index}.jpg",
'url' => 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
'size' => $size,
'meta' => $imageMeta,
], range(1, 5)),
]);
$postPlatform = PostPlatform::factory()->create([
'post_id' => $post->id,
'social_account_id' => $account->id,
'platform' => Platform::Instagram,
'content_type' => ContentType::InstagramFeed,
'meta' => $platformMeta,
]);

test()->actingAs($user);

return [$post, $postPlatform];
}

test('Instagram feed aspect choices do not reject the source image in the editor', function () {
[$post, $postPlatform] = seedInstagramFeedImagePost();

$page = visit(route('app.posts.edit', $post));
waitForChannelIssueTestId($page, "channel-{$postPlatform->id}");

$page->click('@instagram-settings-toggle');

foreach (['1-1' => 1.0, '4-5' => 5 / 4, '16-9' => 9 / 16, 'original' => 4 / 3] as $option => $expectedHeightToWidth) {
$page->click("@instagram-aspect-{$option}")
->assertMissing('@media-rules-warning')
->assertMissing("@channel-issue-{$postPlatform->id}")
->assertEnabled('@post-submit')
->click('@editor-tab-preview');

waitForChannelIssueTestId($page, 'instagram-feed-media');

$heightToWidth = $page->script('(() => { const frame = document.querySelector("[data-testid=instagram-feed-media]"); const rect = frame.getBoundingClientRect(); return rect.height / rect.width; })()');

expect(abs($heightToWidth - $expectedHeightToWidth))->toBeLessThan(0.01);

$page->click('@editor-tab-channels');
}

$page->assertNoJavaScriptErrors();
});

test('Instagram feed without a saved aspect ratio uses the original image', function () {
[$post, $postPlatform] = seedInstagramFeedImagePost([]);

$page = visit(route('app.posts.edit', $post));
waitForChannelIssueTestId($page, "channel-{$postPlatform->id}");

$page->click('@instagram-settings-toggle');

$originalSelected = $page->script('document.querySelector("[data-testid=instagram-aspect-original]").classList.contains("bg-violet-100")');

expect($originalSelected)->toBeTrue();

$page->assertMissing('@media-rules-warning')
->assertMissing("@channel-issue-{$postPlatform->id}")
->assertEnabled('@post-submit')
->click('@editor-tab-preview')
->assertNoJavaScriptErrors();

waitForChannelIssueTestId($page, 'instagram-feed-media');

$heightToWidth = $page->script('(() => { const frame = document.querySelector("[data-testid=instagram-feed-media]"); const rect = frame.getBoundingClientRect(); return rect.height / rect.width; })()');

expect(abs($heightToWidth - 4 / 3))->toBeLessThan(0.01);
});

test('Instagram original preview falls back to square when image dimensions are unavailable', function () {
[$post] = seedInstagramFeedImagePost(imageMeta: []);

$page = visit(route('app.posts.edit', $post));
$page->click('@editor-tab-preview');
waitForChannelIssueTestId($page, 'instagram-feed-media');

$heightToWidth = $page->script('(() => { const frame = document.querySelector("[data-testid=instagram-feed-media]"); const rect = frame.getBoundingClientRect(); return rect.height / rect.width; })()');

expect(abs($heightToWidth - 1.0))->toBeLessThan(0.01);
});

test('Instagram aspect selection still enforces image size limits', function () {
[$post] = seedInstagramFeedImagePost(size: 9 * 1024 * 1024);

$page = visit(route('app.posts.edit', $post));
$page->click('@instagram-settings-toggle');
waitForChannelIssueTestId($page, 'media-rules-warning');

$page->assertPresent('@media-rules-warning')
->assertDisabled('@post-submit')
->assertNoJavaScriptErrors();
});
Loading