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