generated from fahricansecer/boilerplate-fe
This commit is contained in:
@@ -18,7 +18,7 @@ import {
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
useProject,
|
||||
useGenerateScript,
|
||||
@@ -27,7 +27,8 @@ import {
|
||||
useDeleteProject,
|
||||
useGenerateSceneImage,
|
||||
useUpscaleSceneImage,
|
||||
useRegenerateScene
|
||||
useRegenerateScene,
|
||||
useCancelRender
|
||||
} from '@/hooks/use-api';
|
||||
import { useRenderProgress } from '@/hooks/use-render-progress';
|
||||
import { SceneCard } from '@/components/project/scene-card';
|
||||
@@ -124,12 +125,25 @@ export default function ProjectDetailPage() {
|
||||
const router = useRouter();
|
||||
const [showMenu, setShowMenu] = useState(false);
|
||||
const [regeneratingSceneId, setRegeneratingSceneId] = useState<string | null>(null);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
// Veri hook'ları
|
||||
const { data: project, isLoading, error, refetch } = useProject(id);
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
// Veri hook'ları (Aktif işlem varsa 3 saniyede bir polling yap)
|
||||
const { data: project, isLoading, error, refetch } = useProject(id, {
|
||||
refetchInterval: (data: any) => {
|
||||
if (!data) return false;
|
||||
const isGenerating = data.status === 'GENERATING_MEDIA' ||
|
||||
data.renderJobs?.some((j: any) => j.status === 'QUEUED' || j.status === 'PROCESSING');
|
||||
return isGenerating ? 3000 : false;
|
||||
}
|
||||
});
|
||||
const generateScriptMutation = useGenerateScript();
|
||||
const approveMutation = useApproveAndQueue();
|
||||
const deleteMutation = useDeleteProject();
|
||||
const cancelRenderMutation = useCancelRender();
|
||||
|
||||
const generateImageMutation = useGenerateSceneImage();
|
||||
const upscaleImageMutation = useUpscaleSceneImage();
|
||||
@@ -203,6 +217,15 @@ export default function ProjectDetailPage() {
|
||||
});
|
||||
};
|
||||
|
||||
// İptal et
|
||||
const handleCancelRender = () => {
|
||||
if (confirm('Aktif video üretimini iptal etmek istediğinize emin misiniz?')) {
|
||||
cancelRenderMutation.mutate(id, {
|
||||
onSuccess: () => refetch(),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Sil
|
||||
const handleDelete = () => {
|
||||
if (confirm('Bu projeyi silmek istediğinize emin misiniz?')) {
|
||||
@@ -457,6 +480,22 @@ export default function ProjectDetailPage() {
|
||||
Onayla & Video Üret
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Üretimi İptal Et (Sadece aktif işlem varsa) */}
|
||||
{isRendering && (
|
||||
<button
|
||||
onClick={handleCancelRender}
|
||||
disabled={cancelRenderMutation.isPending}
|
||||
className="flex items-center gap-2 px-4 py-2.5 rounded-xl bg-red-500/10 text-red-400 text-sm font-medium hover:bg-red-500/20 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{cancelRenderMutation.isPending ? (
|
||||
<Loader2 size={15} className="animate-spin" />
|
||||
) : (
|
||||
<X size={15} />
|
||||
)}
|
||||
Üretimi İptal Et
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Hata mesajı — kapatılabilir ve aksiyon butonlu */}
|
||||
@@ -571,34 +610,63 @@ export default function ProjectDetailPage() {
|
||||
{/* ── Render Geçmişi ── */}
|
||||
{project.renderJobs && project.renderJobs.length > 0 && (
|
||||
<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-cyan-400" />
|
||||
Render Geçmişi
|
||||
</h2>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="text-sm font-semibold text-[var(--color-text-primary)] flex items-center gap-2">
|
||||
<Clock size={15} className="text-cyan-400" />
|
||||
Render Geçmişi
|
||||
</h2>
|
||||
{project.status === 'GENERATING_MEDIA' || project.renderJobs.some((j: any) => j.status === 'QUEUED' || j.status === 'PROCESSING') ? (
|
||||
<button
|
||||
onClick={handleCancelRender}
|
||||
disabled={cancelRenderMutation.isPending}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-red-500/10 text-red-400 hover:bg-red-500/20 text-xs font-medium transition-colors"
|
||||
>
|
||||
{cancelRenderMutation.isPending ? <Loader2 size={13} className="animate-spin" /> : <X size={13} />}
|
||||
İptal Et
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{project.renderJobs.map((job) => (
|
||||
<div key={job.id} className="card-surface p-3 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className={`w-2 h-2 rounded-full ${
|
||||
job.status === 'COMPLETED' ? 'bg-emerald-400' :
|
||||
job.status === 'FAILED' ? 'bg-red-400' :
|
||||
'bg-amber-400 animate-pulse'
|
||||
}`} />
|
||||
<span className="text-xs text-[var(--color-text-secondary)]">
|
||||
Deneme #{job.attemptNumber}
|
||||
</span>
|
||||
<span className="text-[10px] text-[var(--color-text-ghost)]">
|
||||
{job.status}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-[10px] text-[var(--color-text-ghost)]">
|
||||
{job.processingTimeMs && (
|
||||
<span>{(job.processingTimeMs / 1000).toFixed(1)}s</span>
|
||||
)}
|
||||
<span>
|
||||
{new Date(job.createdAt).toLocaleTimeString('tr-TR', { hour: '2-digit', minute: '2-digit' })}
|
||||
</span>
|
||||
<div key={job.id} className="card-surface p-3 flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className={`w-2 h-2 rounded-full ${
|
||||
job.status === 'COMPLETED' ? 'bg-emerald-400' :
|
||||
job.status === 'FAILED' ? 'bg-red-400' :
|
||||
job.status === 'CANCELLED' ? 'bg-slate-400' :
|
||||
'bg-amber-400 animate-pulse'
|
||||
}`} />
|
||||
<span className="text-xs text-[var(--color-text-secondary)]">
|
||||
Deneme #{job.attemptNumber}
|
||||
</span>
|
||||
<span className="text-[10px] text-[var(--color-text-ghost)]">
|
||||
{job.status}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-[10px] text-[var(--color-text-ghost)]">
|
||||
{job.processingTimeMs && (
|
||||
<span>{(job.processingTimeMs / 1000).toFixed(1)}s</span>
|
||||
)}
|
||||
{mounted ? (
|
||||
<span>
|
||||
{new Date(job.createdAt).toLocaleTimeString('tr-TR', { hour: '2-digit', minute: '2-digit' })}
|
||||
</span>
|
||||
) : (
|
||||
<span>--:--</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{(job.status === 'QUEUED' || job.status === 'PROCESSING') && (
|
||||
<div className="w-full bg-slate-800 rounded-full h-1.5 mt-1 overflow-hidden">
|
||||
<div
|
||||
className="bg-amber-400 h-1.5 rounded-full transition-all duration-1000 ease-out relative"
|
||||
style={{ width: `${job.progress || (job.status === 'QUEUED' ? 5 : 50)}%` }}
|
||||
>
|
||||
<div className="absolute inset-0 bg-white/20 animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,481 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { usePathname } from "next/navigation";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { Home, FolderOpen, LayoutGrid, Settings, Sparkles, AtSign, ShieldCheck, LogOut, Link2, FileText } from "lucide-react";
|
||||
import { Home, FolderOpen, LayoutGrid, Settings, Sparkles, AtSign, ShieldCheck, LogOut, Link2, FileText, Activity } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useCreditBalance, useCurrentUser } from "@/hooks/use-api";
|
||||
@@ -12,6 +12,7 @@ import { signOut } from "next-auth/react";
|
||||
const navItems = [
|
||||
{ href: "/dashboard", icon: Home, label: "Ana Sayfa" },
|
||||
{ href: "/dashboard/projects", icon: FolderOpen, label: "Projeler" },
|
||||
{ href: "/dashboard/render-queue", icon: Activity, label: "Render Monitör" },
|
||||
{ href: "/dashboard/x-to-video", icon: AtSign, label: "X → Video" },
|
||||
{ href: "/dashboard/youtube-to-video", icon: Link2, label: "YT → Video" },
|
||||
{ href: "/dashboard/document-to-video", icon: FileText, label: "Belge → Video" },
|
||||
|
||||
+32
-3
@@ -22,6 +22,7 @@ import {
|
||||
type Template,
|
||||
type PaginatedResponse,
|
||||
} from '@/lib/api/api-service';
|
||||
import { toaster as toast } from '@/components/ui/feedback/toaster';
|
||||
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// Query Keys — React Query cache yönetimi
|
||||
@@ -80,12 +81,28 @@ export function useProjects(params?: { page?: number; limit?: number; status?: s
|
||||
}
|
||||
|
||||
/** Tek proje detayı — sahneler, medya, render job dahil */
|
||||
export function useProject(id: string) {
|
||||
export function useProject(id: string, options?: { refetchInterval?: number | ((data: any) => number | false) }) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.projects.detail(id),
|
||||
queryFn: () => projectsApi.get(id),
|
||||
enabled: !!id,
|
||||
staleTime: 10_000,
|
||||
refetchInterval: options?.refetchInterval,
|
||||
});
|
||||
}
|
||||
|
||||
/** Render kuyruğu genel görünümü — aktif işlem varsa otomatik polling */
|
||||
export function useRenderQueue() {
|
||||
return useQuery({
|
||||
queryKey: ['render-queue'] as const,
|
||||
queryFn: () => projectsApi.getRenderQueue(),
|
||||
staleTime: 5_000,
|
||||
refetchInterval: (query: any) => {
|
||||
const data = query?.state?.data;
|
||||
if (!data?.stats) return 10_000;
|
||||
const hasActive = data.stats.queued > 0 || data.stats.processing > 0;
|
||||
return hasActive ? 4_000 : 15_000;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -152,6 +169,18 @@ export function useApproveAndQueue() {
|
||||
});
|
||||
}
|
||||
|
||||
/** Render işlemini iptal et */
|
||||
export function useCancelRender() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (projectId: string) => projectsApi.cancelRender(projectId),
|
||||
onSuccess: (_data, projectId) => {
|
||||
qc.invalidateQueries({ queryKey: queryKeys.projects.detail(projectId) });
|
||||
qc.invalidateQueries({ queryKey: queryKeys.projects.all });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Sahne güncelleme (narrasyon, prompt) */
|
||||
export function useUpdateScene() {
|
||||
const qc = useQueryClient();
|
||||
@@ -184,11 +213,11 @@ export function useGenerateSceneImage() {
|
||||
projectsApi.generateSceneImage(projectId, sceneId, customPrompt),
|
||||
onSuccess: (updatedScene, variables) => {
|
||||
qc.invalidateQueries({ queryKey: queryKeys.projects.detail(variables.projectId) });
|
||||
toast.success('Görsel başarıyla üretildi');
|
||||
toast.success({ title: 'Görsel başarıyla üretildi' });
|
||||
},
|
||||
onError: (error: any) => {
|
||||
console.error('Görsel üretme hatası:', error);
|
||||
toast.error(error.response?.data?.message || 'Görsel üretilemedi');
|
||||
toast.error({ title: 'Hata', description: error.response?.data?.message || 'Görsel üretilemedi' });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -71,6 +71,7 @@ export interface RenderJob {
|
||||
id: string;
|
||||
status: string;
|
||||
currentStage?: string;
|
||||
progress?: number;
|
||||
attemptNumber: number;
|
||||
processingTimeMs?: number;
|
||||
errorMessage?: string;
|
||||
@@ -347,6 +348,14 @@ export const projectsApi = {
|
||||
`/projects/${id}/approve`,
|
||||
).then((r) => r.data),
|
||||
|
||||
cancelRender: (id: string) =>
|
||||
apiClient.post<{ message: string; projectId: string; renderJobId: string; status: string }>(
|
||||
`/projects/${id}/cancel-render`,
|
||||
).then((r) => r.data),
|
||||
|
||||
getRenderQueue: () =>
|
||||
apiClient.get<any>('/projects/render-queue').then((r) => r.data),
|
||||
|
||||
createFromTweet: (data: CreateFromTweetPayload) =>
|
||||
apiClient.post<Project>('/projects/from-tweet', data).then((r) => r.data),
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user