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:
parent
302d902f75
commit
3d4a42c7b0
9 changed files with 149 additions and 22 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
};
|
||||
|
|
|
|||
23
resources/js/lib/contentTypeMediaRules.ts
Normal file
23
resources/js/lib/contentTypeMediaRules.ts
Normal 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;
|
||||
};
|
||||
|
|
@ -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 },
|
||||
);
|
||||
|
|
|
|||
5
resources/js/types/index.d.ts
vendored
5
resources/js/types/index.d.ts
vendored
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
27
tests/Feature/ContentTypeMediaRulesShareTest.php
Normal file
27
tests/Feature/ContentTypeMediaRulesShareTest.php
Normal 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)
|
||||
);
|
||||
});
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue