generated from fahricansecer/boilerplate-fe
This commit is contained in:
@@ -0,0 +1,133 @@
|
||||
'use client';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
import { Loader2, CheckCircle2, XCircle, Wifi, WifiOff } from 'lucide-react';
|
||||
import type { RenderProgressState } from '@/hooks/use-render-progress';
|
||||
|
||||
const STAGE_ORDER = ['tts', 'image_generation', 'music_generation', 'compositing', 'encoding'];
|
||||
|
||||
const STAGE_DETAILS: Record<string, { label: string; icon: string; color: string }> = {
|
||||
tts: { label: 'Seslendirme', icon: '🔊', color: 'from-violet-500 to-violet-600' },
|
||||
image_generation: { label: 'Görsel Üretim', icon: '🎨', color: 'from-cyan-500 to-cyan-600' },
|
||||
music_generation: { label: 'Müzik Üretim', icon: '🎵', color: 'from-amber-500 to-amber-600' },
|
||||
compositing: { label: 'Birleştirme', icon: '🎬', color: 'from-emerald-500 to-emerald-600' },
|
||||
encoding: { label: 'Kodlama', icon: '📦', color: 'from-rose-500 to-rose-600' },
|
||||
};
|
||||
|
||||
interface RenderProgressProps {
|
||||
renderState: RenderProgressState;
|
||||
}
|
||||
|
||||
export function RenderProgress({ renderState }: RenderProgressProps) {
|
||||
const { progress, stage, stageLabel, currentScene, totalScenes, eta, status, isConnected } = renderState;
|
||||
|
||||
if (status === 'idle') return null;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="card-surface p-5 md:p-6"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2.5">
|
||||
{status === 'rendering' && (
|
||||
<Loader2 size={18} className="animate-spin text-violet-400" />
|
||||
)}
|
||||
{status === 'completed' && (
|
||||
<CheckCircle2 size={18} className="text-emerald-400" />
|
||||
)}
|
||||
{status === 'failed' && (
|
||||
<XCircle size={18} className="text-red-400" />
|
||||
)}
|
||||
<h3 className="text-sm font-semibold text-[var(--color-text-primary)]">
|
||||
{status === 'rendering' && 'Video Üretiliyor...'}
|
||||
{status === 'completed' && 'Video Hazır!'}
|
||||
{status === 'failed' && 'Üretim Başarısız'}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{/* WebSocket bağlantı durumu */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
{isConnected ? (
|
||||
<Wifi size={13} className="text-emerald-400" />
|
||||
) : (
|
||||
<WifiOff size={13} className="text-red-400" />
|
||||
)}
|
||||
<span className="text-[10px] text-[var(--color-text-ghost)]">
|
||||
{isConnected ? 'Canlı' : 'Bağlantı koptu'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Ana Progress Bar */}
|
||||
<div className="mb-4">
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<span className="text-xs text-[var(--color-text-muted)]">{stageLabel}</span>
|
||||
<span className="text-xs font-mono font-semibold text-[var(--color-text-primary)]">
|
||||
%{Math.round(progress)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2.5 w-full rounded-full bg-[var(--color-bg-deep)] overflow-hidden">
|
||||
<motion.div
|
||||
className="h-full rounded-full bg-gradient-to-r from-violet-500 via-cyan-400 to-emerald-400"
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${progress}%` }}
|
||||
transition={{ duration: 0.5, ease: 'easeOut' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Aşama adımları */}
|
||||
{status === 'rendering' && (
|
||||
<div className="grid grid-cols-5 gap-1.5 mb-3">
|
||||
{STAGE_ORDER.map((s) => {
|
||||
const detail = STAGE_DETAILS[s];
|
||||
const stageIndex = STAGE_ORDER.indexOf(s);
|
||||
const currentIndex = STAGE_ORDER.indexOf(stage);
|
||||
const isDone = stageIndex < currentIndex;
|
||||
const isCurrent = s === stage;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={s}
|
||||
className={`flex flex-col items-center gap-1 py-2 px-1 rounded-lg transition-all ${
|
||||
isCurrent
|
||||
? 'bg-violet-500/10 border border-violet-500/20'
|
||||
: isDone
|
||||
? 'bg-emerald-500/5'
|
||||
: 'opacity-40'
|
||||
}`}
|
||||
>
|
||||
<span className="text-sm">{detail.icon}</span>
|
||||
<span className="text-[9px] text-center text-[var(--color-text-ghost)] leading-tight">
|
||||
{detail.label}
|
||||
</span>
|
||||
{isDone && <CheckCircle2 size={10} className="text-emerald-400" />}
|
||||
{isCurrent && <Loader2 size={10} className="animate-spin text-violet-400" />}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Alt bilgi */}
|
||||
<div className="flex items-center justify-between text-[10px] text-[var(--color-text-ghost)]">
|
||||
{currentScene > 0 && totalScenes > 0 && (
|
||||
<span>Sahne {currentScene}/{totalScenes}</span>
|
||||
)}
|
||||
{eta > 0 && status === 'rendering' && (
|
||||
<span>Tahmini kalan: ~{Math.ceil(eta / 60)} dk</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Hata mesajı */}
|
||||
{status === 'failed' && renderState.error && (
|
||||
<div className="mt-3 p-3 rounded-lg bg-red-500/10 border border-red-500/20">
|
||||
<p className="text-xs text-red-400">{renderState.error}</p>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Pencil, Check, X, RefreshCw, Clock, ArrowRight, Wand2, Image, Mic } from 'lucide-react';
|
||||
|
||||
interface SceneCardProps {
|
||||
scene: {
|
||||
id: string;
|
||||
order: number;
|
||||
title?: string;
|
||||
narrationText: string;
|
||||
visualPrompt: string;
|
||||
subtitleText?: string;
|
||||
duration: number;
|
||||
transitionType: string;
|
||||
mediaAssets?: Array<{ id: string; type: string; url?: string }>;
|
||||
};
|
||||
isEditable: boolean;
|
||||
onUpdate?: (sceneId: string, data: { narrationText?: string; visualPrompt?: string; subtitleText?: string }) => void;
|
||||
onRegenerate?: (sceneId: string) => void;
|
||||
isRegenerating?: boolean;
|
||||
}
|
||||
|
||||
export function SceneCard({ scene, isEditable, onUpdate, onRegenerate, isRegenerating }: SceneCardProps) {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [editNarration, setEditNarration] = useState(scene.narrationText);
|
||||
const [editVisual, setEditVisual] = useState(scene.visualPrompt);
|
||||
|
||||
const handleSave = () => {
|
||||
onUpdate?.(scene.id, {
|
||||
narrationText: editNarration,
|
||||
visualPrompt: editVisual,
|
||||
subtitleText: editNarration,
|
||||
});
|
||||
setIsEditing(false);
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setEditNarration(scene.narrationText);
|
||||
setEditVisual(scene.visualPrompt);
|
||||
setIsEditing(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
layout
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: scene.order * 0.05, duration: 0.4 }}
|
||||
className="relative group"
|
||||
>
|
||||
<div className="card-surface p-4 md:p-5 hover:border-violet-500/20 transition-all duration-300">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-violet-500/20 to-violet-600/10 flex items-center justify-center">
|
||||
<span className="text-xs font-bold text-violet-400">{scene.order}</span>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-[var(--color-text-primary)]">
|
||||
{scene.title || `Sahne ${scene.order}`}
|
||||
</h4>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<span className="flex items-center gap-1 text-[10px] text-[var(--color-text-ghost)]">
|
||||
<Clock size={10} /> {scene.duration}s
|
||||
</span>
|
||||
<span className="flex items-center gap-1 text-[10px] text-[var(--color-text-ghost)]">
|
||||
<ArrowRight size={10} /> {scene.transitionType.toLowerCase()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Aksiyon butonları */}
|
||||
{isEditable && !isEditing && (
|
||||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
onClick={() => setIsEditing(true)}
|
||||
className="w-7 h-7 rounded-lg flex items-center justify-center text-[var(--color-text-muted)] hover:text-violet-400 hover:bg-violet-500/10 transition-colors"
|
||||
title="Düzenle"
|
||||
>
|
||||
<Pencil size={13} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onRegenerate?.(scene.id)}
|
||||
disabled={isRegenerating}
|
||||
className="w-7 h-7 rounded-lg flex items-center justify-center text-[var(--color-text-muted)] hover:text-cyan-400 hover:bg-cyan-500/10 transition-colors disabled:opacity-40"
|
||||
title="AI ile yeniden üret"
|
||||
>
|
||||
<RefreshCw size={13} className={isRegenerating ? 'animate-spin' : ''} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<AnimatePresence mode="wait">
|
||||
{isEditing ? (
|
||||
<motion.div
|
||||
key="editing"
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto' }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
className="space-y-3"
|
||||
>
|
||||
{/* Narrasyon düzenleme */}
|
||||
<div>
|
||||
<label className="flex items-center gap-1.5 text-xs font-medium text-[var(--color-text-muted)] mb-1.5">
|
||||
<Mic size={12} /> Narrasyon
|
||||
</label>
|
||||
<textarea
|
||||
value={editNarration}
|
||||
onChange={(e) => setEditNarration(e.target.value)}
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 rounded-lg bg-[var(--color-bg-deep)] border border-[var(--color-border-faint)] text-sm text-[var(--color-text-primary)] resize-none focus:outline-none focus:ring-1 focus:ring-violet-500/40 transition-all"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Görsel prompt düzenleme */}
|
||||
<div>
|
||||
<label className="flex items-center gap-1.5 text-xs font-medium text-[var(--color-text-muted)] mb-1.5">
|
||||
<Image size={12} /> Görsel Prompt
|
||||
</label>
|
||||
<textarea
|
||||
value={editVisual}
|
||||
onChange={(e) => setEditVisual(e.target.value)}
|
||||
rows={2}
|
||||
className="w-full px-3 py-2 rounded-lg bg-[var(--color-bg-deep)] border border-[var(--color-border-faint)] text-sm text-[var(--color-text-secondary)] resize-none focus:outline-none focus:ring-1 focus:ring-cyan-500/40 transition-all"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Kaydet/İptal */}
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
<button
|
||||
onClick={handleSave}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-violet-500/15 text-violet-400 text-xs font-medium hover:bg-violet-500/25 transition-colors"
|
||||
>
|
||||
<Check size={13} /> Kaydet
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCancel}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-[var(--color-bg-elevated)] text-[var(--color-text-muted)] text-xs font-medium hover:text-[var(--color-text-secondary)] transition-colors"
|
||||
>
|
||||
<X size={13} /> İptal
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div key="viewing" className="space-y-2.5">
|
||||
{/* Narrasyon */}
|
||||
<div className="flex gap-2">
|
||||
<div className="w-5 h-5 rounded-md bg-violet-500/10 flex items-center justify-center shrink-0 mt-0.5">
|
||||
<Mic size={11} className="text-violet-400" />
|
||||
</div>
|
||||
<p className="text-sm text-[var(--color-text-secondary)] leading-relaxed">
|
||||
{scene.narrationText}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Görsel Prompt */}
|
||||
<div className="flex gap-2">
|
||||
<div className="w-5 h-5 rounded-md bg-cyan-500/10 flex items-center justify-center shrink-0 mt-0.5">
|
||||
<Image size={11} className="text-cyan-400" />
|
||||
</div>
|
||||
<p className="text-xs text-[var(--color-text-ghost)] leading-relaxed italic">
|
||||
{scene.visualPrompt}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Medya önizleme (varsa) */}
|
||||
{scene.mediaAssets && scene.mediaAssets.length > 0 && (
|
||||
<div className="flex gap-2 pt-1">
|
||||
{scene.mediaAssets.slice(0, 3).map((asset) => (
|
||||
<div
|
||||
key={asset.id}
|
||||
className="w-16 h-16 rounded-lg bg-[var(--color-bg-deep)] border border-[var(--color-border-faint)] flex items-center justify-center overflow-hidden"
|
||||
>
|
||||
{asset.url ? (
|
||||
<img src={asset.url} alt="" className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<Wand2 size={14} className="text-[var(--color-text-ghost)]" />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
{/* Sahne bağlantı çizgisi */}
|
||||
<div className="absolute left-7 -bottom-3 w-px h-3 bg-gradient-to-b from-[var(--color-border-faint)] to-transparent" />
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useRef } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import {
|
||||
Play,
|
||||
Pause,
|
||||
Download,
|
||||
Maximize2,
|
||||
Link2,
|
||||
CheckCircle2,
|
||||
Volume2,
|
||||
VolumeX,
|
||||
} from 'lucide-react';
|
||||
|
||||
interface VideoPlayerProps {
|
||||
videoUrl: string;
|
||||
thumbnailUrl?: string;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export function VideoPlayer({ videoUrl, thumbnailUrl, title }: VideoPlayerProps) {
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [isMuted, setIsMuted] = useState(false);
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [duration, setDuration] = useState(0);
|
||||
const [showControls, setShowControls] = useState(true);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const togglePlay = () => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
if (isPlaying) {
|
||||
video.pause();
|
||||
} else {
|
||||
video.play();
|
||||
}
|
||||
setIsPlaying(!isPlaying);
|
||||
};
|
||||
|
||||
const toggleMute = () => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
video.muted = !isMuted;
|
||||
setIsMuted(!isMuted);
|
||||
};
|
||||
|
||||
const handleTimeUpdate = () => {
|
||||
const video = videoRef.current;
|
||||
if (video) setCurrentTime(video.currentTime);
|
||||
};
|
||||
|
||||
const handleLoadedMetadata = () => {
|
||||
const video = videoRef.current;
|
||||
if (video) setDuration(video.duration);
|
||||
};
|
||||
|
||||
const handleSeek = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
const time = Number(e.target.value);
|
||||
video.currentTime = time;
|
||||
setCurrentTime(time);
|
||||
};
|
||||
|
||||
const handleFullscreen = () => {
|
||||
videoRef.current?.requestFullscreen();
|
||||
};
|
||||
|
||||
const handleDownload = () => {
|
||||
const a = document.createElement('a');
|
||||
a.href = videoUrl;
|
||||
a.download = `${title || 'video'}.mp4`;
|
||||
a.click();
|
||||
};
|
||||
|
||||
const handleCopyLink = () => {
|
||||
navigator.clipboard.writeText(videoUrl);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
const formatTime = (sec: number) => {
|
||||
const m = Math.floor(sec / 60);
|
||||
const s = Math.floor(sec % 60);
|
||||
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.98 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
className="card-surface overflow-hidden"
|
||||
>
|
||||
{/* Video Container */}
|
||||
<div
|
||||
className="relative bg-black aspect-video cursor-pointer group"
|
||||
onMouseEnter={() => setShowControls(true)}
|
||||
onMouseLeave={() => isPlaying && setShowControls(false)}
|
||||
onClick={togglePlay}
|
||||
>
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={videoUrl}
|
||||
poster={thumbnailUrl}
|
||||
className="w-full h-full object-contain"
|
||||
onTimeUpdate={handleTimeUpdate}
|
||||
onLoadedMetadata={handleLoadedMetadata}
|
||||
onEnded={() => setIsPlaying(false)}
|
||||
playsInline
|
||||
/>
|
||||
|
||||
{/* Overlay Controls */}
|
||||
<AnimatePresence>
|
||||
{showControls && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent flex flex-col justify-end"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Play/Pause büyük ikon */}
|
||||
{!isPlaying && (
|
||||
<div className="absolute inset-0 flex items-center justify-center" onClick={togglePlay}>
|
||||
<div className="w-16 h-16 rounded-full bg-white/15 backdrop-blur-md flex items-center justify-center border border-white/20 hover:bg-white/25 transition-colors">
|
||||
<Play size={28} className="text-white ml-1" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Alt kontroller */}
|
||||
<div className="px-4 pb-3 space-y-2">
|
||||
{/* Progress */}
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={duration || 0}
|
||||
value={currentTime}
|
||||
onChange={handleSeek}
|
||||
className="w-full h-1 appearance-none bg-white/20 rounded-full cursor-pointer accent-violet-500"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<button
|
||||
onClick={togglePlay}
|
||||
className="w-8 h-8 rounded-lg flex items-center justify-center text-white hover:bg-white/10 transition-colors"
|
||||
>
|
||||
{isPlaying ? <Pause size={16} /> : <Play size={16} className="ml-0.5" />}
|
||||
</button>
|
||||
<button
|
||||
onClick={toggleMute}
|
||||
className="w-8 h-8 rounded-lg flex items-center justify-center text-white hover:bg-white/10 transition-colors"
|
||||
>
|
||||
{isMuted ? <VolumeX size={16} /> : <Volume2 size={16} />}
|
||||
</button>
|
||||
<span className="text-xs text-white/70 font-mono">
|
||||
{formatTime(currentTime)} / {formatTime(duration)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleFullscreen}
|
||||
className="w-8 h-8 rounded-lg flex items-center justify-center text-white hover:bg-white/10 transition-colors"
|
||||
>
|
||||
<Maximize2 size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
{/* Aksiyon Butonları */}
|
||||
<div className="p-4 flex items-center gap-2">
|
||||
<button
|
||||
onClick={handleDownload}
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-xl bg-gradient-to-r from-violet-500 to-violet-600 text-white text-sm font-medium shadow-lg shadow-violet-500/20 hover:shadow-violet-500/30 transition-shadow"
|
||||
>
|
||||
<Download size={15} /> İndir
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCopyLink}
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-xl bg-[var(--color-bg-elevated)] text-[var(--color-text-secondary)] text-sm font-medium hover:bg-[var(--color-bg-surface)] transition-colors"
|
||||
>
|
||||
{copied ? (
|
||||
<>
|
||||
<CheckCircle2 size={15} className="text-emerald-400" /> Kopyalandı
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Link2 size={15} /> Link Kopyala
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user