generated from fahricansecer/boilerplate-fe
482 lines
19 KiB
TypeScript
482 lines
19 KiB
TypeScript
'use client';
|
||
|
||
import { motion } from 'framer-motion';
|
||
import {
|
||
Loader2,
|
||
Clock,
|
||
CheckCircle2,
|
||
AlertCircle,
|
||
XCircle,
|
||
Play,
|
||
Pause,
|
||
Timer,
|
||
Zap,
|
||
BarChart3,
|
||
Film,
|
||
ArrowRight,
|
||
RefreshCw,
|
||
X,
|
||
} from 'lucide-react';
|
||
import Link from 'next/link';
|
||
import { useState, useEffect } from 'react';
|
||
import { useRenderQueue, useCancelRender } from '@/hooks/use-api';
|
||
|
||
// ── Render Pipeline Aşamaları ──
|
||
const RENDER_STAGES = [
|
||
{ key: 'VIDEO_GENERATION', label: 'Video Üretimi', icon: Film, color: 'violet' },
|
||
{ key: 'TTS_GENERATION', label: 'Seslendirme', icon: Zap, color: 'cyan' },
|
||
{ key: 'MUSIC_GENERATION', label: 'Müzik', icon: Zap, color: 'amber' },
|
||
{ key: 'AMBIENT_GENERATION', label: 'Ortam Sesi', icon: Zap, color: 'emerald' },
|
||
{ key: 'MEDIA_MERGE', label: 'Birleştirme', icon: Zap, color: 'blue' },
|
||
{ key: 'SUBTITLE_OVERLAY', label: 'Altyazı', icon: Zap, color: 'pink' },
|
||
{ key: 'FINALIZATION', label: 'Son İşlem', icon: Zap, color: 'indigo' },
|
||
{ key: 'UPLOAD', label: 'Yükleme', icon: CheckCircle2, color: 'emerald' },
|
||
];
|
||
|
||
const STATUS_CONFIG: Record<string, { label: string; color: string; bg: string; icon: React.ElementType }> = {
|
||
QUEUED: { label: 'Kuyrukta', color: 'text-amber-400', bg: 'bg-amber-500/12 border-amber-500/25', icon: Clock },
|
||
PROCESSING: { label: 'İşleniyor', color: 'text-cyan-400', bg: 'bg-cyan-500/12 border-cyan-500/25', icon: Play },
|
||
COMPLETED: { label: 'Tamamlandı', color: 'text-emerald-400', bg: 'bg-emerald-500/12 border-emerald-500/25', icon: CheckCircle2 },
|
||
FAILED: { label: 'Başarısız', color: 'text-red-400', bg: 'bg-red-500/12 border-red-500/25', icon: AlertCircle },
|
||
CANCELLED: { label: 'İptal Edildi', color: 'text-slate-400', bg: 'bg-slate-500/12 border-slate-500/25', icon: XCircle },
|
||
};
|
||
|
||
const stagger = {
|
||
hidden: { opacity: 0 },
|
||
show: { opacity: 1, transition: { staggerChildren: 0.06 } },
|
||
};
|
||
|
||
const fadeUp = {
|
||
hidden: { opacity: 0, y: 14 },
|
||
show: { opacity: 1, y: 0, transition: { duration: 0.45, ease: [0.16, 1, 0.3, 1] as const } },
|
||
};
|
||
|
||
function formatDuration(ms: number | null | undefined): string {
|
||
if (!ms) return '—';
|
||
const sec = ms / 1000;
|
||
if (sec < 60) return `${sec.toFixed(1)}s`;
|
||
const min = Math.floor(sec / 60);
|
||
const remainSec = Math.round(sec % 60);
|
||
return `${min}dk ${remainSec}s`;
|
||
}
|
||
|
||
function timeAgo(date: string): string {
|
||
if (!date) return '';
|
||
const diff = Date.now() - new Date(date).getTime();
|
||
if (isNaN(diff)) return '';
|
||
const sec = Math.floor(diff / 1000);
|
||
const min = Math.floor(sec / 60);
|
||
if (min < 1) return 'az önce';
|
||
if (min < 60) return `${min}dk önce`;
|
||
const hours = Math.floor(min / 60);
|
||
if (hours < 24) return `${hours}sa önce`;
|
||
const days = Math.floor(hours / 24);
|
||
return `${days}gün önce`;
|
||
}
|
||
|
||
function getStageIndex(stage: string | null | undefined): number {
|
||
if (!stage) return -1;
|
||
return RENDER_STAGES.findIndex((s) => s.key === stage);
|
||
}
|
||
|
||
// ── Stats Card ──
|
||
function StatCard({ label, value, icon: Icon, color, sub }: {
|
||
label: string;
|
||
value: string | number;
|
||
icon: React.ElementType;
|
||
color: string;
|
||
sub?: string;
|
||
}) {
|
||
return (
|
||
<div className="card-surface p-4 md:p-5 flex flex-col">
|
||
<div className="flex items-center gap-3 mb-3">
|
||
<div className={`w-9 h-9 rounded-xl bg-${color}-500/12 flex items-center justify-center`}>
|
||
<Icon size={18} className={`text-${color}-400`} />
|
||
</div>
|
||
</div>
|
||
<div className="font-[family-name:var(--font-display)] text-2xl md:text-3xl font-bold text-[var(--color-text-primary)]">
|
||
{value}
|
||
</div>
|
||
<div className="flex items-center justify-between mt-1">
|
||
<span className="text-xs text-[var(--color-text-muted)]">{label}</span>
|
||
{sub && <span className="text-[10px] text-[var(--color-text-ghost)]">{sub}</span>}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── Stage Stepper ──
|
||
function StageStepper({ currentStage, status }: { currentStage?: string | null; status: string }) {
|
||
const activeIdx = getStageIndex(currentStage);
|
||
|
||
return (
|
||
<div className="flex items-center gap-0.5 overflow-x-auto py-1 scrollbar-hide">
|
||
{RENDER_STAGES.map((stage, idx) => {
|
||
const isCompleted = idx < activeIdx;
|
||
const isCurrent = idx === activeIdx;
|
||
const isPending = idx > activeIdx;
|
||
|
||
return (
|
||
<div key={stage.key} className="flex items-center min-w-0">
|
||
<div className="flex flex-col items-center gap-1">
|
||
<div
|
||
className={`w-6 h-6 rounded-full flex items-center justify-center text-[9px] font-bold transition-all ${
|
||
isCompleted
|
||
? 'bg-emerald-500/20 text-emerald-400 border border-emerald-500/40'
|
||
: isCurrent
|
||
? 'bg-cyan-500/20 text-cyan-300 border-2 border-cyan-400/60 animate-pulse shadow-[0_0_12px_rgba(34,211,238,0.25)]'
|
||
: 'bg-[var(--color-bg-elevated)] text-[var(--color-text-ghost)] border border-[var(--color-border-faint)]'
|
||
}`}
|
||
>
|
||
{isCompleted ? '✓' : idx + 1}
|
||
</div>
|
||
<span
|
||
className={`text-[8px] whitespace-nowrap font-medium ${
|
||
isCurrent ? 'text-cyan-400' : isCompleted ? 'text-emerald-400/70' : 'text-[var(--color-text-ghost)]'
|
||
}`}
|
||
>
|
||
{stage.label}
|
||
</span>
|
||
</div>
|
||
{idx < RENDER_STAGES.length - 1 && (
|
||
<div
|
||
className={`w-3 md:w-5 h-px mx-0.5 mt-[-10px] ${
|
||
isCompleted ? 'bg-emerald-500/40' : 'bg-[var(--color-border-faint)]'
|
||
}`}
|
||
/>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── Active Job Card ──
|
||
function ActiveJobCard({ job, onCancel, isCancelling }: {
|
||
job: any;
|
||
onCancel: (projectId: string) => void;
|
||
isCancelling: boolean;
|
||
}) {
|
||
const [mounted, setMounted] = useState(false);
|
||
useEffect(() => setMounted(true), []);
|
||
|
||
const statusCfg = STATUS_CONFIG[job.status] || STATUS_CONFIG.QUEUED;
|
||
const StatusIcon = statusCfg.icon;
|
||
|
||
return (
|
||
<div className="card-surface p-4 md:p-5 space-y-4">
|
||
{/* Header */}
|
||
<div className="flex items-start justify-between">
|
||
<div className="flex-1 min-w-0">
|
||
<div className="flex items-center gap-2 mb-1">
|
||
<span className={`inline-flex items-center gap-1.5 px-2 py-0.5 rounded-lg text-[10px] font-semibold border ${statusCfg.bg} ${statusCfg.color}`}>
|
||
<StatusIcon size={10} />
|
||
{statusCfg.label}
|
||
</span>
|
||
<span className="text-[10px] text-[var(--color-text-ghost)]">
|
||
Deneme #{job.attemptNumber}/{job.maxAttempts}
|
||
</span>
|
||
</div>
|
||
<Link
|
||
href={`/dashboard/projects/${job.project.id}`}
|
||
className="text-sm font-semibold text-[var(--color-text-primary)] hover:text-violet-400 transition-colors truncate block"
|
||
>
|
||
{job.project.title}
|
||
</Link>
|
||
<div className="flex items-center gap-3 mt-1 text-[10px] text-[var(--color-text-ghost)]">
|
||
<span>{job.project?.targetDuration}s video</span>
|
||
<span className="uppercase">{job.project?.videoStyle}</span>
|
||
<span>{mounted ? timeAgo(job.createdAt) : '--'}</span>
|
||
</div>
|
||
</div>
|
||
|
||
{/* İptal */}
|
||
<button
|
||
onClick={() => onCancel(job.project.id)}
|
||
disabled={isCancelling}
|
||
className="flex items-center gap-1 px-2.5 py-1.5 rounded-lg bg-red-500/10 text-red-400 hover:bg-red-500/20 text-[10px] font-medium transition-colors disabled:opacity-50"
|
||
>
|
||
{isCancelling ? <Loader2 size={11} className="animate-spin" /> : <X size={11} />}
|
||
İptal
|
||
</button>
|
||
</div>
|
||
|
||
{/* Stage Stepper */}
|
||
{job.status === 'PROCESSING' && (
|
||
<StageStepper currentStage={job.currentStage} status={job.status} />
|
||
)}
|
||
|
||
{/* Kuyrukta bekliyor — animasyonlu bar */}
|
||
{job.status === 'QUEUED' && (
|
||
<div className="space-y-1.5">
|
||
<div className="flex items-center gap-2 text-xs text-amber-400">
|
||
<Clock size={12} className="animate-pulse" />
|
||
<span>Kuyrukta bekleniyor...</span>
|
||
</div>
|
||
<div className="w-full bg-slate-800 rounded-full h-1.5 overflow-hidden">
|
||
<div className="bg-gradient-to-r from-amber-500/60 to-amber-400/80 h-1.5 rounded-full w-[8%] relative">
|
||
<div className="absolute inset-0 bg-white/20 animate-pulse rounded-full" />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Son Loglar */}
|
||
{job.logs && job.logs.length > 0 && (
|
||
<div className="border-t border-[var(--color-border-faint)] pt-2 space-y-1">
|
||
{job.logs.slice(0, 3).map((log: any) => (
|
||
<div key={log.id} className="flex items-center gap-2 text-[10px]">
|
||
<span className={`w-1.5 h-1.5 rounded-full shrink-0 ${
|
||
log.level === 'error' ? 'bg-red-400' : log.level === 'warn' ? 'bg-amber-400' : 'bg-emerald-400/60'
|
||
}`} />
|
||
<span className="text-[var(--color-text-ghost)] truncate">{log.message}</span>
|
||
{log.durationMs && (
|
||
<span className="text-[var(--color-text-ghost)] shrink-0 ml-auto">{formatDuration(log.durationMs)}</span>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── Recent Job Row ──
|
||
function RecentJobRow({ job }: { job: any }) {
|
||
const [mounted, setMounted] = useState(false);
|
||
useEffect(() => setMounted(true), []);
|
||
|
||
const statusCfg = STATUS_CONFIG[job.status] || STATUS_CONFIG.COMPLETED;
|
||
const StatusIcon = statusCfg.icon;
|
||
|
||
return (
|
||
<div className="card-surface p-3 flex items-center gap-3">
|
||
<div className={`w-7 h-7 rounded-lg flex items-center justify-center shrink-0 ${statusCfg.bg}`}>
|
||
<StatusIcon size={13} className={statusCfg.color} />
|
||
</div>
|
||
<div className="flex-1 min-w-0">
|
||
<Link
|
||
href={`/dashboard/projects/${job.project.id}`}
|
||
className="text-xs font-medium text-[var(--color-text-secondary)] hover:text-violet-400 transition-colors truncate block"
|
||
>
|
||
{job.project.title}
|
||
</Link>
|
||
<div className="flex items-center gap-2 mt-0.5 text-[10px] text-[var(--color-text-ghost)]">
|
||
<span className={statusCfg.color}>{statusCfg.label}</span>
|
||
{job.processingTimeMs && <span>⏱ {formatDuration(job.processingTimeMs)}</span>}
|
||
<span>{timeAgo(job.completedAt || job.createdAt)}</span>
|
||
</div>
|
||
{job.errorMessage && (
|
||
<p className="text-[10px] text-red-400/80 mt-0.5 truncate">{job.errorMessage}</p>
|
||
)}
|
||
</div>
|
||
<Link href={`/dashboard/projects/${job.project.id}`}>
|
||
<ArrowRight size={14} className="text-[var(--color-text-ghost)] hover:text-violet-400 transition-colors" />
|
||
</Link>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════
|
||
// ANA SAYFA — Render Kuyruk Monitörü
|
||
// ═══════════════════════════════════════════════════════
|
||
|
||
export default function RenderQueuePage() {
|
||
const { data, isLoading, error, refetch } = useRenderQueue();
|
||
const cancelMutation = useCancelRender();
|
||
|
||
const handleCancel = (projectId: string) => {
|
||
if (confirm('Bu render işlemini iptal etmek istediğinize emin misiniz?')) {
|
||
cancelMutation.mutate(projectId, { onSuccess: () => refetch() });
|
||
}
|
||
};
|
||
|
||
if (isLoading) {
|
||
return (
|
||
<div className="flex items-center justify-center min-h-[60vh]">
|
||
<div className="flex flex-col items-center gap-3">
|
||
<Loader2 size={32} className="animate-spin text-violet-400" />
|
||
<p className="text-sm text-[var(--color-text-muted)]">Render kuyruğu yükleniyor...</p>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (error) {
|
||
return (
|
||
<div className="flex items-center justify-center min-h-[60vh]">
|
||
<div className="card-surface p-8 text-center max-w-md">
|
||
<AlertCircle size={40} className="text-red-400 mx-auto mb-3" />
|
||
<h2 className="text-lg font-semibold mb-2">Yüklenemedi</h2>
|
||
<p className="text-sm text-[var(--color-text-muted)] mb-4">
|
||
Render kuyruğu verileri alınamadı. Backend çalışıyor olmalı.
|
||
</p>
|
||
<button onClick={() => refetch()} className="btn-primary text-sm">
|
||
Tekrar Dene
|
||
</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const { stats, activeJobs, recentJobs } = data || { stats: { total: 0, queued: 0, processing: 0, completed: 0, failed: 0, cancelled: 0, avgProcessingTimeMs: null }, activeJobs: [], recentJobs: [] };
|
||
const successRate = stats.total > 0 ? Math.round((stats.completed / stats.total) * 100) : 0;
|
||
|
||
return (
|
||
<motion.div
|
||
variants={stagger}
|
||
initial="hidden"
|
||
animate="show"
|
||
className="space-y-6 max-w-5xl mx-auto pb-8"
|
||
>
|
||
{/* ── Başlık ── */}
|
||
<motion.div variants={fadeUp} className="flex items-center justify-between">
|
||
<div>
|
||
<h1 className="font-[family-name:var(--font-display)] text-2xl md:text-3xl font-bold tracking-tight">
|
||
Render Monitör
|
||
</h1>
|
||
<p className="text-sm text-[var(--color-text-muted)] mt-1">
|
||
Video üretim süreçlerini canlı takip edin
|
||
</p>
|
||
</div>
|
||
<button
|
||
onClick={() => refetch()}
|
||
className="flex items-center gap-2 px-3 py-2 rounded-xl bg-[var(--color-bg-elevated)] text-sm text-[var(--color-text-secondary)] hover:bg-[var(--color-bg-surface)] transition-colors"
|
||
>
|
||
<RefreshCw size={14} />
|
||
Yenile
|
||
</button>
|
||
</motion.div>
|
||
|
||
{/* ── İstatistik Kartları ── */}
|
||
<motion.div variants={fadeUp} className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
||
<StatCard label="Kuyrukta" value={stats.queued} icon={Clock} color="amber" sub="Bekliyor" />
|
||
<StatCard label="İşleniyor" value={stats.processing} icon={Play} color="cyan" sub="Aktif" />
|
||
<StatCard label="Tamamlanan" value={stats.completed} icon={CheckCircle2} color="emerald" sub={`%${successRate} başarı`} />
|
||
<StatCard
|
||
label="Ort. Süre"
|
||
value={formatDuration(stats.avgProcessingTimeMs)}
|
||
icon={Timer}
|
||
color="violet"
|
||
sub={`${stats.failed} başarısız`}
|
||
/>
|
||
</motion.div>
|
||
|
||
{/* ── Durum Dağılımı — Donut Benzeri Görsel ── */}
|
||
<motion.div variants={fadeUp} className="card-surface p-5">
|
||
<h2 className="text-sm font-semibold text-[var(--color-text-primary)] mb-4 flex items-center gap-2">
|
||
<BarChart3 size={15} className="text-violet-400" />
|
||
Durum Dağılımı
|
||
</h2>
|
||
<div className="flex items-center gap-3">
|
||
{/* Horizontal stacked bar */}
|
||
<div className="flex-1 h-4 rounded-full overflow-hidden bg-[var(--color-bg-elevated)] flex">
|
||
{stats.completed > 0 && (
|
||
<div
|
||
className="bg-emerald-500 transition-all duration-700"
|
||
style={{ width: `${(stats.completed / Math.max(stats.total, 1)) * 100}%` }}
|
||
/>
|
||
)}
|
||
{stats.processing > 0 && (
|
||
<div
|
||
className="bg-cyan-500 transition-all duration-700"
|
||
style={{ width: `${(stats.processing / Math.max(stats.total, 1)) * 100}%` }}
|
||
/>
|
||
)}
|
||
{stats.queued > 0 && (
|
||
<div
|
||
className="bg-amber-500 transition-all duration-700"
|
||
style={{ width: `${(stats.queued / Math.max(stats.total, 1)) * 100}%` }}
|
||
/>
|
||
)}
|
||
{stats.failed > 0 && (
|
||
<div
|
||
className="bg-red-500 transition-all duration-700"
|
||
style={{ width: `${(stats.failed / Math.max(stats.total, 1)) * 100}%` }}
|
||
/>
|
||
)}
|
||
{stats.cancelled > 0 && (
|
||
<div
|
||
className="bg-slate-500 transition-all duration-700"
|
||
style={{ width: `${(stats.cancelled / Math.max(stats.total, 1)) * 100}%` }}
|
||
/>
|
||
)}
|
||
</div>
|
||
<span className="text-xs text-[var(--color-text-ghost)] shrink-0">{stats.total} toplam</span>
|
||
</div>
|
||
{/* Lejant */}
|
||
<div className="flex flex-wrap gap-4 mt-3">
|
||
{[
|
||
{ label: 'Tamamlanan', count: stats.completed, color: 'bg-emerald-500' },
|
||
{ label: 'İşleniyor', count: stats.processing, color: 'bg-cyan-500' },
|
||
{ label: 'Kuyrukta', count: stats.queued, color: 'bg-amber-500' },
|
||
{ label: 'Başarısız', count: stats.failed, color: 'bg-red-500' },
|
||
{ label: 'İptal', count: stats.cancelled, color: 'bg-slate-500' },
|
||
].map((item) => (
|
||
<div key={item.label} className="flex items-center gap-1.5 text-[11px] text-[var(--color-text-muted)]">
|
||
<div className={`w-2.5 h-2.5 rounded-sm ${item.color}`} />
|
||
{item.label} ({item.count})
|
||
</div>
|
||
))}
|
||
</div>
|
||
</motion.div>
|
||
|
||
{/* ── Aktif İşler ── */}
|
||
<motion.div variants={fadeUp}>
|
||
<h2 className="text-sm font-semibold text-[var(--color-text-primary)] mb-3 flex items-center gap-2">
|
||
<Zap size={15} className="text-cyan-400" />
|
||
Aktif İşler
|
||
{(stats.queued > 0 || stats.processing > 0) && (
|
||
<span className="text-[10px] px-2 py-0.5 rounded-full bg-cyan-500/12 text-cyan-400 font-medium animate-pulse">
|
||
{stats.queued + stats.processing} aktif
|
||
</span>
|
||
)}
|
||
</h2>
|
||
|
||
{activeJobs.length === 0 ? (
|
||
<div className="card-surface p-8 text-center">
|
||
<Pause size={32} className="text-[var(--color-text-ghost)] mx-auto mb-2" />
|
||
<p className="text-sm text-[var(--color-text-muted)]">Şu anda aktif render işlemi yok</p>
|
||
<p className="text-xs text-[var(--color-text-ghost)] mt-1">
|
||
Bir projeyi onayladığınızda burada görünecek
|
||
</p>
|
||
</div>
|
||
) : (
|
||
<div className="space-y-3">
|
||
{activeJobs.map((job: any) => (
|
||
<ActiveJobCard
|
||
key={job.id}
|
||
job={job}
|
||
onCancel={handleCancel}
|
||
isCancelling={cancelMutation.isPending}
|
||
/>
|
||
))}
|
||
</div>
|
||
)}
|
||
</motion.div>
|
||
|
||
{/* ── Geçmiş İşler ── */}
|
||
<motion.div variants={fadeUp}>
|
||
<h2 className="text-sm font-semibold text-[var(--color-text-primary)] mb-3 flex items-center gap-2">
|
||
<Clock size={15} className="text-emerald-400" />
|
||
Geçmiş İşler
|
||
</h2>
|
||
|
||
{recentJobs.length === 0 ? (
|
||
<div className="card-surface p-8 text-center">
|
||
<Film size={32} className="text-[var(--color-text-ghost)] mx-auto mb-2" />
|
||
<p className="text-sm text-[var(--color-text-muted)]">Henüz tamamlanan render işlemi yok</p>
|
||
</div>
|
||
) : (
|
||
<div className="space-y-2">
|
||
{recentJobs.map((job: any) => (
|
||
<RecentJobRow key={job.id} job={job} />
|
||
))}
|
||
</div>
|
||
)}
|
||
</motion.div>
|
||
</motion.div>
|
||
);
|
||
}
|