732 lines
36 KiB
TypeScript
732 lines
36 KiB
TypeScript
import React, { ReactElement, useEffect, useMemo, useState } from 'react';
|
|
import Head from 'next/head';
|
|
import axios from 'axios';
|
|
import {
|
|
mdiAccessPoint,
|
|
mdiBroadcast,
|
|
mdiMagnify,
|
|
mdiMovieOpenPlay,
|
|
mdiPlayCircleOutline,
|
|
mdiRadio,
|
|
mdiTelevisionClassic,
|
|
mdiViewGridPlus,
|
|
} from '@mdi/js';
|
|
import BaseButton from '../components/BaseButton';
|
|
import BaseIcon from '../components/BaseIcon';
|
|
import CardBox from '../components/CardBox';
|
|
import LayoutAuthenticated from '../layouts/Authenticated';
|
|
import LoadingSpinner from '../components/LoadingSpinner';
|
|
import MediaCenterUploadWidget from '../components/MediaCenterUploadWidget';
|
|
import SectionMain from '../components/SectionMain';
|
|
import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton';
|
|
import { getPageTitle } from '../config';
|
|
import { hasPermission } from '../helpers/userPermissions';
|
|
import { useAppSelector } from '../stores/hooks';
|
|
|
|
type FilterKind = 'all' | 'show' | 'episode' | 'live';
|
|
|
|
type LibraryItem = {
|
|
id: string;
|
|
kind: FilterKind;
|
|
title: string;
|
|
description: string;
|
|
badge: string;
|
|
status: string;
|
|
categoryName: string;
|
|
categoryId: string;
|
|
dateLabel: string;
|
|
meta: string;
|
|
href: string;
|
|
isFeatured: boolean;
|
|
sortDate: number;
|
|
};
|
|
|
|
const filterTabs: Array<{ label: string; value: FilterKind }> = [
|
|
{ label: 'All content', value: 'all' },
|
|
{ label: 'Shows', value: 'show' },
|
|
{ label: 'Episodes', value: 'episode' },
|
|
{ label: 'Live', value: 'live' },
|
|
];
|
|
|
|
const statusStyles: Record<string, string> = {
|
|
live: 'bg-red-500/15 text-red-700 dark:text-red-300 border border-red-500/20',
|
|
scheduled: 'bg-amber-500/15 text-amber-700 dark:text-amber-300 border border-amber-500/20',
|
|
published: 'bg-emerald-500/15 text-emerald-700 dark:text-emerald-300 border border-emerald-500/20',
|
|
draft: 'bg-slate-500/10 text-slate-700 dark:text-slate-300 border border-slate-500/20',
|
|
offline: 'bg-slate-500/10 text-slate-700 dark:text-slate-300 border border-slate-500/20',
|
|
archived: 'bg-slate-500/10 text-slate-700 dark:text-slate-300 border border-slate-500/20',
|
|
};
|
|
|
|
const cardAccent: Record<FilterKind, string> = {
|
|
all: 'from-sky-500/25 to-violet-500/20',
|
|
show: 'from-sky-500/25 to-violet-500/20',
|
|
episode: 'from-fuchsia-500/25 to-cyan-500/20',
|
|
live: 'from-rose-500/25 to-orange-500/20',
|
|
};
|
|
|
|
function getStatusClass(status?: string) {
|
|
return statusStyles[(status || '').toLowerCase()] || 'bg-slate-500/10 text-slate-700 dark:text-slate-300 border border-slate-500/20';
|
|
}
|
|
|
|
function formatDateLabel(value?: string | Date | null) {
|
|
if (!value) return 'No schedule set';
|
|
|
|
const date = new Date(value);
|
|
|
|
if (Number.isNaN(date.getTime())) return 'No schedule set';
|
|
|
|
return new Intl.DateTimeFormat('en-US', {
|
|
month: 'short',
|
|
day: 'numeric',
|
|
year: 'numeric',
|
|
hour: 'numeric',
|
|
minute: '2-digit',
|
|
}).format(date);
|
|
}
|
|
|
|
function getTimestamp(value?: string | Date | null) {
|
|
if (!value) return 0;
|
|
|
|
const date = new Date(value);
|
|
|
|
if (Number.isNaN(date.getTime())) return 0;
|
|
|
|
return date.getTime();
|
|
}
|
|
|
|
function toYouTubeEmbed(url?: string) {
|
|
if (!url) return '';
|
|
|
|
try {
|
|
const parsed = new URL(url);
|
|
|
|
if (parsed.hostname.includes('youtu.be')) {
|
|
const id = parsed.pathname.replace('/', '');
|
|
return id ? `https://www.youtube.com/embed/${id}` : '';
|
|
}
|
|
|
|
if (parsed.hostname.includes('youtube.com')) {
|
|
if (parsed.pathname.includes('/embed/')) return url;
|
|
const id = parsed.searchParams.get('v');
|
|
return id ? `https://www.youtube.com/embed/${id}` : '';
|
|
}
|
|
} catch (error) {
|
|
return '';
|
|
}
|
|
|
|
return '';
|
|
}
|
|
|
|
function isDirectMedia(url?: string, type?: string) {
|
|
if (!url) return false;
|
|
const lowered = url.toLowerCase();
|
|
|
|
if (type === 'audio') {
|
|
return lowered.endsWith('.mp3') || lowered.endsWith('.aac') || lowered.endsWith('.wav') || lowered.endsWith('.ogg');
|
|
}
|
|
|
|
return (
|
|
lowered.endsWith('.mp4') ||
|
|
lowered.endsWith('.webm') ||
|
|
lowered.endsWith('.mov') ||
|
|
lowered.includes('.m3u8')
|
|
);
|
|
}
|
|
|
|
const MediaCenterPage = () => {
|
|
const { currentUser } = useAppSelector((state) => state.auth);
|
|
const [loading, setLoading] = useState(true);
|
|
const [errorMessage, setErrorMessage] = useState('');
|
|
const [shows, setShows] = useState<any[]>([]);
|
|
const [episodes, setEpisodes] = useState<any[]>([]);
|
|
const [liveStreams, setLiveStreams] = useState<any[]>([]);
|
|
const [categories, setCategories] = useState<any[]>([]);
|
|
const [searchTerm, setSearchTerm] = useState('');
|
|
const [selectedKind, setSelectedKind] = useState<FilterKind>('all');
|
|
const [selectedCategory, setSelectedCategory] = useState('all');
|
|
|
|
const canReadShows = currentUser && hasPermission(currentUser, 'READ_SHOWS');
|
|
const canReadEpisodes = currentUser && hasPermission(currentUser, 'READ_EPISODES');
|
|
const canReadLiveStreams = currentUser && hasPermission(currentUser, 'READ_LIVE_STREAMS');
|
|
const canReadCategories = currentUser && hasPermission(currentUser, 'READ_CATEGORIES');
|
|
const canCreateShows = currentUser && hasPermission(currentUser, 'CREATE_SHOWS');
|
|
const canCreateEpisodes = currentUser && hasPermission(currentUser, 'CREATE_EPISODES');
|
|
const canCreateLiveStreams = currentUser && hasPermission(currentUser, 'CREATE_LIVE_STREAMS');
|
|
|
|
useEffect(() => {
|
|
if (!currentUser) return;
|
|
|
|
let isActive = true;
|
|
|
|
const loadContent = async () => {
|
|
setLoading(true);
|
|
setErrorMessage('');
|
|
|
|
try {
|
|
const requests: Promise<{ key: string; rows: any[] }>[] = [];
|
|
|
|
if (canReadCategories) {
|
|
requests.push(
|
|
axios.get('categories?page=0&limit=50').then((response) => ({
|
|
key: 'categories',
|
|
rows: response.data.rows || [],
|
|
})),
|
|
);
|
|
}
|
|
|
|
if (canReadShows) {
|
|
requests.push(
|
|
axios.get('shows?page=0&limit=24').then((response) => ({
|
|
key: 'shows',
|
|
rows: response.data.rows || [],
|
|
})),
|
|
);
|
|
}
|
|
|
|
if (canReadEpisodes) {
|
|
requests.push(
|
|
axios.get('episodes?page=0&limit=36').then((response) => ({
|
|
key: 'episodes',
|
|
rows: response.data.rows || [],
|
|
})),
|
|
);
|
|
}
|
|
|
|
if (canReadLiveStreams) {
|
|
requests.push(
|
|
axios.get('live_streams?page=0&limit=18').then((response) => ({
|
|
key: 'liveStreams',
|
|
rows: response.data.rows || [],
|
|
})),
|
|
);
|
|
}
|
|
|
|
const results = await Promise.all(requests);
|
|
|
|
if (!isActive) return;
|
|
|
|
const mapped = results.reduce<Record<string, any[]>>((accumulator, item) => {
|
|
accumulator[item.key] = item.rows;
|
|
return accumulator;
|
|
}, {});
|
|
|
|
setCategories(mapped.categories || []);
|
|
setShows(mapped.shows || []);
|
|
setEpisodes(mapped.episodes || []);
|
|
setLiveStreams(mapped.liveStreams || []);
|
|
} catch (error) {
|
|
console.error('Failed to load media center data:', error);
|
|
if (isActive) {
|
|
setErrorMessage('We could not load the media library right now. Try refreshing the page in a moment.');
|
|
}
|
|
} finally {
|
|
if (isActive) {
|
|
setLoading(false);
|
|
}
|
|
}
|
|
};
|
|
|
|
loadContent();
|
|
|
|
return () => {
|
|
isActive = false;
|
|
};
|
|
}, [canReadCategories, canReadEpisodes, canReadLiveStreams, canReadShows, currentUser]);
|
|
|
|
const featuredLive = useMemo(() => {
|
|
if (!liveStreams.length) return null;
|
|
|
|
return (
|
|
liveStreams.find((item) => item.status === 'live') ||
|
|
liveStreams.find((item) => item.is_featured) ||
|
|
liveStreams[0]
|
|
);
|
|
}, [liveStreams]);
|
|
|
|
const libraryItems = useMemo<LibraryItem[]>(() => {
|
|
const showItems: LibraryItem[] = shows.map((item) => ({
|
|
id: item.id,
|
|
kind: 'show',
|
|
title: item.title || 'Untitled show',
|
|
description: item.summary || 'No summary yet for this show.',
|
|
badge: item.show_type ? String(item.show_type).replace(/_/g, ' ') : 'Show',
|
|
status: item.status || 'draft',
|
|
categoryName: item.category?.name || 'Uncategorized',
|
|
categoryId: item.category?.id || '',
|
|
dateLabel: item.release_year ? `Released in ${item.release_year}` : 'Release year not set',
|
|
meta: item.owner?.firstName ? `Owner: ${item.owner.firstName}` : 'Program shell',
|
|
href: `/shows/shows-view/?id=${item.id}`,
|
|
isFeatured: Boolean(item.is_featured),
|
|
sortDate: item.release_year ? Number(item.release_year) : 0,
|
|
}));
|
|
|
|
const episodeItems: LibraryItem[] = episodes.map((item) => ({
|
|
id: item.id,
|
|
kind: 'episode',
|
|
title: item.title || 'Untitled episode',
|
|
description: item.description || 'No description yet for this episode.',
|
|
badge:
|
|
item.season_number && item.episode_number
|
|
? `S${item.season_number} • E${item.episode_number}`
|
|
: 'Episode',
|
|
status: item.status || 'draft',
|
|
categoryName: item.show?.title || 'Standalone episode',
|
|
categoryId: '',
|
|
dateLabel: formatDateLabel(item.published_at || item.scheduled_at),
|
|
meta: item.duration_seconds ? `${Math.round(item.duration_seconds / 60)} min runtime` : 'Runtime not set',
|
|
href: `/episodes/episodes-view/?id=${item.id}`,
|
|
isFeatured: Boolean(item.is_featured),
|
|
sortDate: getTimestamp(item.published_at || item.scheduled_at),
|
|
}));
|
|
|
|
const liveItems: LibraryItem[] = liveStreams.map((item) => ({
|
|
id: item.id,
|
|
kind: 'live',
|
|
title: item.title || 'Untitled stream',
|
|
description: item.description || 'No stream description yet.',
|
|
badge: item.stream_type === 'audio' ? 'Audio stream' : 'Video stream',
|
|
status: item.status || 'offline',
|
|
categoryName: item.category?.name || 'Live programming',
|
|
categoryId: item.category?.id || '',
|
|
dateLabel: formatDateLabel(item.starts_at),
|
|
meta: item.host?.firstName ? `Host: ${item.host.firstName}` : 'Host not assigned',
|
|
href: `/live_streams/live_streams-view/?id=${item.id}`,
|
|
isFeatured: Boolean(item.is_featured),
|
|
sortDate: getTimestamp(item.starts_at || item.ends_at),
|
|
}));
|
|
|
|
const allItems = [...showItems, ...episodeItems, ...liveItems];
|
|
const query = searchTerm.trim().toLowerCase();
|
|
|
|
return allItems
|
|
.filter((item) => {
|
|
const matchesKind = selectedKind === 'all' || item.kind === selectedKind;
|
|
const matchesCategory = selectedCategory === 'all' || item.categoryId === selectedCategory;
|
|
const haystack = [item.title, item.description, item.categoryName, item.badge, item.meta]
|
|
.join(' ')
|
|
.toLowerCase();
|
|
const matchesQuery = !query || haystack.includes(query);
|
|
|
|
return matchesKind && matchesCategory && matchesQuery;
|
|
})
|
|
.sort((first, second) => {
|
|
if (first.isFeatured !== second.isFeatured) return Number(second.isFeatured) - Number(first.isFeatured);
|
|
return second.sortDate - first.sortDate;
|
|
});
|
|
}, [episodes, liveStreams, searchTerm, selectedCategory, selectedKind, shows]);
|
|
|
|
const upcomingLive = useMemo(() => {
|
|
return [...liveStreams]
|
|
.filter((item) => item.status === 'live' || item.status === 'scheduled')
|
|
.sort((first, second) => getTimestamp(first.starts_at) - getTimestamp(second.starts_at))
|
|
.slice(0, 4);
|
|
}, [liveStreams]);
|
|
|
|
const recentEpisodes = useMemo(() => {
|
|
return [...episodes]
|
|
.sort((first, second) => getTimestamp(second.published_at || second.scheduled_at) - getTimestamp(first.published_at || first.scheduled_at))
|
|
.slice(0, 4);
|
|
}, [episodes]);
|
|
|
|
const totals = {
|
|
shows: shows.length,
|
|
episodes: episodes.length,
|
|
liveStreams: liveStreams.length,
|
|
featured: [...shows, ...episodes, ...liveStreams].filter((item) => Boolean(item.is_featured)).length,
|
|
};
|
|
|
|
const playerEmbed = toYouTubeEmbed(featuredLive?.stream_url);
|
|
const canUseDirectPlayer = isDirectMedia(featuredLive?.stream_url, featuredLive?.stream_type);
|
|
|
|
return (
|
|
<>
|
|
<Head>
|
|
<title>{getPageTitle('Media center')}</title>
|
|
</Head>
|
|
<SectionMain>
|
|
<SectionTitleLineWithButton icon={mdiBroadcast} title='Media center' main>
|
|
<div className='flex flex-wrap gap-2'>
|
|
{canCreateShows && <BaseButton href='/shows/shows-new' color='info' label='New show' />}
|
|
{canCreateEpisodes && <BaseButton href='/episodes/episodes-new' color='info' outline label='New episode' />}
|
|
{canCreateLiveStreams && <BaseButton href='/live_streams/live_streams-new' color='info' outline label='New live stream' />}
|
|
</div>
|
|
</SectionTitleLineWithButton>
|
|
|
|
<CardBox className='mb-6 overflow-hidden border-0 bg-transparent shadow-none'>
|
|
<div className='grid gap-6 rounded-[30px] bg-[linear-gradient(135deg,_#082032,_#0f172a_50%,_#1e293b)] p-6 text-white lg:grid-cols-[1.1fr_0.9fr] lg:p-8'>
|
|
<div>
|
|
<div className='inline-flex items-center gap-2 rounded-full border border-cyan-300/20 bg-cyan-300/10 px-4 py-2 text-sm text-cyan-100'>
|
|
<span className='h-2 w-2 rounded-full bg-emerald-400' />
|
|
Aliyo Momot editorial workflow
|
|
</div>
|
|
<h2 className='mt-4 text-3xl font-semibold tracking-tight'>Operate the broadcast from one workspace.</h2>
|
|
<p className='mt-4 max-w-2xl text-sm leading-7 text-slate-200/90'>
|
|
Browse live streams, published episodes, and show shells without hopping across multiple CRUD screens. When you need to edit or create content, jump directly into the existing admin forms.
|
|
</p>
|
|
<div className='mt-8 grid gap-4 sm:grid-cols-2 xl:grid-cols-4'>
|
|
<div className='rounded-3xl border border-white/10 bg-white/5 p-4'>
|
|
<p className='text-xs uppercase tracking-[0.24em] text-slate-300'>Shows</p>
|
|
<p className='mt-3 text-3xl font-semibold'>{totals.shows}</p>
|
|
</div>
|
|
<div className='rounded-3xl border border-white/10 bg-white/5 p-4'>
|
|
<p className='text-xs uppercase tracking-[0.24em] text-slate-300'>Episodes</p>
|
|
<p className='mt-3 text-3xl font-semibold'>{totals.episodes}</p>
|
|
</div>
|
|
<div className='rounded-3xl border border-white/10 bg-white/5 p-4'>
|
|
<p className='text-xs uppercase tracking-[0.24em] text-slate-300'>Live streams</p>
|
|
<p className='mt-3 text-3xl font-semibold'>{totals.liveStreams}</p>
|
|
</div>
|
|
<div className='rounded-3xl border border-white/10 bg-white/5 p-4'>
|
|
<p className='text-xs uppercase tracking-[0.24em] text-slate-300'>Featured items</p>
|
|
<p className='mt-3 text-3xl font-semibold'>{totals.featured}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className='rounded-[28px] border border-white/10 bg-slate-950/30 p-5'>
|
|
<div className='flex items-center justify-between gap-4'>
|
|
<div>
|
|
<p className='text-xs uppercase tracking-[0.24em] text-cyan-100/70'>Next action</p>
|
|
<h3 className='mt-2 text-xl font-semibold'>Programming checklist</h3>
|
|
</div>
|
|
<BaseIcon path={mdiViewGridPlus} size={34} className='text-cyan-200' />
|
|
</div>
|
|
<div className='mt-6 space-y-3 text-sm text-slate-200'>
|
|
<div className='rounded-2xl border border-white/10 bg-white/5 p-4'>Review the featured live stream and make sure the link is playable.</div>
|
|
<div className='rounded-2xl border border-white/10 bg-white/5 p-4'>Search the library below, then open details pages for final polish.</div>
|
|
<div className='rounded-2xl border border-white/10 bg-white/5 p-4'>Use the quick actions to create the next show, episode, or stream.</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</CardBox>
|
|
|
|
<MediaCenterUploadWidget className='mb-6' />
|
|
|
|
{loading && <LoadingSpinner />}
|
|
|
|
{!loading && errorMessage && (
|
|
<CardBox className='mb-6 border border-red-200 bg-red-50 dark:border-red-900/40 dark:bg-red-950/20'>
|
|
<div className='space-y-3'>
|
|
<p className='text-lg font-semibold text-red-700 dark:text-red-300'>Unable to load the media center</p>
|
|
<p className='text-sm text-red-600 dark:text-red-200'>{errorMessage}</p>
|
|
</div>
|
|
</CardBox>
|
|
)}
|
|
|
|
{!loading && !errorMessage && (
|
|
<>
|
|
<div className='mb-6 grid gap-6 xl:grid-cols-[1.35fr_0.65fr]'>
|
|
<CardBox className='border border-slate-200/70 bg-white dark:border-slate-800'>
|
|
<div className='flex flex-col gap-5'>
|
|
<div className='flex flex-col gap-4 md:flex-row md:items-center md:justify-between'>
|
|
<div>
|
|
<p className='text-xs uppercase tracking-[0.24em] text-slate-500'>Discovery tools</p>
|
|
<h3 className='mt-2 text-2xl font-semibold text-slate-900 dark:text-white'>Search the catalog</h3>
|
|
</div>
|
|
<div className='flex flex-wrap gap-2'>
|
|
{filterTabs.map((tab) => {
|
|
const active = selectedKind === tab.value;
|
|
|
|
return (
|
|
<button
|
|
key={tab.value}
|
|
type='button'
|
|
onClick={() => setSelectedKind(tab.value)}
|
|
className={`rounded-full px-4 py-2 text-sm font-medium transition ${
|
|
active
|
|
? 'bg-slate-900 text-white dark:bg-white dark:text-slate-950'
|
|
: 'bg-slate-100 text-slate-600 hover:bg-slate-200 dark:bg-slate-800 dark:text-slate-200 dark:hover:bg-slate-700'
|
|
}`}
|
|
>
|
|
{tab.label}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
|
|
<div className='grid gap-4 lg:grid-cols-[minmax(0,1fr)_240px]'>
|
|
<label className='flex items-center gap-3 rounded-2xl border border-slate-200 bg-slate-50 px-4 py-3 text-slate-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-400'>
|
|
<BaseIcon path={mdiMagnify} size={22} />
|
|
<input
|
|
value={searchTerm}
|
|
onChange={(event) => setSearchTerm(event.target.value)}
|
|
placeholder='Search by title, show, category, or status'
|
|
className='w-full bg-transparent text-sm text-slate-900 outline-none placeholder:text-slate-400 dark:text-white'
|
|
/>
|
|
</label>
|
|
|
|
<label className='rounded-2xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-300'>
|
|
<span className='mb-2 block text-xs uppercase tracking-[0.22em]'>Category</span>
|
|
<select
|
|
value={selectedCategory}
|
|
onChange={(event) => setSelectedCategory(event.target.value)}
|
|
className='w-full bg-transparent text-sm text-slate-900 outline-none dark:text-white'
|
|
>
|
|
<option value='all'>All categories</option>
|
|
{categories.map((category) => (
|
|
<option key={category.id} value={category.id}>
|
|
{category.name || 'Unnamed category'}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
</div>
|
|
|
|
<p className='text-sm text-slate-500 dark:text-slate-300'>
|
|
{libraryItems.length} result{libraryItems.length === 1 ? '' : 's'} found. Use the cards below to open detail pages or jump into list screens.
|
|
</p>
|
|
</div>
|
|
</CardBox>
|
|
|
|
<CardBox className='border border-slate-200/70 bg-white dark:border-slate-800'>
|
|
<div className='space-y-4'>
|
|
<div>
|
|
<p className='text-xs uppercase tracking-[0.24em] text-slate-500'>Quick navigation</p>
|
|
<h3 className='mt-2 text-2xl font-semibold text-slate-900 dark:text-white'>Admin shortcuts</h3>
|
|
</div>
|
|
<div className='grid gap-3'>
|
|
{canReadShows && <BaseButton href='/shows/shows-list' color='info' label='Open shows library' />}
|
|
{canReadEpisodes && <BaseButton href='/episodes/episodes-list' color='info' outline label='Open episodes library' />}
|
|
{canReadLiveStreams && <BaseButton href='/live_streams/live_streams-list' color='info' outline label='Open live streams' />}
|
|
</div>
|
|
</div>
|
|
</CardBox>
|
|
</div>
|
|
|
|
<div className='mb-6 grid gap-6 xl:grid-cols-[1.1fr_0.9fr]'>
|
|
<CardBox className='overflow-hidden border border-slate-200/70 bg-white dark:border-slate-800'>
|
|
<div className='flex flex-col gap-5'>
|
|
<div className='flex items-center justify-between gap-4'>
|
|
<div>
|
|
<p className='text-xs uppercase tracking-[0.24em] text-slate-500'>Featured live booth</p>
|
|
<h3 className='mt-2 text-2xl font-semibold text-slate-900 dark:text-white'>Preview the current stream</h3>
|
|
</div>
|
|
<span className={`rounded-full px-3 py-1 text-xs font-medium ${getStatusClass(featuredLive?.status)}`}>
|
|
{featuredLive?.status || 'No live stream'}
|
|
</span>
|
|
</div>
|
|
|
|
{featuredLive ? (
|
|
<>
|
|
<div className='rounded-[26px] bg-[linear-gradient(135deg,_rgba(14,165,233,0.14),_rgba(15,23,42,0.06),_rgba(168,85,247,0.12))] p-4 dark:bg-[linear-gradient(135deg,_rgba(14,165,233,0.16),_rgba(2,6,23,0.95),_rgba(168,85,247,0.18))]'>
|
|
{playerEmbed ? (
|
|
<div className='aspect-video overflow-hidden rounded-[22px] border border-slate-200 bg-slate-950 dark:border-slate-800'>
|
|
<iframe
|
|
src={playerEmbed}
|
|
title={featuredLive.title}
|
|
allow='accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture'
|
|
allowFullScreen
|
|
className='h-full w-full'
|
|
/>
|
|
</div>
|
|
) : featuredLive.stream_type === 'audio' && canUseDirectPlayer ? (
|
|
<div className='rounded-[22px] border border-slate-200 bg-slate-950 p-5 dark:border-slate-800'>
|
|
<div className='flex items-center gap-3 text-white'>
|
|
<BaseIcon path={mdiRadio} size={28} className='text-cyan-300' />
|
|
<div>
|
|
<p className='text-sm uppercase tracking-[0.24em] text-slate-400'>Audio preview</p>
|
|
<p className='text-lg font-semibold text-white'>{featuredLive.title}</p>
|
|
</div>
|
|
</div>
|
|
<audio controls className='mt-5 w-full'>
|
|
<source src={featuredLive.stream_url} />
|
|
Your browser does not support audio playback.
|
|
</audio>
|
|
</div>
|
|
) : featuredLive.stream_type === 'video' && canUseDirectPlayer ? (
|
|
<div className='aspect-video overflow-hidden rounded-[22px] border border-slate-200 bg-slate-950 dark:border-slate-800'>
|
|
<video controls className='h-full w-full bg-black'>
|
|
<source src={featuredLive.stream_url} />
|
|
Your browser does not support video playback.
|
|
</video>
|
|
</div>
|
|
) : (
|
|
<div className='flex aspect-video flex-col items-center justify-center gap-4 rounded-[22px] border border-dashed border-slate-300 bg-slate-50 text-center dark:border-slate-700 dark:bg-slate-900/70'>
|
|
<BaseIcon path={mdiPlayCircleOutline} size={50} className='text-cyan-500' />
|
|
<div className='max-w-md space-y-2 px-5'>
|
|
<p className='text-lg font-semibold text-slate-900 dark:text-white'>Preview not embedded yet</p>
|
|
<p className='text-sm leading-6 text-slate-500 dark:text-slate-300'>
|
|
This stream uses a custom URL. Open the stream directly or update the link to a playable media source or YouTube embed URL.
|
|
</p>
|
|
</div>
|
|
{featuredLive.stream_url && (
|
|
<a
|
|
href={featuredLive.stream_url}
|
|
target='_blank'
|
|
rel='noreferrer'
|
|
className='rounded-full bg-slate-900 px-4 py-2 text-sm font-medium text-white transition hover:bg-slate-700 dark:bg-white dark:text-slate-950 dark:hover:bg-slate-200'
|
|
>
|
|
Open stream URL
|
|
</a>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className='grid gap-4 md:grid-cols-[1fr_auto] md:items-end'>
|
|
<div>
|
|
<h4 className='text-xl font-semibold text-slate-900 dark:text-white'>{featuredLive.title}</h4>
|
|
<p className='mt-2 text-sm leading-7 text-slate-500 dark:text-slate-300'>{featuredLive.description || 'No stream description yet.'}</p>
|
|
<div className='mt-4 flex flex-wrap gap-2'>
|
|
<span className='rounded-full bg-slate-100 px-3 py-1 text-xs text-slate-600 dark:bg-slate-800 dark:text-slate-200'>
|
|
{featuredLive.stream_type === 'audio' ? 'Audio stream' : 'Video stream'}
|
|
</span>
|
|
<span className='rounded-full bg-slate-100 px-3 py-1 text-xs text-slate-600 dark:bg-slate-800 dark:text-slate-200'>
|
|
{featuredLive.category?.name || 'Live programming'}
|
|
</span>
|
|
<span className='rounded-full bg-slate-100 px-3 py-1 text-xs text-slate-600 dark:bg-slate-800 dark:text-slate-200'>
|
|
{formatDateLabel(featuredLive.starts_at)}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<BaseButton href={`/live_streams/live_streams-view/?id=${featuredLive.id}`} color='info' label='Open stream details' />
|
|
</div>
|
|
</>
|
|
) : (
|
|
<div className='rounded-[24px] border border-dashed border-slate-300 bg-slate-50 p-6 text-center dark:border-slate-700 dark:bg-slate-900/70'>
|
|
<p className='text-lg font-semibold text-slate-900 dark:text-white'>No live streams yet</p>
|
|
<p className='mt-2 text-sm leading-6 text-slate-500 dark:text-slate-300'>Create a live stream to turn this area into an operational preview booth.</p>
|
|
{canCreateLiveStreams && <BaseButton href='/live_streams/live_streams-new' color='info' label='Create live stream' className='mt-4' />}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</CardBox>
|
|
|
|
<div className='grid gap-6'>
|
|
<CardBox className='border border-slate-200/70 bg-white dark:border-slate-800'>
|
|
<div>
|
|
<div className='flex items-center gap-3'>
|
|
<BaseIcon path={mdiAccessPoint} size={24} className='text-cyan-500' />
|
|
<h3 className='text-xl font-semibold text-slate-900 dark:text-white'>Upcoming live slots</h3>
|
|
</div>
|
|
<div className='mt-5 space-y-3'>
|
|
{upcomingLive.length ? (
|
|
upcomingLive.map((item) => (
|
|
<a
|
|
key={item.id}
|
|
href={`/live_streams/live_streams-view/?id=${item.id}`}
|
|
className='block rounded-2xl border border-slate-200 p-4 transition hover:border-cyan-300 hover:bg-slate-50 dark:border-slate-800 dark:hover:bg-slate-900/70'
|
|
>
|
|
<div className='flex items-center justify-between gap-3'>
|
|
<p className='font-semibold text-slate-900 dark:text-white'>{item.title || 'Untitled stream'}</p>
|
|
<span className={`rounded-full px-3 py-1 text-xs font-medium ${getStatusClass(item.status)}`}>{item.status || 'offline'}</span>
|
|
</div>
|
|
<p className='mt-2 text-sm text-slate-500 dark:text-slate-300'>{formatDateLabel(item.starts_at)}</p>
|
|
</a>
|
|
))
|
|
) : (
|
|
<p className='text-sm text-slate-500 dark:text-slate-300'>No live schedule yet.</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</CardBox>
|
|
|
|
<CardBox className='border border-slate-200/70 bg-white dark:border-slate-800'>
|
|
<div>
|
|
<div className='flex items-center gap-3'>
|
|
<BaseIcon path={mdiMovieOpenPlay} size={24} className='text-fuchsia-500' />
|
|
<h3 className='text-xl font-semibold text-slate-900 dark:text-white'>Recent episodes</h3>
|
|
</div>
|
|
<div className='mt-5 space-y-3'>
|
|
{recentEpisodes.length ? (
|
|
recentEpisodes.map((item) => (
|
|
<a
|
|
key={item.id}
|
|
href={`/episodes/episodes-view/?id=${item.id}`}
|
|
className='block rounded-2xl border border-slate-200 p-4 transition hover:border-fuchsia-300 hover:bg-slate-50 dark:border-slate-800 dark:hover:bg-slate-900/70'
|
|
>
|
|
<p className='font-semibold text-slate-900 dark:text-white'>{item.title || 'Untitled episode'}</p>
|
|
<p className='mt-2 text-sm text-slate-500 dark:text-slate-300'>{formatDateLabel(item.published_at || item.scheduled_at)}</p>
|
|
</a>
|
|
))
|
|
) : (
|
|
<p className='text-sm text-slate-500 dark:text-slate-300'>No episodes published yet.</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</CardBox>
|
|
</div>
|
|
</div>
|
|
|
|
{libraryItems.length ? (
|
|
<div className='grid gap-5 md:grid-cols-2 xl:grid-cols-3'>
|
|
{libraryItems.map((item) => (
|
|
<CardBox
|
|
key={`${item.kind}-${item.id}`}
|
|
className='border border-slate-200/70 bg-white transition duration-300 hover:-translate-y-1 hover:shadow-xl dark:border-slate-800'
|
|
isHoverable
|
|
>
|
|
<div className='flex h-full flex-col'>
|
|
<div className={`rounded-[24px] bg-gradient-to-br ${cardAccent[item.kind]} p-4 dark:from-slate-900/90 dark:to-slate-800`}>
|
|
<div className='flex items-start justify-between gap-3'>
|
|
<div>
|
|
<span className='rounded-full bg-white/70 px-3 py-1 text-xs font-medium uppercase tracking-[0.2em] text-slate-700 dark:bg-white/10 dark:text-slate-200'>
|
|
{item.badge}
|
|
</span>
|
|
<h3 className='mt-4 text-xl font-semibold text-slate-900 dark:text-white'>{item.title}</h3>
|
|
</div>
|
|
<BaseIcon
|
|
path={item.kind === 'show' ? mdiTelevisionClassic : item.kind === 'episode' ? mdiMovieOpenPlay : mdiBroadcast}
|
|
size={30}
|
|
className='text-slate-700 dark:text-slate-100'
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className='flex flex-1 flex-col justify-between pt-5'>
|
|
<div>
|
|
<div className='flex flex-wrap gap-2'>
|
|
<span className={`rounded-full px-3 py-1 text-xs font-medium ${getStatusClass(item.status)}`}>{item.status}</span>
|
|
<span className='rounded-full bg-slate-100 px-3 py-1 text-xs text-slate-600 dark:bg-slate-800 dark:text-slate-200'>
|
|
{item.categoryName}
|
|
</span>
|
|
</div>
|
|
<p className='mt-4 text-sm leading-7 text-slate-500 dark:text-slate-300'>{item.description}</p>
|
|
</div>
|
|
|
|
<div className='mt-6 space-y-4'>
|
|
<div className='space-y-2 text-sm text-slate-500 dark:text-slate-300'>
|
|
<p>{item.dateLabel}</p>
|
|
<p>{item.meta}</p>
|
|
</div>
|
|
<div className='flex flex-wrap gap-3'>
|
|
<BaseButton href={item.href} color='info' label='View details' />
|
|
{item.kind === 'show' && canReadShows && <BaseButton href='/shows/shows-list' color='whiteDark' outline label='Open list' />}
|
|
{item.kind === 'episode' && canReadEpisodes && <BaseButton href='/episodes/episodes-list' color='whiteDark' outline label='Open list' />}
|
|
{item.kind === 'live' && canReadLiveStreams && <BaseButton href='/live_streams/live_streams-list' color='whiteDark' outline label='Open list' />}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</CardBox>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<CardBox className='border border-dashed border-slate-300 bg-slate-50 dark:border-slate-700 dark:bg-slate-900/60'>
|
|
<div className='rounded-[24px] p-6 text-center'>
|
|
<p className='text-2xl font-semibold text-slate-900 dark:text-white'>No matching content yet</p>
|
|
<p className='mx-auto mt-3 max-w-2xl text-sm leading-7 text-slate-500 dark:text-slate-300'>
|
|
Adjust the search and filters, or create the first show, episode, or live stream to start shaping the Aliyo Momot media catalog.
|
|
</p>
|
|
<div className='mt-6 flex flex-wrap justify-center gap-3'>
|
|
{canCreateShows && <BaseButton href='/shows/shows-new' color='info' label='Create show' />}
|
|
{canCreateEpisodes && <BaseButton href='/episodes/episodes-new' color='info' outline label='Create episode' />}
|
|
{canCreateLiveStreams && <BaseButton href='/live_streams/live_streams-new' color='info' outline label='Create live stream' />}
|
|
</div>
|
|
</div>
|
|
</CardBox>
|
|
)}
|
|
</>
|
|
)}
|
|
</SectionMain>
|
|
</>
|
|
);
|
|
};
|
|
|
|
MediaCenterPage.getLayout = function getLayout(page: ReactElement) {
|
|
return <LayoutAuthenticated>{page}</LayoutAuthenticated>;
|
|
};
|
|
|
|
export default MediaCenterPage;
|