mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-07 16:19:01 +00:00
176 lines
6.3 KiB
TypeScript
176 lines
6.3 KiB
TypeScript
import type { MediaKind, MediaUploadLimits, MediaUploadPolicy } from "@/api/media-upload-contracts";
|
|
|
|
export const EMERGENCY_PHOTO_MAX_BYTES = 10_000_000;
|
|
export const EMERGENCY_VIDEO_MAX_BYTES = 100_000_000;
|
|
export const EMERGENCY_VIDEO_MAX_SECONDS = 90;
|
|
|
|
const PHOTO_MIME_TYPES = new Set(["image/jpeg", "image/jpg", "image/png", "image/heic"]);
|
|
|
|
const PHOTO_EXTENSIONS = new Set([".jpg", ".jpeg", ".png", ".heic"]);
|
|
|
|
const VIDEO_MIME_TYPES = new Set(["video/mp4", "video/quicktime"]);
|
|
|
|
const VIDEO_EXTENSIONS = new Set([".mp4", ".mov"]);
|
|
|
|
export type MediaValidationProbe = (file: File) => Promise<number | null>;
|
|
|
|
export type MediaValidationResult = { ok: true } | { ok: false; reason: string };
|
|
|
|
function fileExtension(file: File): string {
|
|
const name = file.name.toLowerCase();
|
|
const dot = name.lastIndexOf(".");
|
|
return dot >= 0 ? name.slice(dot) : "";
|
|
}
|
|
|
|
export function classifyMediaKind(file: File): MediaKind | null {
|
|
const mime = file.type.toLowerCase();
|
|
if (PHOTO_MIME_TYPES.has(mime) || PHOTO_EXTENSIONS.has(fileExtension(file))) {
|
|
return "Photo";
|
|
}
|
|
if (VIDEO_MIME_TYPES.has(mime) || VIDEO_EXTENSIONS.has(fileExtension(file))) {
|
|
return "Video";
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/** Inclusive limit: exactly at the cap passes, one byte over fails. */
|
|
export function isMediaSizeWithinLimit(
|
|
file: File,
|
|
kind: MediaKind,
|
|
limits: MediaUploadLimits,
|
|
): boolean {
|
|
const maxBytes = kind === "Photo" ? limits.photoMaxBytes : limits.videoMaxBytes;
|
|
return file.size <= maxBytes;
|
|
}
|
|
|
|
export function mediaOverLimitNotice(
|
|
file: File,
|
|
kind: MediaKind,
|
|
limits: MediaUploadLimits,
|
|
): string {
|
|
if (kind === "Video") {
|
|
return `"${file.name}" exceeds the video limit (${Math.round(limits.videoMaxBytes / 1_000_000)} MB, ${limits.videoMaxSeconds} seconds).`;
|
|
}
|
|
return `"${file.name}" exceeds the photo limit (${Math.round(limits.photoMaxBytes / 1_000_000)} MB).`;
|
|
}
|
|
|
|
export function mediaKindLimitReachedNotice(kind: MediaKind, limits: MediaUploadLimits): string {
|
|
const max = kind === "Photo" ? limits.maxPhotos : limits.maxVideos;
|
|
return `${kind} limit reached (${max} per work order). Remove a file to add another.`;
|
|
}
|
|
|
|
export function emergencyMediaBatchCountError(
|
|
file: File,
|
|
acceptedFiles: readonly File[],
|
|
policy?: Pick<MediaUploadPolicy, "limits" | "remaining">,
|
|
): string | undefined {
|
|
const kind = classifyMediaKind(file);
|
|
if (!kind || !policy) return undefined;
|
|
const available = kind === "Photo" ? policy.remaining.photos : policy.remaining.videos;
|
|
const alreadyAccepted = acceptedFiles.filter((accepted) => classifyMediaKind(accepted) === kind);
|
|
if (alreadyAccepted.length < available) return undefined;
|
|
return mediaKindLimitReachedNotice(kind, defaultLimits(policy.limits));
|
|
}
|
|
|
|
export function emergencyMediaAcceptAttribute(): string {
|
|
return "image/jpeg,image/jpg,image/png,image/heic,video/mp4,video/quicktime,.jpg,.jpeg,.png,.heic,.mp4,.mov";
|
|
}
|
|
|
|
/**
|
|
* Media limits apply only to Emergency/Reactive work orders; `canUpload` from the
|
|
* server policy is authoritative and work-order binding/category are fixed by route.
|
|
*/
|
|
export function isEmergencyReactiveWorkOrderType(workOrderType: string | undefined): boolean {
|
|
if (!workOrderType) return false;
|
|
const normalized = workOrderType.trim().toLowerCase();
|
|
return normalized === "emergency" || normalized === "reactive";
|
|
}
|
|
|
|
/**
|
|
* Probe video duration from browser metadata. Returns null whenever probing or
|
|
* preview decoding fails (e.g. some MOV files) — unknown duration proceeds to the
|
|
* server, which enforces the cap at commit. Temporary preview URL is always revoked.
|
|
*/
|
|
export function probeVideoDurationSeconds(file: File): Promise<number | null> {
|
|
return new Promise((resolve) => {
|
|
const url = URL.createObjectURL(file);
|
|
const video = document.createElement("video");
|
|
let settled = false;
|
|
|
|
const finish = (value: number | null) => {
|
|
if (settled) return;
|
|
settled = true;
|
|
clearTimeout(timer);
|
|
URL.revokeObjectURL(url);
|
|
video.removeAttribute("src");
|
|
resolve(value);
|
|
};
|
|
|
|
const timer = setTimeout(() => finish(null), 4_000);
|
|
video.preload = "metadata";
|
|
video.addEventListener("loadedmetadata", () => {
|
|
const duration = video.duration;
|
|
finish(Number.isFinite(duration) && duration > 0 ? duration : null);
|
|
});
|
|
video.addEventListener("error", () => finish(null));
|
|
video.src = url;
|
|
});
|
|
}
|
|
|
|
function defaultLimits(limits?: Partial<MediaUploadLimits>): MediaUploadLimits {
|
|
return {
|
|
photoMaxBytes: limits?.photoMaxBytes ?? EMERGENCY_PHOTO_MAX_BYTES,
|
|
videoMaxBytes: limits?.videoMaxBytes ?? EMERGENCY_VIDEO_MAX_BYTES,
|
|
videoMaxSeconds: limits?.videoMaxSeconds ?? EMERGENCY_VIDEO_MAX_SECONDS,
|
|
maxPhotos: limits?.maxPhotos ?? 10,
|
|
maxVideos: limits?.maxVideos ?? 3,
|
|
};
|
|
}
|
|
|
|
export function emergencyMediaSizeError(
|
|
file: File,
|
|
limits?: Partial<MediaUploadLimits>,
|
|
): string | undefined {
|
|
const kind = classifyMediaKind(file);
|
|
if (!kind) return undefined;
|
|
const resolvedLimits = defaultLimits(limits);
|
|
return isMediaSizeWithinLimit(file, kind, resolvedLimits)
|
|
? undefined
|
|
: mediaOverLimitNotice(file, kind, resolvedLimits);
|
|
}
|
|
|
|
/**
|
|
* Validate one media file against the policy limits: kind, inclusive size caps and
|
|
* duration when it can be probed. A failed duration probe keeps the file eligible.
|
|
*/
|
|
export async function validateMediaFileAgainstLimits(
|
|
file: File,
|
|
policy: Pick<MediaUploadPolicy, "limits" | "remaining">,
|
|
probe: MediaValidationProbe = probeVideoDurationSeconds,
|
|
): Promise<MediaValidationResult> {
|
|
const limits = defaultLimits(policy.limits);
|
|
const kind = classifyMediaKind(file);
|
|
if (!kind) {
|
|
return {
|
|
ok: false,
|
|
reason: "Only photos (JPG, PNG, HEIC) and videos (MP4, MOV) are supported.",
|
|
};
|
|
}
|
|
if (!isMediaSizeWithinLimit(file, kind, limits)) {
|
|
return { ok: false, reason: mediaOverLimitNotice(file, kind, limits) };
|
|
}
|
|
const remaining = kind === "Photo" ? policy.remaining?.photos : policy.remaining?.videos;
|
|
if (typeof remaining === "number" && remaining <= 0) {
|
|
return { ok: false, reason: mediaKindLimitReachedNotice(kind, limits) };
|
|
}
|
|
if (kind === "Video") {
|
|
const seconds = await probe(file);
|
|
if (seconds != null && seconds > limits.videoMaxSeconds) {
|
|
return {
|
|
ok: false,
|
|
reason: `"${file.name}" is longer than the ${limits.videoMaxSeconds} second video limit.`,
|
|
};
|
|
}
|
|
}
|
|
return { ok: true };
|
|
}
|