Share content-type video duration caps to the frontend via Inertia.

Keep ContentType as the single source of truth and stop hardcoding maxVideoDurationSec in useMediaRules.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Paulo Castellano 2026-07-24 21:36:53 -03:00
parent 302d902f75
commit 3d4a42c7b0
9 changed files with 149 additions and 22 deletions

View file

@ -184,7 +184,8 @@ public function maxMediaCount(): int
* platform publishes a hard cap via API. Null when unlimited, unknown,
* or enforced dynamically (e.g. TikTok creator_info).
*
* Mirrors resources/js/composables/useMediaRules.ts.
* Single source of truth for web (via Inertia shared props), REST API,
* and MCP content-type listings.
*/
public function maxVideoDurationSec(): ?int
{
@ -205,6 +206,25 @@ public function maxVideoDurationSec(): ?int
};
}
/**
* Media-rule fields the Vue editor needs. Shared once via Inertia so the
* frontend does not hardcode the same numbers.
*
* @return array<string, array{max_video_duration_sec: int|null}>
*/
public static function mediaRulesForFrontend(): array
{
$rules = [];
foreach (self::cases() as $type) {
$rules[$type->value] = [
'max_video_duration_sec' => $type->maxVideoDurationSec(),
];
}
return $rules;
}
public function supportsVideo(): bool
{
return match ($this) {

View file

@ -4,6 +4,7 @@
namespace App\Http\Middleware\App;
use App\Enums\PostPlatform\ContentType;
use App\Http\Resources\App\HandleInertiaRequests\AuthAccountResource;
use App\Http\Resources\App\HandleInertiaRequests\AuthPlanResource;
use App\Http\Resources\App\HandleInertiaRequests\AuthUserResource;
@ -62,4 +63,15 @@ public function share(Request $request): array
'githubAuthEnabled' => config('trypost.github_auth_enabled'),
];
}
/**
* @return array<string, callable>
*/
public function shareOnce(Request $request): array
{
return [
...parent::shareOnce($request),
'contentTypeMediaRules' => fn (): array => ContentType::mediaRulesForFrontend(),
];
}
}

View file

