2026-04-05 15:16:14 +00:00

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