217 lines
10 KiB
TypeScript
217 lines
10 KiB
TypeScript
import React, { ReactElement, useEffect, useMemo, useState } from 'react';
|
|
import Head from 'next/head';
|
|
import Link from 'next/link';
|
|
import { useRouter } from 'next/router';
|
|
import axios from 'axios';
|
|
import { mdiArrowLeft } from '@mdi/js';
|
|
import BaseIcon from '../../../components/BaseIcon';
|
|
import LoadingSpinner from '../../../components/LoadingSpinner';
|
|
import PublicMediaPlayer from '../../../components/PublicMediaPlayer';
|
|
import { getPageTitle } from '../../../config';
|
|
import {
|
|
formatDuration,
|
|
formatMediaDate,
|
|
getPrimaryPlaybackType,
|
|
getPrimaryPlaybackUrls,
|
|
humanizeMediaKind,
|
|
} from '../../../helpers/publicMedia';
|
|
import LayoutGuest from '../../../layouts/Guest';
|
|
|
|
export default function PublicEpisodeDetailsPage() {
|
|
const router = useRouter();
|
|
const [loading, setLoading] = useState(true);
|
|
const [errorMessage, setErrorMessage] = useState('');
|
|
const [episode, setEpisode] = useState<any>(null);
|
|
|
|
useEffect(() => {
|
|
if (!router.isReady || typeof router.query.id !== 'string') return;
|
|
|
|
let isMounted = true;
|
|
|
|
const loadEpisode = async () => {
|
|
setLoading(true);
|
|
setErrorMessage('');
|
|
|
|
try {
|
|
const response = await axios.get(`/public-media/episodes/${router.query.id}`);
|
|
if (isMounted) {
|
|
setEpisode(response.data);
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to load public episode:', error);
|
|
if (isMounted) {
|
|
setErrorMessage('We could not load this episode right now.');
|
|
}
|
|
} finally {
|
|
if (isMounted) {
|
|
setLoading(false);
|
|
}
|
|
}
|
|
};
|
|
|
|
loadEpisode();
|
|
|
|
return () => {
|
|
isMounted = false;
|
|
};
|
|
}, [router.isReady, router.query.id]);
|
|
|
|
const playback = useMemo(() => getPrimaryPlaybackUrls(episode), [episode]);
|
|
const playbackType = getPrimaryPlaybackType(episode);
|
|
|
|
const alternateAssets = useMemo(() => {
|
|
const assets = Array.isArray(episode?.media_assets) ? episode.media_assets : [];
|
|
return assets.filter((item) => item.id !== episode?.primaryMediaAsset?.id);
|
|
}, [episode]);
|
|
|
|
return (
|
|
<>
|
|
<Head>
|
|
<title>{getPageTitle(episode?.title || 'Episode')}</title>
|
|
<meta name='description' content={episode?.description || 'Public episode detail page for Aliyo Momot.'} />
|
|
</Head>
|
|
|
|
<main className='min-h-screen bg-[#050816] text-white'>
|
|
{loading ? (
|
|
<LoadingSpinner />
|
|
) : errorMessage ? (
|
|
<div className='mx-auto max-w-4xl px-6 py-16 lg:px-10'>
|
|
<div className='rounded-[24px] border border-rose-500/20 bg-rose-500/10 p-6 text-sm text-rose-100'>{errorMessage}</div>
|
|
</div>
|
|
) : !episode ? (
|
|
<div className='mx-auto max-w-4xl px-6 py-16 text-slate-300 lg:px-10'>Episode not found.</div>
|
|
) : (
|
|
<>
|
|
<section className='border-b border-white/5 bg-[linear-gradient(135deg,_#050816_0%,_#0f172a_55%,_#111827_100%)]'>
|
|
<div className='mx-auto max-w-7xl px-6 py-14 lg:px-10'>
|
|
<Link href={episode.show ? `/watch/shows/${episode.show.id}` : '/watch/episodes'} className='inline-flex items-center gap-2 text-sm text-cyan-200 transition hover:text-white'>
|
|
<BaseIcon path={mdiArrowLeft} size={16} />
|
|
{episode.show ? `Back to ${episode.show.title}` : 'Back to episodes'}
|
|
</Link>
|
|
|
|
<div className='mt-8 grid gap-8 lg:grid-cols-[1.15fr_0.85fr]'>
|
|
<div>
|
|
<div className='flex flex-wrap gap-3'>
|
|
<div className='rounded-full border border-white/10 bg-white/5 px-4 py-2 text-xs uppercase tracking-[0.22em] text-cyan-100'>
|
|
{episode.show?.category?.name || 'Episode'}
|
|
</div>
|
|
{episode.primaryMediaAsset?.asset_type && (
|
|
<div className='rounded-full border border-white/10 bg-white/5 px-4 py-2 text-xs uppercase tracking-[0.22em] text-slate-300'>
|
|
{humanizeMediaKind(episode.primaryMediaAsset.asset_type)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
<h1 className='mt-5 text-4xl font-semibold text-white sm:text-5xl'>{episode.title}</h1>
|
|
<p className='mt-5 max-w-3xl text-base leading-8 text-slate-300'>{episode.description || 'Public playback detail page for this published episode.'}</p>
|
|
|
|
<div className='mt-8 flex flex-wrap gap-3 text-sm text-slate-300'>
|
|
{episode.show?.title && <div className='rounded-full border border-white/10 bg-white/5 px-4 py-2'>{episode.show.title}</div>}
|
|
<div className='rounded-full border border-white/10 bg-white/5 px-4 py-2'>{formatMediaDate(episode.published_at, false)}</div>
|
|
<div className='rounded-full border border-white/10 bg-white/5 px-4 py-2'>{formatDuration(episode.duration_seconds)}</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className='rounded-[28px] border border-white/10 bg-white/5 p-5'>
|
|
<PublicMediaPlayer
|
|
title={episode.title}
|
|
url={playback.primaryUrl}
|
|
fallbackUrl={playback.fallbackUrl}
|
|
type={playbackType}
|
|
posterUrl={episode.thumbnailImageUrl}
|
|
externalMessage='This episode uses an external or custom playback URL. Open it in a new tab.'
|
|
externalLabel='Open media source'
|
|
emptyMessage='No public playback source is attached to this episode yet.'
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<section>
|
|
<div className='mx-auto max-w-7xl px-6 py-14 lg:px-10'>
|
|
<div className='grid gap-8 lg:grid-cols-[1fr_320px]'>
|
|
<div className='rounded-[28px] border border-white/10 bg-white/5 p-6'>
|
|
<p className='text-sm uppercase tracking-[0.28em] text-cyan-200/70'>Media sources</p>
|
|
<h2 className='mt-3 text-2xl font-semibold text-white'>Available assets</h2>
|
|
|
|
{episode.primaryMediaAsset ? (
|
|
<div className='mt-6 rounded-[22px] border border-white/10 bg-slate-950/60 p-5'>
|
|
<p className='text-xs uppercase tracking-[0.24em] text-emerald-200'>Primary asset</p>
|
|
<h3 className='mt-3 text-xl font-semibold text-white'>{episode.primaryMediaAsset.title || 'Primary playback source'}</h3>
|
|
<div className='mt-4 flex flex-wrap gap-2 text-sm text-slate-300'>
|
|
<span>{humanizeMediaKind(episode.primaryMediaAsset.asset_type)}</span>
|
|
{episode.primaryMediaAsset.resolution && (
|
|
<>
|
|
<span className='text-slate-500'>•</span>
|
|
<span>{episode.primaryMediaAsset.resolution}</span>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
|
|
{alternateAssets.length ? (
|
|
<div className='mt-5 grid gap-4'>
|
|
{alternateAssets.map((asset) => (
|
|
<div key={asset.id} className='rounded-[22px] border border-white/10 bg-slate-950/40 p-5'>
|
|
<h3 className='text-lg font-semibold text-white'>{asset.title || 'Additional asset'}</h3>
|
|
<div className='mt-3 flex flex-wrap gap-2 text-sm text-slate-300'>
|
|
<span>{humanizeMediaKind(asset.asset_type)}</span>
|
|
{asset.resolution && (
|
|
<>
|
|
<span className='text-slate-500'>•</span>
|
|
<span>{asset.resolution}</span>
|
|
</>
|
|
)}
|
|
</div>
|
|
{(asset.fileUrl || asset.source_url) && (
|
|
<a
|
|
href={asset.fileUrl || asset.source_url}
|
|
target='_blank'
|
|
rel='noreferrer'
|
|
className='mt-4 inline-flex rounded-full border border-white/10 px-4 py-2 text-sm text-cyan-200 transition hover:border-cyan-300/40 hover:text-white'
|
|
>
|
|
Open asset
|
|
</a>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
|
|
<aside className='rounded-[28px] border border-white/10 bg-white/5 p-6'>
|
|
<p className='text-sm uppercase tracking-[0.28em] text-cyan-200/70'>Episode info</p>
|
|
<div className='mt-6 space-y-4 text-sm text-slate-300'>
|
|
<div>
|
|
<p className='text-slate-500'>Published</p>
|
|
<p className='mt-1 text-white'>{formatMediaDate(episode.published_at)}</p>
|
|
</div>
|
|
<div>
|
|
<p className='text-slate-500'>Duration</p>
|
|
<p className='mt-1 text-white'>{formatDuration(episode.duration_seconds)}</p>
|
|
</div>
|
|
{episode.show?.title && (
|
|
<div>
|
|
<p className='text-slate-500'>Show</p>
|
|
<Link href={`/watch/shows/${episode.show.id}`} className='mt-1 inline-block text-cyan-200 transition hover:text-white'>
|
|
{episode.show.title}
|
|
</Link>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</aside>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
</>
|
|
)}
|
|
</main>
|
|
</>
|
|
);
|
|
}
|
|
|
|
PublicEpisodeDetailsPage.getLayout = function getLayout(page: ReactElement) {
|
|
return <LayoutGuest>{page}</LayoutGuest>;
|
|
};
|