feat(dashboard): Meet Kevin video detail page (tabs + iframe + deep-links)

This commit is contained in:
Viktor Barzin 2026-05-21 20:05:14 +00:00
parent 625c22b833
commit 9ce0e44929

View file

@ -0,0 +1,304 @@
import { useRef, useState } from 'react';
import { useParams } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import meetKevinApi from '../../api/meetKevin';
import {
ActionChip,
ConvictionBar,
YouTubeEmbed,
type YouTubeEmbedHandle,
} from '../../components/meetKevin';
import type { MarketOutlook } from '../../types/meetKevin';
function formatTime(seconds: number): string {
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = Math.floor(seconds % 60);
const mm = String(m).padStart(2, '0');
const ss = String(s).padStart(2, '0');
return h > 0 ? `${h}:${mm}:${ss}` : `${m}:${ss}`;
}
const OUTLOOK_BANNER: Record<MarketOutlook, string> = {
bullish: 'bg-green-600/20 border-green-500/40 text-green-300',
bearish: 'bg-red-600/20 border-red-500/40 text-red-300',
neutral: 'bg-slate-600/20 border-slate-500/40 text-slate-300',
mixed: 'bg-purple-600/20 border-purple-500/40 text-purple-300',
};
type Tab = 'analysis' | 'transcript' | 'raw';
export default function VideoDetail() {
const { id } = useParams<{ id: string }>();
const videoId = Number(id);
const playerRef = useRef<YouTubeEmbedHandle>(null);
const [tab, setTab] = useState<Tab>('analysis');
const queryClient = useQueryClient();
const { data: detail, isLoading } = useQuery({
queryKey: ['meet-kevin', 'video', videoId],
queryFn: () => meetKevinApi.getVideo(videoId),
enabled: !isNaN(videoId),
});
const { data: transcript } = useQuery({
queryKey: ['meet-kevin', 'transcript', videoId],
queryFn: () => meetKevinApi.getTranscript(videoId),
enabled: Boolean(detail?.transcript_available),
});
const reprocessMutation = useMutation({
mutationFn: (stage: 'captions' | 'analysis' | 'auto') =>
meetKevinApi.reprocess(videoId, stage),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ['meet-kevin', 'video', videoId],
});
},
});
if (isLoading || !detail) {
return (
<div className="flex justify-center py-16">
<span className="inline-block w-8 h-8 border-2 border-blue-400/30 border-t-blue-400 rounded-full animate-spin" />
</div>
);
}
const v = detail.video;
const analysis = detail.analysis;
const showReprocess =
v.status === 'failed' || v.status === 'discovered';
const tabs: { id: Tab; label: string; disabled?: boolean }[] = [
{ id: 'analysis', label: 'Analysis' },
{ id: 'transcript', label: 'Transcript', disabled: !detail.transcript_available },
{ id: 'raw', label: 'Raw JSON' },
];
return (
<div className="space-y-6">
{/* Header */}
<div className="space-y-1">
<h2 className="text-2xl font-bold text-white leading-snug">{v.title}</h2>
<p className="text-sm text-slate-400">
{new Date(v.published_at).toLocaleDateString(undefined, {
year: 'numeric',
month: 'long',
day: 'numeric',
})}
<span className="mx-2 text-slate-600">·</span>
<span className="capitalize">{v.status}</span>
</p>
</div>
{/* Reprocess button */}
{showReprocess && (
<div className="flex gap-2">
{(['captions', 'analysis', 'auto'] as const).map((stage) => (
<button
key={stage}
onClick={() => reprocessMutation.mutate(stage)}
disabled={reprocessMutation.isPending}
className="px-3 py-1.5 text-xs font-medium bg-slate-700 hover:bg-slate-600 border border-slate-600 text-slate-200 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed capitalize"
>
{reprocessMutation.isPending ? 'Processing…' : `Reprocess ${stage}`}
</button>
))}
</div>
)}
{/* YouTube embed */}
<YouTubeEmbed
ref={playerRef}
videoId={v.youtube_video_id}
/>
{/* Tab strip */}
<div className="border-b border-slate-700 flex gap-6">
{tabs.map((t) => (
<button
key={t.id}
onClick={() => !t.disabled && setTab(t.id)}
disabled={t.disabled}
className={[
'pb-2 text-sm font-medium transition-colors',
t.disabled
? 'text-slate-600 cursor-not-allowed'
: tab === t.id
? 'text-white border-b-2 border-blue-400 -mb-px'
: 'text-slate-400 hover:text-slate-200',
].join(' ')}
>
{t.label}
</button>
))}
</div>
{/* Analysis tab */}
{tab === 'analysis' && (
<div className="space-y-5">
{!analysis ? (
<div className="bg-slate-800 border border-slate-700 rounded-xl p-6 text-center text-slate-400">
Analysis not available video has not been analyzed yet.
</div>
) : (
<>
{/* Outlook card */}
<div
className={`border rounded-xl p-5 space-y-2 ${OUTLOOK_BANNER[analysis.market_outlook_direction]}`}
>
<p className="text-xs font-semibold uppercase tracking-wide opacity-70">
Market Outlook
</p>
<p className="text-lg font-bold capitalize">
{analysis.market_outlook_direction}
</p>
<p className="text-sm opacity-80 leading-relaxed">
{analysis.market_outlook_reasoning}
</p>
</div>
{/* Macro themes */}
{analysis.macro_themes.length > 0 && (
<div className="bg-slate-800 border border-slate-700 rounded-xl p-5 space-y-3">
<h3 className="text-xs font-semibold text-slate-300 uppercase tracking-wide">
Macro Themes
</h3>
<div className="flex flex-wrap gap-2">
{analysis.macro_themes.map((theme) => (
<span
key={theme}
className="px-3 py-1 text-xs bg-slate-700 border border-slate-600 text-slate-200 rounded-full"
>
{theme}
</span>
))}
</div>
</div>
)}
{/* Key risks */}
{analysis.key_risks.length > 0 && (
<div className="bg-slate-800 border border-slate-700 rounded-xl p-5 space-y-3">
<h3 className="text-xs font-semibold text-slate-300 uppercase tracking-wide">
Key Risks
</h3>
<ul className="space-y-1.5 list-disc list-inside">
{analysis.key_risks.map((risk) => (
<li key={risk} className="text-sm text-slate-300 leading-snug">
{risk}
</li>
))}
</ul>
</div>
)}
{/* Summary */}
{analysis.summary && (
<div className="bg-slate-800 border border-slate-700 rounded-xl p-5 space-y-2">
<h3 className="text-xs font-semibold text-slate-300 uppercase tracking-wide">
Summary
</h3>
<p className="text-sm text-slate-300 leading-relaxed">
{analysis.summary}
</p>
</div>
)}
{/* Per-ticker grid */}
{analysis.tickers.length > 0 && (
<div className="bg-slate-800 border border-slate-700 rounded-xl overflow-hidden">
<div className="px-5 py-3 border-b border-slate-700">
<h3 className="text-xs font-semibold text-slate-300 uppercase tracking-wide">
Tickers
</h3>
</div>
<div className="divide-y divide-slate-700">
{analysis.tickers.map((m, idx) => (
<div
key={`${m.symbol}-${idx}`}
className="px-5 py-4 space-y-2"
>
<div className="flex items-center gap-3 flex-wrap">
<span className="text-sm font-bold text-white font-mono">
${m.symbol}
</span>
<ActionChip action={m.action} />
<span className="text-xs text-slate-400 capitalize">
{m.time_horizon.replace('_', ' ')}
</span>
{m.video_timestamp_seconds !== null && (
<button
onClick={() =>
playerRef.current?.seekTo(m.video_timestamp_seconds!)
}
className="ml-auto text-xs text-blue-400 hover:text-blue-300 transition-colors font-mono"
>
{formatTime(m.video_timestamp_seconds)}
</button>
)}
</div>
<div className="flex items-center gap-3">
<div className="flex-1 max-w-xs">
<ConvictionBar value={m.conviction} />
</div>
<span className="text-xs text-slate-400">
{(m.conviction * 100).toFixed(0)}%
</span>
</div>
{m.rationale_quote && (
<p className="text-xs text-slate-400 italic leading-snug">
"{m.rationale_quote}"
</p>
)}
</div>
))}
</div>
</div>
)}
</>
)}
</div>
)}
{/* Transcript tab */}
{tab === 'transcript' && (
<div className="bg-slate-800 border border-slate-700 rounded-xl overflow-hidden">
{!transcript ? (
<div className="p-6 text-center text-slate-400 text-sm">
Loading transcript
</div>
) : (
<div className="divide-y divide-slate-700/60 max-h-[600px] overflow-y-auto">
{transcript.segments.map((s, idx) => (
<button
key={idx}
onClick={() => playerRef.current?.seekTo(Math.floor(s.start))}
className="w-full flex items-start gap-3 px-5 py-3 text-left hover:bg-slate-700/40 transition-colors group"
>
<span className="shrink-0 text-xs text-blue-400 font-mono group-hover:text-blue-300 pt-0.5 w-12">
{formatTime(s.start)}
</span>
<span className="text-sm text-slate-300 leading-snug">
{s.text}
</span>
</button>
))}
</div>
)}
</div>
)}
{/* Raw JSON tab */}
{tab === 'raw' && (
<div className="bg-slate-800 border border-slate-700 rounded-xl p-5 overflow-auto max-h-[600px]">
<pre className="text-xs text-slate-300 whitespace-pre-wrap break-words leading-relaxed">
{JSON.stringify(detail.analysis, null, 2)}
</pre>
</div>
)}
</div>
);
}