@ -9,6 +9,7 @@ import { createApp, h } from 'vue';
import { initializeDataLayer } from './datalayer';
import dayjs from './dayjs';
import { syncContentTypeMediaRules } from './lib/contentTypeMediaRules';
import { capturePageview, initializePostHog, syncPostHogContext } from './posthog';
import type { Auth } from './types';
@ -46,10 +47,12 @@ createInertiaApp({
// re-attach the right workspace group.
initializePostHog();
syncPostHogContext(props.initialPage);
syncContentTypeMediaRules(props.initialPage);
capturePageview();
router.on('navigate', (event) => {
syncPostHogContext(event.detail.page);
syncContentTypeMediaRules(event.detail.page);
capturePageview();
});

View file

@ -1,5 +1,6 @@
import { computed, type Ref, type ComputedRef } from 'vue';
import { maxVideoDurationSecFor } from '@/lib/contentTypeMediaRules';
import { fromMimeType, MediaType } from '@/lib/mediaType';
export interface MediaRules {
@ -23,24 +24,29 @@ export interface MediaRules {
const MB = 1024 * 1024;
const GB = 1024 * MB;
/**
* Client-side media constraints that are not duration caps.
* Video duration comes from ContentType::mediaRulesForFrontend() via
* Inertia shared once-props (see contentTypeMediaRules).
*/
const CONTENT_TYPE_RULES: Record<string, MediaRules> = {
// Instagram
instagram_feed: {
maxFiles: 10, acceptImages: true, acceptVideos: true, requiresMedia: true,
acceptsGif: false,
maxImageBytes: 8 * MB, maxVideoBytes: 100 * MB, maxVideoDurationSec: 60,
maxImageBytes: 8 * MB, maxVideoBytes: 100 * MB,
aspectRatioMin: 0.8, aspectRatioMax: 1.91,
},
instagram_reel: {
maxFiles: 1, acceptImages: false, acceptVideos: true, requiresMedia: true,
acceptsGif: false,
maxVideoBytes: 1 * GB, maxVideoDurationSec: 15 * 60,
maxVideoBytes: 1 * GB,
aspectRatioMin: 0.5, aspectRatioMax: 0.6,
},
instagram_story: {
maxFiles: 1, acceptImages: true, acceptVideos: true, requiresMedia: true,
acceptsGif: false,
maxImageBytes: 8 * MB, maxVideoBytes: 100 * MB, maxVideoDurationSec: 60,
maxImageBytes: 8 * MB, maxVideoBytes: 100 * MB,
aspectRatioMin: 0.5, aspectRatioMax: 0.6, autoFitsImage: true,
},
@ -48,18 +54,18 @@ const CONTENT_TYPE_RULES: Record<string, MediaRules> = {
facebook_post: {
maxFiles: 10, acceptImages: true, acceptVideos: true, requiresMedia: false,
acceptsGif: false,
maxImageBytes: 4 * MB, maxVideoBytes: 10 * GB, maxVideoDurationSec: 240 * 60,
maxImageBytes: 4 * MB, maxVideoBytes: 10 * GB,
},
facebook_reel: {
maxFiles: 1, acceptImages: false, acceptVideos: true, requiresMedia: true,
acceptsGif: false,
maxVideoBytes: 1 * GB, maxVideoDurationSec: 90,
maxVideoBytes: 1 * GB,
aspectRatioMin: 0.5, aspectRatioMax: 0.6,
},
facebook_story: {
maxFiles: 1, acceptImages: false, acceptVideos: true, requiresMedia: true,
acceptsGif: false,
maxVideoBytes: 1 * GB, maxVideoDurationSec: 60,
maxVideoBytes: 1 * GB,
aspectRatioMin: 0.5, aspectRatioMax: 0.6,
},
@ -69,12 +75,12 @@ const CONTENT_TYPE_RULES: Record<string, MediaRules> = {
linkedin_post: {
maxFiles: 10, acceptImages: true, acceptVideos: true, acceptDocuments: true,
requiresMedia: false, acceptsGif: false, forbidsMixedMedia: true,
maxImageBytes: 5 * MB, maxVideoBytes: 5 * GB, maxVideoDurationSec: 10 * 60, maxDocumentBytes: 100 * MB,
maxImageBytes: 5 * MB, maxVideoBytes: 5 * GB, maxDocumentBytes: 100 * MB,
},
linkedin_page_post: {
maxFiles: 10, acceptImages: true, acceptVideos: true, acceptDocuments: true,
requiresMedia: false, acceptsGif: false, forbidsMixedMedia: true,
maxImageBytes: 5 * MB, maxVideoBytes: 5 * GB, maxVideoDurationSec: 10 * 60, maxDocumentBytes: 100 * MB,
maxImageBytes: 5 * MB, maxVideoBytes: 5 * GB, maxDocumentBytes: 100 * MB,
},
// TikTok
@ -92,7 +98,7 @@ const CONTENT_TYPE_RULES: Record<string, MediaRules> = {
youtube_short: {
maxFiles: 1, acceptImages: false, acceptVideos: true, requiresMedia: true,
acceptsGif: false,
maxVideoBytes: 256 * GB, maxVideoDurationSec: 3 * 60,
maxVideoBytes: 256 * GB,
aspectRatioMin: 0.5, aspectRatioMax: 0.6,
},
@ -105,7 +111,7 @@ const CONTENT_TYPE_RULES: Record<string, MediaRules> = {
pinterest_video_pin: {
maxFiles: 1, acceptImages: false, acceptVideos: true, requiresMedia: true,
acceptsGif: false,
maxVideoBytes: 2 * GB, maxVideoDurationSec: 15 * 60,
maxVideoBytes: 2 * GB,
},
pinterest_carousel: {
maxFiles: 5, minFiles: 2, acceptImages: true, acceptVideos: false, requiresMedia: true,
@ -117,14 +123,14 @@ const CONTENT_TYPE_RULES: Record<string, MediaRules> = {
x_post: {
maxFiles: 4, acceptImages: true, acceptVideos: true, requiresMedia: false,
acceptsGif: true,
maxImageBytes: 5 * MB, maxVideoBytes: 512 * MB, maxVideoDurationSec: 140,
maxImageBytes: 5 * MB, maxVideoBytes: 512 * MB,
},
// Threads
threads_post: {
maxFiles: 10, acceptImages: true, acceptVideos: true, requiresMedia: false,
acceptsGif: false,
maxImageBytes: 8 * MB, maxVideoBytes: 1 * GB, maxVideoDurationSec: 5 * 60,
maxImageBytes: 8 * MB, maxVideoBytes: 1 * GB,
},
// Bluesky — accepts GIF; tight image size (auto-resized by backend).
@ -132,7 +138,7 @@ const CONTENT_TYPE_RULES: Record<string, MediaRules> = {
bluesky_post: {
maxFiles: 4, acceptImages: true, acceptVideos: true, requiresMedia: false,
acceptsGif: true, forbidsMixedMedia: true,
maxVideoBytes: 100 * MB, maxVideoDurationSec: 60,
maxVideoBytes: 100 * MB,
},
// Mastodon — accepts GIF
@ -151,9 +157,24 @@ const DEFAULT_RULES: MediaRules = {
acceptsGif: true,
};
export function useMediaRules(contentType: Ref<string> | ComputedRef<string>) {
const withSharedDuration = (contentType: string, rules: MediaRules): MediaRules => {
const maxVideoDurationSec = maxVideoDurationSecFor(contentType);
if (maxVideoDurationSec === undefined) {
return rules;
}
return {
...rules,
maxVideoDurationSec,
};
};
export const useMediaRules = (contentType: Ref<string> | ComputedRef<string>) => {
const rules = computed<MediaRules>(() => {
return CONTENT_TYPE_RULES[contentType.value] || DEFAULT_RULES;
const base = CONTENT_TYPE_RULES[contentType.value] || DEFAULT_RULES;
return withSharedDuration(contentType.value, base);
});
const acceptMimeTypes = computed<string>(() => {
@ -214,8 +235,10 @@ export function useMediaRules(contentType: Ref<string> | ComputedRef<string>) {
isValidFileType,
getAcceptDescription,
};
}
};
export function getMediaRulesForContentType(contentType: string): MediaRules {
return CONTENT_TYPE_RULES[contentType] || DEFAULT_RULES;
}
export const getMediaRulesForContentType = (contentType: string): MediaRules => {
const base = CONTENT_TYPE_RULES[contentType] || DEFAULT_RULES;
return withSharedDuration(contentType, base);
};

View file

@ -0,0 +1,23 @@
import type { Page } from '@inertiajs/core';
export type ContentTypeMediaRule = {
max_video_duration_sec: number | null;
};
type ContentTypeMediaRulesMap = Record<string, ContentTypeMediaRule>;
let cachedRules: ContentTypeMediaRulesMap | null = null;
export const syncContentTypeMediaRules = (page: Page): void => {
const rules = page.props.contentTypeMediaRules as ContentTypeMediaRulesMap | undefined;
if (rules) {
cachedRules = rules;
}
};
export const maxVideoDurationSecFor = (contentType: string): number | undefined => {
const seconds = cachedRules?.[contentType]?.max_video_duration_sec;
return typeof seconds === 'number' ? seconds : undefined;
};

View file

@ -4,6 +4,8 @@ import { resolvePageComponent } from 'laravel-vite-plugin/inertia-helpers';
import { createSSRApp, DefineComponent, h } from 'vue';
import { renderToString } from 'vue/server-renderer';
import { syncContentTypeMediaRules } from './lib/contentTypeMediaRules';
const appName = import.meta.env.VITE_APP_NAME || 'Laravel';
createServer(
@ -17,8 +19,11 @@ createServer(
`./pages/${name}.vue`,
import.meta.glob<DefineComponent>('./pages/**/*.vue'),
),
setup: ({ App, props, plugin }) =>
createSSRApp({ render: () => h(App, props) }).use(plugin),
setup: ({ App, props, plugin }) => {
syncContentTypeMediaRules(props.initialPage);
return createSSRApp({ render: () => h(App, props) }).use(plugin);
},
}),
{ cluster: true },
);

View file

@ -62,12 +62,17 @@ export interface NavItem {
badge?: string;
}
export interface ContentTypeMediaRule {
max_video_duration_sec: number | null;
}
export interface SharedData {
name: string;
auth: Auth;
flash: FlashData;
sidebarOpen: boolean;
selfHosted: boolean;
contentTypeMediaRules?: Record<string, ContentTypeMediaRule>;
[key: string]: unknown;
}

View file

@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
use App\Enums\UserWorkspace\Role;
use App\Models\User;
use App\Models\Workspace;
use Inertia\Testing\AssertableInertia;
beforeEach(function () {
$this->user = User::factory()->create();
$this->workspace = Workspace::factory()->create(['user_id' => $this->user->id]);
$this->workspace->members()->attach($this->user->id, ['role' => Role::Member->value]);
$this->user->update(['current_workspace_id' => $this->workspace->id]);
});
test('inertia shares content type media rules for the frontend', function () {
$this->actingAs($this->user)
->get(route('app.posts.index'))
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page
->has('contentTypeMediaRules')
->where('contentTypeMediaRules.instagram_reel.max_video_duration_sec', 900)
->where('contentTypeMediaRules.facebook_reel.max_video_duration_sec', 90)
->where('contentTypeMediaRules.tiktok_video.max_video_duration_sec', null)
);
});

View file

@ -34,6 +34,15 @@
expect(ContentType::TikTokVideo->maxVideoDurationSec())->toBeNull();
});
test('media rules for frontend expose duration caps keyed by content type', function () {
$rules = ContentType::mediaRulesForFrontend();
expect($rules['instagram_reel']['max_video_duration_sec'])->toBe(900);
expect($rules['facebook_reel']['max_video_duration_sec'])->toBe(90);
expect($rules['tiktok_video']['max_video_duration_sec'])->toBeNull();
expect($rules)->toHaveCount(count(ContentType::cases()));
});
test('content type maps to correct platform', function () {
expect(ContentType::InstagramFeed->platform())->toBe(Platform::Instagram);
expect(ContentType::InstagramReel->platform())->toBe(Platform::Instagram);