feat(dashboard): Meet Kevin videos feed page
This commit is contained in:
parent
d4a1ca870e
commit
625c22b833
1 changed files with 195 additions and 0 deletions
195
dashboard/src/pages/meetKevin/Videos.tsx
Normal file
195
dashboard/src/pages/meetKevin/Videos.tsx
Normal file
|
|
@ -0,0 +1,195 @@
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import meetKevinApi from '../../api/meetKevin';
|
||||||
|
import type { VideoStatus } from '../../types/meetKevin';
|
||||||
|
|
||||||
|
const STATUS_OPTIONS: { value: VideoStatus | ''; label: string }[] = [
|
||||||
|
{ value: '', label: 'All' },
|
||||||
|
{ value: 'analyzed', label: 'Analyzed' },
|
||||||
|
{ value: 'captioned', label: 'Captioned' },
|
||||||
|
{ value: 'discovered', label: 'Discovered' },
|
||||||
|
{ value: 'failed', label: 'Failed' },
|
||||||
|
{ value: 'skipped', label: 'Skipped' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function statusColor(status: VideoStatus): string {
|
||||||
|
if (status === 'analyzed') return 'text-green-400';
|
||||||
|
if (status === 'failed') return 'text-red-400';
|
||||||
|
return 'text-yellow-300';
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MeetKevinVideos() {
|
||||||
|
const [status, setStatus] = useState<VideoStatus | ''>('');
|
||||||
|
const [q, setQ] = useState('');
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const per_page = 20;
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ['meet-kevin', 'videos', status, q, page],
|
||||||
|
queryFn: () =>
|
||||||
|
meetKevinApi.listVideos({
|
||||||
|
status: status || undefined,
|
||||||
|
q: q || undefined,
|
||||||
|
page,
|
||||||
|
per_page,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalPages = data ? Math.ceil(data.total / per_page) : 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="text-2xl font-bold text-white">Meet Kevin — Videos</h2>
|
||||||
|
<span className="text-sm text-slate-400">{data?.total ?? 0} videos</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="bg-slate-800 rounded-xl p-4 border border-slate-700">
|
||||||
|
<div className="flex flex-wrap gap-4 items-end">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-slate-400 mb-1">Status</label>
|
||||||
|
<select
|
||||||
|
value={status}
|
||||||
|
onChange={(e) => {
|
||||||
|
setStatus(e.target.value as VideoStatus | '');
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
className="px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white text-sm focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||||
|
>
|
||||||
|
{STATUS_OPTIONS.map((opt) => (
|
||||||
|
<option key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 max-w-sm">
|
||||||
|
<label className="block text-xs text-slate-400 mb-1">
|
||||||
|
Search title
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={q}
|
||||||
|
onChange={(e) => {
|
||||||
|
setQ(e.target.value);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
placeholder="e.g. Fed, rate cut…"
|
||||||
|
className="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white text-sm placeholder-slate-400 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(status || q) && (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setStatus('');
|
||||||
|
setQ('');
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
className="px-3 py-2 text-sm text-slate-400 hover:text-white bg-slate-700 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Cards */}
|
||||||
|
{isLoading ? (
|
||||||
|
<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>
|
||||||
|
) : data && data.videos.length > 0 ? (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
{data.videos.map((video) => (
|
||||||
|
<Link
|
||||||
|
key={video.id}
|
||||||
|
to={`/meet-kevin/videos/${video.id}`}
|
||||||
|
className="bg-slate-800 border border-slate-700 rounded-xl p-4 hover:border-slate-600 transition-colors flex gap-4"
|
||||||
|
>
|
||||||
|
{/* Thumbnail */}
|
||||||
|
{video.thumbnail_url ? (
|
||||||
|
<img
|
||||||
|
src={video.thumbnail_url}
|
||||||
|
alt=""
|
||||||
|
className="w-32 h-20 object-cover rounded-lg flex-shrink-0"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-32 h-20 bg-slate-700 rounded-lg flex-shrink-0 flex items-center justify-center">
|
||||||
|
<span className="text-slate-500 text-xs">No image</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Details */}
|
||||||
|
<div className="flex-1 min-w-0 space-y-1.5">
|
||||||
|
<p className="text-white text-sm font-medium leading-snug line-clamp-2">
|
||||||
|
{video.title}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 text-xs text-slate-400">
|
||||||
|
<span>
|
||||||
|
{new Date(video.published_at).toLocaleDateString()}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={`font-semibold ${statusColor(video.status)}`}
|
||||||
|
>
|
||||||
|
{video.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{video.failure_reason && (
|
||||||
|
<p className="text-xs text-red-400 truncate">
|
||||||
|
{video.failure_reason}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{video.top_tickers.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{video.top_tickers.map((ticker) => (
|
||||||
|
<span
|
||||||
|
key={ticker}
|
||||||
|
className="px-1.5 py-0.5 bg-slate-700 rounded text-xs text-slate-300 font-mono"
|
||||||
|
>
|
||||||
|
{ticker}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-16 text-slate-400 bg-slate-800 rounded-xl border border-slate-700">
|
||||||
|
No videos found
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<button
|
||||||
|
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||||
|
disabled={page === 1}
|
||||||
|
className="px-4 py-2 text-sm bg-slate-800 border border-slate-700 text-slate-300 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed hover:bg-slate-700 transition-colors"
|
||||||
|
>
|
||||||
|
Previous
|
||||||
|
</button>
|
||||||
|
<span className="text-sm text-slate-400">
|
||||||
|
Page {page} of {totalPages}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||||
|
disabled={page === totalPages}
|
||||||
|
className="px-4 py-2 text-sm bg-slate-800 border border-slate-700 text-slate-300 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed hover:bg-slate-700 transition-colors"
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue