Files
ContentGen_FE/src/components/project/scene-card.tsx
T
Harun CAN 1f8f24fcf5
UI Deploy (Next-Auth Support) 🎨 / build-and-deploy (push) Has been cancelled
main
2026-05-06 10:47:57 +02:00

326 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import { useState, useEffect } from 'react';
import { createPortal } from 'react-dom';
import { motion, AnimatePresence } from 'framer-motion';
import { Pencil, Check, X, RefreshCw, Clock, ArrowRight, Wand2, Image as ImageIcon, Mic, Maximize2, Sparkles } 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;
isRendering?: boolean;
onUpdate?: (sceneId: string, data: { narrationText?: string; visualPrompt?: string; subtitleText?: string }) => void;
onRegenerate?: (sceneId: string) => void;
onGenerateImage?: (sceneId: string, customPrompt?: string) => void;
onUpscaleImage?: (sceneId: string) => void;
isRegenerating?: boolean;
isGeneratingImage?: boolean;
isUpscalingImage?: boolean;
}
export function SceneCard({
scene,
isEditable,
isRendering = false,
onUpdate,
onRegenerate,
onGenerateImage,
onUpscaleImage,
isRegenerating,
isGeneratingImage,
isUpscalingImage,
}: SceneCardProps) {
const [isEditing, setIsEditing] = useState(false);
const [editNarration, setEditNarration] = useState(scene.narrationText);
const [editVisual, setEditVisual] = useState(scene.visualPrompt);
const [lightboxOpen, setLightboxOpen] = useState(false);
const [mounted, setMounted] = useState(false);
useEffect(() => { setMounted(true); }, []);
const handleSave = () => {
onUpdate?.(scene.id, {
narrationText: editNarration,
visualPrompt: editVisual,
subtitleText: editNarration,
});
// If user edited visual prompt, and maybe wants to generate, they can click generate visual later.
setIsEditing(false);
};
const handleCancel = () => {
setEditNarration(scene.narrationText);
setEditVisual(scene.visualPrompt);
setIsEditing(false);
};
const thumbnailAsset = scene.mediaAssets?.find(a => a.type === 'THUMBNAIL');
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-5 md:p-6 hover:border-neutral-500/30 transition-all duration-300 shadow-sm">
{/* Header */}
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-violet-500/20 to-cyan-500/20 flex items-center justify-center border border-white/10 shadow-sm">
<span className="text-sm font-bold text-[var(--color-text-primary)]">{scene.order}</span>
</div>
<div>
<h4 className="text-base font-bold text-[var(--color-text-primary)]">
{scene.title || `Sahne ${scene.order}`}
</h4>
<div className="flex items-center gap-3 mt-1">
<span className="flex items-center gap-1 text-xs font-medium text-[var(--color-text-muted)] bg-[var(--color-bg-elevated)] px-2 py-0.5 rounded-md">
<Clock size={12} className="text-violet-400" /> {scene.duration}s
</span>
<span className="flex items-center gap-1 text-xs font-medium text-[var(--color-text-muted)] bg-[var(--color-bg-elevated)] px-2 py-0.5 rounded-md">
<ArrowRight size={12} className="text-cyan-400" /> {scene.transitionType.toLowerCase()}
</span>
</div>
</div>
</div>
{/* Aksiyon butonları */}
{!isEditing && (
<div className="flex items-center gap-1.5 opacity-100 md:opacity-0 md:group-hover:opacity-100 transition-opacity">
<button
onClick={() => setIsEditing(true)}
className="w-8 h-8 rounded-xl flex items-center justify-center text-[var(--color-text-muted)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-bg-elevated)] transition-all"
title="Düzenle"
>
<Pencil size={14} />
</button>
<button
onClick={() => onRegenerate?.(scene.id)}
disabled={!isEditable || isRendering || isRegenerating}
className="w-8 h-8 rounded-xl flex items-center justify-center text-[var(--color-text-muted)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-bg-elevated)] transition-all disabled:opacity-40 disabled:cursor-not-allowed"
title="AI ile yeniden üret"
>
<RefreshCw size={14} 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-4"
>
{/* Narrasyon düzenleme */}
<div>
<label className="flex items-center gap-1.5 text-sm font-medium text-violet-400 mb-2">
<Mic size={14} /> Narrasyon
</label>
<textarea
value={editNarration}
onChange={(e) => setEditNarration(e.target.value)}
rows={3}
className="w-full px-4 py-3 rounded-xl bg-[var(--color-bg-deep)] border border-violet-500/30 text-base font-medium text-[var(--color-text-primary)] resize-none focus:outline-none focus:border-violet-500/60 focus:ring-1 focus:ring-violet-500/60 transition-all shadow-inner"
/>
</div>
{/* Görsel prompt düzenleme */}
<div>
<label className="flex items-center gap-1.5 text-sm font-medium text-cyan-400 mb-2">
<ImageIcon size={14} /> Görsel Prompt
</label>
<textarea
value={editVisual}
onChange={(e) => setEditVisual(e.target.value)}
rows={3}
className="w-full px-4 py-3 rounded-xl bg-[var(--color-bg-deep)] border border-cyan-500/30 text-sm font-medium text-cyan-50 resize-none focus:outline-none focus:border-cyan-500/60 focus:ring-1 focus:ring-cyan-500/60 transition-all shadow-inner"
/>
</div>
{/* Kaydet/İptal */}
<div className="flex items-center gap-3 pt-2">
<button
onClick={handleSave}
className="flex items-center gap-1.5 px-4 py-2 rounded-xl bg-gradient-to-r from-violet-600 to-cyan-500 text-white text-sm font-medium hover:shadow-[0_0_15px_rgba(34,211,238,0.4)] hover:scale-[1.02] transition-all"
>
<Check size={14} /> Kaydet
</button>
<button
onClick={handleCancel}
className="flex items-center gap-1.5 px-4 py-2 rounded-xl bg-[var(--color-bg-elevated)] text-[var(--color-text-muted)] text-sm font-medium hover:text-[var(--color-text-secondary)] hover:bg-[var(--color-bg-surface)] transition-all"
>
<X size={14} /> İptal
</button>
</div>
</motion.div>
) : (
<motion.div key="viewing" className="space-y-4">
{/* Narrasyon */}
<div className="flex gap-3">
<div className="w-6 h-6 rounded-md bg-violet-500/10 flex items-center justify-center shrink-0 mt-1 border border-violet-500/20">
<Mic size={14} className="text-violet-400" />
</div>
<div className="flex-1 group/narration relative bg-violet-900/10 p-3.5 md:p-5 rounded-xl border border-violet-500/20 hover:border-violet-500/40 transition-colors">
<p className="text-lg md:text-xl font-[family-name:var(--font-display)] text-[var(--color-text-primary)] font-medium leading-relaxed tracking-wide pr-8">
{scene.narrationText}
</p>
<button
onClick={() => {
navigator.clipboard.writeText(scene.narrationText);
}}
className="absolute top-2 right-2 p-1.5 opacity-0 group-hover/narration:opacity-100 transition-opacity bg-violet-500/20 rounded-md text-violet-300 hover:text-violet-100 hover:bg-violet-500/40"
title="Metni Kopyala"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect width="14" height="14" x="8" y="8" rx="2" ry="2" />
<path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" />
</svg>
</button>
</div>
</div>
{/* Görsel Prompt ve Görsel Alanı */}
<div className="flex flex-col md:flex-row gap-4 pt-2">
{/* Sol: Prompt */}
<div className="flex gap-3 flex-1">
<div className="w-6 h-6 rounded-md bg-cyan-500/10 flex items-center justify-center shrink-0 mt-1 border border-cyan-500/20">
<ImageIcon size={14} className="text-cyan-400" />
</div>
<div className="flex-1 group/prompt relative bg-cyan-900/10 p-3.5 rounded-xl border border-cyan-500/20 hover:border-cyan-500/40 transition-colors">
<p className="text-sm font-medium text-cyan-50 leading-relaxed pr-8">
{scene.visualPrompt}
</p>
<button
onClick={() => {
navigator.clipboard.writeText(scene.visualPrompt);
}}
className="absolute top-2 right-2 p-1.5 opacity-0 group-hover/prompt:opacity-100 transition-opacity bg-cyan-500/20 rounded-md text-cyan-300 hover:text-cyan-100 hover:bg-cyan-500/40"
title="Prompt'u Kopyala"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect width="14" height="14" x="8" y="8" rx="2" ry="2" />
<path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" />
</svg>
</button>
</div>
</div>
{/* Sağ: Görsel Önizleme ve Butonlar */}
<div className="flex flex-col gap-2 w-full md:w-64 shrink-0">
{thumbnailAsset?.url && !isGeneratingImage ? (
<div className="relative group/thumb rounded-lg overflow-hidden border border-[var(--color-border-faint)] aspect-video w-full">
<img
src={thumbnailAsset.url}
alt="Scene Thumbnail"
className="w-full h-full object-cover cursor-pointer hover:scale-105 transition-transform duration-500"
onClick={() => setLightboxOpen(true)}
/>
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover/thumb:opacity-100 transition-opacity flex items-center justify-center pointer-events-none">
<Maximize2 size={24} className="text-white" />
</div>
</div>
) : (
<div className="rounded-lg border border-dashed border-[var(--color-border-faint)] bg-[var(--color-bg-deep)] aspect-video w-full flex flex-col items-center justify-center p-4 relative overflow-hidden">
{isGeneratingImage ? (
<div className="flex flex-col items-center justify-center animate-in fade-in zoom-in duration-300">
<div className="relative w-12 h-12 mb-3">
<div className="absolute inset-0 rounded-full border-2 border-emerald-500/20"></div>
<div className="absolute inset-0 rounded-full border-2 border-emerald-500 border-t-transparent animate-spin"></div>
<Sparkles size={16} className="absolute inset-0 m-auto text-emerald-400 animate-pulse" />
</div>
<p className="text-xs font-medium text-emerald-400 text-center animate-pulse">
AI Görsel Üretiyor...
</p>
</div>
) : (
<>
<ImageIcon size={24} className="text-[var(--color-text-ghost)] mb-2" />
<p className="text-xs text-[var(--color-text-muted)] text-center">Görsel Henüz Üretilmedi</p>
</>
)}
</div>
)}
{/* Görsel üretim butonları */}
<div className="flex items-center gap-2 mt-1 flex-wrap">
<button
onClick={() => onGenerateImage?.(scene.id, scene.visualPrompt)}
disabled={isGeneratingImage || isUpscalingImage}
className="flex-1 flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-lg bg-emerald-500/15 text-emerald-400 text-xs font-medium hover:bg-emerald-500/25 transition-colors disabled:opacity-50 disabled:cursor-not-allowed whitespace-nowrap"
>
{isGeneratingImage ? <RefreshCw size={13} className="animate-spin" /> : <ImageIcon size={13} />}
{thumbnailAsset ? "Yeniden Üret" : "Görsel Üret"}
</button>
{thumbnailAsset?.url && (
<button
onClick={() => onUpscaleImage?.(scene.id)}
disabled={isUpscalingImage || isGeneratingImage}
className="flex-1 flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-lg bg-orange-500/15 text-orange-400 text-xs font-medium hover:bg-orange-500/25 transition-colors disabled:opacity-50 disabled:cursor-not-allowed whitespace-nowrap"
>
{isUpscalingImage ? <RefreshCw size={13} className="animate-spin" /> : <Wand2 size={13} />}
Upscale (4K)
</button>
)}
</div>
</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" />
{/* Lightbox Modal — Portal ile document.body'e render (overflow clipping önleme) */}
{mounted && createPortal(
<AnimatePresence>
{lightboxOpen && thumbnailAsset?.url && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={() => setLightboxOpen(false)}
className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/85 backdrop-blur-md p-4 md:p-10 cursor-zoom-out"
>
<motion.img
initial={{ scale: 0.85, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.85, opacity: 0 }}
transition={{ type: "spring", damping: 25, stiffness: 300 }}
src={thumbnailAsset.url}
alt="Fullscreen Scene"
className="max-w-[90vw] max-h-[90vh] object-contain rounded-xl shadow-2xl"
onClick={(e) => e.stopPropagation()}
/>
<button
onClick={() => setLightboxOpen(false)}
className="absolute top-6 right-6 p-2.5 rounded-full bg-black/60 text-white/80 hover:text-white hover:bg-black/80 transition-colors"
>
<X size={24} />
</button>
</motion.div>
)}
</AnimatePresence>,
document.body
)}
</motion.div>
);
}