feat(dashboard): Meet Kevin video detail page (tabs + iframe + deep-links)
This commit is contained in:
parent
625c22b833
commit
9ce0e44929
1 changed files with 304 additions and 0 deletions
304
dashboard/src/pages/meetKevin/VideoDetail.tsx
Normal file
304
dashboard/src/pages/meetKevin/VideoDetail.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue