39487-vm/frontend/src/components/MediaCenterUploadWidget.tsx
Flatlogic Bot 14079e71ec 1.3
2026-04-05 16:01:59 +00:00

1126 lines
46 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>
);
}