generated from fahricansecer/boilerplate-fe
This commit is contained in:
+2655
File diff suppressed because it is too large
Load Diff
Vendored
+1
-1
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
Generated
+20
@@ -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 Aç</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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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" },
|
||||
];
|
||||
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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: () =>
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user