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

This commit is contained in:
Harun CAN
2026-05-06 10:47:57 +02:00
parent d3a83bf901
commit 1f8f24fcf5
25 changed files with 5077 additions and 1423 deletions
+2655
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts";
import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+1
View File
@@ -15,6 +15,7 @@
"@hookform/resolvers": "^5.2.2",
"@tailwindcss/postcss": "^4.2.2",
"@tanstack/react-query": "^5.90.16",
"@tanstack/react-virtual": "^3.13.24",
"autoprefixer": "^10.4.27",
"axios": "^1.13.1",
"class-variance-authority": "^0.7.1",
+20
View File
@@ -26,6 +26,9 @@ importers:
'@tanstack/react-query':
specifier: ^5.90.16
version: 5.95.2(react@19.2.0)
'@tanstack/react-virtual':
specifier: ^3.13.24
version: 3.13.24(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
autoprefixer:
specifier: ^10.4.27
version: 10.4.27(postcss@8.5.8)
@@ -1185,6 +1188,15 @@ packages:
peerDependencies:
react: ^18 || ^19
'@tanstack/react-virtual@3.13.24':
resolution: {integrity: sha512-aIJvz5OSkhNIhZIpYivrxrPTKYsjW9Uzy+sP/mx0S3sev2HyvPb7xmjbYvokzEpfgYHy/HjzJ2zFAETuUfgCpg==}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
'@tanstack/virtual-core@3.14.0':
resolution: {integrity: sha512-JLANqGy/D6k4Ujmh8Tr25lGimuOXNiaVyXaCAZS0W+1390sADdGnyUdSWNIfd49gebtIxGMij4IktRVzrdr12Q==}
'@tybys/wasm-util@0.10.1':
resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==}
@@ -4539,6 +4551,14 @@ snapshots:
'@tanstack/query-core': 5.95.2
react: 19.2.0
'@tanstack/react-virtual@3.13.24(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
dependencies:
'@tanstack/virtual-core': 3.14.0
react: 19.2.0
react-dom: 19.2.0(react@19.2.0)
'@tanstack/virtual-core@3.14.0': {}
'@tybys/wasm-util@0.10.1':
dependencies:
tslib: 2.8.1
@@ -0,0 +1,540 @@
"use client";
import { useState, useRef, useEffect } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { motion, AnimatePresence } from "framer-motion";
import { cn } from "@/lib/utils";
import { useToast } from "@/components/ui/toast";
import {
Type,
AtSign,
PlaySquare,
FileText,
Link2,
Upload,
File,
X,
Loader2,
ArrowRight,
Wand2,
Eye,
MessageSquare,
Heart,
Repeat2,
Sparkles,
Settings2,
ChevronDown
} from "lucide-react";
import {
useCreateFromText,
useCreateFromTweet,
useCreateFromYoutube,
useCreateFromDocument,
useTweetPreview,
} from "@/hooks/use-api";
import {
LanguageSelector,
StyleSelector,
DurationSelector,
AspectRatioSelector,
} from "@/components/projects/ProjectConfiguration";
type TabType = "text" | "x" | "youtube" | "document";
export default function CreateProjectPage() {
const router = useRouter();
const searchParams = useSearchParams();
const { toast } = useToast();
// Tab State
const [activeTab, setActiveTab] = useState<TabType>("text");
// Read ?tab= from URL on mount
useEffect(() => {
const tabParam = searchParams.get("tab") as TabType;
if (tabParam && ["text", "x", "youtube", "document"].includes(tabParam)) {
setActiveTab(tabParam);
}
}, [searchParams]);
// Shared Configurations State
const [style, setStyle] = useState("CINEMATIC");
const [cinematicReference, setCinematicReference] = useState("");
const [duration, setDuration] = useState(60);
const [aspectRatio, setAspectRatio] = useState("PORTRAIT_9_16");
const [language, setLanguage] = useState("tr");
const [showAdvanced, setShowAdvanced] = useState(true);
// API Hooks
const createFromText = useCreateFromText();
const createFromTweet = useCreateFromTweet();
const createFromYoutube = useCreateFromYoutube();
const createFromDocument = useCreateFromDocument();
const tweetPreview = useTweetPreview();
// ----- Inputs State -----
// TEXT
const [textInput, setTextInput] = useState("");
// X / TWEET
const [tweetUrl, setTweetUrl] = useState("");
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const [previewData, setPreviewData] = useState<any>(null);
const isValidTweetUrl = /https?:\/\/(x\.com|twitter\.com)\/\w+\/status\/\d+/.test(tweetUrl);
// YOUTUBE
const [youtubeUrl, setYoutubeUrl] = useState("");
const isValidYoutubeUrl = /https?:\/\/(www\.)?(youtube\.com|youtu\.be)\/.+/.test(youtubeUrl);
// DOCUMENT
const fileInputRef = useRef<HTMLInputElement>(null);
const [file, setFile] = useState<File | null>(null);
// Is Any Mutation Pending?
const isPending =
createFromText.isPending ||
createFromTweet.isPending ||
createFromYoutube.isPending ||
createFromDocument.isPending;
// Handlers
const handlePreviewTweet = async () => {
if (!isValidTweetUrl) {
toast("error", "Geçerli bir X/Twitter URL'si girin.");
return;
}
try {
const result = await tweetPreview.mutateAsync(tweetUrl);
const preview = result && typeof result === 'object' && 'data' in result ? (result as any).data : result;
setPreviewData(preview);
toast("success", "Tweet başarıyla yüklendi!");
} catch {
toast("error", "Tweet yüklenemedi. URL'yi kontrol edin.");
}
};
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files.length > 0) {
const selectedFile = e.target.files[0];
if (selectedFile.size > 10 * 1024 * 1024) {
toast("error", "Dosya boyutu 10MB'dan küçük olmalıdır.");
return;
}
setFile(selectedFile);
}
};
const clearFile = () => {
setFile(null);
if (fileInputRef.current) fileInputRef.current.value = "";
};
const handleGenerate = async () => {
try {
let result: any;
if (activeTab === "text") {
if (!textInput.trim()) {
toast("error", "Lütfen bir konu, hikaye veya metin girin.");
return;
}
result = await createFromText.mutateAsync({
text: textInput,
language,
aspectRatio,
videoStyle: style,
cinematicReference: cinematicReference || undefined,
targetDuration: duration,
});
} else if (activeTab === "x") {
if (!isValidTweetUrl) {
toast("error", "Lütfen geçerli bir Tweet URL'si girin.");
return;
}
result = await createFromTweet.mutateAsync({
tweetUrl,
language,
aspectRatio,
videoStyle: style,
cinematicReference: cinematicReference || undefined,
targetDuration: duration,
});
} else if (activeTab === "youtube") {
if (!isValidYoutubeUrl) {
toast("error", "Lütfen geçerli bir YouTube URL'si girin.");
return;
}
result = await createFromYoutube.mutateAsync({
youtubeUrl,
language,
aspectRatio,
videoStyle: style,
cinematicReference: cinematicReference || undefined,
targetDuration: duration,
});
} else if (activeTab === "document") {
if (!file) {
toast("error", "Lütfen bir belge yükleyin.");
return;
}
result = await createFromDocument.mutateAsync({
file,
language,
aspectRatio,
videoStyle: style,
cinematicReference: cinematicReference || undefined,
targetDuration: duration,
});
}
toast("success", "Proje başarıyla oluşturuldu!");
if (result?.id) {
router.push(`/dashboard/projects/${result.id}`);
} else {
router.push("/dashboard/projects");
}
} catch (error: any) {
if (error?.response?.status === 500) {
toast("error", "Yapay zeka yoğunluktan ötürü sahne üretimini tamamlayamadı veya yanıt yapısı geçersiz. Lütfen 'Video Üret' butonuna tekrar tıklayın.");
} else {
toast("error", error?.response?.data?.message || "Proje oluşturulurken bir hata oluştu.");
}
}
};
const tabs = [
{ id: "text", label: "Metin", icon: Type },
{ id: "x", label: "X/Twitter", icon: AtSign },
{ id: "youtube", label: "YouTube", icon: PlaySquare },
{ id: "document", label: "Belge", icon: FileText },
];
return (
<div className="max-w-4xl mx-auto space-y-8 pb-24">
{/* Header */}
<div className="text-center space-y-3 pb-2 pt-4">
<h1 className="font-[family-name:var(--font-display)] text-3xl md:text-4xl font-bold tracking-tight text-[var(--color-text-primary)]">
Yeni Proje Üret
</h1>
<p className="text-[var(--color-text-muted)] text-sm md:text-base max-w-xl mx-auto">
İstediğiniz kaynağı seçin ve yapay zeka sizin için dakikalar içinde profesyonel bir video oluştursun.
</p>
</div>
{/* Tabs */}
<div className="flex p-1 space-x-1 bg-[var(--color-bg-elevated)] rounded-2xl w-full max-w-2xl mx-auto border border-[var(--color-border-faint)] overflow-x-auto no-scrollbar shadow-inner">
{tabs.map((tab) => {
const isActive = activeTab === tab.id;
const Icon = tab.icon;
return (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id as TabType)}
className={cn(
"relative flex-1 flex items-center justify-center gap-2 py-3 px-4 text-sm font-medium rounded-xl transition-all whitespace-nowrap",
isActive
? "text-[var(--color-bg-base)]"
: "text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-bg-surface)]"
)}
>
{isActive && (
<motion.div
layoutId="active-tab"
className="absolute inset-0 bg-[var(--color-text-primary)] rounded-xl shadow-md"
initial={false}
transition={{ type: "spring", stiffness: 300, damping: 30 }}
/>
)}
<span className="relative z-10 flex items-center gap-2">
<Icon size={16} />
<span>{tab.label}</span>
</span>
</button>
);
})}
</div>
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8 items-start">
{/* Left Column: Source Input */}
<div className="lg:col-span-7 space-y-6">
<div className="card p-6 md:p-8 space-y-6 min-h-[320px] flex flex-col relative overflow-hidden">
{/* Background Icon Watermark */}
<div className="absolute -bottom-6 -right-6 opacity-[0.02] pointer-events-none">
{activeTab === "text" && <Type size={180} />}
{activeTab === "x" && <AtSign size={180} />}
{activeTab === "youtube" && <PlaySquare size={180} />}
{activeTab === "document" && <FileText size={180} />}
</div>
{/* Content Based on Tab */}
<AnimatePresence mode="wait">
<motion.div
key={activeTab}
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 10 }}
transition={{ duration: 0.2 }}
className="flex-1 flex flex-col"
>
{/* TEXT INPUT */}
{activeTab === "text" && (
<div className="space-y-4 flex-1 flex flex-col">
<label className="text-sm font-medium text-[var(--color-text-secondary)] block">
Fikriniz veya Metniniz
</label>
<textarea
value={textInput}
onChange={(e) => setTextInput(e.target.value)}
placeholder="Örn: Evrenin başlangıcı ve kara deliklerin sırları hakkında detaylı ve sürükleyici bir anlatım..."
className="w-full flex-1 min-h-[200px] bg-[var(--color-bg-surface)] border border-[var(--color-border-faint)] rounded-xl px-4 py-4 text-sm focus:outline-none focus:ring-2 focus:ring-[var(--color-bg-inverted)]/50 resize-none transition-all placeholder:text-[var(--color-text-ghost)] shadow-inner"
/>
</div>
)}
{/* X / TWITTER INPUT */}
{activeTab === "x" && (
<div className="space-y-4">
<label className="text-sm font-medium text-[var(--color-text-secondary)] block flex items-center gap-1.5">
<Link2 size={14} className="text-cyan-400" />
Tweet URL
</label>
<div className="flex flex-col sm:flex-row gap-3">
<input
type="url"
value={tweetUrl}
onChange={(e) => {
setTweetUrl(e.target.value);
setPreviewData(null);
}}
placeholder="https://x.com/username/status/123456..."
className="flex-1 bg-[var(--color-bg-surface)] border border-[var(--color-border-faint)] rounded-xl px-4 py-3.5 text-sm focus:outline-none focus:ring-2 focus:ring-[var(--color-bg-inverted)]/50 transition-all placeholder:text-[var(--color-text-ghost)]"
/>
<button
onClick={handlePreviewTweet}
disabled={!isValidTweetUrl || tweetPreview.isPending}
className={cn(
"px-6 py-3.5 rounded-xl text-sm font-semibold flex items-center justify-center gap-2 transition-all shrink-0",
isValidTweetUrl
? "btn-primary"
: "bg-[var(--color-bg-elevated)] text-[var(--color-text-ghost)] cursor-not-allowed"
)}
>
{tweetPreview.isPending ? (
<Loader2 size={16} className="animate-spin" />
) : (
<>
<Eye size={16} /> Önizle
</>
)}
</button>
</div>
{/* Preview Area */}
<AnimatePresence>
{previewData && previewData.tweet && (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
className="p-5 rounded-xl border border-[var(--color-border-faint)] bg-[var(--color-bg-deep)] shadow-inner mt-4"
>
<div className="flex items-center gap-3 mb-3">
{previewData.tweet.author?.avatarUrl ? (
<img src={previewData.tweet.author.avatarUrl} alt="" className="w-10 h-10 rounded-full" />
) : (
<div className="w-10 h-10 rounded-full bg-[var(--color-bg-surface)] flex items-center justify-center border border-[var(--color-border-faint)]">
<Link2 size={16} className="text-neutral-400" />
</div>
)}
<div>
<p className="text-sm font-bold text-[var(--color-text-primary)]">{previewData.tweet.author?.name || "X Kullanıcısı"}</p>
<p className="text-xs text-[var(--color-text-muted)]">@{previewData.tweet.author?.username || "username"}</p>
</div>
</div>
<p className="text-[13px] text-[var(--color-text-secondary)] leading-relaxed mb-4 line-clamp-4">
{previewData.tweet.text}
</p>
{previewData.tweet.media && previewData.tweet.media.length > 0 && (
<div className="flex gap-2 overflow-x-auto pb-4">
{previewData.tweet.media.slice(0, 4).map((m: any, i: number) => (
<div
key={i}
className="w-20 h-20 rounded-lg bg-[var(--color-bg-elevated)] border border-[var(--color-border-faint)] overflow-hidden flex-shrink-0"
>
{m.type === "photo" ? (
<img
src={m.url}
alt={`Media ${i + 1}`}
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full flex items-center justify-center text-[var(--color-text-ghost)]">
<Link2 size={20} />
</div>
)}
</div>
))}
</div>
)}
<div className="flex items-center gap-6 text-[var(--color-text-muted)] text-xs font-medium">
<span className="flex items-center gap-1.5"><MessageSquare size={14} /> {previewData.tweet.metrics?.replies || 0}</span>
<span className="flex items-center gap-1.5"><Repeat2 size={14} /> {previewData.tweet.metrics?.retweets || 0}</span>
<span className="flex items-center gap-1.5"><Heart size={14} /> {previewData.tweet.metrics?.likes || 0}</span>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
)}
{/* YOUTUBE INPUT */}
{activeTab === "youtube" && (
<div className="space-y-4">
<label className="text-sm font-medium text-[var(--color-text-secondary)] block flex items-center gap-1.5">
<Link2 size={14} className="text-red-500" />
YouTube Video URL
</label>
<input
type="url"
value={youtubeUrl}
onChange={(e) => setYoutubeUrl(e.target.value)}
placeholder="https://youtube.com/watch?v=... veya https://youtu.be/..."
className="w-full bg-[var(--color-bg-surface)] border border-[var(--color-border-faint)] rounded-xl px-4 py-4 text-sm focus:outline-none focus:ring-2 focus:ring-[var(--color-bg-inverted)]/50 transition-all placeholder:text-[var(--color-text-ghost)]"
/>
<div className="mt-4 p-4 rounded-xl bg-red-500/5 border border-red-500/10 text-sm text-[var(--color-text-muted)] flex items-start gap-3">
<Sparkles size={16} className="text-red-400 shrink-0 mt-0.5" />
<p>YouTube veya Shorts bağlantınızı yapıştırın, sistemimiz orijinal videonun transkriptini çıkararak yeni bir senaryoya dönüştürecektir.</p>
</div>
</div>
)}
{/* DOCUMENT INPUT */}
{activeTab === "document" && (
<div className="space-y-4">
<label className="text-sm font-medium text-[var(--color-text-secondary)] block">
Belge Yükle (.pdf, .docx, .txt vb.)
</label>
{!file ? (
<div
onClick={() => fileInputRef.current?.click()}
className="w-full h-48 border-2 border-dashed border-[var(--color-border-default)] rounded-xl flex flex-col items-center justify-center gap-4 bg-[var(--color-bg-surface)] hover:bg-[var(--color-bg-elevated)] transition-colors cursor-pointer"
>
<div className="w-12 h-12 rounded-full bg-[var(--color-bg-elevated)] flex items-center justify-center text-[var(--color-text-muted)] shadow-sm">
<Upload size={24} />
</div>
<div className="text-center">
<p className="text-sm font-medium text-[var(--color-text-primary)]">Tıklayın veya sürükleyin</p>
<p className="text-xs text-[var(--color-text-ghost)] mt-1.5">Maksimum 10MB boyutunda dökümanlar</p>
</div>
</div>
) : (
<div className="w-full p-5 border border-[var(--color-border-default)] rounded-xl flex items-center justify-between bg-[var(--color-bg-elevated)]">
<div className="flex items-center gap-4 overflow-hidden">
<div className="w-12 h-12 rounded-xl bg-[var(--color-bg-surface)] flex items-center justify-center text-[var(--color-text-secondary)] shrink-0 border border-[var(--color-border-faint)]">
<File size={24} />
</div>
<div className="truncate">
<p className="text-sm font-bold text-[var(--color-text-primary)] truncate mb-1">{file.name}</p>
<p className="text-xs text-[var(--color-text-ghost)] font-medium">{(file.size / 1024 / 1024).toFixed(2)} MB</p>
</div>
</div>
<button
onClick={clearFile}
className="w-10 h-10 flex items-center justify-center rounded-full hover:bg-[var(--color-bg-surface)] text-[var(--color-text-muted)] hover:text-red-500 transition-colors shrink-0"
>
<X size={18} />
</button>
</div>
)}
<input
type="file"
ref={fileInputRef}
onChange={handleFileChange}
accept=".pdf,.doc,.docx,.txt,.csv"
className="hidden"
/>
</div>
)}
</motion.div>
</AnimatePresence>
</div>
</div>
{/* Right Column: Settings & Submit */}
<div className="lg:col-span-5 space-y-6">
<div className="card p-5 md:p-6 space-y-6">
<div className="flex items-center justify-between">
<h2 className="text-base font-bold text-[var(--color-text-primary)] flex items-center gap-2">
<Settings2 size={18} className="text-[var(--color-text-muted)]" />
Proje Ayarları
</h2>
<button
onClick={() => setShowAdvanced(!showAdvanced)}
className="text-xs font-medium text-[var(--color-text-muted)] hover:text-[var(--color-text-primary)] flex items-center gap-1 transition-colors"
>
{showAdvanced ? "Gizle" : "Göster"}
<ChevronDown size={14} className={cn("transition-transform", showAdvanced && "rotate-180")} />
</button>
</div>
<AnimatePresence initial={false}>
{showAdvanced && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
className="space-y-6 overflow-hidden"
>
<LanguageSelector value={language} onChange={setLanguage} />
<StyleSelector
value={style}
onChange={setStyle}
cinematicReference={cinematicReference}
onCinematicReferenceChange={setCinematicReference}
/>
<DurationSelector value={duration} onChange={setDuration} />
<AspectRatioSelector value={aspectRatio} onChange={setAspectRatio} />
</motion.div>
)}
</AnimatePresence>
</div>
{/* Submit Button */}
<button
onClick={handleGenerate}
disabled={
isPending ||
(activeTab === "text" && !textInput.trim()) ||
(activeTab === "x" && !isValidTweetUrl) ||
(activeTab === "youtube" && !isValidYoutubeUrl) ||
(activeTab === "document" && !file)
}
className={cn(
"w-full group relative overflow-hidden flex items-center justify-center gap-3 px-8 py-4 rounded-2xl font-bold text-white shadow-none transition-all",
"bg-gradient-to-r from-violet-600 to-cyan-500 hover:shadow-[0_0_20px_rgba(34,211,238,0.4)] hover:scale-[1.02] border border-white/10",
"disabled:opacity-50 disabled:hover:scale-100 disabled:cursor-not-allowed disabled:hover:shadow-none"
)}
>
{isPending ? (
<>
<Loader2 size={20} className="animate-spin" />
<span>Proje Hazırlanıyor...</span>
</>
) : (
<>
<Wand2 size={20} className="group-hover:rotate-12 transition-transform" />
<span>Video Projesi Üret</span>
<ArrowRight size={18} className="group-hover:translate-x-1 transition-transform" />
</>
)}
</button>
</div>
</div>
</div>
);
}
@@ -1,210 +0,0 @@
"use client";
import { useState, useRef } from "react";
import { useRouter } from "next/navigation";
import { cn } from "@/lib/utils";
import { useCreateFromDocument } from "@/hooks/use-api";
import { useToast } from "@/components/ui/toast";
import {
FileText,
Upload,
Loader2,
ArrowRight,
Sparkles,
Wand2,
X,
File,
} from "lucide-react";
import {
LanguageSelector,
StyleSelector,
DurationSelector,
AspectRatioSelector,
} from "@/components/projects/ProjectConfiguration";
export default function DocumentToVideoPage() {
const router = useRouter();
const { toast } = useToast();
const createFromDoc = useCreateFromDocument();
const fileInputRef = useRef<HTMLInputElement>(null);
const [file, setFile] = useState<File | null>(null);
const [style, setStyle] = useState("CINEMATIC");
const [cinematicReference, setCinematicReference] = useState("");
const [duration, setDuration] = useState(60);
const [aspectRatio, setAspectRatio] = useState("PORTRAIT_9_16");
const [language, setLanguage] = useState("tr");
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files.length > 0) {
const selectedFile = e.target.files[0];
// Boyut kontrolü (örn: 10MB)
if (selectedFile.size > 10 * 1024 * 1024) {
toast("error", "Dosya boyutu 10MB'dan küçük olmalıdır.");
return;
}
setFile(selectedFile);
}
};
const clearFile = () => {
setFile(null);
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
};
const handleGenerate = async () => {
if (!file) {
toast("error", "Lütfen bir belge yükleyin.");
return;
}
try {
const result: any = await createFromDoc.mutateAsync({
file,
language,
aspectRatio,
videoStyle: style,
cinematicReference: cinematicReference ? cinematicReference : undefined,
targetDuration: duration,
});
toast("success", "Belge → Video projesi oluşturuldu!");
router.push(`/dashboard/projects/${result.id}`);
} catch {
toast("error", "Proje oluşturulurken bir hata oluştu.");
}
};
return (
<div className="max-w-3xl mx-auto space-y-8 pb-24">
{/* Header */}
<div className="text-center space-y-3 pb-4">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-3xl bg-[var(--color-bg-inverted)] text-[var(--color-text-inverted)] mb-2 ring-1 ring-[var(--color-bg-inverted)]/20 shadow-none">
<FileText size={32} />
</div>
<h1 className="font-[family-name:var(--font-display)] text-3xl md:text-4xl font-bold tracking-tight text-[var(--color-text-primary)]">
Belgeden Video Üret
</h1>
<p className="text-[var(--color-text-muted)] text-sm max-w-lg mx-auto">
PDF, Word veya Text dosyalarınızı yükleyin, yapay zeka sizin için profesyonel bir videoya dönüştürsün
</p>
</div>
{/* Main Form */}
<div className="card p-6 md:p-8 space-y-6">
{/* File Upload */}
<div>
<label className="text-sm font-medium text-[var(--color-text-secondary)] mb-2 block">
Belge Yükle (.pdf, .docx, .txt, vb.)
</label>
{!file ? (
<div
onClick={() => fileInputRef.current?.click()}
className="w-full h-32 border-2 border-dashed border-[var(--color-border-default)] rounded-xl flex flex-col items-center justify-center gap-3 bg-[var(--color-bg-surface)] hover:bg-[var(--color-bg-elevated)] transition-colors cursor-pointer"
>
<div className="w-10 h-10 rounded-full bg-[var(--color-bg-elevated)] flex items-center justify-center text-[var(--color-text-muted)]">
<Upload size={20} />
</div>
<div className="text-center">
<p className="text-sm font-medium text-[var(--color-text-primary)]">Tıklayın veya sürükleyin</p>
<p className="text-xs text-[var(--color-text-ghost)] mt-1">Maksimum 10MB</p>
</div>
</div>
) : (
<div className="w-full p-4 border border-[var(--color-border-default)] rounded-xl flex items-center justify-between bg-[var(--color-bg-elevated)]">
<div className="flex items-center gap-3 overflow-hidden">
<div className="w-10 h-10 rounded-lg bg-[var(--color-bg-surface)] flex items-center justify-center text-[var(--color-text-secondary)] shrink-0">
<File size={20} />
</div>
<div className="truncate">
<p className="text-sm font-medium text-[var(--color-text-primary)] truncate">{file.name}</p>
<p className="text-xs text-[var(--color-text-ghost)]">{(file.size / 1024 / 1024).toFixed(2)} MB</p>
</div>
</div>
<button
onClick={clearFile}
className="w-8 h-8 flex items-center justify-center rounded-full hover:bg-[var(--color-bg-surface)] text-[var(--color-text-muted)] transition-colors shrink-0"
>
<X size={16} />
</button>
</div>
)}
<input
type="file"
ref={fileInputRef}
onChange={handleFileChange}
accept=".pdf,.doc,.docx,.txt,.csv"
className="hidden"
/>
</div>
{/* Configurations */}
<div className="space-y-8 pt-6 border-t border-[var(--color-border-faint)]">
<LanguageSelector value={language} onChange={setLanguage} />
<StyleSelector
value={style}
onChange={setStyle}
cinematicReference={cinematicReference}
onCinematicReferenceChange={setCinematicReference}
/>
<DurationSelector value={duration} onChange={setDuration} />
<AspectRatioSelector value={aspectRatio} onChange={setAspectRatio} />
</div>
</div>
{/* Action Button */}
<div className="flex justify-end">
<button
onClick={handleGenerate}
disabled={createFromDoc.isPending || !file}
className={cn(
"group relative overflow-hidden flex items-center gap-3 px-8 py-4 rounded-2xl font-bold text-[var(--color-text-inverted)] shadow-none transition-all",
"bg-[var(--color-bg-inverted)] hover:scale-[1.02]",
"disabled:opacity-50 disabled:hover:scale-100 disabled:cursor-not-allowed"
)}
>
{createFromDoc.isPending ? (
<>
<Loader2 size={20} className="animate-spin" />
<span>Yapay Zeka Belgeyi Okuyor...</span>
</>
) : (
<>
<Wand2 size={20} className="group-hover:rotate-12 transition-transform" />
<span>Belgeden Video Üret</span>
<ArrowRight size={18} className="group-hover:translate-x-1 transition-transform" />
</>
)}
</button>
</div>
{/* Info Box */}
<div className="card p-5 bg-[var(--color-bg-surface)]">
<div className="flex items-start gap-3">
<Sparkles size={20} className="text-[var(--color-text-secondary)] shrink-0 mt-0.5" />
<div className="space-y-2">
<h3 className="text-sm font-semibold text-[var(--color-text-primary)]">
Nasıl Çalışır?
</h3>
<ol className="text-xs text-[var(--color-text-muted)] space-y-1.5 list-decimal list-inside">
<li>PDF, Word veya TXT formatında bir metin dosyası yükleyin</li>
<li>MarkItDown AI teknolojisiyle belgeniz analiz edilip özetlenir</li>
<li>Belgenin içeriğine en uygun görseller ve anlatım senaryosu çıkarılır</li>
<li>Sizin seçtiğiniz dil, stil ve süreye göre yepyeni bir video oluşturulur</li>
</ol>
</div>
</div>
</div>
</div>
);
}
@@ -25,6 +25,7 @@ import Link from "next/link";
import { DashboardCharts } from "@/components/dashboard/dashboard-charts";
import { RecentProjects } from "@/components/dashboard/recent-projects";
import { TweetImportCard } from "@/components/dashboard/tweet-import-card";
import { YoutubeImportCard } from "@/components/dashboard/youtube-import-card";
import { useDashboardStats, useCreditBalance } from "@/hooks/use-api";
const stagger = {
@@ -139,7 +140,7 @@ export default function DashboardPage() {
</p>
</div>
<Link
href="/dashboard/projects/new"
href="/dashboard/create-project"
className="btn-primary flex items-center gap-2 text-sm"
>
<Plus size={16} />
@@ -183,7 +184,7 @@ export default function DashboardPage() {
{/* ── Hızlı Eylemler ── */}
<motion.div variants={fadeUp} className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
<Link
href="/dashboard/projects/new"
href="/dashboard/create-project"
className="group card-surface p-4 flex items-center gap-4 hover:border-neutral-500/30"
>
<div className="w-11 h-11 rounded-xl bg-[var(--color-bg-inverted)] flex items-center justify-center shrink-0 shadow-lg group-hover:shadow-neutral-500/20 transition-shadow">
@@ -241,12 +242,17 @@ export default function DashboardPage() {
{/* ── Tweet Import + Grafikler ── */}
<motion.div variants={fadeUp} className="grid grid-cols-1 lg:grid-cols-5 gap-4">
<div id="tweet-import" className="lg:col-span-2">
<div id="tweet-import" className="lg:col-span-2 flex flex-col gap-4">
<TweetImportCard
onProjectCreated={(id) => {
window.location.href = `/dashboard/projects/${id}`;
}}
/>
<YoutubeImportCard
onProjectCreated={(id) => {
window.location.href = `/dashboard/projects/${id}`;
}}
/>
</div>
<div className="lg:col-span-3">
<DashboardCharts />
@@ -1,4 +1,5 @@
'use client';
import { useVirtualizer } from '@tanstack/react-virtual';
import { useParams, useRouter } from 'next/navigation';
import { motion, AnimatePresence } from 'framer-motion';
@@ -26,7 +27,8 @@ import {
Hash,
} from 'lucide-react';
import Link from 'next/link';
import { useState, useEffect } from 'react';
import { useState, useEffect, useRef } from 'react';
import { createPortal } from 'react-dom';
import {
useProject,
useGenerateScript,
@@ -38,7 +40,8 @@ import {
useRegenerateScene,
useCancelRender,
useGenerateSeoTitles,
useSelectSeoTitle
useSelectSeoTitle,
useGenerateSocialContent
} from '@/hooks/use-api';
import { useRenderProgress } from '@/hooks/use-render-progress';
import { SceneCard } from '@/components/project/scene-card';
@@ -190,11 +193,20 @@ export default function ProjectDetailPage() {
const deleteMutation = useDeleteProject();
const cancelRenderMutation = useCancelRender();
// Virtualization for long-form video scenes
const parentRef = useRef<HTMLDivElement>(null);
const rowVirtualizer = useVirtualizer({
count: project?.scenes?.length || 0,
getScrollElement: () => parentRef.current,
estimateSize: () => 300, // Estimated height of a SceneCard
overscan: 5, // Render 5 items outside of the visible area
});
const generateImageMutation = useGenerateSceneImage();
const upscaleImageMutation = useUpscaleSceneImage();
const regenerateSceneMutation = useRegenerateScene();
const seoTitlesMutation = useGenerateSeoTitles();
const selectTitleMutation = useSelectSeoTitle();
const generateSocialMutation = useGenerateSocialContent();
const [generatingImageId, setGeneratingImageId] = useState<string | null>(null);
const [upscalingImageId, setUpscalingImageId] = useState<string | null>(null);
@@ -295,7 +307,7 @@ export default function ProjectDetailPage() {
// Onayla ve gönder
const handleApprove = () => {
approveMutation.mutate(id, {
approveMutation.mutate({ projectId: id }, {
onSuccess: () => refetch(),
});
};
@@ -651,10 +663,34 @@ export default function ProjectDetailPage() {
</span>
</div>
<div className="space-y-3">
{project.scenes!.map((scene) => (
<div
ref={parentRef}
className="w-full h-[800px] overflow-auto pr-2 rounded-xl"
>
<div
style={{
height: `${rowVirtualizer.getTotalSize()}px`,
width: '100%',
position: 'relative',
}}
>
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
const scene = project.scenes![virtualRow.index];
return (
<div
key={virtualRow.key}
data-index={virtualRow.index}
ref={rowVirtualizer.measureElement}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${virtualRow.start}px)`,
paddingBottom: '12px',
}}
>
<SceneCard
key={scene.id}
scene={scene}
isEditable={isEditable}
isRendering={isRendering}
@@ -666,7 +702,10 @@ export default function ProjectDetailPage() {
isGeneratingImage={generatingImageId === scene.id}
isUpscalingImage={upscalingImageId === scene.id}
/>
))}
</div>
);
})}
</div>
</div>
</motion.div>
)}
@@ -677,10 +716,25 @@ export default function ProjectDetailPage() {
<div className="card-surface p-5 rounded-2xl border border-[var(--color-border-subtle)]">
{/* Header */}
<div className="flex items-center justify-between mb-5">
<div className="flex items-center gap-4">
<h2 className="text-sm font-semibold text-[var(--color-text-primary)] flex items-center gap-2">
<TrendingUp size={15} className="text-emerald-400" />
SEO & Sosyal Medya
</h2>
<button
onClick={() => generateSocialMutation.mutate(id as string)}
disabled={generateSocialMutation.isPending}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[11px] font-medium bg-gradient-to-r from-emerald-500/20 to-teal-500/20 text-emerald-300 hover:from-emerald-500/30 hover:to-teal-500/30 border border-emerald-500/20 transition-all disabled:opacity-50"
title="Eksik SEO veya Sosyal Medya içeriklerini yapay zeka ile yeniden üret"
>
{generateSocialMutation.isPending ? (
<Loader2 size={12} className="animate-spin" />
) : (
<RefreshCw size={12} />
)}
Tümünü Yeniden Üret
</button>
</div>
{project.seoScore != null && (
<div className="flex items-center gap-2">
<div className="relative w-10 h-10">
@@ -1040,6 +1094,8 @@ export default function ProjectDetailPage() {
)}
{/* ─── Çeviri Modal ─── */}
{mounted && createPortal(
<div className="portal-container">
<AnimatePresence>
{showTranslateModal && (
<motion.div
@@ -1125,6 +1181,9 @@ export default function ProjectDetailPage() {
</motion.div>
)}
</AnimatePresence>
</div>,
document.body
)}
</motion.div>
);
}
@@ -1,286 +0,0 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { motion, AnimatePresence } from "framer-motion";
import {
ArrowLeft,
ArrowRight,
Sparkles,
Loader2,
Check,
Wand2,
} from "lucide-react";
import Link from "next/link";
import { cn } from "@/lib/utils";
import { useCreateProject } from "@/hooks/use-api";
import { useToast } from "@/components/ui/toast";
import { projectsApi } from "@/lib/api/api-service";
import {
LanguageSelector,
StyleSelector,
DurationSelector,
AspectRatioSelector,
languages,
videoStyles,
aspectRatios,
} from "@/components/projects/ProjectConfiguration";
const steps = ["Konu & Dil", "Stil & Süre", "AI Senaryo"];
export default function NewProjectPage() {
const router = useRouter();
const createProject = useCreateProject();
const toast = useToast();
const [currentStep, setCurrentStep] = useState(0);
const [topic, setTopic] = useState("");
const [language, setLanguage] = useState("tr");
const [style, setStyle] = useState("CINEMATIC");
const [cinematicReference, setCinematicReference] = useState("");
const [duration, setDuration] = useState(60);
const [aspectRatio, setAspectRatio] = useState("PORTRAIT_9_16");
const canProceed = currentStep === 0 ? topic.trim().length >= 5 : true;
const isGenerating = createProject.isPending;
const handleGenerate = async () => {
try {
// Backend DTO alanları: prompt (zorunlu), videoStyle, title, language, aspectRatio, targetDuration
const result = await createProject.mutateAsync({
title: topic.slice(0, 80),
prompt: topic,
language,
videoStyle: style,
cinematicReference: cinematicReference ? cinematicReference : undefined,
targetDuration: duration,
aspectRatio,
});
toast.success("Proje başarıyla oluşturuldu! AI senaryo üretiliyor...");
const projectId = result?.id;
if (projectId) {
// Proje oluşturulduktan sonra otomatik senaryo üretimini tetikle
projectsApi.generateScript(projectId).catch((err) => {
console.error("Senaryo üretimi başlatılamadı:", err);
});
router.push(`/dashboard/projects/${projectId}`);
} else {
router.push("/dashboard/projects");
}
} catch {
toast.error("Proje oluşturulurken bir hata oluştu. Lütfen tekrar deneyin.");
}
};
return (
<div className="max-w-2xl mx-auto">
{/* Geri + Başlık */}
<div className="flex items-center gap-3 mb-6">
<Link
href="/dashboard/projects"
className="w-9 h-9 rounded-xl flex items-center justify-center text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)] hover:bg-[var(--color-bg-elevated)] transition-colors"
>
<ArrowLeft size={18} />
</Link>
<div>
<h1 className="font-[family-name:var(--font-display)] text-xl font-bold">Yeni Proje</h1>
<p className="text-xs text-[var(--color-text-muted)]">AI ile video oluştur</p>
</div>
</div>
{/* Step Indicator */}
<div className="flex items-center gap-2 mb-8">
{steps.map((step, i) => (
<div key={step} className="flex items-center gap-2">
<button
onClick={() => i < currentStep && setCurrentStep(i)}
className={cn(
"flex items-center gap-2 px-3 py-1.5 rounded-full text-xs font-medium transition-all",
i === currentStep
? "bg-[var(--color-bg-inverted)] text-[var(--color-text-inverted)]"
: i < currentStep
? "bg-[var(--color-bg-inverted)]/10 text-[var(--color-text-primary)] border border-[var(--color-bg-inverted)]/20 cursor-pointer"
: "text-[var(--color-text-ghost)] border border-[var(--color-border-faint)]"
)}
>
{i < currentStep ? (
<Check size={12} />
) : (
<span className="w-4 h-4 rounded-full border border-current flex items-center justify-center text-[10px]">
{i + 1}
</span>
)}
<span className="hidden sm:inline">{step}</span>
</button>
{i < steps.length - 1 && (
<div className={cn(
"w-6 h-px",
i < currentStep ? "bg-[var(--color-bg-inverted)]/40" : "bg-[var(--color-border-faint)]"
)} />
)}
</div>
))}
</div>
{/* Step Content */}
<AnimatePresence mode="wait">
{currentStep === 0 && (
<motion.div
key="step-0"
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -20 }}
transition={{ duration: 0.3, ease: [0.16, 1, 0.3, 1] }}
className="space-y-6"
>
{/* Konu */}
<div>
<label className="text-sm font-medium text-[var(--color-text-secondary)] mb-2 block">
<Sparkles size={14} className="inline mr-1.5 text-[var(--color-text-primary)]" />
Videonun Konusu
</label>
<textarea
value={topic}
onChange={(e) => setTopic(e.target.value)}
placeholder="Örn: Boötes Boşluğu — evrendeki en büyük boşluk ve gizemi..."
className="w-full h-32 px-4 py-3 rounded-xl bg-[var(--color-bg-surface)] border border-[var(--color-border-faint)] text-[var(--color-text-primary)] text-sm placeholder:text-[var(--color-text-ghost)] focus:outline-none focus:border-[var(--color-border-default)] transition-all resize-none"
/>
<p className="text-[11px] text-[var(--color-text-ghost)] mt-1.5">
Ne kadar detaylı yazarsan, AI o kadar iyi senaryo üretir ({topic.length} karakter)
</p>
</div>
{/* Dil Seçimi */}
<LanguageSelector value={language} onChange={setLanguage} />
</motion.div>
)}
{currentStep === 1 && (
<motion.div
key="step-1"
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -20 }}
transition={{ duration: 0.3, ease: [0.16, 1, 0.3, 1] }}
className="space-y-6"
>
{/* Video Stili */}
<StyleSelector
value={style}
onChange={setStyle}
cinematicReference={cinematicReference}
onCinematicReferenceChange={setCinematicReference}
/>
{/* Süre */}
<DurationSelector value={duration} onChange={setDuration} />
{/* En-Boy Oranı */}
<AspectRatioSelector value={aspectRatio} onChange={setAspectRatio} />
</motion.div>
)}
{currentStep === 2 && (
<motion.div
key="step-2"
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -20 }}
transition={{ duration: 0.3, ease: [0.16, 1, 0.3, 1] }}
className="space-y-6"
>
{/* Özet */}
<div className="card-surface p-5 space-y-4">
<h3 className="font-[family-name:var(--font-display)] text-base font-semibold">Proje Özeti</h3>
<div className="grid grid-cols-2 gap-3 text-sm">
<div>
<span className="text-[var(--color-text-muted)] text-xs">Konu</span>
<p className="font-medium mt-0.5 line-clamp-2">{topic || "—"}</p>
</div>
<div>
<span className="text-[var(--color-text-muted)] text-xs">Dil</span>
<p className="font-medium mt-0.5">
{languages.find((l) => l.code === language)?.flag}{" "}
{languages.find((l) => l.code === language)?.label}
</p>
</div>
<div>
<span className="text-[var(--color-text-muted)] text-xs">Stil</span>
<p className="font-medium mt-0.5">
{videoStyles.find((s) => s.id === style)?.emoji}{" "}
{videoStyles.find((s) => s.id === style)?.label}
</p>
</div>
<div>
<span className="text-[var(--color-text-muted)] text-xs">Süre / Oran</span>
<p className="font-medium mt-0.5">{duration}s {aspectRatios.find((a) => a.id === aspectRatio)?.label}</p>
</div>
</div>
</div>
{/* Generate butonu */}
<button
onClick={handleGenerate}
disabled={isGenerating}
className={cn(
"w-full py-4 rounded-xl font-semibold text-base flex items-center justify-center gap-2 transition-all",
isGenerating
? "bg-[var(--color-bg-inverted)]/20 text-[var(--color-text-primary)] cursor-wait"
: "btn-primary text-lg"
)}
>
{isGenerating ? (
<>
<Loader2 size={20} className="animate-spin" />
<span>AI Senaryo Üretiliyor...</span>
</>
) : (
<>
<Wand2 size={20} />
<span>AI ile Senaryo Üret</span>
</>
)}
</button>
<p className="text-center text-[11px] text-[var(--color-text-ghost)]">
Bu işlem 1 kredi kullanır Tahmini süre: ~15 saniye
</p>
</motion.div>
)}
</AnimatePresence>
{/* Navigation Buttons */}
<div className="flex items-center justify-between mt-8 pt-4 border-t border-[var(--color-border-faint)]">
<button
onClick={() => setCurrentStep((s) => Math.max(0, s - 1))}
disabled={currentStep === 0}
className={cn(
"flex items-center gap-1.5 text-sm font-medium transition-colors",
currentStep === 0
? "text-[var(--color-text-ghost)] cursor-not-allowed"
: "text-[var(--color-text-muted)] hover:text-[var(--color-text-primary)]"
)}
>
<ArrowLeft size={16} />
Geri
</button>
{currentStep < steps.length - 1 && (
<button
onClick={() => setCurrentStep((s) => Math.min(steps.length - 1, s + 1))}
disabled={!canProceed}
className={cn(
"flex items-center gap-1.5 px-5 py-2 rounded-xl text-sm font-semibold transition-all",
canProceed
? "btn-primary"
: "bg-[var(--color-bg-elevated)] text-[var(--color-text-ghost)] cursor-not-allowed"
)}
>
İleri
<ArrowRight size={16} />
</button>
)}
</div>
</div>
);
}
@@ -1,6 +1,7 @@
"use client";
import { useState, useMemo, useCallback } from "react";
import { useState, useMemo, useCallback, useEffect } from "react";
import { createPortal } from "react-dom";
import { motion, AnimatePresence } from "framer-motion";
import {
Plus,
@@ -98,6 +99,11 @@ export default function ProjectsPage() {
const [translateTarget, setTranslateTarget] = useState<ProjectItem | null>(null);
const [targetLanguage, setTargetLanguage] = useState<string>("");
const [isTranslating, setIsTranslating] = useState(false);
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
const { data, isLoading, refetch } = useProjects({ limit: 100 });
const deleteMutation = useDeleteProject();
@@ -396,6 +402,8 @@ export default function ProjectsPage() {
)}
{/* ─── Silme Onay Modal ─── */}
{mounted && createPortal(
<div className="portal-container">
<AnimatePresence>
{deleteTarget && (
<motion.div
@@ -557,6 +565,9 @@ export default function ProjectsPage() {
</motion.div>
)}
</AnimatePresence>
</div>,
document.body
)}
</div>
);
}
@@ -1,22 +0,0 @@
'use client'
import { useEffect } from 'react'
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}) {
useEffect(() => {
console.error('TEXT TO VIDEO ERROR:', error)
}, [error])
return (
<div>
<h2>Something went wrong!</h2>
<pre>{error.message}</pre>
<pre>{error.stack}</pre>
</div>
)
}
@@ -1,130 +0,0 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { cn } from "@/lib/utils";
import { useCreateFromText } from "@/hooks/use-api";
import { useToast } from "@/components/ui/toast";
import { Loader2, ArrowRight, Wand2, Type } from "lucide-react";
import {
LanguageSelector,
StyleSelector,
DurationSelector,
AspectRatioSelector,
} from "@/components/projects/ProjectConfiguration";
export default function TextToVideoPage() {
const router = useRouter();
const { toast } = useToast();
const createFromText = useCreateFromText();
const [textInput, setTextInput] = useState("");
const [style, setStyle] = useState("CINEMATIC");
const [cinematicReference, setCinematicReference] = useState("");
const [duration, setDuration] = useState(60);
const [aspectRatio, setAspectRatio] = useState("PORTRAIT_9_16");
const [language, setLanguage] = useState("tr");
const handleGenerate = async () => {
if (!textInput.trim()) {
toast("error", "Lütfen bir konu, hikaye veya metin girin.");
return;
}
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const result: any = await createFromText.mutateAsync({
text: textInput,
language,
aspectRatio,
videoStyle: style,
cinematicReference: cinematicReference ? cinematicReference : undefined,
targetDuration: duration,
});
toast("success", "Video projesi başarıyla oluşturuldu!");
router.push(`/dashboard/projects/${result.id}`);
} catch (error) {
toast("error", "Proje oluşturulurken bir hata oluştu.");
}
};
return (
<div className="max-w-3xl mx-auto space-y-8 pb-24">
{/* Header */}
<div className="text-center space-y-3 pb-4">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-3xl bg-[var(--color-bg-inverted)] text-[var(--color-text-inverted)] mb-2 ring-1 ring-[var(--color-bg-inverted)]/20 shadow-none">
<Type size={32} />
</div>
<h1 className="font-[family-name:var(--font-display)] text-3xl md:text-4xl font-bold tracking-tight text-[var(--color-text-primary)]">
Metinden Video Üret
</h1>
<p className="text-[var(--color-text-muted)] text-sm max-w-lg mx-auto">
İstediğiniz konuyu, bir hikayeyi veya makaleyi kopyalayıp yapıştırın; yapay zeka sizin için detaylı bir video senaryosu üretsin.
</p>
</div>
{/* Input */}
<div className="card p-6 md:p-8 space-y-6">
<div>
<label className="text-sm font-medium text-[var(--color-text-secondary)] mb-2 block">
Fikriniz veya Metniniz
</label>
<textarea
value={textInput}
onChange={(e) => setTextInput(e.target.value)}
placeholder="Örn: Evrenin başlangıcı ve kara deliklerin sırları hakkında detaylı ve sürükleyici bir anlatım..."
rows={6}
className="w-full bg-[var(--color-bg-surface)] border border-[var(--color-border-faint)] rounded-xl px-4 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-[var(--color-bg-inverted)]/50 resize-none transition-all placeholder:text-[var(--color-text-ghost)]"
/>
</div>
{/* Configurations */}
<div className="space-y-8 pt-6 border-t border-[var(--color-border-faint)]">
<LanguageSelector value={language} onChange={setLanguage} />
<StyleSelector
value={style}
onChange={setStyle}
cinematicReference={cinematicReference}
onCinematicReferenceChange={setCinematicReference}
/>
<DurationSelector value={duration} onChange={setDuration} />
<AspectRatioSelector value={aspectRatio} onChange={setAspectRatio} />
</div>
</div>
{/* Action Button */}
<div className="flex justify-end">
<button
onClick={handleGenerate}
disabled={createFromText.isPending || !textInput.trim()}
className={cn(
"group relative overflow-hidden flex items-center gap-3 px-8 py-4 rounded-2xl font-bold text-[var(--color-text-inverted)] shadow-none transition-all",
"bg-[var(--color-bg-inverted)] hover:scale-[1.02]",
"disabled:opacity-50 disabled:hover:scale-100 disabled:cursor-not-allowed"
)}
>
{createFromText.isPending ? (
<>
<Loader2 size={20} className="animate-spin" />
<span>Yapay Zeka Senaryoyu Yazıyor...</span>
</>
) : (
<>
<Wand2 size={20} className="group-hover:rotate-12 transition-transform" />
<span>Video Projesi Oluştur</span>
<ArrowRight size={18} className="group-hover:translate-x-1 transition-transform" />
</>
)}
</button>
</div>
</div>
);
}
@@ -0,0 +1,143 @@
"use client";
import { Wrench, Video, ArrowRight } from "lucide-react";
import Link from "next/link";
import { motion, useMotionTemplate, useMotionValue, useSpring } from "framer-motion";
import { useState, useRef } from "react";
function ToolCard({
href,
title,
description,
icon: Icon,
colorClass,
spotlightColor
}: {
href: string,
title: string,
description: string,
icon: any,
colorClass: string,
spotlightColor: string
}) {
const mouseX = useMotionValue(0);
const mouseY = useMotionValue(0);
const rotateX = useSpring(0, { stiffness: 300, damping: 20 });
const rotateY = useSpring(0, { stiffness: 300, damping: 20 });
const handleMouseMove = (e: React.MouseEvent<HTMLDivElement>) => {
const rect = e.currentTarget.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
mouseX.set(x);
mouseY.set(y);
const xPct = (x / rect.width) - 0.5;
const yPct = (y / rect.height) - 0.5;
// Hover üst kenar (yPct negatif) -> top backward (pozitif rotateX)
rotateX.set(-yPct * 20);
// Hover sağ kenar (xPct pozitif) -> right backward (pozitif rotateY)
rotateY.set(xPct * 20);
};
const handleMouseLeave = () => {
rotateX.set(0);
rotateY.set(0);
};
return (
<Link href={href} className="block h-full" style={{ perspective: 1200 }}>
<motion.div
onMouseMove={handleMouseMove}
onMouseLeave={handleMouseLeave}
style={{
rotateX,
rotateY,
transformStyle: "preserve-3d",
}}
whileHover={{ scale: 1.02 }}
className="group relative h-full glass p-6 rounded-2xl border border-[var(--color-border-faint)] overflow-hidden"
>
{/* Spotlight */}
<motion.div
className="pointer-events-none absolute -inset-px opacity-0 transition duration-500 group-hover:opacity-100 mix-blend-screen"
style={{
background: useMotionTemplate`
radial-gradient(
600px circle at ${mouseX}px ${mouseY}px,
${spotlightColor},
transparent 40%
)
`,
}}
/>
{/* İçerik, Z-ekseninde hafifçe öne çıkarılır ki 3D efekti belirginleşsin */}
<div style={{ transform: "translateZ(40px)" }} className="flex flex-col h-full pointer-events-none">
<div className="absolute top-0 right-0 w-32 h-32 bg-white/5 rounded-bl-full -z-10 transition-transform group-hover:scale-110" />
<div className={`w-12 h-12 rounded-xl flex items-center justify-center mb-6 shadow-lg ${colorClass}`}>
<Icon size={24} />
</div>
<h3 className="text-xl font-bold text-white mb-3 font-[family-name:var(--font-display)]">
{title}
</h3>
<p className="text-[var(--color-text-ghost)] text-sm mb-6 leading-relaxed flex-grow">
{description}
</p>
<div className={`flex items-center text-sm font-medium ${colorClass.split(" ")[1]}`}>
<span>Aracı Başlat</span>
<ArrowRight size={16} className="ml-2 group-hover:translate-x-1 transition-transform" />
</div>
</div>
</motion.div>
</Link>
);
}
export default function ToolsPage() {
return (
<div className="max-w-7xl mx-auto">
{/* Header */}
<div className="mb-10">
<div className="flex items-center gap-3 text-[var(--color-primary)] mb-3">
<Wrench size={24} className="animate-pulse-subtle" />
<h2 className="text-sm font-semibold tracking-widest uppercase">Gelişmiş Araçlar</h2>
</div>
<h1 className="text-4xl md:text-5xl font-[family-name:var(--font-display)] font-bold text-white mb-4 tracking-tight">
İçerik Strateji Merkezi
</h1>
<p className="text-[var(--color-text-muted)] text-lg max-w-2xl leading-relaxed">
Yapay zeka destekli analiz araçlarıyla içeriklerinizi optimize edin, kitle analizleri yapın ve rakip stratejilerini çözün.
</p>
</div>
{/* Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<ToolCard
href="/dashboard/tools/youtube-analyzer"
title="YouTube Video & Yorum Analizi"
description="Uzun format videoların transkriptlerini ve on binlerce yorumu tek tıkla analiz edin. Duygu durumları, özetler ve yepyeni içerik fikirleri elde edin."
icon={Video}
colorClass="bg-red-500/20 text-red-400"
spotlightColor="rgba(239, 68, 68, 0.15)"
/>
<ToolCard
href="/dashboard/tools/youtube-seo"
title="YouTube SEO Power Engine"
description="Videolarınızı sıralamada zirveye taşıyın. A/B test başlıkları, kanca analizi, long-tail keywordler ve viral kapak görseli promptları elde edin."
icon={Wrench}
colorClass="bg-orange-500/20 text-orange-400"
spotlightColor="rgba(249, 115, 22, 0.15)"
/>
</div>
</div>
);
}
@@ -0,0 +1,553 @@
"use client";
import { useState, useEffect } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { Video, Search, AlertCircle, CheckCircle2, Clock, MessageSquare, Play, BarChart2, TrendingUp, HelpCircle, ExternalLink, History, ThumbsUp, MessageCircle } from "lucide-react";
import { toolsApi } from "@/lib/api/api-service";
import { useToast } from "@/components/ui/toast";
import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip as RechartsTooltip, Legend } from "recharts";
export default function YoutubeAnalyzerPage() {
const [url, setUrl] = useState("");
const [isAnalyzing, setIsAnalyzing] = useState(false);
const [activeTab, setActiveTab] = useState("summary");
const [result, setResult] = useState<any>(null);
const [history, setHistory] = useState<any[]>([]);
const [isHistoryOpen, setIsHistoryOpen] = useState(false);
const [isLoadingHistory, setIsLoadingHistory] = useState(false);
const toast = useToast();
useEffect(() => {
if (isHistoryOpen && history.length === 0) {
loadHistory();
}
}, [isHistoryOpen]);
const loadHistory = async () => {
setIsLoadingHistory(true);
try {
const data = await toolsApi.getYoutubeAnalysisHistory();
setHistory(data);
} catch (e) {
console.error(e);
} finally {
setIsLoadingHistory(false);
}
};
const handleLoadAnalysis = async (id: string) => {
setIsAnalyzing(true);
setResult(null);
setActiveTab("summary");
try {
const data = await toolsApi.getYoutubeAnalysisById(id);
setResult(data.analysisData);
setUrl(data.videoUrl);
setIsHistoryOpen(false);
window.scrollTo({ top: 0, behavior: 'smooth' });
} catch (error: any) {
toast.error("Geçmiş analiz yüklenirken hata oluştu.");
} finally {
setIsAnalyzing(false);
}
};
const handleAnalyze = async (e: React.FormEvent) => {
e.preventDefault();
if (!url.trim()) return;
setIsAnalyzing(true);
setResult(null);
setActiveTab("summary");
try {
const data = await toolsApi.analyzeYoutubeVideo(url);
setResult(data);
toast.success("Analiz başarıyla tamamlandı!");
} catch (error: any) {
toast.error(error?.response?.data?.message || "Analiz sırasında bir hata oluştu.");
} finally {
setIsAnalyzing(false);
}
};
const COLORS = ['#22c55e', '#ef4444', '#64748b'];
const sentimentData = result?.commentsAnalysis?.sentiment ? [
{ name: 'Pozitif', value: result.commentsAnalysis.sentiment.positive },
{ name: 'Negatif', value: result.commentsAnalysis.sentiment.negative },
{ name: 'Nötr', value: result.commentsAnalysis.sentiment.neutral },
] : [];
return (
<div className="max-w-7xl mx-auto pb-20">
{/* Header */}
<div className="mb-10 text-center max-w-3xl mx-auto">
<div className="inline-flex items-center justify-center p-3 rounded-2xl bg-red-500/10 text-red-500 mb-6">
<Video size={32} />
</div>
<h1 className="text-4xl md:text-5xl font-[family-name:var(--font-display)] font-bold text-white mb-4">
YouTube Analiz Aracı
</h1>
<p className="text-[var(--color-text-muted)] text-lg">
Uzun videoların transkriptini çıkarın, binlerce yorumu yapay zekayla analiz edin ve kitlenizin nabzını tutun.
</p>
</div>
{/* Input Section */}
<div className="max-w-3xl mx-auto mb-4">
<form onSubmit={handleAnalyze} className="relative group">
<div className="absolute -inset-1 bg-gradient-to-r from-red-500 to-purple-500 rounded-2xl blur opacity-20 group-hover:opacity-40 transition duration-1000 group-hover:duration-200"></div>
<div className="relative flex items-center bg-[var(--color-bg-deep)] border border-[var(--color-border-faint)] rounded-2xl p-2 shadow-2xl">
<div className="pl-4 pr-2 text-[var(--color-text-ghost)]">
<LinkIcon />
</div>
<input
type="url"
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder="YouTube video linkini yapıştırın..."
className="w-full bg-transparent border-none text-white focus:ring-0 placeholder-[var(--color-text-ghost)] h-12 text-lg"
required
disabled={isAnalyzing}
/>
<button
type="submit"
disabled={isAnalyzing || !url}
className="ml-2 px-8 h-12 bg-white text-black rounded-xl font-bold flex items-center gap-2 hover:bg-gray-100 disabled:opacity-50 disabled:cursor-not-allowed transition-all"
>
{isAnalyzing ? (
<>
<div className="w-5 h-5 border-2 border-black border-t-transparent rounded-full animate-spin" />
<span>Analiz Ediliyor...</span>
</>
) : (
<>
<Search size={18} />
<span>Analiz Et</span>
</>
)}
</button>
</div>
</form>
</div>
<div className="max-w-3xl mx-auto flex justify-end mb-8">
<button
onClick={() => setIsHistoryOpen(!isHistoryOpen)}
className="flex items-center gap-2 text-[var(--color-text-muted)] hover:text-white transition-colors text-sm font-medium bg-[var(--color-bg-elevated)] px-4 py-2 rounded-xl border border-[var(--color-border-faint)]"
>
<History size={16} />
<span>{isHistoryOpen ? 'Geçmişi Gizle' : 'Geçmiş Analizlerim'}</span>
</button>
</div>
<div className="max-w-3xl mx-auto mb-12">
{/* History Section */}
<AnimatePresence>
{isHistoryOpen && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
className="mb-8 overflow-hidden"
>
<div className="glass rounded-2xl p-6 border border-[var(--color-border-faint)]">
<h3 className="text-lg font-bold text-white mb-4 flex items-center gap-2">
<History size={20} /> Geçmiş Analizler
</h3>
{isLoadingHistory ? (
<div className="flex justify-center p-8">
<div className="w-8 h-8 border-2 border-red-500 border-t-transparent rounded-full animate-spin"></div>
</div>
) : history.length === 0 ? (
<p className="text-center text-[var(--color-text-muted)] py-8">Henüz analiz edilmiş bir video bulunmuyor.</p>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{history.map((item) => (
<button
key={item.id}
onClick={() => handleLoadAnalysis(item.id)}
className="text-left bg-[var(--color-bg-deep)] border border-[var(--color-border-faint)] rounded-xl p-4 hover:border-red-500/50 hover:bg-white/5 transition-all group flex gap-4"
>
<img
src={item.thumbnail}
alt=""
className="w-24 h-16 object-cover rounded-lg shrink-0"
/>
<div className="overflow-hidden">
<h4 className="font-bold text-white text-sm line-clamp-2 mb-1 group-hover:text-red-400 transition-colors">
{item.title}
</h4>
<div className="flex items-center gap-3 text-xs text-[var(--color-text-ghost)]">
<span className="flex items-center gap-1"><MessageSquare size={12} /> {item.commentCount}</span>
<span>{new Date(item.createdAt).toLocaleDateString('tr-TR')}</span>
</div>
</div>
</button>
))}
</div>
)}
</div>
</motion.div>
)}
</AnimatePresence>
{/* Loading Steps */}
<AnimatePresence>
{isAnalyzing && (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, height: 0 }}
className="mt-8 glass rounded-2xl p-6 border border-[var(--color-border-faint)]"
>
<div className="space-y-4">
<LoadingStep icon={<Play size={18} />} text="YouTube videosu bulunuyor..." delay={0} />
<LoadingStep icon={<FileTextIcon />} text="Transkript çekiliyor ve özetleniyor..." delay={2} />
<LoadingStep icon={<MessageSquare size={18} />} text="Yorumlar toplanıyor ve kümeleniyor..." delay={4} />
<LoadingStep icon={<ActivityIcon />} text="Yapay zeka çapraz analizi gerçekleştiriyor..." delay={6} />
</div>
</motion.div>
)}
</AnimatePresence>
</div>
{/* Results Section */}
<AnimatePresence>
{result && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="space-y-8"
>
{/* Video Info Card */}
{result.commentsAnalysis?.videoDetails && (
<div className="glass rounded-3xl p-6 border border-[var(--color-border-faint)] flex flex-col md:flex-row gap-6 items-center md:items-start bg-gradient-to-br from-[var(--color-bg-deep)] to-transparent">
<img
src={result.commentsAnalysis.videoDetails.thumbnail}
alt={result.commentsAnalysis.videoDetails.title}
className="w-full md:w-72 rounded-xl object-cover aspect-video shadow-2xl border border-[var(--color-border-faint)]"
/>
<div className="flex-1 space-y-4 w-full">
<h2 className="text-2xl md:text-3xl font-bold text-white leading-tight line-clamp-2 font-[family-name:var(--font-display)]">
{result.commentsAnalysis.videoDetails.title}
</h2>
<div className="flex flex-wrap gap-3 text-sm font-medium">
<div className="flex items-center gap-1.5 text-[var(--color-text-muted)] bg-[var(--color-bg-elevated)] px-4 py-2 rounded-xl border border-[var(--color-border-faint)] shadow-sm">
<Play size={16} className="text-red-500" />
<span>{new Intl.NumberFormat('tr-TR').format(result.commentsAnalysis.videoDetails.viewCount || 0)} İzlenme</span>
</div>
<div className="flex items-center gap-1.5 text-[var(--color-text-muted)] bg-[var(--color-bg-elevated)] px-4 py-2 rounded-xl border border-[var(--color-border-faint)] shadow-sm">
<TrendingUp size={16} className="text-blue-500" />
<span>{new Intl.NumberFormat('tr-TR').format(result.commentsAnalysis.videoDetails.likeCount || 0)} Beğeni</span>
</div>
<a href={result.url} target="_blank" rel="noreferrer" className="flex items-center gap-1.5 text-white bg-red-600 hover:bg-red-700 px-4 py-2 rounded-xl transition-colors shadow-lg shadow-red-500/20">
<ExternalLink size={16} />
<span>YouTube'da </span>
</a>
</div>
</div>
</div>
)}
{/* Stats Row */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<StatCard icon={<Clock />} title="Video Uzunluğu" value={result.transcriptAnalysis?.chapters?.length ? `${result.transcriptAnalysis.chapters.length} Bölüm` : 'Bilinmiyor'} />
<StatCard icon={<MessageSquare />} title="İncelenen Yorum" value={result.commentsAnalysis?.commentCount || 0} />
<StatCard icon={<TrendingUp />} title="Genel Duygu" value={result.commentsAnalysis?.sentiment?.positive > 50 ? 'Pozitif 🟢' : 'Karışık 🟡'} />
<StatCard icon={<CheckCircle2 />} title="Durum" value="Başarılı" />
</div>
{/* Tabs */}
<div className="flex overflow-x-auto hide-scrollbar gap-2 p-1 bg-[var(--color-bg-elevated)] rounded-xl border border-[var(--color-border-faint)] w-fit mx-auto">
<TabButton active={activeTab === 'summary'} onClick={() => setActiveTab('summary')} icon={<BarChart2 size={16} />} label="Genel Özet" />
<TabButton active={activeTab === 'chapters'} onClick={() => setActiveTab('chapters')} icon={<Play size={16} />} label="Bölümler" />
<TabButton active={activeTab === 'comments'} onClick={() => setActiveTab('comments')} icon={<MessageSquare size={16} />} label="Yorum Analizi" />
<TabButton active={activeTab === 'cross'} onClick={() => setActiveTab('cross')} icon={<ActivityIcon />} label="Çapraz Analiz" />
<TabButton active={activeTab === 'ideas'} onClick={() => setActiveTab('ideas')} icon={<HelpCircle size={16} />} label="Yeni Fikirler" />
</div>
{/* Tab Content */}
<div className="glass rounded-3xl p-6 md:p-10 border border-[var(--color-border-faint)] min-h-[400px]">
{/* Summary Tab */}
{activeTab === 'summary' && (
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="space-y-8">
<div>
<h3 className="text-xl font-bold text-white mb-4 flex items-center gap-2">
<FileTextIcon /> Transkript Özeti
</h3>
<div className="prose prose-invert max-w-none text-[var(--color-text-muted)] leading-relaxed bg-[var(--color-bg-deep)] p-6 rounded-2xl border border-[var(--color-border-faint)]">
{result.transcriptAnalysis?.overallSummary ? (
result.transcriptAnalysis.overallSummary.split('\n').map((p: string, i: number) => (
<p key={i}>{p}</p>
))
) : (
<p>Transkript özeti bulunamadı.</p>
)}
</div>
</div>
<div>
<h3 className="text-xl font-bold text-white mb-4 flex items-center gap-2">
<MessageSquare size={20} /> Genel Yankı (Yorumlar)
</h3>
<div className="bg-[var(--color-bg-deep)] p-6 rounded-2xl border border-[var(--color-border-faint)]">
<p className="text-[var(--color-text-muted)] leading-relaxed">
{result.commentsAnalysis?.generalResonance || "Yorum verisi bulunamadı."}
</p>
</div>
</div>
</motion.div>
)}
{/* Chapters Tab */}
{activeTab === 'chapters' && (
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="space-y-6">
<h3 className="text-xl font-bold text-white mb-6">Bölüm Bazlı Analiz</h3>
<div className="grid gap-4">
{result.transcriptAnalysis?.chapters?.map((chapter: any, i: number) => (
<div key={i} className="bg-[var(--color-bg-deep)] p-5 rounded-2xl border border-[var(--color-border-faint)]">
<h4 className="font-bold text-lg text-white mb-2">{chapter.title}</h4>
<p className="text-[var(--color-text-muted)] mb-4 text-sm">{chapter.summary}</p>
{chapter.points && chapter.points.length > 0 && (
<ul className="space-y-1">
{chapter.points.map((pt: string, j: number) => (
<li key={j} className="text-sm text-[var(--color-text-ghost)] flex items-start gap-2">
<span className="text-blue-500 mt-1"></span>
<span>{pt}</span>
</li>
))}
</ul>
)}
</div>
))}
</div>
</motion.div>
)}
{/* Comments Tab */}
{activeTab === 'comments' && (
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="space-y-8">
<h3 className="text-xl font-bold text-white mb-6">Yorum & Duygu Analizi</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 items-center">
<div className="h-64 bg-[var(--color-bg-deep)] p-4 rounded-2xl border border-[var(--color-border-faint)] flex items-center justify-center">
{sentimentData.length > 0 ? (
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={sentimentData}
cx="50%"
cy="50%"
innerRadius={60}
outerRadius={80}
paddingAngle={5}
dataKey="value"
>
{sentimentData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
))}
</Pie>
<RechartsTooltip
contentStyle={{ backgroundColor: '#111', border: '1px solid #333', borderRadius: '8px' }}
itemStyle={{ color: '#fff' }}
/>
<Legend />
</PieChart>
</ResponsiveContainer>
) : (
<span className="text-[var(--color-text-ghost)]">Grafik verisi yok</span>
)}
</div>
<div className="space-y-4">
<div className="bg-[var(--color-bg-deep)] p-5 rounded-2xl border border-green-500/20">
<h4 className="font-bold text-green-500 mb-1">Pozitif Etkileşim (%{result.commentsAnalysis?.sentiment?.positive || 0})</h4>
<p className="text-sm text-[var(--color-text-muted)]">Kitle genel olarak videonun değerinden ve anlatımından memnun.</p>
</div>
<div className="bg-[var(--color-bg-deep)] p-5 rounded-2xl border border-red-500/20">
<h4 className="font-bold text-red-500 mb-1">Negatif Etkileşim (%{result.commentsAnalysis?.sentiment?.negative || 0})</h4>
<p className="text-sm text-[var(--color-text-muted)]">Eleştiriler veya videodaki eksik bulunan noktalar.</p>
</div>
</div>
</div>
<div className="mt-8">
<h4 className="font-bold text-lg text-white mb-4 flex items-center gap-2">
<HelpCircle size={18} /> Sık Sorulan Sorular (FAQ)
</h4>
<div className="grid gap-4">
{result.commentsAnalysis?.faq?.map((f: any, i: number) => (
<div key={i} className="bg-[var(--color-bg-deep)] p-5 md:p-6 rounded-2xl border border-[var(--color-border-faint)] hover:border-[var(--color-border-hover)] transition-colors">
<p className="font-bold text-white text-base md:text-lg mb-2 leading-relaxed">
<span className="text-blue-400 mr-2">Soru:</span>
{f.question}
</p>
<p className="text-sm md:text-base text-[var(--color-text-muted)] leading-relaxed">
<span className="text-[var(--color-text-ghost)] mr-2 font-medium">Bağlam:</span>
{f.context}
</p>
</div>
))}
</div>
</div>
{result.commentsAnalysis?.topComments && result.commentsAnalysis.topComments.length > 0 && (
<div className="mt-8">
<h4 className="font-bold text-lg text-white mb-4 flex items-center gap-2">
<MessageSquare size={18} /> En Yüksek Etkileşim Alan 10 Yorum
</h4>
<div className="grid gap-4">
{result.commentsAnalysis.topComments.map((c: any, i: number) => (
<div key={i} className="bg-[var(--color-bg-deep)] p-5 md:p-6 rounded-2xl border border-[var(--color-border-faint)] hover:border-[var(--color-border-hover)] transition-colors flex flex-col gap-3">
<div className="flex items-center justify-between">
<span className="font-bold text-blue-400">{c.author}</span>
<div className="flex items-center gap-4 text-xs font-medium text-[var(--color-text-muted)]">
<span className="flex items-center gap-1"><ThumbsUp size={14} className="text-green-500" /> {c.likes}</span>
<span className="flex items-center gap-1"><MessageCircle size={14} className="text-blue-500" /> {c.replies}</span>
</div>
</div>
<p className="text-sm md:text-base text-white leading-relaxed whitespace-pre-wrap">
{c.text}
</p>
</div>
))}
</div>
</div>
)}
</motion.div>
)}
{/* Cross Analysis Tab */}
{activeTab === 'cross' && (
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="space-y-6">
<h3 className="text-xl font-bold text-white mb-6">İçerik - Yorum Çapraz Analizi</h3>
{result.crossAnalysis && !result.crossAnalysis.error ? (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="bg-[var(--color-bg-deep)] p-6 rounded-2xl border border-[var(--color-border-faint)] md:col-span-2">
<h4 className="font-bold text-white mb-2">Örtüşme (Alignment)</h4>
<p className="text-[var(--color-text-muted)] text-sm">{result.crossAnalysis.alignment}</p>
</div>
<div className="bg-red-500/5 p-6 rounded-2xl border border-red-500/20">
<h4 className="font-bold text-red-400 mb-4">Yanlış Anlaşılan / Atlanan Noktalar</h4>
<ul className="space-y-2">
{result.crossAnalysis.misunderstoodPoints?.map((pt: string, i: number) => (
<li key={i} className="text-sm text-[var(--color-text-muted)] flex items-start gap-2">
<span className="text-red-500 mt-0.5"></span>
<span>{pt}</span>
</li>
))}
</ul>
</div>
<div className="bg-green-500/5 p-6 rounded-2xl border border-green-500/20">
<h4 className="font-bold text-green-400 mb-4">Öne Çıkan Güçlü Yönler</h4>
<ul className="space-y-2">
{result.crossAnalysis.highlightedStrengths?.map((pt: string, i: number) => (
<li key={i} className="text-sm text-[var(--color-text-muted)] flex items-start gap-2">
<span className="text-green-500 mt-0.5"></span>
<span>{pt}</span>
</li>
))}
</ul>
</div>
<div className="bg-blue-500/5 p-6 rounded-2xl border border-blue-500/20 md:col-span-2">
<h4 className="font-bold text-blue-400 mb-4">İçerik Boşlukları (Content Gaps)</h4>
<ul className="space-y-2">
{result.crossAnalysis.contentGaps?.map((pt: string, i: number) => (
<li key={i} className="text-sm text-[var(--color-text-muted)] flex items-start gap-2">
<span className="text-blue-500 mt-0.5"></span>
<span>{pt}</span>
</li>
))}
</ul>
</div>
</div>
) : (
<div className="p-6 text-center text-[var(--color-text-ghost)]">Çapraz analiz verisi bulunamadı.</div>
)}
</motion.div>
)}
{/* Ideas Tab */}
{activeTab === 'ideas' && (
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="space-y-6">
<h3 className="text-xl font-bold text-white mb-6">Gelecek İçerik Fikirleri</h3>
<p className="text-[var(--color-text-ghost)] text-sm mb-6">
Kitle tepkilerine ve eksik bırakılan noktalara dayalı video fikirleri:
</p>
<div className="grid gap-4">
{result.commentsAnalysis?.suggestions?.map((idea: string, i: number) => (
<div key={i} className="bg-[var(--color-bg-deep)] p-5 rounded-2xl border border-[var(--color-border-faint)] flex items-start gap-4">
<div className="w-8 h-8 rounded-full bg-blue-500/20 text-blue-500 flex items-center justify-center font-bold shrink-0">
{i + 1}
</div>
<p className="text-[var(--color-text-muted)] mt-1">{idea}</p>
</div>
))}
</div>
</motion.div>
)}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}
function TabButton({ active, onClick, icon, label }: { active: boolean; onClick: () => void; icon: React.ReactNode; label: string }) {
return (
<button
onClick={onClick}
className={`flex items-center gap-2 px-4 py-2.5 rounded-lg text-sm font-semibold transition-all whitespace-nowrap ${
active
? 'bg-white text-black shadow-md'
: 'text-[var(--color-text-ghost)] hover:text-white hover:bg-white/5'
}`}
>
{icon}
<span>{label}</span>
</button>
);
}
function StatCard({ icon, title, value }: { icon: React.ReactNode; title: string; value: string | number }) {
return (
<div className="glass p-5 rounded-2xl border border-[var(--color-border-faint)] flex items-center gap-4">
<div className="w-12 h-12 rounded-xl bg-white/5 flex items-center justify-center text-[var(--color-text-muted)]">
{icon}
</div>
<div>
<p className="text-xs text-[var(--color-text-ghost)] uppercase tracking-wider font-semibold mb-1">{title}</p>
<p className="text-xl font-bold text-white font-[family-name:var(--font-display)]">{value}</p>
</div>
</div>
);
}
function LoadingStep({ icon, text, delay }: { icon: React.ReactNode; text: string; delay: number }) {
return (
<motion.div
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: delay * 0.5, duration: 0.5 }}
className="flex items-center gap-3 text-[var(--color-text-muted)]"
>
<div className="w-8 h-8 rounded-full bg-[var(--color-bg-elevated)] flex items-center justify-center">
{icon}
</div>
<span className="text-sm font-medium">{text}</span>
</motion.div>
);
}
const LinkIcon = () => <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path></svg>;
const FileTextIcon = () => <svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"></path><polyline points="14 2 14 8 20 8"></polyline><line x1="16" y1="13" x2="8" y2="13"></line><line x1="16" y1="17" x2="8" y2="17"></line><line x1="10" y1="9" x2="8" y2="9"></line></svg>;
const ActivityIcon = () => <svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"></polyline></svg>;
@@ -0,0 +1,544 @@
"use client";
import { useState, useEffect } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { Search, History, Target, TrendingUp, ImageIcon, Lightbulb, PenTool, HelpCircle, Copy, Check, Loader2, Download } from "lucide-react";
import { toolsApi } from "@/lib/api/api-service";
import { useToast } from "@/components/ui/toast";
const YoutubeIcon = ({ size = 20, className = "" }: { size?: number; className?: string }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor" className={className}>
<path d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z" />
</svg>
);
const CopyButton = ({ text }: { text: string }) => {
const [copied, setCopied] = useState(false);
const handleCopy = () => {
navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return (
<button onClick={(e) => { e.preventDefault(); handleCopy(); }} className="p-1.5 bg-white/5 hover:bg-white/10 rounded-lg transition-colors flex-shrink-0" title="Kopyala">
{copied ? <Check className="w-4 h-4 text-green-400" /> : <Copy className="w-4 h-4 text-white/40 hover:text-white" />}
</button>
);
};
const ThumbnailCard = ({ idea, index }: { idea: any, index: number }) => {
const [loading, setLoading] = useState(false);
const [imageUrl, setImageUrl] = useState<string | null>(null);
const [isFullscreen, setIsFullscreen] = useState(false);
const toast = useToast();
const handleGenerate = async () => {
setLoading(true);
try {
const res = await toolsApi.generateYoutubeSeoImage(idea.midjourneyPrompt);
setImageUrl(res.url);
toast.success("Kapak görseli başarıyla üretildi!");
} catch(err: any) {
toast.error(err.message || "Görsel üretilemedi.");
} finally {
setLoading(false);
}
};
const handleDownload = async (e: React.MouseEvent) => {
e.stopPropagation();
if (!imageUrl) return;
try {
const response = await fetch(imageUrl);
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement("a");
a.style.display = "none";
a.href = url;
a.download = `thumbnail-concept-${index + 1}.jpg`;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
} catch (err) {
toast.error("Görsel indirilirken hata oluştu.");
}
};
return (
<>
<div className="bg-black/40 border border-white/10 rounded-2xl p-4 flex flex-col relative">
<h4 className="font-medium text-pink-300 mb-2">Konsept {index + 1}</h4>
<p className="text-sm text-white/80 mb-4">{idea.concept}</p>
{imageUrl ? (
<div
className="mb-4 rounded-xl overflow-hidden border border-white/10 cursor-pointer relative group"
onClick={() => setIsFullscreen(true)}
>
<img src={imageUrl} alt={`Thumbnail ${index + 1}`} className="w-full aspect-video object-cover transition-transform duration-300 group-hover:scale-105" />
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
<Search className="w-8 h-8 text-white" />
</div>
</div>
) : (
<button
onClick={handleGenerate}
disabled={loading}
className="mb-4 w-full py-2 bg-pink-500/10 hover:bg-pink-500/20 text-pink-400 border border-pink-500/20 rounded-xl transition-all text-sm font-medium flex items-center justify-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? <Loader2 className="w-4 h-4 animate-spin" /> : <ImageIcon className="w-4 h-4" />}
{loading ? "Görsel Üretiliyor..." : "Görseli Oluştur"}
</button>
)}
<div className="mt-auto">
<div className="text-xs text-white/50 mb-1 flex justify-between items-center">
<span>AI Prompt:</span>
<CopyButton text={idea.midjourneyPrompt} />
</div>
<div className="bg-white/5 border border-white/10 p-2 rounded-lg text-xs text-white/70 font-mono break-all mb-3">
{idea.midjourneyPrompt}
</div>
<div className="flex items-center gap-2 text-xs">
<span className="text-white/40">Renk Paleti:</span>
<span className="text-white font-medium">{idea.colorPalette}</span>
</div>
</div>
</div>
<AnimatePresence>
{isFullscreen && imageUrl && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={() => setIsFullscreen(false)}
className="fixed inset-0 z-[100] flex items-center justify-center bg-black/95 backdrop-blur-md cursor-zoom-out"
>
<div className="relative w-full h-full p-4 md:p-8 flex items-center justify-center" onClick={e => e.stopPropagation()}>
<img
src={imageUrl}
alt={`Thumbnail Fullscreen ${index + 1}`}
className="w-full h-full object-contain rounded-xl"
/>
<button
onClick={handleDownload}
className="absolute top-6 right-6 bg-black/60 hover:bg-black border border-white/20 p-3 rounded-xl backdrop-blur-md transition-all text-white flex items-center gap-2"
title="Görseli İndir"
>
<Download className="w-5 h-5" />
<span className="text-sm font-medium hidden sm:block">İndir</span>
</button>
<button
onClick={() => setIsFullscreen(false)}
className="absolute top-6 right-36 md:right-40 text-white/60 hover:text-white transition-colors bg-black/40 hover:bg-black/60 px-4 py-3 rounded-xl border border-transparent hover:border-white/20"
title="Kapat"
>
Kapat
</button>
</div>
</motion.div>
)}
</AnimatePresence>
</>
);
};
export default function YoutubeSeoPage() {
const [url, setUrl] = useState("");
const [isAnalyzing, setIsAnalyzing] = useState(false);
const [result, setResult] = useState<any>(null);
const [history, setHistory] = useState<any[]>([]);
const [isHistoryOpen, setIsHistoryOpen] = useState(false);
const [isLoadingHistory, setIsLoadingHistory] = useState(false);
const toast = useToast();
useEffect(() => {
if (isHistoryOpen && history.length === 0) {
loadHistory();
}
}, [isHistoryOpen]);
const loadHistory = async () => {
setIsLoadingHistory(true);
try {
const data = await toolsApi.getYoutubeSeoHistory();
setHistory(data);
} catch (e) {
console.error(e);
} finally {
setIsLoadingHistory(false);
}
};
const handleLoadAnalysis = async (id: string) => {
setIsAnalyzing(true);
setResult(null);
try {
const data = await toolsApi.getYoutubeSeoAnalysisById(id);
setResult(data);
setUrl(data.videoUrl);
setIsHistoryOpen(false);
window.scrollTo({ top: 0, behavior: 'smooth' });
} catch (error: any) {
toast.error("Geçmiş analiz yüklenirken hata oluştu.");
} finally {
setIsAnalyzing(false);
}
};
const handleAnalyze = async (e: React.FormEvent) => {
e.preventDefault();
if (!url.trim()) return;
setIsAnalyzing(true);
setResult(null);
setIsHistoryOpen(false);
try {
const data = await toolsApi.analyzeYoutubeSEO(url);
setResult(data);
} catch (error: any) {
toast.error(error.message || "Analiz sırasında bir hata oluştu.");
} finally {
setIsAnalyzing(false);
}
};
const ScoreCircle = ({ score }: { score: number }) => {
const color = score >= 80 ? "text-green-500" : score >= 50 ? "text-yellow-500" : "text-red-500";
const strokeColor = score >= 80 ? "#22c55e" : score >= 50 ? "#eab308" : "#ef4444";
return (
<div className="relative w-24 h-24 flex items-center justify-center">
<svg className="w-full h-full transform -rotate-90">
<circle cx="48" cy="48" r="40" stroke="currentColor" strokeWidth="8" fill="transparent" className="text-white/10" />
<circle
cx="48" cy="48" r="40" stroke={strokeColor} strokeWidth="8" fill="transparent"
strokeDasharray={251.2}
strokeDashoffset={251.2 - (251.2 * score) / 100}
className="transition-all duration-1000 ease-out"
/>
</svg>
<span className={`absolute text-2xl font-bold ${color}`}>{score}</span>
</div>
);
};
return (
<div className="min-h-screen bg-black text-white p-4 md:p-8 font-sans">
<div className="max-w-6xl mx-auto space-y-8">
{/* Header */}
<div className="flex flex-col md:flex-row items-start md:items-center justify-between gap-4">
<div className="flex items-center gap-4">
<div className="p-3 bg-red-500/20 rounded-2xl border border-red-500/30">
<YoutubeIcon className="w-8 h-8 text-red-500" />
</div>
<div>
<h1 className="text-3xl font-bold bg-gradient-to-r from-red-400 to-orange-400 bg-clip-text text-transparent">
YouTube SEO Power Engine
</h1>
<p className="text-white/60 mt-1">
Videolarınızı sıralamada zirveye taşıyacak premium analiz aracı
</p>
</div>
</div>
<button
onClick={() => setIsHistoryOpen(!isHistoryOpen)}
className="flex items-center gap-2 px-4 py-2 bg-white/5 hover:bg-white/10 border border-white/10 rounded-xl transition-colors"
>
<History className="w-4 h-4" />
Geçmiş Analizlerim
</button>
</div>
{/* URL Input Form */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="bg-white/5 border border-white/10 rounded-3xl p-6 backdrop-blur-xl relative overflow-hidden"
>
<div className="absolute top-0 left-0 w-full h-1 bg-gradient-to-r from-red-500 via-orange-500 to-yellow-500"></div>
<form onSubmit={handleAnalyze} className="flex flex-col md:flex-row gap-4">
<div className="flex-1 relative">
<div className="absolute inset-y-0 left-4 flex items-center pointer-events-none">
<Search className="h-5 w-5 text-white/40" />
</div>
<input
type="text"
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder="YouTube video linkini yapıştırın (örn: https://youtube.com/watch?v=...)"
className="w-full bg-black/50 border border-white/10 rounded-2xl pl-12 pr-4 py-4 text-white placeholder-white/40 focus:outline-none focus:ring-2 focus:ring-red-500/50 transition-all"
required
/>
</div>
<button
type="submit"
disabled={isAnalyzing}
className="bg-gradient-to-r from-red-500 to-orange-500 hover:from-red-600 hover:to-orange-600 text-white px-8 py-4 rounded-2xl font-semibold transition-all disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
{isAnalyzing ? (
<>
<div className="animate-spin rounded-full h-5 w-5 border-2 border-white/20 border-t-white"></div>
Analiz Ediliyor...
</>
) : (
<>
<Target className="w-5 h-5" />
SEO Analizi Başlat
</>
)}
</button>
</form>
</motion.div>
{/* History Dropdown */}
<AnimatePresence>
{isHistoryOpen && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }}
className="overflow-hidden"
>
<div className="bg-white/5 border border-white/10 rounded-3xl p-6 backdrop-blur-xl mb-8">
<h3 className="text-xl font-semibold mb-4 flex items-center gap-2">
<History className="w-5 h-5 text-white/60" /> Geçmiş Analizler
</h3>
{isLoadingHistory ? (
<div className="text-center text-white/50 py-4">Yükleniyor...</div>
) : history.length === 0 ? (
<div className="text-center text-white/50 py-4">Henüz geçmiş analiz bulunmuyor.</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{history.map((item) => (
<button
key={item.id}
onClick={() => handleLoadAnalysis(item.id)}
className="text-left bg-black/40 hover:bg-white/10 border border-white/10 rounded-xl p-4 transition-all group flex gap-4 items-center"
>
{item.thumbnail ? (
<img src={item.thumbnail} alt={item.title || 'Video'} className="w-24 h-16 object-cover rounded-lg" />
) : (
<div className="w-24 h-16 bg-white/5 rounded-lg flex items-center justify-center">
<YoutubeIcon className="w-6 h-6 text-white/20" />
</div>
)}
<div className="flex-1 overflow-hidden">
<p className="font-medium text-sm text-white truncate group-hover:text-red-400 transition-colors">
{item.title || 'İsimsiz Video'}
</p>
<div className="flex items-center gap-2 mt-2 text-xs text-white/50">
<span className="bg-white/10 px-2 py-1 rounded">Skor: {item.seoScore}</span>
<span>{new Date(item.createdAt).toLocaleDateString()}</span>
</div>
</div>
</button>
))}
</div>
)}
</div>
</motion.div>
)}
</AnimatePresence>
{/* Results */}
{result && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="grid grid-cols-1 lg:grid-cols-3 gap-8"
>
{/* Left Column: Video Info & Scores */}
<div className="lg:col-span-1 space-y-6">
<div className="bg-white/5 border border-white/10 rounded-3xl p-6 backdrop-blur-xl">
{result.videoDetails?.thumbnail && (
<img src={result.videoDetails.thumbnail} alt="Thumbnail" className="w-full aspect-video object-cover rounded-2xl mb-4 border border-white/10" />
)}
<h2 className="text-lg font-semibold text-white mb-2 leading-tight">
{result.videoDetails?.title}
</h2>
<div className="flex gap-4 text-sm text-white/60 mb-6">
<span>{Number(result.videoDetails?.viewCount || 0).toLocaleString()} İzl.</span>
<span>{Number(result.videoDetails?.likeCount || 0).toLocaleString()} Beğeni</span>
</div>
<div className="border-t border-white/10 pt-6">
<div className="flex items-center justify-between mb-4">
<h3 className="font-medium text-white/80">Genel SEO Skoru</h3>
<ScoreCircle score={result.seoAnalysis?.currentStatus?.seoScore || 0} />
</div>
<div className="space-y-4 text-sm">
<div className="bg-white/5 p-3 rounded-xl border border-white/5">
<span className="text-white/40 block mb-1">Başlık Durumu</span>
<p className="text-white/80">{result.seoAnalysis?.currentStatus?.titleFeedback}</p>
</div>
<div className="bg-white/5 p-3 rounded-xl border border-white/5">
<span className="text-white/40 block mb-1">Açıklama Durumu</span>
<p className="text-white/80">{result.seoAnalysis?.currentStatus?.descriptionFeedback}</p>
</div>
<div className="bg-white/5 p-3 rounded-xl border border-white/5">
<span className="text-white/40 block mb-1">Etiket (Keyword) Durumu</span>
<p className="text-white/80">{result.seoAnalysis?.currentStatus?.keywordsFeedback}</p>
</div>
</div>
</div>
<div className="border-t border-white/10 pt-6 mt-6">
<div className="flex items-center justify-between mb-4">
<h3 className="font-medium text-white/80 flex items-center gap-2">
<Target className="w-4 h-4 text-orange-400" /> Kanca (Hook) Skoru
</h3>
<ScoreCircle score={result.seoAnalysis?.hookAnalysis?.score || 0} />
</div>
<div className="space-y-4 text-sm">
<div className="bg-white/5 p-3 rounded-xl border border-white/5">
<p className="text-white/80">{result.seoAnalysis?.hookAnalysis?.feedback}</p>
<div className="mt-2 text-orange-300 font-medium bg-orange-500/10 p-2 rounded border border-orange-500/20">
💡 Öneri: {result.seoAnalysis?.hookAnalysis?.suggestion}
</div>
</div>
</div>
</div>
</div>
</div>
{/* Right Column: SEO Assets */}
<div className="lg:col-span-2 space-y-6">
{/* A/B Test Titles */}
<div className="bg-white/5 border border-white/10 rounded-3xl p-6 backdrop-blur-xl">
<h3 className="text-xl font-semibold mb-4 flex items-center gap-2 text-white">
<Lightbulb className="w-5 h-5 text-yellow-400" /> A/B Test Başlık Stratejileri
</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{result.seoAnalysis?.abTestTitles?.map((titleObj: any, i: number) => (
<div key={i} className="bg-gradient-to-br from-black/60 to-black/40 border border-white/10 p-5 rounded-2xl relative group flex flex-col">
<div className="absolute top-0 left-0 w-full h-1 bg-yellow-500/50 opacity-0 group-hover:opacity-100 transition-opacity rounded-t-2xl"></div>
<div className="flex justify-between items-start mb-3">
<span className="inline-block px-2 py-1 bg-white/10 rounded text-xs font-medium text-yellow-300">
{titleObj.type}
</span>
{titleObj.seoScore && (
<span className="inline-block px-2 py-1 bg-green-500/10 border border-green-500/20 rounded text-xs font-bold text-green-400" title="Tahmini SEO Skoru">
SEO: {titleObj.seoScore}
</span>
)}
</div>
<div className="flex justify-between items-start gap-2 mb-3">
<p className="font-semibold text-white text-lg leading-tight flex-1">
{titleObj.title}
</p>
<CopyButton text={titleObj.title} />
</div>
<p className="text-xs text-white/50 border-t border-white/10 pt-3 mt-auto">
<span className="text-white/70 block mb-1">Neden Çalışır?</span>
{titleObj.reason}
</p>
</div>
))}
</div>
</div>
{/* Keywords & FAQ */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="bg-white/5 border border-white/10 rounded-3xl p-6 backdrop-blur-xl flex flex-col">
<div className="flex justify-between items-center mb-4">
<h3 className="text-xl font-semibold flex items-center gap-2 text-white">
<Search className="w-5 h-5 text-blue-400" /> Long-tail Keywords
</h3>
<CopyButton text={result.seoAnalysis?.suggestedKeywords?.join(", ") || ""} />
</div>
<div className="flex flex-wrap gap-2">
{result.seoAnalysis?.suggestedKeywords?.map((kw: string, i: number) => (
<span key={i} className="bg-blue-500/10 border border-blue-500/20 text-blue-300 px-3 py-1.5 rounded-lg text-sm flex items-center gap-1">
<TrendingUp className="w-3 h-3" /> {kw}
</span>
))}
</div>
</div>
<div className="bg-white/5 border border-white/10 rounded-3xl p-6 backdrop-blur-xl flex flex-col">
<div className="flex justify-between items-center mb-4">
<h3 className="text-xl font-semibold flex items-center gap-2 text-white">
<HelpCircle className="w-5 h-5 text-purple-400" /> Sık Sorulan Sorular (SSS)
</h3>
<CopyButton text={result.seoAnalysis?.faqQuestions?.join("\n") || ""} />
</div>
<ul className="space-y-3">
{result.seoAnalysis?.faqQuestions?.map((q: string, i: number) => (
<li key={i} className="flex gap-3 text-sm text-white/80 bg-black/30 p-3 rounded-xl border border-white/5">
<span className="text-purple-400 font-bold">Q:</span> {q}
</li>
))}
</ul>
</div>
</div>
{/* Suggested Description */}
<div className="bg-white/5 border border-white/10 rounded-3xl p-6 backdrop-blur-xl">
<div className="flex justify-between items-center mb-4">
<h3 className="text-xl font-semibold flex items-center gap-2 text-white">
<PenTool className="w-5 h-5 text-green-400" /> SEO Uyumlu Açıklama Şablonu
</h3>
<CopyButton text={result.seoAnalysis?.suggestedDescriptionTemplate || ""} />
</div>
<div className="bg-black/50 border border-white/10 p-4 rounded-2xl">
<pre className="text-sm text-white/70 whitespace-pre-wrap font-sans">
{result.seoAnalysis?.suggestedDescriptionTemplate}
</pre>
</div>
</div>
{/* Thumbnail Concepts */}
<div className="bg-white/5 border border-white/10 rounded-3xl p-6 backdrop-blur-xl">
<h3 className="text-xl font-semibold mb-4 flex items-center gap-2 text-white">
<ImageIcon className="w-5 h-5 text-pink-400" /> Kapak Görseli (Thumbnail) Konseptleri
</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{result.seoAnalysis?.thumbnailIdeas?.map((idea: any, i: number) => (
<ThumbnailCard key={i} idea={idea} index={i} />
))}
</div>
</div>
{/* Shorts Ideas */}
{result.seoAnalysis?.shortsIdeas && result.seoAnalysis.shortsIdeas.length > 0 && (
<div className="bg-white/5 border border-white/10 rounded-3xl p-6 backdrop-blur-xl">
<h3 className="text-xl font-semibold mb-4 flex items-center gap-2 text-white">
<YoutubeIcon className="w-5 h-5 text-red-500" /> Shorts Fikirleri
</h3>
<div className="space-y-4">
{result.seoAnalysis.shortsIdeas.map((idea: any, i: number) => (
<div key={i} className="bg-black/30 border border-white/5 p-4 rounded-xl flex items-start gap-4">
<div className="bg-red-500/20 p-2 rounded-lg text-red-400 flex-shrink-0 w-8 h-8 flex items-center justify-center font-bold">
{i+1}
</div>
<div className="flex-1 min-w-0">
<div className="flex justify-between items-start gap-2 mb-1">
<h4 className="font-semibold text-white">{idea.title}</h4>
{idea.timestamp && (
<span className="inline-flex items-center px-2 py-1 bg-red-500/10 border border-red-500/20 text-red-400 text-xs rounded font-medium whitespace-nowrap">
{idea.timestamp}
</span>
)}
</div>
<p className="text-sm text-white/60">{idea.context}</p>
</div>
</div>
))}
</div>
</div>
)}
</div>
</motion.div>
)}
</div>
</div>
);
}
@@ -1,322 +0,0 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { motion, AnimatePresence } from "framer-motion";
import {
AtSign,
Link2,
Loader2,
ArrowRight,
Sparkles,
Wand2,
MessageSquare,
Heart,
Repeat2,
Eye,
Image as ImageIcon,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { useTweetPreview, useCreateFromTweet } from "@/hooks/use-api";
import { useToast } from "@/components/ui/toast";
import {
LanguageSelector,
StyleSelector,
DurationSelector,
AspectRatioSelector,
} from "@/components/projects/ProjectConfiguration";
export default function XToVideoPage() {
const router = useRouter();
const { toast } = useToast();
const tweetPreview = useTweetPreview();
const createFromTweet = useCreateFromTweet();
const [tweetUrl, setTweetUrl] = useState("");
const [style, setStyle] = useState("CINEMATIC");
const [cinematicReference, setCinematicReference] = useState("");
const [duration, setDuration] = useState(60);
const [aspectRatio, setAspectRatio] = useState("PORTRAIT_9_16");
const [language, setLanguage] = useState("tr");
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const [previewData, setPreviewData] = useState<any>(null);
const isValidUrl = /https?:\/\/(x\.com|twitter\.com)\/\w+\/status\/\d+/.test(tweetUrl);
const handlePreview = async () => {
if (!isValidUrl) {
toast("error", "Geçerli bir X/Twitter URL'si girin (https://x.com/...)");
return;
}
try {
const result = await tweetPreview.mutateAsync(tweetUrl);
setPreviewData(result);
toast("success", "Tweet başarıyla yüklendi!");
} catch {
toast("error", "Tweet yüklenemedi. URL'yi kontrol edin.");
}
};
const handleGenerate = async () => {
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const result: any = await createFromTweet.mutateAsync({
tweetUrl,
language,
aspectRatio,
videoStyle: style,
cinematicReference: cinematicReference ? cinematicReference : undefined,
targetDuration: duration,
});
toast("success", "Tweet → Video projesi oluşturuldu!");
const projectId = result?.id;
if (projectId) {
router.push(`/dashboard/projects/${projectId}`);
} else {
router.push("/dashboard/projects");
}
} catch {
toast("error", "Proje oluşturulurken bir hata oluştu.");
}
};
return (
<div className="max-w-3xl mx-auto space-y-6 pb-24">
{/* Header */}
<div className="text-center space-y-3 pb-4">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-3xl bg-[var(--color-bg-inverted)] text-[var(--color-text-inverted)] mb-2 ring-1 ring-[var(--color-bg-inverted)]/20 shadow-none">
<AtSign size={32} />
</div>
<h1 className="font-[family-name:var(--font-display)] text-3xl md:text-4xl font-bold tracking-tight text-[var(--color-text-primary)]">
Tweet'ten Video Oluştur
</h1>
<p className="text-[var(--color-text-muted)] text-sm max-w-lg mx-auto">
X/Twitter yazılarını AI ile kısa videolara dönüştürün
</p>
</div>
{/* URL Input */}
<div className="card p-5 space-y-4">
<label className="text-sm font-medium text-[var(--color-text-secondary)] block">
<Link2 size={14} className="inline mr-1.5 text-cyan-400" />
Tweet URL
</label>
<div className="flex gap-2">
<input
type="url"
value={tweetUrl}
onChange={(e) => {
setTweetUrl(e.target.value);
setPreviewData(null);
}}
placeholder="https://x.com/username/status/123456..."
className="flex-1 px-4 py-2.5 rounded-xl bg-[var(--color-bg-surface)] border border-[var(--color-border-faint)] text-sm text-[var(--color-text-primary)] placeholder:text-[var(--color-text-ghost)] focus:outline-none focus:border-[var(--color-bg-inverted)]/40 focus:ring-1 focus:ring-[var(--color-bg-inverted)]/20 transition-all"
/>
<button
onClick={handlePreview}
disabled={!isValidUrl || tweetPreview.isPending}
className={cn(
"px-4 py-2.5 rounded-xl text-sm font-semibold flex items-center gap-2 transition-all shrink-0",
isValidUrl
? "btn-primary"
: "bg-[var(--color-bg-elevated)] text-[var(--color-text-ghost)] cursor-not-allowed",
)}
>
{tweetPreview.isPending ? (
<Loader2 size={16} className="animate-spin" />
) : (
<>
<Eye size={16} />
Önizle
</>
)}
</button>
</div>
<p className="text-[11px] text-[var(--color-text-ghost)]">
Thread desteği: Çoklu tweet zincirleri de otomatik olarak algılanır
</p>
</div>
{/* Tweet Preview */}
<AnimatePresence>
{previewData && (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
className="card p-5 space-y-3"
>
<h3 className="text-sm font-semibold text-[var(--color-text-secondary)]">
<MessageSquare
size={14}
className="inline mr-1.5 text-violet-400"
/>
Tweet Önizleme
</h3>
<div className="p-4 rounded-xl bg-[var(--color-bg-surface)] border border-[var(--color-border-faint)] space-y-3">
{/* Author */}
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-full bg-gradient-to-br from-violet-500/20 to-cyan-500/20 flex items-center justify-center text-sm font-bold text-violet-300">
{(previewData.tweet?.author?.name ?? "X")?.[0]}
</div>
<div>
<p className="text-sm font-bold text-[var(--color-text-primary)]">
{previewData.tweet?.author?.name ?? "Kullanıcı"}
</p>
<p className="text-[11px] text-[var(--color-text-ghost)]">
@{previewData.tweet?.author?.username ?? "handle"}
</p>
</div>
</div>
{/* Content */}
<p className="text-sm text-[var(--color-text-secondary)] whitespace-pre-line">
{previewData.tweet?.text ?? ""}
</p>
{/* Images */}
{(previewData.tweet?.media?.length > 0) && (
<div className="flex gap-2 overflow-x-auto">
{(previewData.tweet.media ?? [])
.filter((m: any) => m.type === 'photo')
.map(
(m: any, i: number) => (
<div
key={i}
className="w-20 h-20 rounded-lg bg-[var(--color-bg-elevated)] overflow-hidden shrink-0"
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={m.url}
alt={`Media ${i + 1}`}
className="w-full h-full object-cover"
/>
</div>
),
)}
</div>
)}
{/* Stats */}
<div className="flex items-center gap-4 text-[11px] text-[var(--color-text-ghost)]">
<span className="flex items-center gap-1">
<Heart size={12} />
{previewData.tweet?.metrics?.likes ?? 0}
</span>
<span className="flex items-center gap-1">
<Repeat2 size={12} />
{previewData.tweet?.metrics?.retweets ?? 0}
</span>
<span className="flex items-center gap-1">
<Eye size={12} />
{previewData.tweet?.metrics?.views ?? 0}
</span>
{previewData.tweet?.isThread && (
<span className="flex items-center gap-1 text-[var(--color-bg-inverted)]">
<MessageSquare size={12} />
{previewData.tweet?.threadTweets?.length ?? 0} tweet thread
</span>
)}
</div>
</div>
{/* Suggested info */}
{previewData.suggestedTitle && (
<div className="flex items-center gap-1.5 text-[11px] text-[var(--color-text-secondary)]">
<Sparkles size={12} />
Önerilen başlık: {previewData.suggestedTitle} · Tahmini süre: {previewData.estimatedDuration}sn · Viral skoru: {previewData.viralScore}/100
</div>
)}
{/* Images tag */}
{(previewData.tweet?.media?.filter((m: any) => m.type === 'photo')?.length > 0) && (
<div className="flex items-center gap-1.5 text-[11px] text-[var(--color-text-secondary)]">
<ImageIcon size={12} />
{previewData.tweet.media.filter((m: any) => m.type === 'photo').length} görsel referans olarak kullanılacak + AI görsel üretilecek
</div>
)}
</motion.div>
)}
</AnimatePresence>
{/* Video Settings */}
<AnimatePresence>
{previewData && (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className="space-y-6"
>
<div className="card p-6 space-y-8">
<LanguageSelector value={language} onChange={setLanguage} />
<StyleSelector
value={style}
onChange={setStyle}
cinematicReference={cinematicReference}
onCinematicReferenceChange={setCinematicReference}
/>
<DurationSelector value={duration} onChange={setDuration} />
<AspectRatioSelector value={aspectRatio} onChange={setAspectRatio} />
</div>
{/* Generate Button */}
<div className="flex justify-end">
<button
onClick={handleGenerate}
disabled={createFromTweet.isPending}
className={cn(
"group relative overflow-hidden flex items-center gap-3 px-8 py-4 rounded-2xl font-bold text-[var(--color-text-inverted)] shadow-none transition-all",
"bg-[var(--color-bg-inverted)] hover:scale-[1.02]",
"disabled:opacity-50 disabled:hover:scale-100 disabled:cursor-not-allowed"
)}
>
{createFromTweet.isPending ? (
<>
<Loader2 size={20} className="animate-spin" />
<span>Video Projesi Oluşturuluyor...</span>
</>
) : (
<>
<Wand2 size={20} className="group-hover:rotate-12 transition-transform" />
<span>Tweet Video Oluştur</span>
<ArrowRight size={18} className="group-hover:translate-x-1 transition-transform" />
</>
)}
</button>
</div>
<p className="text-center text-[11px] text-[var(--color-text-ghost)]">
Bu işlem 1 kredi kullanır AI senaryo + görsel üretim dahil
</p>
</motion.div>
)}
</AnimatePresence>
{/* Info Box */}
{!previewData && (
<div className="card p-5 bg-[var(--color-bg-surface)]">
<div className="flex items-start gap-3">
<Sparkles size={20} className="text-[var(--color-text-secondary)] shrink-0 mt-0.5" />
<div className="space-y-2">
<h3 className="text-sm font-semibold text-[var(--color-text-primary)]">
Nasıl Çalışır?
</h3>
<ol className="text-xs text-[var(--color-text-muted)] space-y-1.5 list-decimal list-inside">
<li>X/Twitter URL'sini yapıştırın ve "Önizle" butonuna tıklayın</li>
<li>Tweet içeriği otomatik olarak çekilir (thread desteği dahil)</li>
<li>Video stilini, süresini ve dilini seçin</li>
<li>AI otomatik olarak senaryo yazar ve görseller üretir</li>
<li>Video render edilir ve indirilmeye hazır hale gelir</li>
</ol>
</div>
</div>
</div>
)}
</div>
);
}
@@ -1,157 +0,0 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { cn } from "@/lib/utils";
import { useCreateFromYoutube } from "@/hooks/use-api";
import { useToast } from "@/components/ui/toast";
import {
PlaySquare,
Link2,
Loader2,
ArrowRight,
Sparkles,
Wand2,
} from "lucide-react";
import {
LanguageSelector,
StyleSelector,
DurationSelector,
AspectRatioSelector,
} from "@/components/projects/ProjectConfiguration";
export default function YoutubeToVideoPage() {
const router = useRouter();
const { toast } = useToast();
const createFromYoutube = useCreateFromYoutube();
const [youtubeUrl, setYoutubeUrl] = useState("");
const [style, setStyle] = useState("CINEMATIC");
const [cinematicReference, setCinematicReference] = useState("");
const [duration, setDuration] = useState(60);
const [aspectRatio, setAspectRatio] = useState("PORTRAIT_9_16");
const [language, setLanguage] = useState("tr");
const isValidUrl = /https?:\/\/(www\.)?(youtube\.com|youtu\.be)\/.+/.test(youtubeUrl);
const handleGenerate = async () => {
if (!isValidUrl) {
toast("error", "Lütfen geçerli bir YouTube URL'si girin.");
return;
}
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const result: any = await createFromYoutube.mutateAsync({
youtubeUrl,
language,
aspectRatio,
videoStyle: style,
cinematicReference: cinematicReference ? cinematicReference : undefined,
targetDuration: duration,
});
toast("success", "YouTube → Video projesi oluşturuldu!");
router.push(`/dashboard/projects/${result.id}`);
} catch {
toast("error", "Proje oluşturulurken bir hata oluştu.");
}
};
return (
<div className="max-w-3xl mx-auto space-y-8 pb-24">
{/* Header */}
<div className="text-center space-y-3 pb-4">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-3xl bg-[var(--color-bg-inverted)] text-[var(--color-text-inverted)] mb-2 ring-1 ring-[var(--color-bg-inverted)]/20 shadow-none">
<PlaySquare size={32} />
</div>
<h1 className="font-[family-name:var(--font-display)] text-3xl md:text-4xl font-bold tracking-tight text-[var(--color-text-primary)]">
YouTube'dan Video Oluştur
</h1>
<p className="text-[var(--color-text-muted)] text-sm max-w-lg mx-auto">
YouTube videolarını veya Shorts içeriklerini kendi tarzınızda yeniden üretin
</p>
</div>
{/* Main Form */}
<div className="card p-6 md:p-8 space-y-6">
{/* Input */}
<div>
<label className="text-sm font-medium text-[var(--color-text-secondary)] mb-2 block flex items-center gap-1.5">
<Link2 size={14} className="text-red-500" />
YouTube Video URL
</label>
<input
type="url"
value={youtubeUrl}
onChange={(e) => setYoutubeUrl(e.target.value)}
placeholder="https://youtube.com/watch?v=... veya https://youtu.be/..."
className="w-full bg-[var(--color-bg-surface)] border border-[var(--color-border-faint)] rounded-xl px-4 py-3.5 text-sm focus:outline-none focus:ring-2 focus:ring-[var(--color-bg-inverted)]/50 transition-all placeholder:text-[var(--color-text-ghost)]"
/>
</div>
{/* Configurations */}
<div className="space-y-8 pt-6 border-t border-[var(--color-border-faint)]">
<LanguageSelector value={language} onChange={setLanguage} />
<StyleSelector
value={style}
onChange={setStyle}
cinematicReference={cinematicReference}
onCinematicReferenceChange={setCinematicReference}
/>
<DurationSelector value={duration} onChange={setDuration} />
<AspectRatioSelector value={aspectRatio} onChange={setAspectRatio} />
</div>
</div>
{/* Action Button */}
<div className="flex justify-end">
<button
onClick={handleGenerate}
disabled={createFromYoutube.isPending || !isValidUrl}
className={cn(
"group relative overflow-hidden flex items-center gap-3 px-8 py-4 rounded-2xl font-bold text-[var(--color-text-inverted)] shadow-none transition-all",
"bg-[var(--color-bg-inverted)] hover:scale-[1.02]",
"disabled:opacity-50 disabled:hover:scale-100 disabled:cursor-not-allowed"
)}
>
{createFromYoutube.isPending ? (
<>
<Loader2 size={20} className="animate-spin" />
<span>Yapay Zeka Videoyu İşliyor...</span>
</>
) : (
<>
<Wand2 size={20} className="group-hover:rotate-12 transition-transform" />
<span>YouTube Video Üret</span>
<ArrowRight size={18} className="group-hover:translate-x-1 transition-transform" />
</>
)}
</button>
</div>
{/* Info Box */}
<div className="card p-5 bg-[var(--color-bg-surface)]">
<div className="flex items-start gap-3">
<Sparkles size={20} className="text-[var(--color-text-secondary)] shrink-0 mt-0.5" />
<div className="space-y-2">
<h3 className="text-sm font-semibold text-[var(--color-text-primary)]">
Nasıl Çalışır?
</h3>
<ol className="text-xs text-[var(--color-text-muted)] space-y-1.5 list-decimal list-inside">
<li>Uzun bir YouTube videosu veya Shorts URL'si yapıştırın</li>
<li>Video otomatik olarak indirilir ve deşifre edilir (transkript)</li>
<li>Belirttiğiniz süreye ve tarza göre yepyeni bir senaryo çıkarılır</li>
<li>Orijinal video kullanılmaz, referans olarak alınıp yeni görseller + ses üretilir</li>
</ol>
</div>
</div>
</div>
</div>
);
}
+32 -23
View File
@@ -14,7 +14,7 @@ import {
} from "recharts";
import { useDashboardStats } from "@/hooks/use-api";
const COLORS = ["#ffffff", "#a3a3a3", "#525252", "#262626"];
const COLORS = ["#06b6d4", "#8b5cf6", "#3b82f6", "#6366f1"];
function formatWeekData(stats: Record<string, unknown> | undefined) {
if (!stats) {
@@ -96,12 +96,12 @@ export function DashboardCharts() {
<AreaChart data={weekData}>
<defs>
<linearGradient id="colorProjects" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#ffffff" stopOpacity={0.2} />
<stop offset="95%" stopColor="#ffffff" stopOpacity={0} />
<stop offset="5%" stopColor="#06b6d4" stopOpacity={0.3} />
<stop offset="95%" stopColor="#06b6d4" stopOpacity={0} />
</linearGradient>
<linearGradient id="colorVideos" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#737373" stopOpacity={0.2} />
<stop offset="95%" stopColor="#737373" stopOpacity={0} />
<stop offset="5%" stopColor="#8b5cf6" stopOpacity={0.3} />
<stop offset="95%" stopColor="#8b5cf6" stopOpacity={0} />
</linearGradient>
</defs>
<XAxis
@@ -123,16 +123,16 @@ export function DashboardCharts() {
<Area
type="monotone"
dataKey="projects"
stroke="#ffffff"
strokeWidth={2}
stroke="#06b6d4"
strokeWidth={3}
fill="url(#colorProjects)"
name="Projeler"
/>
<Area
type="monotone"
dataKey="videos"
stroke="#737373"
strokeWidth={2}
stroke="#8b5cf6"
strokeWidth={3}
fill="url(#colorVideos)"
name="Videolar"
/>
@@ -141,27 +141,29 @@ export function DashboardCharts() {
</div>
{/* Proje Durumu */}
<div className="card-surface p-6 md:p-8">
<h3 className="text-base font-semibold text-[var(--color-text-secondary)] mb-6">
<div className="card-surface p-6 md:p-8 flex flex-col">
<h3 className="text-base font-semibold text-[var(--color-text-secondary)] mb-2">
Proje Durumu
</h3>
{pieData.length === 0 ? (
<div className="flex items-center justify-center h-[220px] text-sm text-[var(--color-text-ghost)]">
<div className="flex flex-1 items-center justify-center text-sm text-[var(--color-text-ghost)]">
Henüz proje verisi yok
</div>
) : (
<div className="flex items-center gap-6">
<ResponsiveContainer width="50%" height={220}>
<div className="flex flex-1 flex-row items-center justify-center gap-4 sm:gap-8 min-h-[220px]">
<div className="w-[160px] h-[160px] sm:w-[200px] sm:h-[200px]">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={pieData}
cx="50%"
cy="50%"
outerRadius={80}
innerRadius={50}
outerRadius="85%"
innerRadius="65%"
dataKey="value"
stroke="var(--color-bg-surface)"
strokeWidth={2}
strokeWidth={3}
paddingAngle={4}
>
{pieData.map((_: unknown, index: number) => (
<Cell key={index} fill={COLORS[index % COLORS.length]} />
@@ -174,24 +176,31 @@ export function DashboardCharts() {
borderRadius: 12,
fontSize: 13,
color: "#fff",
boxShadow: "0 4px 20px rgba(0,0,0,0.3)"
}}
itemStyle={{
color: "#e5e5e5"
}}
/>
</PieChart>
</ResponsiveContainer>
<div className="space-y-2">
</div>
<div className="flex flex-col justify-center space-y-3">
{pieData.map((item: { name: string; value: number }, idx: number) => (
<div key={item.name} className="flex items-center gap-2">
<div key={item.name} className="flex items-center gap-3">
<div
className="w-2.5 h-2.5 rounded-full"
style={{ backgroundColor: COLORS[idx % COLORS.length] }}
className="w-3 h-3 rounded-full shadow-sm"
style={{ backgroundColor: COLORS[idx % COLORS.length], boxShadow: `0 0 8px ${COLORS[idx % COLORS.length]}80` }}
/>
<span className="text-xs text-[var(--color-text-muted)]">
<div className="flex flex-col">
<span className="text-sm text-[var(--color-text-muted)] font-medium leading-none mb-1">
{item.name}
</span>
<span className="text-xs font-bold text-[var(--color-text-secondary)] ml-auto">
<span className="text-base font-bold text-[var(--color-text-primary)] leading-none">
{item.value}
</span>
</div>
</div>
))}
</div>
</div>
@@ -0,0 +1,171 @@
"use client";
import { useState, useCallback } from "react";
import { motion, AnimatePresence } from "framer-motion";
import {
Loader2,
CheckCircle2,
XCircle,
Link2,
Sparkles,
} from "lucide-react";
import { useCreateFromYoutube } from "@/hooks/use-api";
import { cn } from "@/lib/utils";
const YoutubeIcon = ({ size = 20, className = "" }: { size?: number; className?: string }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor" className={className}>
<path d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z" />
</svg>
);
interface YoutubeImportCardProps {
onProjectCreated?: (projectId: string) => void;
}
export function YoutubeImportCard({ onProjectCreated }: YoutubeImportCardProps) {
const [youtubeUrl, setYoutubeUrl] = useState("");
const [error, setError] = useState<string | null>(null);
const [createdProject, setCreatedProject] = useState<{ id: string; title: string } | null>(null);
const createFromYoutube = useCreateFromYoutube();
const isValidUrl = /https?:\/\/(www\.)?(youtube\.com|youtu\.be)\/.+/.test(youtubeUrl);
const handleCreate = useCallback(async () => {
if (!youtubeUrl.trim() || !isValidUrl) return;
setError(null);
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const project: any = await createFromYoutube.mutateAsync({
youtubeUrl: youtubeUrl.trim(),
language: "tr",
aspectRatio: "PORTRAIT_9_16",
videoStyle: "CINEMATIC",
targetDuration: 60,
});
setCreatedProject({ id: project.id, title: project.title || "YouTube Projesi" });
if (onProjectCreated) {
onProjectCreated(project.id);
}
} catch (err: any) {
setError(err?.response?.data?.message || "YouTube videosu işlenirken bir hata oluştu.");
}
}, [youtubeUrl, isValidUrl, createFromYoutube, onProjectCreated]);
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleCreate();
}
};
return (
<div className="card-surface overflow-hidden">
{/* Header */}
<div className="flex items-center gap-3 p-4 md:p-5 border-b border-[var(--color-border-faint)]">
<div className="w-9 h-9 rounded-xl bg-[var(--color-bg-elevated)] border border-[var(--color-border-faint)] flex items-center justify-center">
<YoutubeIcon size={18} className="text-red-500" />
</div>
<div>
<h3 className="font-[family-name:var(--font-display)] text-sm font-semibold">
YouTube Import
</h3>
<p className="text-[11px] text-[var(--color-text-ghost)]">
YouTube Video pipeline
</p>
</div>
</div>
{/* URL Input */}
<div className="p-4 md:p-5 space-y-4">
<div className="relative">
<div className="absolute left-3 top-1/2 -translate-y-1/2 text-[var(--color-text-ghost)]">
<Link2 size={16} />
</div>
<input
type="url"
value={youtubeUrl}
onChange={(e) => {
setYoutubeUrl(e.target.value);
setError(null);
}}
onKeyDown={handleKeyDown}
placeholder="https://youtube.com/watch?v=... veya youtu.be/..."
className="w-full h-11 pl-10 pr-28 rounded-xl bg-[var(--color-bg-elevated)] border border-[var(--color-border-faint)] text-sm text-[var(--color-text-primary)] placeholder:text-[var(--color-text-ghost)] focus:border-red-500/50 focus:ring-1 focus:ring-red-500/25 outline-none transition-all"
/>
<button
onClick={handleCreate}
disabled={!isValidUrl || createFromYoutube.isPending}
className={cn(
"absolute right-1.5 top-1/2 -translate-y-1/2 h-8 px-3 rounded-lg text-xs font-medium transition-all flex items-center gap-1.5",
isValidUrl && !createFromYoutube.isPending
? "bg-[var(--color-bg-inverted)] text-[var(--color-text-inverted)] hover:bg-neutral-800 shadow-sm"
: "bg-[var(--color-bg-surface)] text-[var(--color-text-ghost)] cursor-not-allowed"
)}
>
{createFromYoutube.isPending ? (
<>
<Loader2 size={13} className="animate-spin" />
Üretiliyor
</>
) : (
<>
<Sparkles size={13} />
Oluştur
</>
)}
</button>
</div>
{/* Error */}
<AnimatePresence mode="wait">
{error && (
<motion.div
initial={{ opacity: 0, y: -8, height: 0 }}
animate={{ opacity: 1, y: 0, height: "auto" }}
exit={{ opacity: 0, y: -8, height: 0 }}
className="flex items-center gap-2 text-xs text-rose-400 bg-rose-500/10 border border-rose-500/20 rounded-lg px-3 py-2 mt-2"
>
<XCircle size={14} />
{error}
</motion.div>
)}
</AnimatePresence>
{/* Success */}
<AnimatePresence>
{createdProject && (
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
className="flex items-center gap-3 p-3 rounded-xl bg-emerald-500/10 border border-emerald-500/25 mt-2"
>
<CheckCircle2 size={18} className="text-emerald-400 shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-emerald-300">
Proje oluşturuldu!
</p>
<p className="text-[11px] text-emerald-400/70 truncate">
{createdProject.title}
</p>
</div>
<button
onClick={() => {
if (onProjectCreated) onProjectCreated(createdProject.id);
}}
className="text-[11px] text-emerald-400 hover:text-emerald-300 font-medium transition-colors whitespace-nowrap"
>
Görüntüle
</button>
</motion.div>
)}
</AnimatePresence>
</div>
</div>
);
}
+3 -5
View File
@@ -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, Activity } from "lucide-react";
import { Home, FolderOpen, LayoutGrid, Settings, Sparkles, AtSign, ShieldCheck, LogOut, Link2, FileText, Activity, Wrench } from "lucide-react";
import Link from "next/link";
import { cn } from "@/lib/utils";
import { useCreditBalance, useCurrentUser } from "@/hooks/use-api";
@@ -11,13 +11,11 @@ import { signOut } from "next-auth/react";
const navItems = [
{ href: "/dashboard", icon: Home, label: "Ana Sayfa" },
{ href: "/dashboard/create-project", icon: Sparkles, label: "Yeni Proje Üret" },
{ href: "/dashboard/projects", icon: FolderOpen, label: "Projeler" },
{ href: "/dashboard/render-queue", icon: Activity, label: "Render Monitör" },
{ href: "/dashboard/text-to-video", icon: FileText, label: "Metin → Video" },
{ 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" },
{ href: "/dashboard/templates", icon: LayoutGrid, label: "Şablonlar" },
{ href: "/dashboard/tools", icon: Wrench, label: "Araçlar" },
{ href: "/dashboard/settings", icon: Settings, label: "Ayarlar" },
];
+67 -50
View File
@@ -74,23 +74,23 @@ export function SceneCard({
transition={{ delay: scene.order * 0.05, duration: 0.4 }}
className="relative group"
>
<div className="card-surface p-4 md:p-5 hover:border-neutral-500/20 transition-all duration-300">
<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-3">
<div className="flex items-center gap-2.5">
<div className="w-8 h-8 rounded-lg bg-[var(--color-bg-elevated)] flex items-center justify-center border border-[var(--color-border-faint)]">
<span className="text-xs font-bold text-neutral-400">{scene.order}</span>
<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-sm font-semibold text-[var(--color-text-primary)]">
<h4 className="text-base font-bold 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
<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-[10px] text-[var(--color-text-ghost)]">
<ArrowRight size={10} /> {scene.transitionType.toLowerCase()}
<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>
@@ -98,21 +98,21 @@ export function SceneCard({
{/* Aksiyon butonları */}
{!isEditing && (
<div className="flex items-center gap-1 opacity-100 md:opacity-0 md:group-hover:opacity-100 transition-opacity">
<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-7 h-7 rounded-lg flex items-center justify-center text-[var(--color-text-muted)] hover:text-neutral-300 hover:bg-neutral-800 transition-colors"
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={13} />
<Pencil size={14} />
</button>
<button
onClick={() => onRegenerate?.(scene.id)}
disabled={!isEditable || isRendering || isRegenerating}
className="w-7 h-7 rounded-lg flex items-center justify-center text-[var(--color-text-muted)] hover:text-neutral-300 hover:bg-neutral-800 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
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={13} className={isRegenerating ? 'animate-spin' : ''} />
<RefreshCw size={14} className={isRegenerating ? 'animate-spin' : ''} />
</button>
</div>
)}
@@ -125,76 +125,92 @@ export function SceneCard({
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
className="space-y-3"
className="space-y-4"
>
{/* 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 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-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-neutral-500/40 transition-all"
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-xs font-medium text-[var(--color-text-muted)] mb-1.5">
<ImageIcon size={12} /> Görsel Prompt
<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={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-neutral-500/40 transition-all"
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-2 pt-1">
<div className="flex items-center gap-3 pt-2">
<button
onClick={handleSave}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-[var(--color-bg-inverted)] text-[var(--color-text-inverted)] text-xs font-medium hover:bg-neutral-800 transition-colors"
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={13} /> Kaydet
<Check size={14} /> 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"
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={13} /> İptal
<X size={14} /> İptal
</button>
</div>
</motion.div>
) : (
<motion.div key="viewing" className="space-y-2.5">
<motion.div key="viewing" className="space-y-4">
{/* Narrasyon */}
<div className="flex gap-2">
<div className="w-5 h-5 rounded-md bg-[var(--color-bg-elevated)] flex items-center justify-center shrink-0 mt-0.5 border border-[var(--color-border-faint)]">
<Mic size={11} className="text-neutral-400" />
<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>
<p className="text-sm text-[var(--color-text-secondary)] leading-relaxed">
<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 */}
<div className="flex gap-2">
<div className="w-5 h-5 rounded-md bg-[var(--color-bg-elevated)] flex items-center justify-center shrink-0 mt-0.5 border border-[var(--color-border-faint)]">
<ImageIcon size={11} className="text-neutral-400" />
{/* 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">
<p className="text-xs text-[var(--color-text-ghost)] leading-relaxed italic pr-6">
<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-0 right-0 p-1 opacity-0 group-hover/prompt:opacity-100 transition-opacity bg-[var(--color-bg-elevated)] rounded-md text-[var(--color-text-muted)] hover:text-neutral-300 hover:bg-neutral-800"
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">
@@ -205,10 +221,10 @@ export function SceneCard({
</div>
</div>
{/* Görsel / Upscale Alanı */}
<div className="flex flex-col gap-2 pt-2">
{/* 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 max-w-sm">
<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"
@@ -220,7 +236,7 @@ export function SceneCard({
</div>
</div>
) : (
<div className="rounded-lg border border-dashed border-[var(--color-border-faint)] bg-[var(--color-bg-deep)] aspect-video max-w-sm flex flex-col items-center justify-center p-4 relative overflow-hidden">
<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">
@@ -241,21 +257,21 @@ export function SceneCard({
</div>
)}
{/* Görsel üretim butonları — tüm projelerde her zaman göster, render sürecinde disable et */}
<div className="flex items-center gap-2 mt-1">
{/* 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 items-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"
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 ? "Görseli Yeniden Üret" : "Görsel Üret"}
{thumbnailAsset ? "Yeniden Üret" : "Görsel Üret"}
</button>
{thumbnailAsset?.url && (
<button
onClick={() => onUpscaleImage?.(scene.id)}
disabled={isUpscalingImage || isGeneratingImage}
className="flex items-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"
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)
@@ -263,6 +279,7 @@ export function SceneCard({
)}
</div>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
@@ -304,25 +304,26 @@ export function DurationSelector({
<input
type="range"
min={15}
max={180}
max={900}
step={5}
value={value}
onChange={(e) => onChange(Number(e.target.value))}
className={cn(
"w-full h-1.5 rounded-full bg-[var(--color-bg-elevated)] appearance-none cursor-pointer",
"w-full h-2 rounded-full bg-neutral-200 dark:bg-neutral-700 border border-neutral-300 dark:border-neutral-600 appearance-none cursor-pointer outline-none",
"[&::-webkit-slider-thumb]:appearance-none",
"[&::-webkit-slider-thumb]:w-5 [&::-webkit-slider-thumb]:h-5",
"[&::-webkit-slider-thumb]:w-6 [&::-webkit-slider-thumb]:h-6",
"[&::-webkit-slider-thumb]:rounded-full",
"[&::-webkit-slider-thumb]:bg-[var(--color-bg-inverted)]",
"[&::-webkit-slider-thumb]:shadow-md",
"[&::-webkit-slider-thumb]:bg-cyan-400",
"[&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:border-white",
"[&::-webkit-slider-thumb]:shadow-[0_0_10px_rgba(34,211,238,0.6)]",
"[&::-webkit-slider-thumb]:cursor-grab"
)}
/>
<div className="flex justify-between text-[10px] text-[var(--color-text-ghost)] mt-1">
<div className="flex justify-between text-[10px] text-[var(--color-text-ghost)] mt-2">
<span>15s</span>
<span>60s</span>
<span>120s</span>
<span>180s</span>
<span>900s</span>
</div>
</div>
);
@@ -348,19 +349,19 @@ export function AspectRatioSelector({
key={ar.id}
onClick={() => onChange(ar.id)}
className={cn(
"flex-1 flex flex-col items-center gap-1.5 py-3 rounded-xl text-xs transition-all",
"flex-1 flex flex-col items-center gap-1.5 py-4 rounded-xl text-xs transition-all duration-300 relative overflow-hidden",
value === ar.id
? "bg-[var(--color-bg-inverted)] text-[var(--color-text-inverted)]"
? "bg-gradient-to-b from-cyan-500/10 to-transparent border border-cyan-400/50 shadow-[0_0_20px_rgba(34,211,238,0.15)] text-cyan-400"
: "bg-[var(--color-bg-surface)] border border-[var(--color-border-faint)] text-[var(--color-text-muted)] hover:border-[var(--color-border-default)]"
)}
>
<Icon size={20} />
<span className="font-semibold">{ar.label}</span>
<Icon size={24} className={value === ar.id ? "drop-shadow-[0_0_8px_rgba(34,211,238,0.5)]" : ""} />
<span className="font-semibold text-[13px]">{ar.label}</span>
<span
className={cn(
"text-[10px]",
value === ar.id
? "text-[var(--color-text-inverted)]/70"
? "text-cyan-400/70"
: "text-[var(--color-text-ghost)]"
)}
>
+21 -3
View File
@@ -160,9 +160,10 @@ export function useGenerateScript() {
export function useApproveAndQueue() {
const qc = useQueryClient();
return useMutation({
mutationFn: (projectId: string) => projectsApi.approveAndQueue(projectId),
onSuccess: (_data, projectId) => {
qc.invalidateQueries({ queryKey: queryKeys.projects.detail(projectId) });
mutationFn: ({ projectId, data }: { projectId: string; data?: { ttsProvider?: string; visualEffect?: string } }) =>
projectsApi.approveAndQueue(projectId, data),
onSuccess: (_data, variables) => {
qc.invalidateQueries({ queryKey: queryKeys.projects.detail(variables.projectId) });
qc.invalidateQueries({ queryKey: queryKeys.projects.all });
qc.invalidateQueries({ queryKey: queryKeys.credits.balance });
qc.invalidateQueries({ queryKey: queryKeys.dashboard.stats });
@@ -194,6 +195,23 @@ export function useGenerateSeoTitles() {
});
}
/** AI ile tüm SEO ve Sosyal Medya içeriklerini (caption, description vs) üret */
export function useGenerateSocialContent() {
const qc = useQueryClient();
return useMutation({
mutationFn: (projectId: string) =>
projectsApi.generateSocialContent(projectId),
onSuccess: (_data, projectId) => {
qc.invalidateQueries({ queryKey: queryKeys.projects.detail(projectId) });
toast.success({ title: 'SEO ve Sosyal Medya içerikleri başarıyla üretildi' });
},
onError: (error: any) => {
console.error('İçerik üretme hatası:', error);
toast.error({ title: 'Hata', description: error.response?.data?.message || 'İçerikler üretilemedi' });
},
});
}
/** Alternatif SEO başlıklarından birini seç ve proje başlığını güncelle */
export function useSelectSeoTitle() {
const qc = useQueryClient();
+24 -1
View File
@@ -370,9 +370,10 @@ export const projectsApi = {
generateScript: (id: string) =>
apiClient.post<Project>(`/projects/${id}/generate-script`).then((r) => r.data),
approveAndQueue: (id: string) =>
approveAndQueue: (id: string, data?: { ttsProvider?: string; visualEffect?: string }) =>
apiClient.post<{ projectId: string; renderJobId: string; bullJobId: string }>(
`/projects/${id}/approve`,
data || {}
).then((r) => r.data),
cancelRender: (id: string) =>
@@ -385,6 +386,9 @@ export const projectsApi = {
`/projects/${id}/generate-seo-titles`,
).then((r) => r.data),
generateSocialContent: (id: string) =>
apiClient.post<any>(`/projects/${id}/generate-social-content`).then((r) => r.data),
selectSeoTitle: (id: string, title: string) =>
apiClient.patch<Project>(
`/projects/${id}/select-title`,
@@ -445,6 +449,25 @@ export const projectsApi = {
apiClient.post<Scene>(`/projects/${projectId}/scenes/${sceneId}/upscale-image`).then((r) => r.data),
};
export const toolsApi = {
analyzeYoutubeVideo: (url: string) =>
apiClient.post<any>('/youtube-tools/analyze', { url }).then((r) => r.data),
getYoutubeAnalysisHistory: () =>
apiClient.get<any[]>('/youtube-tools/history').then((r) => r.data),
getYoutubeAnalysisById: (id: string) =>
apiClient.get<any>(`/youtube-tools/analyze/${id}`).then((r) => r.data),
// SEO
analyzeYoutubeSEO: (url: string) =>
apiClient.post<any>('/youtube-tools/seo/analyze', { url }).then((r) => r.data),
getYoutubeSeoHistory: () =>
apiClient.get<any[]>('/youtube-tools/seo/history').then((r) => r.data),
getYoutubeSeoAnalysisById: (id: string) =>
apiClient.get<any>(`/youtube-tools/seo/analyze/${id}`).then((r) => r.data),
generateYoutubeSeoImage: (prompt: string) =>
apiClient.post<{ url: string }>('/youtube-tools/seo/generate-image', { prompt }).then((r) => r.data),
};
// Backend path: /billing/credits/balance (billing controller prefix)
export const creditsApi = {
getBalance: () =>
+15 -3
View File
@@ -55,15 +55,27 @@ export function createApiClient(baseURL: string): AxiosInstance {
client.interceptors.response.use(
(response) => {
// Backend ResponseInterceptor tüm yanıtları { success, status, message, data } ile sarıyor.
// Frontend'in her yerde .data.data yazmasına gerek kalmadan otomatik unwrap yapıyoruz.
// GlobalExceptionFilter hataları da HTTP 200 ile dönüyor, bu yüzden success bayrağını kontrol etmeliyiz.
if (
response.data &&
typeof response.data === 'object' &&
'success' in response.data &&
'data' in response.data
'success' in response.data
) {
if (response.data.success === false) {
if (response.data.status === 401) {
const isAuthPath =
typeof window !== 'undefined' &&
(window.location.pathname.includes('/api/auth') || window.location.pathname === '/');
if (!isAuthPath && typeof window !== 'undefined') {
signOut({ redirect: true, callbackUrl: '/' });
}
}
return Promise.reject(new Error(response.data.message || 'Bir hata oluştu'));
}
if ('data' in response.data) {
response.data = response.data.data;
}
}
return response;
},
async (error) => {