main
UI Deploy (Next-Auth Support) 🎨 / build-and-deploy (push) Has been cancelled

This commit is contained in:
Harun CAN
2026-04-27 12:50:54 +02:00
parent cf12fc3942
commit 89eb9d4dfd
6 changed files with 622 additions and 34 deletions
@@ -18,7 +18,7 @@ import {
X, X,
} from 'lucide-react'; } from 'lucide-react';
import Link from 'next/link'; import Link from 'next/link';
import { useState } from 'react'; import { useState, useEffect } from 'react';
import { import {
useProject, useProject,
useGenerateScript, useGenerateScript,
@@ -27,7 +27,8 @@ import {
useDeleteProject, useDeleteProject,
useGenerateSceneImage, useGenerateSceneImage,
useUpscaleSceneImage, useUpscaleSceneImage,
useRegenerateScene useRegenerateScene,
useCancelRender
} from '@/hooks/use-api'; } from '@/hooks/use-api';
import { useRenderProgress } from '@/hooks/use-render-progress'; import { useRenderProgress } from '@/hooks/use-render-progress';
import { SceneCard } from '@/components/project/scene-card'; import { SceneCard } from '@/components/project/scene-card';
@@ -124,12 +125,25 @@ export default function ProjectDetailPage() {
const router = useRouter(); const router = useRouter();
const [showMenu, setShowMenu] = useState(false); const [showMenu, setShowMenu] = useState(false);
const [regeneratingSceneId, setRegeneratingSceneId] = useState<string | null>(null); const [regeneratingSceneId, setRegeneratingSceneId] = useState<string | null>(null);
const [mounted, setMounted] = useState(false);
// Veri hook'ları useEffect(() => {
const { data: project, isLoading, error, refetch } = useProject(id); 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 generateScriptMutation = useGenerateScript();
const approveMutation = useApproveAndQueue(); const approveMutation = useApproveAndQueue();
const deleteMutation = useDeleteProject(); const deleteMutation = useDeleteProject();
const cancelRenderMutation = useCancelRender();
const generateImageMutation = useGenerateSceneImage(); const generateImageMutation = useGenerateSceneImage();
const upscaleImageMutation = useUpscaleSceneImage(); 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 // Sil
const handleDelete = () => { const handleDelete = () => {
if (confirm('Bu projeyi silmek istediğinize emin misiniz?')) { if (confirm('Bu projeyi silmek istediğinize emin misiniz?')) {
@@ -457,6 +480,22 @@ export default function ProjectDetailPage() {
Onayla & Video Üret Onayla & Video Üret
</button> </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> </div>
{/* Hata mesajı — kapatılabilir ve aksiyon butonlu */} {/* Hata mesajı — kapatılabilir ve aksiyon butonlu */}
@@ -571,34 +610,63 @@ export default function ProjectDetailPage() {
{/* ── Render Geçmişi ── */} {/* ── Render Geçmişi ── */}
{project.renderJobs && project.renderJobs.length > 0 && ( {project.renderJobs && project.renderJobs.length > 0 && (
<motion.div variants={fadeUp}> <motion.div variants={fadeUp}>
<h2 className="text-sm font-semibold text-[var(--color-text-primary)] mb-3 flex items-center gap-2"> <div className="flex items-center justify-between mb-3">
<Clock size={15} className="text-cyan-400" /> <h2 className="text-sm font-semibold text-[var(--color-text-primary)] flex items-center gap-2">
Render Geçmişi <Clock size={15} className="text-cyan-400" />
</h2> 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"> <div className="space-y-2">
{project.renderJobs.map((job) => ( {project.renderJobs.map((job) => (
<div key={job.id} className="card-surface p-3 flex items-center justify-between"> <div key={job.id} className="card-surface p-3 flex flex-col gap-2">
<div className="flex items-center gap-2.5"> <div className="flex items-center justify-between">
<div className={`w-2 h-2 rounded-full ${ <div className="flex items-center gap-2.5">
job.status === 'COMPLETED' ? 'bg-emerald-400' : <div className={`w-2 h-2 rounded-full ${
job.status === 'FAILED' ? 'bg-red-400' : job.status === 'COMPLETED' ? 'bg-emerald-400' :
'bg-amber-400 animate-pulse' job.status === 'FAILED' ? 'bg-red-400' :
}`} /> job.status === 'CANCELLED' ? 'bg-slate-400' :
<span className="text-xs text-[var(--color-text-secondary)]"> 'bg-amber-400 animate-pulse'
Deneme #{job.attemptNumber} }`} />
</span> <span className="text-xs text-[var(--color-text-secondary)]">
<span className="text-[10px] text-[var(--color-text-ghost)]"> Deneme #{job.attemptNumber}
{job.status} </span>
</span> <span className="text-[10px] text-[var(--color-text-ghost)]">
</div> {job.status}
<div className="flex items-center gap-3 text-[10px] text-[var(--color-text-ghost)]"> </span>
{job.processingTimeMs && ( </div>
<span>{(job.processingTimeMs / 1000).toFixed(1)}s</span> <div className="flex items-center gap-3 text-[10px] text-[var(--color-text-ghost)]">
)} {job.processingTimeMs && (
<span> <span>{(job.processingTimeMs / 1000).toFixed(1)}s</span>
{new Date(job.createdAt).toLocaleTimeString('tr-TR', { hour: '2-digit', minute: '2-digit' })} )}
</span> {mounted ? (
<span>
{new Date(job.createdAt).toLocaleTimeString('tr-TR', { hour: '2-digit', minute: '2-digit' })}
</span>
) : (
<span>--:--</span>
)}
</div>
</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>
))} ))}
</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 -1
View File
@@ -2,7 +2,7 @@
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { motion, AnimatePresence } from "framer-motion"; 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 Link from "next/link";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useCreditBalance, useCurrentUser } from "@/hooks/use-api"; import { useCreditBalance, useCurrentUser } from "@/hooks/use-api";
@@ -12,6 +12,7 @@ import { signOut } from "next-auth/react";
const navItems = [ const navItems = [
{ href: "/dashboard", icon: Home, label: "Ana Sayfa" }, { href: "/dashboard", icon: Home, label: "Ana Sayfa" },
{ href: "/dashboard/projects", icon: FolderOpen, label: "Projeler" }, { 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/x-to-video", icon: AtSign, label: "X → Video" },
{ href: "/dashboard/youtube-to-video", icon: Link2, label: "YT → Video" }, { href: "/dashboard/youtube-to-video", icon: Link2, label: "YT → Video" },
{ href: "/dashboard/document-to-video", icon: FileText, label: "Belge → Video" }, { href: "/dashboard/document-to-video", icon: FileText, label: "Belge → Video" },
+32 -3
View File
@@ -22,6 +22,7 @@ import {
type Template, type Template,
type PaginatedResponse, type PaginatedResponse,
} from '@/lib/api/api-service'; } from '@/lib/api/api-service';
import { toaster as toast } from '@/components/ui/feedback/toaster';
// ═══════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════
// Query Keys — React Query cache yönetimi // 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 */ /** 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({ return useQuery({
queryKey: queryKeys.projects.detail(id), queryKey: queryKeys.projects.detail(id),
queryFn: () => projectsApi.get(id), queryFn: () => projectsApi.get(id),
enabled: !!id, enabled: !!id,
staleTime: 10_000, 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) */ /** Sahne güncelleme (narrasyon, prompt) */
export function useUpdateScene() { export function useUpdateScene() {
const qc = useQueryClient(); const qc = useQueryClient();
@@ -184,11 +213,11 @@ export function useGenerateSceneImage() {
projectsApi.generateSceneImage(projectId, sceneId, customPrompt), projectsApi.generateSceneImage(projectId, sceneId, customPrompt),
onSuccess: (updatedScene, variables) => { onSuccess: (updatedScene, variables) => {
qc.invalidateQueries({ queryKey: queryKeys.projects.detail(variables.projectId) }); 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) => { onError: (error: any) => {
console.error('Görsel üretme hatası:', error); 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' });
}, },
}); });
} }
+9
View File
@@ -71,6 +71,7 @@ export interface RenderJob {
id: string; id: string;
status: string; status: string;
currentStage?: string; currentStage?: string;
progress?: number;
attemptNumber: number; attemptNumber: number;
processingTimeMs?: number; processingTimeMs?: number;
errorMessage?: string; errorMessage?: string;
@@ -347,6 +348,14 @@ export const projectsApi = {
`/projects/${id}/approve`, `/projects/${id}/approve`,
).then((r) => r.data), ).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) => createFromTweet: (data: CreateFromTweetPayload) =>
apiClient.post<Project>('/projects/from-tweet', data).then((r) => r.data), apiClient.post<Project>('/projects/from-tweet', data).then((r) => r.data),
+1 -1
View File
File diff suppressed because one or more lines are too long