1126 lines
46 KiB
TypeScript
1126 lines
46 KiB
TypeScript
import React, { ChangeEvent, DragEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||
import axios from 'axios';
|
||
import {
|
||
mdiArrowDown,
|
||
mdiArrowUp,
|
||
mdiClockOutline,
|
||
mdiClose,
|
||
mdiImageOutline,
|
||
mdiInformationOutline,
|
||
mdiOpenInNew,
|
||
mdiPlayCircleOutline,
|
||
mdiRadio,
|
||
mdiStar,
|
||
mdiStarOutline,
|
||
mdiTelevisionClassic,
|
||
mdiUpload,
|
||
} from '@mdi/js';
|
||
import BaseButton from './BaseButton';
|
||
import BaseIcon from './BaseIcon';
|
||
import CardBox from './CardBox';
|
||
import ImageField from './ImageField';
|
||
import LoadingSpinner from './LoadingSpinner';
|
||
import FileUploader from './Uploaders/UploadService';
|
||
import { hasPermission } from '../helpers/userPermissions';
|
||
import { useAppSelector } from '../stores/hooks';
|
||
|
||
type UploadMode = 'audio' | 'video';
|
||
type DropzoneType = 'media' | 'preview';
|
||
|
||
type UploadedFile = {
|
||
id?: string;
|
||
name?: string;
|
||
publicUrl?: string;
|
||
};
|
||
|
||
type UploadedAsset = {
|
||
id: string;
|
||
title?: string;
|
||
asset_type?: UploadMode | 'image' | 'document';
|
||
delivery?: string;
|
||
mime_type?: string;
|
||
source_url?: string;
|
||
file_size_bytes?: number;
|
||
bitrate_kbps?: number;
|
||
resolution?: string;
|
||
duration_seconds?: number;
|
||
is_primary?: boolean;
|
||
sort_order?: number;
|
||
createdAt?: string;
|
||
file?: UploadedFile[];
|
||
preview_image?: UploadedFile[];
|
||
};
|
||
|
||
type DetectedMediaMetadata = {
|
||
mime_type: string;
|
||
file_size_bytes: number | null;
|
||
bitrate_kbps: number | null;
|
||
resolution: string;
|
||
duration_seconds: number | null;
|
||
};
|
||
|
||
type FileDropzoneProps = {
|
||
accept: string;
|
||
badge?: string;
|
||
description: string;
|
||
emptyValueLabel: string;
|
||
file: File | null;
|
||
icon: string;
|
||
inputRef: React.RefObject<HTMLInputElement | null>;
|
||
isHighlighted: boolean;
|
||
label: string;
|
||
onChange: (event: ChangeEvent<HTMLInputElement>) => void;
|
||
onDragLeave: () => void;
|
||
onDragOver: (event: DragEvent<HTMLLabelElement>) => void;
|
||
onDrop: (event: DragEvent<HTMLLabelElement>) => void;
|
||
};
|
||
|
||
type Props = {
|
||
className?: string;
|
||
};
|
||
|
||
const modeOptions: Array<{
|
||
value: UploadMode;
|
||
label: string;
|
||
helper: string;
|
||
icon: string;
|
||
accept: string;
|
||
emptyTitle: string;
|
||
emptyDescription: string;
|
||
buttonLabel: string;
|
||
schema: { formats: string[] };
|
||
}> = [
|
||
{
|
||
value: 'audio',
|
||
label: 'Radio',
|
||
helper: 'Upload MP3, AAC, WAV, OGG, or M4A files for your saved radio rotation.',
|
||
icon: mdiRadio,
|
||
accept: '.mp3,.aac,.wav,.ogg,.m4a',
|
||
emptyTitle: 'No radio uploads yet',
|
||
emptyDescription: 'Upload the first radio segment, show intro, or replay clip to build the saved playlist.',
|
||
buttonLabel: 'Save radio upload',
|
||
schema: { formats: ['mp3', 'aac', 'wav', 'ogg', 'm4a'] },
|
||
},
|
||
{
|
||
value: 'video',
|
||
label: 'Television',
|
||
helper: 'Upload MP4, WebM, MOV, or M4V files for your saved television playlist.',
|
||
icon: mdiTelevisionClassic,
|
||
accept: '.mp4,.webm,.mov,.m4v',
|
||
emptyTitle: 'No television uploads yet',
|
||
emptyDescription: 'Upload the first televised segment, promo, or replay clip to populate the playlist.',
|
||
buttonLabel: 'Save television upload',
|
||
schema: { formats: ['mp4', 'webm', 'mov', 'm4v'] },
|
||
},
|
||
];
|
||
|
||
const imageSchema = {
|
||
image: true,
|
||
formats: ['png', 'jpg', 'jpeg', 'webp', 'gif', 'svg'],
|
||
};
|
||
|
||
const emptyMetadata: DetectedMediaMetadata = {
|
||
mime_type: '',
|
||
file_size_bytes: null,
|
||
bitrate_kbps: null,
|
||
resolution: '',
|
||
duration_seconds: null,
|
||
};
|
||
|
||
function getAssetUrl(asset?: UploadedAsset | null) {
|
||
if (!asset) return '';
|
||
|
||
return asset.file?.[0]?.publicUrl || asset.source_url || '';
|
||
}
|
||
|
||
function getSortOrderValue(asset?: UploadedAsset | null) {
|
||
return Number.isFinite(asset?.sort_order) ? Number(asset?.sort_order) : Number.MAX_SAFE_INTEGER;
|
||
}
|
||
|
||
function sortUploads(items: UploadedAsset[]) {
|
||
return [...items].sort((first, second) => {
|
||
if (Boolean(first.is_primary) !== Boolean(second.is_primary)) {
|
||
return first.is_primary ? -1 : 1;
|
||
}
|
||
|
||
const orderDifference = getSortOrderValue(first) - getSortOrderValue(second);
|
||
|
||
if (orderDifference !== 0) {
|
||
return orderDifference;
|
||
}
|
||
|
||
return new Date(second.createdAt || 0).getTime() - new Date(first.createdAt || 0).getTime();
|
||
});
|
||
}
|
||
|
||
function getNextSortOrder(items: UploadedAsset[], mode: UploadMode) {
|
||
const currentMax = items
|
||
.filter((item) => item.asset_type === mode)
|
||
.reduce((maxValue, item) => {
|
||
const numericValue = Number(item.sort_order);
|
||
return Number.isFinite(numericValue) ? Math.max(maxValue, numericValue) : maxValue;
|
||
}, 0);
|
||
|
||
return currentMax + 1;
|
||
}
|
||
|
||
function deriveTitleFromFile(file: File) {
|
||
return file.name.replace(/\.[^.]+$/, '');
|
||
}
|
||
|
||
function formatCreatedAt(value?: string) {
|
||
if (!value) return 'Saved recently';
|
||
|
||
const date = new Date(value);
|
||
|
||
if (Number.isNaN(date.getTime())) return 'Saved recently';
|
||
|
||
return new Intl.DateTimeFormat('en-US', {
|
||
month: 'short',
|
||
day: 'numeric',
|
||
year: 'numeric',
|
||
hour: 'numeric',
|
||
minute: '2-digit',
|
||
}).format(date);
|
||
}
|
||
|
||
function formatBytes(value?: number | null) {
|
||
if (!value) return 'Size unavailable';
|
||
|
||
const units = ['B', 'KB', 'MB', 'GB'];
|
||
let nextValue = value;
|
||
let index = 0;
|
||
|
||
while (nextValue >= 1024 && index < units.length - 1) {
|
||
nextValue /= 1024;
|
||
index += 1;
|
||
}
|
||
|
||
const precision = nextValue >= 10 || index === 0 ? 0 : 1;
|
||
|
||
return `${nextValue.toFixed(precision)} ${units[index]}`;
|
||
}
|
||
|
||
function formatDuration(value?: number | null) {
|
||
if (!value || value <= 0) return 'Not detected';
|
||
|
||
const totalSeconds = Math.round(value);
|
||
const hours = Math.floor(totalSeconds / 3600);
|
||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||
const seconds = totalSeconds % 60;
|
||
|
||
if (hours) {
|
||
return `${hours}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
|
||
}
|
||
|
||
return `${minutes}:${String(seconds).padStart(2, '0')}`;
|
||
}
|
||
|
||
function normalizeResolutionForMode(mode: UploadMode, mediaElement: HTMLAudioElement | HTMLVideoElement) {
|
||
if (mode === 'audio') {
|
||
return 'Audio only';
|
||
}
|
||
|
||
const videoElement = mediaElement as HTMLVideoElement;
|
||
|
||
if (videoElement.videoWidth && videoElement.videoHeight) {
|
||
return `${videoElement.videoWidth}×${videoElement.videoHeight}`;
|
||
}
|
||
|
||
return '';
|
||
}
|
||
|
||
async function detectMediaMetadata(file: File, mode: UploadMode): Promise<DetectedMediaMetadata> {
|
||
const objectUrl = URL.createObjectURL(file);
|
||
|
||
try {
|
||
const metadata = await new Promise<DetectedMediaMetadata>((resolve, reject) => {
|
||
const mediaElement = document.createElement(mode === 'audio' ? 'audio' : 'video');
|
||
let isSettled = false;
|
||
|
||
const finalize = (callback: () => void) => {
|
||
if (isSettled) return;
|
||
isSettled = true;
|
||
callback();
|
||
};
|
||
|
||
mediaElement.preload = 'metadata';
|
||
mediaElement.onloadedmetadata = () => {
|
||
finalize(() => {
|
||
const durationSeconds = Number.isFinite(mediaElement.duration) && mediaElement.duration > 0
|
||
? Math.round(mediaElement.duration)
|
||
: null;
|
||
const bitrateKbps = durationSeconds && durationSeconds > 0
|
||
? Math.max(1, Math.round((file.size * 8) / durationSeconds / 1000))
|
||
: null;
|
||
|
||
resolve({
|
||
mime_type: file.type || '',
|
||
file_size_bytes: file.size || null,
|
||
bitrate_kbps: bitrateKbps,
|
||
resolution: normalizeResolutionForMode(mode, mediaElement),
|
||
duration_seconds: durationSeconds,
|
||
});
|
||
});
|
||
};
|
||
|
||
mediaElement.onerror = () => {
|
||
finalize(() => reject(new Error('The browser could not read media metadata from this file.')));
|
||
};
|
||
|
||
mediaElement.src = objectUrl;
|
||
});
|
||
|
||
return metadata;
|
||
} finally {
|
||
URL.revokeObjectURL(objectUrl);
|
||
}
|
||
}
|
||
|
||
function FileDropzone({
|
||
accept,
|
||
badge,
|
||
description,
|
||
emptyValueLabel,
|
||
file,
|
||
icon,
|
||
inputRef,
|
||
isHighlighted,
|
||
label,
|
||
onChange,
|
||
onDragLeave,
|
||
onDragOver,
|
||
onDrop,
|
||
}: FileDropzoneProps) {
|
||
return (
|
||
<div>
|
||
<label className='mb-2 block text-sm font-medium text-slate-700 dark:text-slate-200'>{label}</label>
|
||
<label
|
||
className={`flex cursor-pointer flex-col items-center justify-center gap-3 rounded-2xl border border-dashed px-4 py-6 text-center transition ${isHighlighted ? 'border-cyan-400 bg-cyan-50/70 dark:border-cyan-500 dark:bg-cyan-950/30' : 'border-slate-300 bg-white hover:border-cyan-400 hover:bg-cyan-50/40 dark:border-slate-700 dark:bg-slate-900 dark:hover:border-cyan-500 dark:hover:bg-cyan-950/20'}`}
|
||
onDragOver={onDragOver}
|
||
onDragLeave={onDragLeave}
|
||
onDrop={onDrop}
|
||
>
|
||
<BaseIcon path={icon} size={30} className='text-cyan-500' />
|
||
<div>
|
||
<p className='font-medium text-slate-900 dark:text-white'>{file ? file.name : emptyValueLabel}</p>
|
||
<p className='mt-1 text-sm text-slate-500 dark:text-slate-300'>{description}</p>
|
||
<p className='mt-2 text-xs uppercase tracking-[0.2em] text-slate-400'>{accept.replace(/,/g, ', ')}</p>
|
||
</div>
|
||
{badge && <span className='rounded-full bg-slate-100 px-3 py-1 text-xs font-medium text-slate-700 dark:bg-slate-800 dark:text-slate-200'>{badge}</span>}
|
||
<input ref={inputRef} type='file' accept={accept} className='hidden' onChange={onChange} />
|
||
</label>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export default function MediaCenterUploadWidget({ className = '' }: Props) {
|
||
const { currentUser } = useAppSelector((state) => state.auth);
|
||
const canReadMediaAssets = currentUser && hasPermission(currentUser, 'READ_MEDIA_ASSETS');
|
||
const canCreateMediaAssets = currentUser && hasPermission(currentUser, 'CREATE_MEDIA_ASSETS');
|
||
const canUpdateMediaAssets = currentUser && hasPermission(currentUser, 'UPDATE_MEDIA_ASSETS');
|
||
const canDeleteMediaAssets = currentUser && hasPermission(currentUser, 'DELETE_MEDIA_ASSETS');
|
||
|
||
const [mode, setMode] = useState<UploadMode>('audio');
|
||
const [title, setTitle] = useState('');
|
||
const [mediaFile, setMediaFile] = useState<File | null>(null);
|
||
const [previewImageFile, setPreviewImageFile] = useState<File | null>(null);
|
||
const [uploadedAssets, setUploadedAssets] = useState<UploadedAsset[]>([]);
|
||
const [selectedAssetId, setSelectedAssetId] = useState('');
|
||
const [isLoading, setIsLoading] = useState(true);
|
||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||
const [isDeletingId, setIsDeletingId] = useState('');
|
||
const [isUpdatingPlaylist, setIsUpdatingPlaylist] = useState(false);
|
||
const [metadata, setMetadata] = useState<DetectedMediaMetadata>(emptyMetadata);
|
||
const [isAnalyzingMetadata, setIsAnalyzingMetadata] = useState(false);
|
||
const [metadataNotice, setMetadataNotice] = useState('');
|
||
const [dragTarget, setDragTarget] = useState<DropzoneType | ''>('');
|
||
const [errorMessage, setErrorMessage] = useState('');
|
||
const [successMessage, setSuccessMessage] = useState('');
|
||
|
||
const mediaInputRef = useRef<HTMLInputElement | null>(null);
|
||
const previewInputRef = useRef<HTMLInputElement | null>(null);
|
||
const metadataRequestIdRef = useRef(0);
|
||
|
||
const currentMode = useMemo(() => {
|
||
return modeOptions.find((item) => item.value === mode) || modeOptions[0];
|
||
}, [mode]);
|
||
|
||
const clearMessages = useCallback(() => {
|
||
setErrorMessage('');
|
||
setSuccessMessage('');
|
||
}, []);
|
||
|
||
const clearMetadata = useCallback(() => {
|
||
metadataRequestIdRef.current += 1;
|
||
setMetadata(emptyMetadata);
|
||
setMetadataNotice('');
|
||
setIsAnalyzingMetadata(false);
|
||
}, []);
|
||
|
||
const clearFileInputs = useCallback(() => {
|
||
if (mediaInputRef.current) {
|
||
mediaInputRef.current.value = '';
|
||
}
|
||
|
||
if (previewInputRef.current) {
|
||
previewInputRef.current.value = '';
|
||
}
|
||
}, []);
|
||
|
||
const resetUploaderForm = useCallback(() => {
|
||
setTitle('');
|
||
setMediaFile(null);
|
||
setPreviewImageFile(null);
|
||
clearMessages();
|
||
clearMetadata();
|
||
clearFileInputs();
|
||
}, [clearFileInputs, clearMessages, clearMetadata]);
|
||
|
||
const loadUploadedAssets = useCallback(async () => {
|
||
if (!currentUser || !canReadMediaAssets) {
|
||
setUploadedAssets([]);
|
||
setIsLoading(false);
|
||
return [];
|
||
}
|
||
|
||
setIsLoading(true);
|
||
|
||
try {
|
||
const response = await axios.get('media_assets?page=0&limit=100&delivery=upload');
|
||
const rows = Array.isArray(response.data?.rows) ? response.data.rows : [];
|
||
setUploadedAssets(rows);
|
||
setErrorMessage('');
|
||
return rows;
|
||
} catch (error: any) {
|
||
console.error('Failed to load uploaded media assets:', error);
|
||
setErrorMessage('We could not load the saved upload playlist right now.');
|
||
setUploadedAssets([]);
|
||
return [];
|
||
} finally {
|
||
setIsLoading(false);
|
||
}
|
||
}, [canReadMediaAssets, currentUser]);
|
||
|
||
useEffect(() => {
|
||
void loadUploadedAssets();
|
||
}, [loadUploadedAssets]);
|
||
|
||
const savedUploads = useMemo(() => {
|
||
return sortUploads(
|
||
uploadedAssets.filter((item) => item.delivery === 'upload' && (item.asset_type === 'audio' || item.asset_type === 'video')),
|
||
);
|
||
}, [uploadedAssets]);
|
||
|
||
const activePlaylist = useMemo(() => {
|
||
return sortUploads(savedUploads.filter((item) => item.asset_type === mode));
|
||
}, [mode, savedUploads]);
|
||
|
||
useEffect(() => {
|
||
if (!activePlaylist.length) {
|
||
setSelectedAssetId('');
|
||
return;
|
||
}
|
||
|
||
const selectedExists = activePlaylist.some((item) => item.id === selectedAssetId);
|
||
|
||
if (!selectedExists) {
|
||
setSelectedAssetId(activePlaylist[0].id);
|
||
}
|
||
}, [activePlaylist, selectedAssetId]);
|
||
|
||
const selectedAsset = useMemo(() => {
|
||
return activePlaylist.find((item) => item.id === selectedAssetId) || activePlaylist[0] || null;
|
||
}, [activePlaylist, selectedAssetId]);
|
||
|
||
const selectedAssetIndex = useMemo(() => {
|
||
if (!selectedAsset) return -1;
|
||
return activePlaylist.findIndex((item) => item.id === selectedAsset.id);
|
||
}, [activePlaylist, selectedAsset]);
|
||
|
||
const analyzeAndStoreMediaFile = useCallback(
|
||
async (file: File) => {
|
||
clearMessages();
|
||
setMetadataNotice('');
|
||
|
||
try {
|
||
FileUploader.validate(file, currentMode.schema);
|
||
} catch (error: any) {
|
||
console.error('Media file validation failed:', error);
|
||
setErrorMessage(error?.message || 'That media file is not allowed for this upload mode.');
|
||
setMediaFile(null);
|
||
clearMetadata();
|
||
if (mediaInputRef.current) {
|
||
mediaInputRef.current.value = '';
|
||
}
|
||
return;
|
||
}
|
||
|
||
setMediaFile(file);
|
||
setMetadata({
|
||
mime_type: file.type || '',
|
||
file_size_bytes: file.size || null,
|
||
bitrate_kbps: null,
|
||
resolution: mode === 'audio' ? 'Audio only' : '',
|
||
duration_seconds: null,
|
||
});
|
||
|
||
if (!title.trim()) {
|
||
setTitle(deriveTitleFromFile(file));
|
||
}
|
||
|
||
const requestId = metadataRequestIdRef.current + 1;
|
||
metadataRequestIdRef.current = requestId;
|
||
setIsAnalyzingMetadata(true);
|
||
|
||
try {
|
||
const detectedMetadata = await detectMediaMetadata(file, mode);
|
||
|
||
if (metadataRequestIdRef.current !== requestId) {
|
||
return;
|
||
}
|
||
|
||
setMetadata(detectedMetadata);
|
||
setMetadataNotice('Metadata auto-filled from the uploaded file before save.');
|
||
} catch (error: any) {
|
||
if (metadataRequestIdRef.current !== requestId) {
|
||
return;
|
||
}
|
||
|
||
console.error('Media metadata detection failed:', error);
|
||
setMetadata((currentValue) => ({
|
||
...currentValue,
|
||
mime_type: file.type || currentValue.mime_type,
|
||
file_size_bytes: file.size || currentValue.file_size_bytes,
|
||
resolution: mode === 'audio' ? 'Audio only' : currentValue.resolution,
|
||
}));
|
||
setMetadataNotice('Basic metadata was captured, but duration or resolution could not be detected in the browser.');
|
||
} finally {
|
||
if (metadataRequestIdRef.current === requestId) {
|
||
setIsAnalyzingMetadata(false);
|
||
}
|
||
}
|
||
},
|
||
[clearMessages, clearMetadata, currentMode.schema, mode, title],
|
||
);
|
||
|
||
const handlePreviewImageSelected = useCallback(
|
||
(file: File) => {
|
||
clearMessages();
|
||
|
||
try {
|
||
FileUploader.validate(file, imageSchema);
|
||
} catch (error: any) {
|
||
console.error('Preview image validation failed:', error);
|
||
setErrorMessage(error?.message || 'That preview image could not be used.');
|
||
setPreviewImageFile(null);
|
||
if (previewInputRef.current) {
|
||
previewInputRef.current.value = '';
|
||
}
|
||
return;
|
||
}
|
||
|
||
setPreviewImageFile(file);
|
||
},
|
||
[clearMessages],
|
||
);
|
||
|
||
const handleDrop = useCallback(
|
||
async (event: DragEvent<HTMLLabelElement>, target: DropzoneType) => {
|
||
event.preventDefault();
|
||
setDragTarget('');
|
||
|
||
const file = event.dataTransfer.files?.[0];
|
||
|
||
if (!file) {
|
||
return;
|
||
}
|
||
|
||
if (target === 'media') {
|
||
await analyzeAndStoreMediaFile(file);
|
||
return;
|
||
}
|
||
|
||
handlePreviewImageSelected(file);
|
||
},
|
||
[analyzeAndStoreMediaFile, handlePreviewImageSelected],
|
||
);
|
||
|
||
const handleMediaInputChange = useCallback(
|
||
async (event: ChangeEvent<HTMLInputElement>) => {
|
||
const file = event.target.files?.[0];
|
||
|
||
if (!file) {
|
||
return;
|
||
}
|
||
|
||
await analyzeAndStoreMediaFile(file);
|
||
},
|
||
[analyzeAndStoreMediaFile],
|
||
);
|
||
|
||
const handlePreviewInputChange = useCallback(
|
||
(event: ChangeEvent<HTMLInputElement>) => {
|
||
const file = event.target.files?.[0];
|
||
|
||
if (!file) {
|
||
return;
|
||
}
|
||
|
||
handlePreviewImageSelected(file);
|
||
},
|
||
[handlePreviewImageSelected],
|
||
);
|
||
|
||
const persistPlaylistState = useCallback(
|
||
async (items: UploadedAsset[], nextSuccessMessage: string) => {
|
||
if (!canUpdateMediaAssets) {
|
||
setErrorMessage('You do not have permission to rearrange saved uploads.');
|
||
return;
|
||
}
|
||
|
||
setIsUpdatingPlaylist(true);
|
||
clearMessages();
|
||
|
||
try {
|
||
const normalizedItems = items.map((item, index) => ({
|
||
...item,
|
||
sort_order: index + 1,
|
||
is_primary: Boolean(item.is_primary),
|
||
}));
|
||
|
||
await Promise.all(
|
||
normalizedItems.map((item) =>
|
||
axios.put(`media_assets/${item.id}`, {
|
||
id: item.id,
|
||
data: {
|
||
sort_order: item.sort_order,
|
||
is_primary: item.is_primary,
|
||
},
|
||
}),
|
||
),
|
||
);
|
||
|
||
await loadUploadedAssets();
|
||
setSelectedAssetId((currentValue) => currentValue || normalizedItems[0]?.id || '');
|
||
setSuccessMessage(nextSuccessMessage);
|
||
} catch (error: any) {
|
||
console.error('Failed to update upload playlist order:', error);
|
||
setErrorMessage(error?.response?.data?.message || error?.message || 'We could not update the saved playlist right now.');
|
||
} finally {
|
||
setIsUpdatingPlaylist(false);
|
||
}
|
||
},
|
||
[canUpdateMediaAssets, clearMessages, loadUploadedAssets],
|
||
);
|
||
|
||
const handleSubmit = async () => {
|
||
if (!canCreateMediaAssets) {
|
||
setErrorMessage('You do not have permission to save uploaded media assets.');
|
||
return;
|
||
}
|
||
|
||
if (!mediaFile) {
|
||
setErrorMessage(`Choose a ${mode === 'audio' ? 'radio' : 'television'} file before saving.`);
|
||
return;
|
||
}
|
||
|
||
setIsSubmitting(true);
|
||
clearMessages();
|
||
|
||
try {
|
||
const uploadedMediaFile = await FileUploader.upload('media_assets/file', mediaFile, currentMode.schema);
|
||
const uploadedPreviewImage = previewImageFile
|
||
? [await FileUploader.upload('media_assets/preview_image', previewImageFile, imageSchema)]
|
||
: [];
|
||
const hasPrimaryInMode = activePlaylist.some((item) => item.is_primary);
|
||
|
||
await axios.post('media_assets', {
|
||
data: {
|
||
asset_type: mode,
|
||
title: title.trim() || deriveTitleFromFile(mediaFile),
|
||
delivery: 'upload',
|
||
source_url: uploadedMediaFile.publicUrl,
|
||
mime_type: metadata.mime_type || mediaFile.type || '',
|
||
file_size_bytes: metadata.file_size_bytes || mediaFile.size || null,
|
||
bitrate_kbps: metadata.bitrate_kbps || null,
|
||
resolution: metadata.resolution || (mode === 'audio' ? 'Audio only' : null),
|
||
duration_seconds: metadata.duration_seconds || null,
|
||
sort_order: getNextSortOrder(savedUploads, mode),
|
||
is_primary: !hasPrimaryInMode,
|
||
file: [{ ...uploadedMediaFile }],
|
||
preview_image: uploadedPreviewImage,
|
||
},
|
||
});
|
||
|
||
resetUploaderForm();
|
||
setSelectedAssetId('');
|
||
await loadUploadedAssets();
|
||
setSuccessMessage(
|
||
`${currentMode.label} upload saved to the playlist${!hasPrimaryInMode ? ' and marked as the primary playback item' : ''}.`,
|
||
);
|
||
} catch (error: any) {
|
||
console.error('Failed to save uploaded media asset:', error);
|
||
setErrorMessage(error?.response?.data?.message || error?.message || 'We could not save this upload right now.');
|
||
} finally {
|
||
setIsSubmitting(false);
|
||
}
|
||
};
|
||
|
||
const handleDelete = async (assetId: string) => {
|
||
if (!canDeleteMediaAssets) {
|
||
setErrorMessage('You do not have permission to remove uploaded media assets.');
|
||
return;
|
||
}
|
||
|
||
setIsDeletingId(assetId);
|
||
clearMessages();
|
||
|
||
try {
|
||
await axios.delete(`media_assets/${assetId}`);
|
||
await loadUploadedAssets();
|
||
setSelectedAssetId((currentValue) => (currentValue === assetId ? '' : currentValue));
|
||
setSuccessMessage('Upload removed from the saved playlist.');
|
||
} catch (error: any) {
|
||
console.error('Failed to delete uploaded media asset:', error);
|
||
setErrorMessage(error?.response?.data?.message || error?.message || 'We could not remove this upload right now.');
|
||
} finally {
|
||
setIsDeletingId('');
|
||
}
|
||
};
|
||
|
||
const handleMoveAsset = async (assetId: string, direction: 'up' | 'down') => {
|
||
const currentIndex = activePlaylist.findIndex((item) => item.id === assetId);
|
||
|
||
if (currentIndex < 0) {
|
||
return;
|
||
}
|
||
|
||
const nextIndex = direction === 'up' ? currentIndex - 1 : currentIndex + 1;
|
||
|
||
if (nextIndex < 0 || nextIndex >= activePlaylist.length) {
|
||
return;
|
||
}
|
||
|
||
const reorderedItems = [...activePlaylist];
|
||
const temporaryItem = reorderedItems[currentIndex];
|
||
reorderedItems[currentIndex] = reorderedItems[nextIndex];
|
||
reorderedItems[nextIndex] = temporaryItem;
|
||
|
||
await persistPlaylistState(reorderedItems, `${currentMode.label} playlist order updated.`);
|
||
setSelectedAssetId(assetId);
|
||
};
|
||
|
||
const handleMarkPrimary = async (assetId: string) => {
|
||
await persistPlaylistState(
|
||
activePlaylist.map((item) => ({
|
||
...item,
|
||
is_primary: item.id === assetId,
|
||
})),
|
||
`${currentMode.label} primary playback item updated.`,
|
||
);
|
||
setSelectedAssetId(assetId);
|
||
};
|
||
|
||
const metadataCards = useMemo(() => {
|
||
return [
|
||
{
|
||
label: 'File size',
|
||
value: formatBytes(metadata.file_size_bytes),
|
||
icon: mdiInformationOutline,
|
||
},
|
||
{
|
||
label: 'Duration',
|
||
value: isAnalyzingMetadata ? 'Analyzing…' : formatDuration(metadata.duration_seconds),
|
||
icon: mdiClockOutline,
|
||
},
|
||
{
|
||
label: 'Resolution',
|
||
value: isAnalyzingMetadata ? 'Analyzing…' : metadata.resolution || (mode === 'audio' ? 'Audio only' : 'Not detected'),
|
||
icon: mdiTelevisionClassic,
|
||
},
|
||
{
|
||
label: 'Bitrate',
|
||
value: isAnalyzingMetadata ? 'Analyzing…' : metadata.bitrate_kbps ? `${metadata.bitrate_kbps} kbps` : 'Not detected',
|
||
icon: mdiRadio,
|
||
},
|
||
];
|
||
}, [isAnalyzingMetadata, metadata, mode]);
|
||
|
||
if (!currentUser || (!canReadMediaAssets && !canCreateMediaAssets)) {
|
||
return null;
|
||
}
|
||
|
||
return (
|
||
<CardBox className={`border border-slate-200/70 bg-white dark:border-slate-800 ${className}`}>
|
||
<div className='space-y-6'>
|
||
<div className='flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between'>
|
||
<div>
|
||
<p className='text-xs uppercase tracking-[0.24em] text-slate-500'>Saved uploads</p>
|
||
<h3 className='mt-2 text-2xl font-semibold text-slate-900 dark:text-white'>Radio and television upload widget</h3>
|
||
<p className='mt-2 max-w-3xl text-sm leading-7 text-slate-500 dark:text-slate-300'>
|
||
Upload audio or video files into <code>media_assets</code>, auto-fill metadata before save, then reuse the saved playlist directly from the media center.
|
||
</p>
|
||
</div>
|
||
<div className='flex flex-wrap gap-2'>
|
||
{modeOptions.map((item) => (
|
||
<BaseButton
|
||
key={item.value}
|
||
color={mode === item.value ? 'info' : 'whiteDark'}
|
||
outline={mode !== item.value}
|
||
icon={item.icon}
|
||
label={item.label}
|
||
onClick={() => {
|
||
setMode(item.value);
|
||
setMediaFile(null);
|
||
clearMetadata();
|
||
clearMessages();
|
||
if (mediaInputRef.current) {
|
||
mediaInputRef.current.value = '';
|
||
}
|
||
}}
|
||
/>
|
||
))}
|
||
{canReadMediaAssets && <BaseButton href='/media_assets/media_assets-list' color='whiteDark' outline label='Open media assets' />}
|
||
</div>
|
||
</div>
|
||
|
||
<div className='grid gap-6 xl:grid-cols-[0.92fr_1.08fr]'>
|
||
<div className='space-y-6'>
|
||
<div className='rounded-[28px] border border-slate-200 bg-slate-50 p-5 dark:border-slate-800 dark:bg-slate-950/40'>
|
||
<div className='flex items-center gap-3'>
|
||
<BaseIcon path={currentMode.icon} size={26} className={mode === 'audio' ? 'text-amber-500' : 'text-cyan-500'} />
|
||
<div>
|
||
<p className='text-base font-semibold text-slate-900 dark:text-white'>{currentMode.label} uploader</p>
|
||
<p className='text-sm text-slate-500 dark:text-slate-300'>{currentMode.helper}</p>
|
||
</div>
|
||
</div>
|
||
|
||
{canCreateMediaAssets ? (
|
||
<div className='mt-5 space-y-4'>
|
||
<div>
|
||
<label className='mb-2 block text-sm font-medium text-slate-700 dark:text-slate-200'>Title</label>
|
||
<input
|
||
value={title}
|
||
onChange={(event) => setTitle(event.target.value)}
|
||
placeholder={mode === 'audio' ? 'Morning radio replay' : 'Evening television segment'}
|
||
className='w-full rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm text-slate-900 outline-none transition focus:border-cyan-400 focus:ring focus:ring-cyan-100 dark:border-slate-700 dark:bg-slate-900 dark:text-white dark:focus:border-cyan-500 dark:focus:ring-cyan-950'
|
||
/>
|
||
</div>
|
||
|
||
<FileDropzone
|
||
accept={currentMode.accept}
|
||
description='Click to browse or drag and drop a file directly into the uploader.'
|
||
emptyValueLabel={`Select ${mode === 'audio' ? 'radio' : 'television'} file`}
|
||
file={mediaFile}
|
||
icon={mdiUpload}
|
||
inputRef={mediaInputRef}
|
||
isHighlighted={dragTarget === 'media'}
|
||
label='Media file'
|
||
onChange={handleMediaInputChange}
|
||
onDragLeave={() => setDragTarget('')}
|
||
onDragOver={(event) => {
|
||
event.preventDefault();
|
||
setDragTarget('media');
|
||
}}
|
||
onDrop={(event) => void handleDrop(event, 'media')}
|
||
/>
|
||
|
||
<FileDropzone
|
||
accept='.png,.jpg,.jpeg,.webp,.gif,.svg'
|
||
badge='Optional'
|
||
description='Add artwork, poster, or channel art for the saved upload.'
|
||
emptyValueLabel='Attach poster or artwork'
|
||
file={previewImageFile}
|
||
icon={mdiImageOutline}
|
||
inputRef={previewInputRef}
|
||
isHighlighted={dragTarget === 'preview'}
|
||
label='Preview image (optional)'
|
||
onChange={handlePreviewInputChange}
|
||
onDragLeave={() => setDragTarget('')}
|
||
onDragOver={(event) => {
|
||
event.preventDefault();
|
||
setDragTarget('preview');
|
||
}}
|
||
onDrop={(event) => void handleDrop(event, 'preview')}
|
||
/>
|
||
|
||
{(mediaFile || isAnalyzingMetadata) && (
|
||
<div className='rounded-[24px] border border-slate-200 bg-white p-4 dark:border-slate-800 dark:bg-slate-900'>
|
||
<div className='flex flex-wrap items-center justify-between gap-3'>
|
||
<div>
|
||
<p className='text-sm font-semibold text-slate-900 dark:text-white'>Auto-detected media metadata</p>
|
||
<p className='text-sm text-slate-500 dark:text-slate-300'>This preview is captured in the browser before the upload is saved.</p>
|
||
</div>
|
||
<span className='rounded-full bg-slate-100 px-3 py-1 text-xs font-medium text-slate-700 dark:bg-slate-800 dark:text-slate-200'>
|
||
{isAnalyzingMetadata ? 'Analyzing…' : 'Ready to save'}
|
||
</span>
|
||
</div>
|
||
|
||
<div className='mt-4 grid gap-3 sm:grid-cols-2'>
|
||
{metadataCards.map((item) => (
|
||
<div key={item.label} className='rounded-2xl border border-slate-200 bg-slate-50 px-4 py-3 dark:border-slate-800 dark:bg-slate-950/50'>
|
||
<div className='flex items-center gap-2'>
|
||
<BaseIcon path={item.icon} size={16} className='text-slate-400' />
|
||
<p className='text-xs uppercase tracking-[0.2em] text-slate-400'>{item.label}</p>
|
||
</div>
|
||
<p className='mt-2 font-medium text-slate-700 dark:text-slate-100'>{item.value}</p>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
<div className='mt-3 rounded-2xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-600 dark:border-slate-800 dark:bg-slate-950/50 dark:text-slate-300'>
|
||
<span className='font-medium text-slate-900 dark:text-white'>MIME type:</span> {metadata.mime_type || mediaFile?.type || 'Not detected'}
|
||
</div>
|
||
|
||
{metadataNotice && (
|
||
<div className='mt-3 rounded-2xl border border-cyan-200 bg-cyan-50 px-4 py-3 text-sm text-cyan-700 dark:border-cyan-900/40 dark:bg-cyan-950/20 dark:text-cyan-200'>
|
||
{metadataNotice}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{(errorMessage || successMessage) && (
|
||
<div className={`rounded-2xl border px-4 py-3 text-sm ${errorMessage ? 'border-red-200 bg-red-50 text-red-700 dark:border-red-900/40 dark:bg-red-950/20 dark:text-red-200' : 'border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-900/40 dark:bg-emerald-950/20 dark:text-emerald-200'}`}>
|
||
{errorMessage || successMessage}
|
||
</div>
|
||
)}
|
||
|
||
<div className='flex flex-wrap gap-3'>
|
||
<BaseButton
|
||
color='info'
|
||
icon={mdiUpload}
|
||
label={isSubmitting ? 'Saving upload...' : currentMode.buttonLabel}
|
||
onClick={handleSubmit}
|
||
disabled={isSubmitting || isAnalyzingMetadata}
|
||
/>
|
||
<BaseButton
|
||
color='whiteDark'
|
||
outline
|
||
label='Reset form'
|
||
onClick={resetUploaderForm}
|
||
disabled={isSubmitting}
|
||
/>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div className='mt-5 rounded-2xl border border-dashed border-slate-300 bg-white p-4 text-sm text-slate-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-300'>
|
||
You can view the saved playlist here, but you do not currently have permission to upload new media assets.
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<div className='space-y-6'>
|
||
<div className='rounded-[28px] border border-slate-200 bg-slate-50 p-5 dark:border-slate-800 dark:bg-slate-950/40'>
|
||
<div className='flex items-center justify-between gap-4'>
|
||
<div>
|
||
<p className='text-base font-semibold text-slate-900 dark:text-white'>{currentMode.label} playlist</p>
|
||
<p className='text-sm text-slate-500 dark:text-slate-300'>Saved uploads stay in the playlist and can be replayed any time.</p>
|
||
</div>
|
||
<span className='rounded-full bg-slate-200 px-3 py-1 text-xs font-medium text-slate-700 dark:bg-slate-800 dark:text-slate-200'>
|
||
{activePlaylist.length} item{activePlaylist.length === 1 ? '' : 's'}
|
||
</span>
|
||
</div>
|
||
|
||
{isLoading ? (
|
||
<LoadingSpinner />
|
||
) : activePlaylist.length ? (
|
||
<div className='mt-5 grid gap-5 lg:grid-cols-[0.92fr_1.08fr]'>
|
||
<div className='space-y-3'>
|
||
{activePlaylist.map((item, index) => {
|
||
const isSelected = selectedAsset?.id === item.id;
|
||
const fileUrl = getAssetUrl(item);
|
||
const isFirstItem = index === 0;
|
||
const isLastItem = index === activePlaylist.length - 1;
|
||
|
||
return (
|
||
<div
|
||
key={item.id}
|
||
className={`rounded-2xl border p-4 transition ${isSelected ? 'border-cyan-400 bg-cyan-50/60 dark:border-cyan-500 dark:bg-cyan-950/20' : 'border-slate-200 bg-white hover:border-slate-300 dark:border-slate-800 dark:bg-slate-900'}`}
|
||
>
|
||
<button
|
||
type='button'
|
||
className='w-full text-left'
|
||
onClick={() => setSelectedAssetId(item.id)}
|
||
>
|
||
<div className='flex items-start gap-3'>
|
||
<div className='h-14 w-14 shrink-0 overflow-hidden rounded-2xl bg-slate-200 dark:bg-slate-800'>
|
||
{item.preview_image?.[0]?.publicUrl ? (
|
||
<ImageField name={item.title || 'Upload artwork'} image={item.preview_image} className='h-full w-full' imageClassName='h-full w-full object-cover' />
|
||
) : (
|
||
<div className='flex h-full items-center justify-center'>
|
||
<BaseIcon path={item.asset_type === 'audio' ? mdiRadio : mdiTelevisionClassic} size={26} className='text-slate-500 dark:text-slate-300' />
|
||
</div>
|
||
)}
|
||
</div>
|
||
<div className='min-w-0 flex-1'>
|
||
<div className='flex flex-wrap items-center gap-2'>
|
||
<p className='truncate font-semibold text-slate-900 dark:text-white'>{item.title || 'Untitled upload'}</p>
|
||
{item.is_primary && (
|
||
<span className='rounded-full bg-amber-100 px-2 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-amber-700 dark:bg-amber-950/40 dark:text-amber-200'>
|
||
Primary
|
||
</span>
|
||
)}
|
||
</div>
|
||
<p className='mt-1 text-sm text-slate-500 dark:text-slate-300'>{formatCreatedAt(item.createdAt)}</p>
|
||
<div className='mt-1 flex flex-wrap gap-x-3 gap-y-1 text-xs uppercase tracking-[0.18em] text-slate-400'>
|
||
<span>{formatBytes(item.file_size_bytes)}</span>
|
||
<span>{formatDuration(item.duration_seconds)}</span>
|
||
{item.asset_type === 'video' && <span>{item.resolution || 'Resolution n/a'}</span>}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</button>
|
||
<div className='mt-3 flex flex-wrap gap-2'>
|
||
<BaseButton small color='info' icon={mdiPlayCircleOutline} label='Play' onClick={() => setSelectedAssetId(item.id)} />
|
||
{fileUrl && <BaseButton small color='whiteDark' outline icon={mdiOpenInNew} label='Open file' href={fileUrl} target='_blank' />}
|
||
{canUpdateMediaAssets && (
|
||
<>
|
||
<BaseButton
|
||
small
|
||
color='warning'
|
||
outline={!item.is_primary}
|
||
icon={item.is_primary ? mdiStar : mdiStarOutline}
|
||
label={item.is_primary ? 'Primary' : 'Make primary'}
|
||
onClick={() => void handleMarkPrimary(item.id)}
|
||
disabled={isUpdatingPlaylist}
|
||
/>
|
||
<BaseButton
|
||
small
|
||
color='whiteDark'
|
||
outline
|
||
icon={mdiArrowUp}
|
||
label='Up'
|
||
onClick={() => void handleMoveAsset(item.id, 'up')}
|
||
disabled={isUpdatingPlaylist || isFirstItem}
|
||
/>
|
||
<BaseButton
|
||
small
|
||
color='whiteDark'
|
||
outline
|
||
icon={mdiArrowDown}
|
||
label='Down'
|
||
onClick={() => void handleMoveAsset(item.id, 'down')}
|
||
disabled={isUpdatingPlaylist || isLastItem}
|
||
/>
|
||
</>
|
||
)}
|
||
{canDeleteMediaAssets && (
|
||
<BaseButton
|
||
small
|
||
color='danger'
|
||
outline
|
||
icon={mdiClose}
|
||
label={isDeletingId === item.id ? 'Removing...' : 'Remove'}
|
||
onClick={() => handleDelete(item.id)}
|
||
disabled={Boolean(isDeletingId) || isUpdatingPlaylist}
|
||
/>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
<div className='rounded-[24px] border border-slate-200 bg-white p-4 dark:border-slate-800 dark:bg-slate-900'>
|
||
{selectedAsset ? (
|
||
<div className='space-y-4'>
|
||
<div className='flex items-center gap-3'>
|
||
<BaseIcon path={selectedAsset.asset_type === 'audio' ? mdiRadio : mdiTelevisionClassic} size={26} className={selectedAsset.asset_type === 'audio' ? 'text-amber-500' : 'text-cyan-500'} />
|
||
<div>
|
||
<p className='text-xs uppercase tracking-[0.24em] text-slate-500'>Now queued</p>
|
||
<h4 className='text-xl font-semibold text-slate-900 dark:text-white'>{selectedAsset.title || 'Untitled upload'}</h4>
|
||
</div>
|
||
</div>
|
||
|
||
{selectedAsset.preview_image?.[0]?.publicUrl && selectedAsset.asset_type === 'video' && (
|
||
<div className='overflow-hidden rounded-2xl border border-slate-200 dark:border-slate-800'>
|
||
<ImageField
|
||
name={selectedAsset.title || 'Upload preview'}
|
||
image={selectedAsset.preview_image}
|
||
className='aspect-video w-full'
|
||
imageClassName='h-full w-full object-cover'
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
<div className='rounded-2xl border border-slate-200 bg-slate-50 p-3 dark:border-slate-800 dark:bg-slate-950/50'>
|
||
{getAssetUrl(selectedAsset) ? (
|
||
selectedAsset.asset_type === 'audio' ? (
|
||
<audio key={selectedAsset.id} controls preload='metadata' className='w-full' src={getAssetUrl(selectedAsset)} />
|
||
) : (
|
||
<video
|
||
key={selectedAsset.id}
|
||
controls
|
||
preload='metadata'
|
||
className='aspect-video w-full rounded-xl bg-black'
|
||
src={getAssetUrl(selectedAsset)}
|
||
poster={selectedAsset.preview_image?.[0]?.publicUrl || undefined}
|
||
/>
|
||
)
|
||
) : (
|
||
<div className='py-10 text-center'>
|
||
<BaseIcon path={mdiPlayCircleOutline} size={42} className='text-cyan-500' />
|
||
<p className='mt-3 text-sm text-slate-500 dark:text-slate-300'>The saved upload is missing a playable file URL.</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div className='grid gap-3 text-sm text-slate-500 dark:text-slate-300 md:grid-cols-2'>
|
||
<div className='rounded-2xl border border-slate-200 bg-slate-50 px-4 py-3 dark:border-slate-800 dark:bg-slate-950/50'>
|
||
<p className='text-xs uppercase tracking-[0.2em] text-slate-400'>Saved</p>
|
||
<p className='mt-2 font-medium text-slate-700 dark:text-slate-100'>{formatCreatedAt(selectedAsset.createdAt)}</p>
|
||
</div>
|
||
<div className='rounded-2xl border border-slate-200 bg-slate-50 px-4 py-3 dark:border-slate-800 dark:bg-slate-950/50'>
|
||
<p className='text-xs uppercase tracking-[0.2em] text-slate-400'>File size</p>
|
||
<p className='mt-2 font-medium text-slate-700 dark:text-slate-100'>{formatBytes(selectedAsset.file_size_bytes)}</p>
|
||
</div>
|
||
<div className='rounded-2xl border border-slate-200 bg-slate-50 px-4 py-3 dark:border-slate-800 dark:bg-slate-950/50'>
|
||
<p className='text-xs uppercase tracking-[0.2em] text-slate-400'>Duration</p>
|
||
<p className='mt-2 font-medium text-slate-700 dark:text-slate-100'>{formatDuration(selectedAsset.duration_seconds)}</p>
|
||
</div>
|
||
<div className='rounded-2xl border border-slate-200 bg-slate-50 px-4 py-3 dark:border-slate-800 dark:bg-slate-950/50'>
|
||
<p className='text-xs uppercase tracking-[0.2em] text-slate-400'>Resolution</p>
|
||
<p className='mt-2 font-medium text-slate-700 dark:text-slate-100'>{selectedAsset.resolution || (selectedAsset.asset_type === 'audio' ? 'Audio only' : 'Not stored')}</p>
|
||
</div>
|
||
<div className='rounded-2xl border border-slate-200 bg-slate-50 px-4 py-3 dark:border-slate-800 dark:bg-slate-950/50'>
|
||
<p className='text-xs uppercase tracking-[0.2em] text-slate-400'>Bitrate</p>
|
||
<p className='mt-2 font-medium text-slate-700 dark:text-slate-100'>{selectedAsset.bitrate_kbps ? `${selectedAsset.bitrate_kbps} kbps` : 'Not stored'}</p>
|
||
</div>
|
||
<div className='rounded-2xl border border-slate-200 bg-slate-50 px-4 py-3 dark:border-slate-800 dark:bg-slate-950/50'>
|
||
<p className='text-xs uppercase tracking-[0.2em] text-slate-400'>Playlist status</p>
|
||
<p className='mt-2 font-medium text-slate-700 dark:text-slate-100'>
|
||
{selectedAsset.is_primary ? 'Primary playback item' : 'Secondary playlist item'}{selectedAssetIndex >= 0 ? ` • Position ${selectedAssetIndex + 1}` : ''}
|
||
</p>
|
||
</div>
|
||
<div className='rounded-2xl border border-slate-200 bg-slate-50 px-4 py-3 dark:border-slate-800 dark:bg-slate-950/50'>
|
||
<p className='text-xs uppercase tracking-[0.2em] text-slate-400'>MIME</p>
|
||
<p className='mt-2 break-all font-medium text-slate-700 dark:text-slate-100'>{selectedAsset.mime_type || 'Not stored'}</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div className='flex flex-wrap gap-3'>
|
||
{getAssetUrl(selectedAsset) && <BaseButton color='info' icon={mdiOpenInNew} label='Open media file' href={getAssetUrl(selectedAsset)} target='_blank' />}
|
||
<BaseButton href={`/media_assets/media_assets-view/?id=${selectedAsset.id}`} color='whiteDark' outline label='Open asset details' />
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div className='mt-5 rounded-[24px] border border-dashed border-slate-300 bg-white p-6 text-center dark:border-slate-700 dark:bg-slate-900'>
|
||
<BaseIcon path={currentMode.icon} size={34} className={mode === 'audio' ? 'text-amber-500' : 'text-cyan-500'} />
|
||
<p className='mt-4 text-lg font-semibold text-slate-900 dark:text-white'>{currentMode.emptyTitle}</p>
|
||
<p className='mx-auto mt-2 max-w-xl text-sm leading-7 text-slate-500 dark:text-slate-300'>{currentMode.emptyDescription}</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</CardBox>
|
||
);
|
||
}
|