trypost/resources/js/composables/useMediaRules.ts
Paulo Castellano a8bed34d17 refactor(media): centralize frontend media-type detection in lib/mediaType
Mirror the backend App\Enums\Media\Type on the frontend so "is this an
image/video/PDF?" has a single definition instead of being hand-written
(type === 'x' || mime_type startsWith ...) across a dozen files.

- add lib/mediaType.ts: MediaType union, classify(), fromMimeType(),
  fromExtension(), isImage/isVideo/isDocument/isGif, acceptAttribute()
- route every MediaItem detection and every File-upload validator through it
- fix MediaDropzone rendering a typeless image item (AI/Unsplash/Giphy)
  as a broken <video> via the img-vs-video v-else
- collapse getMediaIncompatibilityReason onto the shared
  getMediaValidationWarning core (canSchedule / variant selection unchanged)
- tighten MediaItem/AssetMedia/SavedMedia/PickedMedia .type to MediaType
- replace GalleryBrowser's hardcoded accept list with acceptAttribute()
2026-06-25 18:19:58 -03:00

220 lines
7.4 KiB
TypeScript

import { computed, type Ref, type ComputedRef } from 'vue';
import { fromMimeType, MediaType } from '@/lib/mediaType';
export interface MediaRules {
maxFiles: number;
minFiles?: number;
acceptImages: boolean;
acceptVideos: boolean;
acceptDocuments?: boolean;
requiresMedia: boolean;
acceptsGif: boolean;
forbidsMixedMedia?: boolean;
maxImageBytes?: number;
maxVideoBytes?: number;
maxDocumentBytes?: number;
maxVideoDurationSec?: number;
aspectRatioMin?: number;
aspectRatioMax?: number;
}
const MB = 1024 * 1024;
const GB = 1024 * MB;
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,
aspectRatioMin: 0.8, aspectRatioMax: 1.91,
},
instagram_reel: {
maxFiles: 1, acceptImages: false, acceptVideos: true, requiresMedia: true,
acceptsGif: false,
maxVideoBytes: 1 * GB, maxVideoDurationSec: 15 * 60,
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,
aspectRatioMin: 0.5, aspectRatioMax: 0.6,
},
// Facebook
facebook_post: {
maxFiles: 10, acceptImages: true, acceptVideos: true, requiresMedia: false,
acceptsGif: false,
maxImageBytes: 4 * MB, maxVideoBytes: 10 * GB, maxVideoDurationSec: 240 * 60,
},
facebook_reel: {
maxFiles: 1, acceptImages: false, acceptVideos: true, requiresMedia: true,
acceptsGif: false,
maxVideoBytes: 1 * GB, maxVideoDurationSec: 90,
aspectRatioMin: 0.5, aspectRatioMax: 0.6,
},
facebook_story: {
maxFiles: 1, acceptImages: false, acceptVideos: true, requiresMedia: true,
acceptsGif: false,
maxVideoBytes: 1 * GB, maxVideoDurationSec: 60,
aspectRatioMin: 0.5, aspectRatioMax: 0.6,
},
// LinkedIn — one type per account kind; the publish format (single image,
// multi-image, video, or PDF document) is inferred from the attached media.
// Images (up to 10) XOR one video XOR one PDF, never mixed.
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,
},
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,
},
// TikTok
tiktok_video: {
maxFiles: 1, acceptImages: false, acceptVideos: true, requiresMedia: true,
acceptsGif: false,
// maxVideoDurationSec is enforced dynamically via creator_info
},
tiktok_photo: {
maxFiles: 35, minFiles: 1, acceptImages: true, acceptVideos: false, requiresMedia: true,
acceptsGif: false,
},
// YouTube
youtube_short: {
maxFiles: 1, acceptImages: false, acceptVideos: true, requiresMedia: true,
acceptsGif: false,
maxVideoBytes: 256 * GB, maxVideoDurationSec: 60,
aspectRatioMin: 0.5, aspectRatioMax: 0.6,
},
// Pinterest
pinterest_pin: {
maxFiles: 1, acceptImages: true, acceptVideos: false, requiresMedia: true,
acceptsGif: false,
maxImageBytes: 20 * MB,
},
pinterest_video_pin: {
maxFiles: 1, acceptImages: false, acceptVideos: true, requiresMedia: true,
acceptsGif: false,
maxVideoBytes: 2 * GB, maxVideoDurationSec: 15 * 60,
},
pinterest_carousel: {
maxFiles: 5, minFiles: 2, acceptImages: true, acceptVideos: false, requiresMedia: true,
acceptsGif: false,
maxImageBytes: 20 * MB,
},
// X (Twitter) — accepts GIF with animation
x_post: {
maxFiles: 4, acceptImages: true, acceptVideos: true, requiresMedia: false,
acceptsGif: true,
maxImageBytes: 5 * MB, maxVideoBytes: 512 * MB, maxVideoDurationSec: 140,
},
// Threads
threads_post: {
maxFiles: 10, acceptImages: true, acceptVideos: true, requiresMedia: false,
acceptsGif: false,
maxImageBytes: 8 * MB, maxVideoBytes: 1 * GB, maxVideoDurationSec: 5 * 60,
},
// Bluesky — accepts GIF; tight image size (auto-resized by backend).
// The embed is images XOR video, so the two can't be combined in one post.
bluesky_post: {
maxFiles: 4, acceptImages: true, acceptVideos: true, requiresMedia: false,
acceptsGif: true, forbidsMixedMedia: true,
maxVideoBytes: 100 * MB, maxVideoDurationSec: 60,
},
// Mastodon — accepts GIF
mastodon_post: {
maxFiles: 4, acceptImages: true, acceptVideos: true, requiresMedia: false,
acceptsGif: true,
maxImageBytes: 10 * MB, maxVideoBytes: 40 * MB,
},
};
const DEFAULT_RULES: MediaRules = {
maxFiles: 10,
acceptImages: true,
acceptVideos: true,
requiresMedia: false,
acceptsGif: true,
};
export function useMediaRules(contentType: Ref<string> | ComputedRef<string>) {
const rules = computed<MediaRules>(() => {
return CONTENT_TYPE_RULES[contentType.value] || DEFAULT_RULES;
});
const acceptMimeTypes = computed<string>(() => {
const types: string[] = [];
if (rules.value.acceptImages) {
types.push('image/*');
}
if (rules.value.acceptVideos) {
types.push('video/*');
}
if (rules.value.acceptDocuments) {
types.push('application/pdf');
}
return types.join(',');
});
const canAddMore = computed(() => {
return (currentCount: number) => currentCount < rules.value.maxFiles;
});
const isValidFileType = computed(() => {
return (file: File): boolean => {
const type = fromMimeType(file.type);
if (type === MediaType.Image && !rules.value.acceptImages) {
return false;
}
if (type === MediaType.Video && !rules.value.acceptVideos) {
return false;
}
if (type === MediaType.Document) {
return Boolean(rules.value.acceptDocuments);
}
return type === MediaType.Image || type === MediaType.Video;
};
});
const getAcceptDescription = computed<string>(() => {
if (rules.value.acceptDocuments && !rules.value.acceptImages && !rules.value.acceptVideos) {
return 'PDF document';
}
if (rules.value.acceptImages && rules.value.acceptVideos) {
return 'Images or videos';
}
if (rules.value.acceptImages) {
return 'Images only';
}
if (rules.value.acceptVideos) {
return 'Videos only';
}
return 'No media';
});
return {
rules,
acceptMimeTypes,
canAddMore,
isValidFileType,
getAcceptDescription,
};
}
export function getMediaRulesForContentType(contentType: string): MediaRules {
return CONTENT_TYPE_RULES[contentType] || DEFAULT_RULES;
}