From 3d4a42c7b05dd264b52073180504589736129cb7 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Fri, 24 Jul 2026 21:36:53 -0300 Subject: [PATCH] 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 --- app/Enums/PostPlatform/ContentType.php | 22 ++++++- .../Middleware/App/HandleInertiaRequests.php | 12 ++++ resources/js/app.ts | 3 + resources/js/composables/useMediaRules.ts | 61 +++++++++++++------ resources/js/lib/contentTypeMediaRules.ts | 23 +++++++ resources/js/ssr.ts | 9 ++- resources/js/types/index.d.ts | 5 ++ .../ContentTypeMediaRulesShareTest.php | 27 ++++++++ tests/Unit/Enums/ContentTypeTest.php | 9 +++ 9 files changed, 149 insertions(+), 22 deletions(-) create mode 100644 resources/js/lib/contentTypeMediaRules.ts create mode 100644 tests/Feature/ContentTypeMediaRulesShareTest.php diff --git a/app/Enums/PostPlatform/ContentType.php b/app/Enums/PostPlatform/ContentType.php index 936337cc..7de5e100 100644 --- a/app/Enums/PostPlatform/ContentType.php +++ b/app/Enums/PostPlatform/ContentType.php @@ -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 + */ + 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) { diff --git a/app/Http/Middleware/App/HandleInertiaRequests.php b/app/Http/Middleware/App/HandleInertiaRequests.php index 5e51d6d8..167185be 100644 --- a/app/Http/Middleware/App/HandleInertiaRequests.php +++ b/app/Http/Middleware/App/HandleInertiaRequests.php @@ -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 + */ + public function shareOnce(Request $request): array + { + return [ + ...parent::shareOnce($request), + 'contentTypeMediaRules' => fn (): array => ContentType::mediaRulesForFrontend(), + ]; + } } diff --git a/resources/js/app.ts b/resources/js/app.ts index de757e69..6bdb68d6 100644 --- a/resources/js/app.ts +++ b/resources/js/app.ts @@ -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(); }); diff --git a/resources/js/composables/useMediaRules.ts b/resources/js/composables/useMediaRules.ts index e8782d1e..60d11898 100644 --- a/resources/js/composables/useMediaRules.ts +++ b/resources/js/composables/useMediaRules.ts @@ -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 = { // 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 = { 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 = { 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 = { 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 = { 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 = { 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 = { 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 | ComputedRef) { +const withSharedDuration = (contentType: string, rules: MediaRules): MediaRules => { + const maxVideoDurationSec = maxVideoDurationSecFor(contentType); + + if (maxVideoDurationSec === undefined) { + return rules; + } + + return { + ...rules, + maxVideoDurationSec, + }; +}; + +export const useMediaRules = (contentType: Ref | ComputedRef) => { const rules = computed(() => { - 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(() => { @@ -214,8 +235,10 @@ export function useMediaRules(contentType: Ref | ComputedRef) { 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); +}; diff --git a/resources/js/lib/contentTypeMediaRules.ts b/resources/js/lib/contentTypeMediaRules.ts new file mode 100644 index 00000000..5ecb541c --- /dev/null +++ b/resources/js/lib/contentTypeMediaRules.ts @@ -0,0 +1,23 @@ +import type { Page } from '@inertiajs/core'; + +export type ContentTypeMediaRule = { + max_video_duration_sec: number | null; +}; + +type ContentTypeMediaRulesMap = Record; + +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; +}; diff --git a/resources/js/ssr.ts b/resources/js/ssr.ts index b57a4638..0c43b75e 100644 --- a/resources/js/ssr.ts +++ b/resources/js/ssr.ts @@ -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('./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 }, ); diff --git a/resources/js/types/index.d.ts b/resources/js/types/index.d.ts index e95a5037..53a18636 100644 --- a/resources/js/types/index.d.ts +++ b/resources/js/types/index.d.ts @@ -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; [key: string]: unknown; } diff --git a/tests/Feature/ContentTypeMediaRulesShareTest.php b/tests/Feature/ContentTypeMediaRulesShareTest.php new file mode 100644 index 00000000..cd78219d --- /dev/null +++ b/tests/Feature/ContentTypeMediaRulesShareTest.php @@ -0,0 +1,27 @@ +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) + ); +}); diff --git a/tests/Unit/Enums/ContentTypeTest.php b/tests/Unit/Enums/ContentTypeTest.php index d37ac5e2..18a9e1ec 100644 --- a/tests/Unit/Enums/ContentTypeTest.php +++ b/tests/Unit/Enums/ContentTypeTest.php @@ -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);