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" />
|
||||||
/// <reference types="next/image-types/global" />
|
/// <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
|
// NOTE: This file should not be edited
|
||||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
"@hookform/resolvers": "^5.2.2",
|
"@hookform/resolvers": "^5.2.2",
|
||||||
"@tailwindcss/postcss": "^4.2.2",
|
"@tailwindcss/postcss": "^4.2.2",
|
||||||
"@tanstack/react-query": "^5.90.16",
|
"@tanstack/react-query": "^5.90.16",
|
||||||
|
"@tanstack/react-virtual": "^3.13.24",
|
||||||
"autoprefixer": "^10.4.27",
|
"autoprefixer": "^10.4.27",
|
||||||
"axios": "^1.13.1",
|
"axios": "^1.13.1",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
|
|||||||
Generated
+20
@@ -26,6 +26,9 @@ importers:
|
|||||||
'@tanstack/react-query':
|
'@tanstack/react-query':
|
||||||
specifier: ^5.90.16
|
specifier: ^5.90.16
|
||||||
version: 5.95.2(react@19.2.0)
|
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:
|
autoprefixer:
|
||||||
specifier: ^10.4.27
|
specifier: ^10.4.27
|
||||||
version: 10.4.27(postcss@8.5.8)
|
version: 10.4.27(postcss@8.5.8)
|
||||||
@@ -1185,6 +1188,15 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
react: ^18 || ^19
|
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':
|
'@tybys/wasm-util@0.10.1':
|
||||||
resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==}
|
resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==}
|
||||||
|
|
||||||
@@ -4539,6 +4551,14 @@ snapshots:
|
|||||||
'@tanstack/query-core': 5.95.2
|
'@tanstack/query-core': 5.95.2
|
||||||
react: 19.2.0
|
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':
|
'@tybys/wasm-util@0.10.1':
|
||||||
dependencies:
|
dependencies:
|
||||||
tslib: 2.8.1
|
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 { DashboardCharts } from "@/components/dashboard/dashboard-charts";
|
||||||
import { RecentProjects } from "@/components/dashboard/recent-projects";
|
import { RecentProjects } from "@/components/dashboard/recent-projects";
|
||||||
import { TweetImportCard } from "@/components/dashboard/tweet-import-card";
|
import { TweetImportCard } from "@/components/dashboard/tweet-import-card";
|
||||||
|
import { YoutubeImportCard } from "@/components/dashboard/youtube-import-card";
|
||||||
import { useDashboardStats, useCreditBalance } from "@/hooks/use-api";
|
import { useDashboardStats, useCreditBalance } from "@/hooks/use-api";
|
||||||
|
|
||||||
const stagger = {
|
const stagger = {
|
||||||
@@ -139,7 +140,7 @@ export default function DashboardPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Link
|
<Link
|
||||||
href="/dashboard/projects/new"
|
href="/dashboard/create-project"
|
||||||
className="btn-primary flex items-center gap-2 text-sm"
|
className="btn-primary flex items-center gap-2 text-sm"
|
||||||
>
|
>
|
||||||
<Plus size={16} />
|
<Plus size={16} />
|
||||||
@@ -183,7 +184,7 @@ export default function DashboardPage() {
|
|||||||
{/* ── Hızlı Eylemler ── */}
|
{/* ── Hızlı Eylemler ── */}
|
||||||
<motion.div variants={fadeUp} className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
|
<motion.div variants={fadeUp} className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
|
||||||
<Link
|
<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"
|
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">
|
<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 ── */}
|
{/* ── Tweet Import + Grafikler ── */}
|
||||||
<motion.div variants={fadeUp} className="grid grid-cols-1 lg:grid-cols-5 gap-4">
|
<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
|
<TweetImportCard
|
||||||
onProjectCreated={(id) => {
|
onProjectCreated={(id) => {
|
||||||
window.location.href = `/dashboard/projects/${id}`;
|
window.location.href = `/dashboard/projects/${id}`;
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<YoutubeImportCard
|
||||||
|
onProjectCreated={(id) => {
|
||||||
|
window.location.href = `/dashboard/projects/${id}`;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="lg:col-span-3">
|
<div className="lg:col-span-3">
|
||||||
<DashboardCharts />
|
<DashboardCharts />
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||||
|
|
||||||
import { useParams, useRouter } from 'next/navigation';
|
import { useParams, useRouter } from 'next/navigation';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
@@ -26,7 +27,8 @@ import {
|
|||||||
Hash,
|
Hash,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
import {
|
import {
|
||||||
useProject,
|
useProject,
|
||||||
useGenerateScript,
|
useGenerateScript,
|
||||||
@@ -38,7 +40,8 @@ import {
|
|||||||
useRegenerateScene,
|
useRegenerateScene,
|
||||||
useCancelRender,
|
useCancelRender,
|
||||||
useGenerateSeoTitles,
|
useGenerateSeoTitles,
|
||||||
useSelectSeoTitle
|
useSelectSeoTitle,
|
||||||
|
useGenerateSocialContent
|
||||||
} from '@/hooks/use-api';
|
} from '@/hooks/use-api';
|
||||||
import { useRenderProgress } from '@/hooks/use-render-progress';
|
import { useRenderProgress } from '@/hooks/use-render-progress';
|
||||||
import { SceneCard } from '@/components/project/scene-card';
|
import { SceneCard } from '@/components/project/scene-card';
|
||||||
@@ -190,11 +193,20 @@ export default function ProjectDetailPage() {
|
|||||||
const deleteMutation = useDeleteProject();
|
const deleteMutation = useDeleteProject();
|
||||||
const cancelRenderMutation = useCancelRender();
|
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 generateImageMutation = useGenerateSceneImage();
|
||||||
const upscaleImageMutation = useUpscaleSceneImage();
|
const upscaleImageMutation = useUpscaleSceneImage();
|
||||||
const regenerateSceneMutation = useRegenerateScene();
|
const regenerateSceneMutation = useRegenerateScene();
|
||||||
const seoTitlesMutation = useGenerateSeoTitles();
|
const seoTitlesMutation = useGenerateSeoTitles();
|
||||||
const selectTitleMutation = useSelectSeoTitle();
|
const selectTitleMutation = useSelectSeoTitle();
|
||||||
|
const generateSocialMutation = useGenerateSocialContent();
|
||||||
|
|
||||||
const [generatingImageId, setGeneratingImageId] = useState<string | null>(null);
|
const [generatingImageId, setGeneratingImageId] = useState<string | null>(null);
|
||||||
const [upscalingImageId, setUpscalingImageId] = useState<string | null>(null);
|
const [upscalingImageId, setUpscalingImageId] = useState<string | null>(null);
|
||||||
@@ -295,7 +307,7 @@ export default function ProjectDetailPage() {
|
|||||||
|
|
||||||
// Onayla ve gönder
|
// Onayla ve gönder
|
||||||
const handleApprove = () => {
|
const handleApprove = () => {
|
||||||
approveMutation.mutate(id, {
|
approveMutation.mutate({ projectId: id }, {
|
||||||
onSuccess: () => refetch(),
|
onSuccess: () => refetch(),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -651,22 +663,49 @@ export default function ProjectDetailPage() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div
|
||||||
{project.scenes!.map((scene) => (
|
ref={parentRef}
|
||||||
<SceneCard
|
className="w-full h-[800px] overflow-auto pr-2 rounded-xl"
|
||||||
key={scene.id}
|
>
|
||||||
scene={scene}
|
<div
|
||||||
isEditable={isEditable}
|
style={{
|
||||||
isRendering={isRendering}
|
height: `${rowVirtualizer.getTotalSize()}px`,
|
||||||
onUpdate={handleSceneUpdate}
|
width: '100%',
|
||||||
onRegenerate={handleSceneRegenerate}
|
position: 'relative',
|
||||||
onGenerateImage={handleGenerateImage}
|
}}
|
||||||
onUpscaleImage={handleUpscaleImage}
|
>
|
||||||
isRegenerating={regeneratingSceneId === scene.id}
|
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
|
||||||
isGeneratingImage={generatingImageId === scene.id}
|
const scene = project.scenes![virtualRow.index];
|
||||||
isUpscalingImage={upscalingImageId === scene.id}
|
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
|
||||||
|
scene={scene}
|
||||||
|
isEditable={isEditable}
|
||||||
|
isRendering={isRendering}
|
||||||
|
onUpdate={handleSceneUpdate}
|
||||||
|
onRegenerate={handleSceneRegenerate}
|
||||||
|
onGenerateImage={handleGenerateImage}
|
||||||
|
onUpscaleImage={handleUpscaleImage}
|
||||||
|
isRegenerating={regeneratingSceneId === scene.id}
|
||||||
|
isGeneratingImage={generatingImageId === scene.id}
|
||||||
|
isUpscalingImage={upscalingImageId === scene.id}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</motion.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)]">
|
<div className="card-surface p-5 rounded-2xl border border-[var(--color-border-subtle)]">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between mb-5">
|
<div className="flex items-center justify-between mb-5">
|
||||||
<h2 className="text-sm font-semibold text-[var(--color-text-primary)] flex items-center gap-2">
|
<div className="flex items-center gap-4">
|
||||||
<TrendingUp size={15} className="text-emerald-400" />
|
<h2 className="text-sm font-semibold text-[var(--color-text-primary)] flex items-center gap-2">
|
||||||
SEO & Sosyal Medya
|
<TrendingUp size={15} className="text-emerald-400" />
|
||||||
</h2>
|
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 && (
|
{project.seoScore != null && (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="relative w-10 h-10">
|
<div className="relative w-10 h-10">
|
||||||
@@ -1040,91 +1094,96 @@ export default function ProjectDetailPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ─── Çeviri Modal ─── */}
|
{/* ─── Çeviri Modal ─── */}
|
||||||
<AnimatePresence>
|
{mounted && createPortal(
|
||||||
{showTranslateModal && (
|
<div className="portal-container">
|
||||||
<motion.div
|
<AnimatePresence>
|
||||||
initial={{ opacity: 0 }}
|
{showTranslateModal && (
|
||||||
animate={{ opacity: 1 }}
|
<motion.div
|
||||||
exit={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
transition={{ duration: 0.15 }}
|
animate={{ opacity: 1 }}
|
||||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
|
exit={{ opacity: 0 }}
|
||||||
onClick={() => !isTranslating && setShowTranslateModal(false)}
|
transition={{ duration: 0.15 }}
|
||||||
>
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
|
||||||
<motion.div
|
onClick={() => !isTranslating && setShowTranslateModal(false)}
|
||||||
initial={{ opacity: 0, scale: 0.95, y: 10 }}
|
|
||||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
|
||||||
exit={{ opacity: 0, scale: 0.95, y: 10 }}
|
|
||||||
transition={{ duration: 0.2, ease: [0.16, 1, 0.3, 1] }}
|
|
||||||
className="relative bg-[var(--color-bg-elevated)] border border-[var(--color-border-default)] rounded-2xl p-6 max-w-sm w-full mx-4 shadow-2xl"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
{/* Kapatma butonu */}
|
|
||||||
<button
|
|
||||||
onClick={() => setShowTranslateModal(false)}
|
|
||||||
disabled={isTranslating}
|
|
||||||
className="absolute top-3 right-3 p-1 rounded-lg text-[var(--color-text-ghost)] hover:text-[var(--color-text-secondary)] hover:bg-[var(--color-bg-surface)] transition-colors disabled:opacity-50"
|
|
||||||
>
|
>
|
||||||
<X size={16} />
|
<motion.div
|
||||||
</button>
|
initial={{ opacity: 0, scale: 0.95, y: 10 }}
|
||||||
|
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||||
{/* Icon */}
|
exit={{ opacity: 0, scale: 0.95, y: 10 }}
|
||||||
<div className="flex items-center justify-center w-12 h-12 rounded-full bg-blue-500/10 mb-4">
|
transition={{ duration: 0.2, ease: [0.16, 1, 0.3, 1] }}
|
||||||
<Languages size={22} className="text-blue-400" />
|
className="relative bg-[var(--color-bg-elevated)] border border-[var(--color-border-default)] rounded-2xl p-6 max-w-sm w-full mx-4 shadow-2xl"
|
||||||
</div>
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
|
||||||
{/* İçerik */}
|
|
||||||
<h3 className="text-base font-semibold text-[var(--color-text-primary)] mb-1.5">
|
|
||||||
Projeyi Çevir
|
|
||||||
</h3>
|
|
||||||
<p className="text-sm text-[var(--color-text-muted)] mb-3">
|
|
||||||
"{project?.title}" projesini başka bir dile çevirin. (1 kredi)
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<select
|
|
||||||
value={targetLanguage}
|
|
||||||
onChange={(e) => setTargetLanguage(e.target.value)}
|
|
||||||
disabled={isTranslating}
|
|
||||||
className="w-full bg-[var(--color-bg-surface)] border border-[var(--color-border-default)] rounded-xl px-4 py-2.5 text-sm text-[var(--color-text-primary)] mb-5 outline-none focus:border-blue-500"
|
|
||||||
>
|
|
||||||
<option value="">Dil Seçin...</option>
|
|
||||||
{languages.map((lang) => (
|
|
||||||
<option key={lang.code} value={lang.code}>
|
|
||||||
{lang.flag} {lang.label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
|
|
||||||
{/* Butonlar */}
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<button
|
|
||||||
onClick={() => setShowTranslateModal(false)}
|
|
||||||
disabled={isTranslating}
|
|
||||||
className="flex-1 px-4 py-2.5 rounded-xl border border-[var(--color-border-default)] text-sm font-medium text-[var(--color-text-secondary)] hover:bg-[var(--color-bg-surface)] transition-colors disabled:opacity-50"
|
|
||||||
>
|
>
|
||||||
İptal
|
{/* Kapatma butonu */}
|
||||||
</button>
|
<button
|
||||||
<button
|
onClick={() => setShowTranslateModal(false)}
|
||||||
onClick={confirmTranslate}
|
disabled={isTranslating}
|
||||||
disabled={isTranslating || !targetLanguage}
|
className="absolute top-3 right-3 p-1 rounded-lg text-[var(--color-text-ghost)] hover:text-[var(--color-text-secondary)] hover:bg-[var(--color-bg-surface)] transition-colors disabled:opacity-50"
|
||||||
className="flex-1 flex items-center justify-center gap-2 px-4 py-2.5 rounded-xl bg-blue-500 hover:bg-blue-600 text-white text-sm font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
>
|
||||||
>
|
<X size={16} />
|
||||||
{isTranslating ? (
|
</button>
|
||||||
<>
|
|
||||||
<Loader2 size={14} className="animate-spin" />
|
{/* Icon */}
|
||||||
Çevriliyor...
|
<div className="flex items-center justify-center w-12 h-12 rounded-full bg-blue-500/10 mb-4">
|
||||||
</>
|
<Languages size={22} className="text-blue-400" />
|
||||||
) : (
|
</div>
|
||||||
<>
|
|
||||||
<Languages size={14} />
|
{/* İçerik */}
|
||||||
Çevir (1 🪙)
|
<h3 className="text-base font-semibold text-[var(--color-text-primary)] mb-1.5">
|
||||||
</>
|
Projeyi Çevir
|
||||||
)}
|
</h3>
|
||||||
</button>
|
<p className="text-sm text-[var(--color-text-muted)] mb-3">
|
||||||
</div>
|
"{project?.title}" projesini başka bir dile çevirin. (1 kredi)
|
||||||
</motion.div>
|
</p>
|
||||||
</motion.div>
|
|
||||||
)}
|
<select
|
||||||
</AnimatePresence>
|
value={targetLanguage}
|
||||||
|
onChange={(e) => setTargetLanguage(e.target.value)}
|
||||||
|
disabled={isTranslating}
|
||||||
|
className="w-full bg-[var(--color-bg-surface)] border border-[var(--color-border-default)] rounded-xl px-4 py-2.5 text-sm text-[var(--color-text-primary)] mb-5 outline-none focus:border-blue-500"
|
||||||
|
>
|
||||||
|
<option value="">Dil Seçin...</option>
|
||||||
|
{languages.map((lang) => (
|
||||||
|
<option key={lang.code} value={lang.code}>
|
||||||
|
{lang.flag} {lang.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{/* Butonlar */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowTranslateModal(false)}
|
||||||
|
disabled={isTranslating}
|
||||||
|
className="flex-1 px-4 py-2.5 rounded-xl border border-[var(--color-border-default)] text-sm font-medium text-[var(--color-text-secondary)] hover:bg-[var(--color-bg-surface)] transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
İptal
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={confirmTranslate}
|
||||||
|
disabled={isTranslating || !targetLanguage}
|
||||||
|
className="flex-1 flex items-center justify-center gap-2 px-4 py-2.5 rounded-xl bg-blue-500 hover:bg-blue-600 text-white text-sm font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{isTranslating ? (
|
||||||
|
<>
|
||||||
|
<Loader2 size={14} className="animate-spin" />
|
||||||
|
Çevriliyor...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Languages size={14} />
|
||||||
|
Çevir (1 🪙)
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
)}
|
||||||
</motion.div>
|
</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";
|
"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 { motion, AnimatePresence } from "framer-motion";
|
||||||
import {
|
import {
|
||||||
Plus,
|
Plus,
|
||||||
@@ -98,6 +99,11 @@ export default function ProjectsPage() {
|
|||||||
const [translateTarget, setTranslateTarget] = useState<ProjectItem | null>(null);
|
const [translateTarget, setTranslateTarget] = useState<ProjectItem | null>(null);
|
||||||
const [targetLanguage, setTargetLanguage] = useState<string>("");
|
const [targetLanguage, setTargetLanguage] = useState<string>("");
|
||||||
const [isTranslating, setIsTranslating] = useState(false);
|
const [isTranslating, setIsTranslating] = useState(false);
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMounted(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const { data, isLoading, refetch } = useProjects({ limit: 100 });
|
const { data, isLoading, refetch } = useProjects({ limit: 100 });
|
||||||
const deleteMutation = useDeleteProject();
|
const deleteMutation = useDeleteProject();
|
||||||
@@ -396,8 +402,10 @@ export default function ProjectsPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ─── Silme Onay Modal ─── */}
|
{/* ─── Silme Onay Modal ─── */}
|
||||||
<AnimatePresence>
|
{mounted && createPortal(
|
||||||
{deleteTarget && (
|
<div className="portal-container">
|
||||||
|
<AnimatePresence>
|
||||||
|
{deleteTarget && (
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
@@ -469,11 +477,11 @@ export default function ProjectsPage() {
|
|||||||
</motion.div>
|
</motion.div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
|
||||||
{/* ─── Çeviri Modal ─── */}
|
{/* ─── Çeviri Modal ─── */}
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{translateTarget && (
|
{translateTarget && (
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
@@ -556,7 +564,10 @@ export default function ProjectsPage() {
|
|||||||
</motion.div>
|
</motion.div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
)}
|
||||||
</div>
|
</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";
|
} from "recharts";
|
||||||
import { useDashboardStats } from "@/hooks/use-api";
|
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) {
|
function formatWeekData(stats: Record<string, unknown> | undefined) {
|
||||||
if (!stats) {
|
if (!stats) {
|
||||||
@@ -96,12 +96,12 @@ export function DashboardCharts() {
|
|||||||
<AreaChart data={weekData}>
|
<AreaChart data={weekData}>
|
||||||
<defs>
|
<defs>
|
||||||
<linearGradient id="colorProjects" x1="0" y1="0" x2="0" y2="1">
|
<linearGradient id="colorProjects" x1="0" y1="0" x2="0" y2="1">
|
||||||
<stop offset="5%" stopColor="#ffffff" stopOpacity={0.2} />
|
<stop offset="5%" stopColor="#06b6d4" stopOpacity={0.3} />
|
||||||
<stop offset="95%" stopColor="#ffffff" stopOpacity={0} />
|
<stop offset="95%" stopColor="#06b6d4" stopOpacity={0} />
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
<linearGradient id="colorVideos" x1="0" y1="0" x2="0" y2="1">
|
<linearGradient id="colorVideos" x1="0" y1="0" x2="0" y2="1">
|
||||||
<stop offset="5%" stopColor="#737373" stopOpacity={0.2} />
|
<stop offset="5%" stopColor="#8b5cf6" stopOpacity={0.3} />
|
||||||
<stop offset="95%" stopColor="#737373" stopOpacity={0} />
|
<stop offset="95%" stopColor="#8b5cf6" stopOpacity={0} />
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
</defs>
|
</defs>
|
||||||
<XAxis
|
<XAxis
|
||||||
@@ -123,16 +123,16 @@ export function DashboardCharts() {
|
|||||||
<Area
|
<Area
|
||||||
type="monotone"
|
type="monotone"
|
||||||
dataKey="projects"
|
dataKey="projects"
|
||||||
stroke="#ffffff"
|
stroke="#06b6d4"
|
||||||
strokeWidth={2}
|
strokeWidth={3}
|
||||||
fill="url(#colorProjects)"
|
fill="url(#colorProjects)"
|
||||||
name="Projeler"
|
name="Projeler"
|
||||||
/>
|
/>
|
||||||
<Area
|
<Area
|
||||||
type="monotone"
|
type="monotone"
|
||||||
dataKey="videos"
|
dataKey="videos"
|
||||||
stroke="#737373"
|
stroke="#8b5cf6"
|
||||||
strokeWidth={2}
|
strokeWidth={3}
|
||||||
fill="url(#colorVideos)"
|
fill="url(#colorVideos)"
|
||||||
name="Videolar"
|
name="Videolar"
|
||||||
/>
|
/>
|
||||||
@@ -141,56 +141,65 @@ export function DashboardCharts() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Proje Durumu */}
|
{/* Proje Durumu */}
|
||||||
<div className="card-surface p-6 md:p-8">
|
<div className="card-surface p-6 md:p-8 flex flex-col">
|
||||||
<h3 className="text-base font-semibold text-[var(--color-text-secondary)] mb-6">
|
<h3 className="text-base font-semibold text-[var(--color-text-secondary)] mb-2">
|
||||||
Proje Durumu
|
Proje Durumu
|
||||||
</h3>
|
</h3>
|
||||||
{pieData.length === 0 ? (
|
{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
|
Henüz proje verisi yok
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-center gap-6">
|
<div className="flex flex-1 flex-row items-center justify-center gap-4 sm:gap-8 min-h-[220px]">
|
||||||
<ResponsiveContainer width="50%" height={220}>
|
<div className="w-[160px] h-[160px] sm:w-[200px] sm:h-[200px]">
|
||||||
<PieChart>
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<Pie
|
<PieChart>
|
||||||
data={pieData}
|
<Pie
|
||||||
cx="50%"
|
data={pieData}
|
||||||
cy="50%"
|
cx="50%"
|
||||||
outerRadius={80}
|
cy="50%"
|
||||||
innerRadius={50}
|
outerRadius="85%"
|
||||||
dataKey="value"
|
innerRadius="65%"
|
||||||
stroke="var(--color-bg-surface)"
|
dataKey="value"
|
||||||
strokeWidth={2}
|
stroke="var(--color-bg-surface)"
|
||||||
>
|
strokeWidth={3}
|
||||||
{pieData.map((_: unknown, index: number) => (
|
paddingAngle={4}
|
||||||
<Cell key={index} fill={COLORS[index % COLORS.length]} />
|
>
|
||||||
))}
|
{pieData.map((_: unknown, index: number) => (
|
||||||
</Pie>
|
<Cell key={index} fill={COLORS[index % COLORS.length]} />
|
||||||
<Tooltip
|
))}
|
||||||
contentStyle={{
|
</Pie>
|
||||||
backgroundColor: "rgba(10,10,10,0.95)",
|
<Tooltip
|
||||||
border: "1px solid rgba(255,255,255,0.1)",
|
contentStyle={{
|
||||||
borderRadius: 12,
|
backgroundColor: "rgba(10,10,10,0.95)",
|
||||||
fontSize: 13,
|
border: "1px solid rgba(255,255,255,0.1)",
|
||||||
color: "#fff",
|
borderRadius: 12,
|
||||||
}}
|
fontSize: 13,
|
||||||
/>
|
color: "#fff",
|
||||||
</PieChart>
|
boxShadow: "0 4px 20px rgba(0,0,0,0.3)"
|
||||||
</ResponsiveContainer>
|
}}
|
||||||
<div className="space-y-2">
|
itemStyle={{
|
||||||
{pieData.map((item: { name: string; value: number }, idx: number) => (
|
color: "#e5e5e5"
|
||||||
<div key={item.name} className="flex items-center gap-2">
|
}}
|
||||||
<div
|
|
||||||
className="w-2.5 h-2.5 rounded-full"
|
|
||||||
style={{ backgroundColor: COLORS[idx % COLORS.length] }}
|
|
||||||
/>
|
/>
|
||||||
<span className="text-xs text-[var(--color-text-muted)]">
|
</PieChart>
|
||||||
{item.name}
|
</ResponsiveContainer>
|
||||||
</span>
|
</div>
|
||||||
<span className="text-xs font-bold text-[var(--color-text-secondary)] ml-auto">
|
<div className="flex flex-col justify-center space-y-3">
|
||||||
{item.value}
|
{pieData.map((item: { name: string; value: number }, idx: number) => (
|
||||||
</span>
|
<div key={item.name} className="flex items-center gap-3">
|
||||||
|
<div
|
||||||
|
className="w-3 h-3 rounded-full shadow-sm"
|
||||||
|
style={{ backgroundColor: COLORS[idx % COLORS.length], boxShadow: `0 0 8px ${COLORS[idx % COLORS.length]}80` }}
|
||||||
|
/>
|
||||||
|
<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-base font-bold text-[var(--color-text-primary)] leading-none">
|
||||||
|
{item.value}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</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 { usePathname } from "next/navigation";
|
||||||
import { motion, AnimatePresence } from "framer-motion";
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
import { Home, FolderOpen, LayoutGrid, Settings, Sparkles, AtSign, ShieldCheck, LogOut, Link2, FileText, 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 Link from "next/link";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { useCreditBalance, useCurrentUser } from "@/hooks/use-api";
|
import { useCreditBalance, useCurrentUser } from "@/hooks/use-api";
|
||||||
@@ -11,13 +11,11 @@ import { signOut } from "next-auth/react";
|
|||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ href: "/dashboard", icon: Home, label: "Ana Sayfa" },
|
{ href: "/dashboard", icon: Home, label: "Ana Sayfa" },
|
||||||
|
{ href: "/dashboard/create-project", icon: Sparkles, label: "Yeni Proje Üret" },
|
||||||
{ href: "/dashboard/projects", icon: FolderOpen, label: "Projeler" },
|
{ href: "/dashboard/projects", icon: FolderOpen, label: "Projeler" },
|
||||||
{ href: "/dashboard/render-queue", icon: Activity, label: "Render Monitör" },
|
{ href: "/dashboard/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/templates", icon: LayoutGrid, label: "Şablonlar" },
|
||||||
|
{ href: "/dashboard/tools", icon: Wrench, label: "Araçlar" },
|
||||||
{ href: "/dashboard/settings", icon: Settings, label: "Ayarlar" },
|
{ href: "/dashboard/settings", icon: Settings, label: "Ayarlar" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -74,23 +74,23 @@ export function SceneCard({
|
|||||||
transition={{ delay: scene.order * 0.05, duration: 0.4 }}
|
transition={{ delay: scene.order * 0.05, duration: 0.4 }}
|
||||||
className="relative group"
|
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 */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<div className="flex items-center gap-2.5">
|
<div className="flex items-center gap-3">
|
||||||
<div className="w-8 h-8 rounded-lg bg-[var(--color-bg-elevated)] flex items-center justify-center border border-[var(--color-border-faint)]">
|
<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-xs font-bold text-neutral-400">{scene.order}</span>
|
<span className="text-sm font-bold text-[var(--color-text-primary)]">{scene.order}</span>
|
||||||
</div>
|
</div>
|
||||||
<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}`}
|
{scene.title || `Sahne ${scene.order}`}
|
||||||
</h4>
|
</h4>
|
||||||
<div className="flex items-center gap-2 mt-0.5">
|
<div className="flex items-center gap-3 mt-1">
|
||||||
<span className="flex items-center gap-1 text-[10px] text-[var(--color-text-ghost)]">
|
<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={10} /> {scene.duration}s
|
<Clock size={12} className="text-violet-400" /> {scene.duration}s
|
||||||
</span>
|
</span>
|
||||||
<span className="flex items-center gap-1 text-[10px] text-[var(--color-text-ghost)]">
|
<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={10} /> {scene.transitionType.toLowerCase()}
|
<ArrowRight size={12} className="text-cyan-400" /> {scene.transitionType.toLowerCase()}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -98,21 +98,21 @@ export function SceneCard({
|
|||||||
|
|
||||||
{/* Aksiyon butonları */}
|
{/* Aksiyon butonları */}
|
||||||
{!isEditing && (
|
{!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
|
<button
|
||||||
onClick={() => setIsEditing(true)}
|
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"
|
title="Düzenle"
|
||||||
>
|
>
|
||||||
<Pencil size={13} />
|
<Pencil size={14} />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => onRegenerate?.(scene.id)}
|
onClick={() => onRegenerate?.(scene.id)}
|
||||||
disabled={!isEditable || isRendering || isRegenerating}
|
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"
|
title="AI ile yeniden üret"
|
||||||
>
|
>
|
||||||
<RefreshCw size={13} className={isRegenerating ? 'animate-spin' : ''} />
|
<RefreshCw size={14} className={isRegenerating ? 'animate-spin' : ''} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -125,77 +125,67 @@ export function SceneCard({
|
|||||||
initial={{ opacity: 0, height: 0 }}
|
initial={{ opacity: 0, height: 0 }}
|
||||||
animate={{ opacity: 1, height: 'auto' }}
|
animate={{ opacity: 1, height: 'auto' }}
|
||||||
exit={{ opacity: 0, height: 0 }}
|
exit={{ opacity: 0, height: 0 }}
|
||||||
className="space-y-3"
|
className="space-y-4"
|
||||||
>
|
>
|
||||||
{/* Narrasyon düzenleme */}
|
{/* Narrasyon düzenleme */}
|
||||||
<div>
|
<div>
|
||||||
<label className="flex items-center gap-1.5 text-xs font-medium text-[var(--color-text-muted)] mb-1.5">
|
<label className="flex items-center gap-1.5 text-sm font-medium text-violet-400 mb-2">
|
||||||
<Mic size={12} /> Narrasyon
|
<Mic size={14} /> Narrasyon
|
||||||
</label>
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
value={editNarration}
|
value={editNarration}
|
||||||
onChange={(e) => setEditNarration(e.target.value)}
|
onChange={(e) => setEditNarration(e.target.value)}
|
||||||
rows={3}
|
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>
|
</div>
|
||||||
|
|
||||||
{/* Görsel prompt düzenleme */}
|
{/* Görsel prompt düzenleme */}
|
||||||
<div>
|
<div>
|
||||||
<label className="flex items-center gap-1.5 text-xs font-medium text-[var(--color-text-muted)] mb-1.5">
|
<label className="flex items-center gap-1.5 text-sm font-medium text-cyan-400 mb-2">
|
||||||
<ImageIcon size={12} /> Görsel Prompt
|
<ImageIcon size={14} /> Görsel Prompt
|
||||||
</label>
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
value={editVisual}
|
value={editVisual}
|
||||||
onChange={(e) => setEditVisual(e.target.value)}
|
onChange={(e) => setEditVisual(e.target.value)}
|
||||||
rows={2}
|
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-secondary)] 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-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>
|
</div>
|
||||||
|
|
||||||
{/* Kaydet/İptal */}
|
{/* Kaydet/İptal */}
|
||||||
<div className="flex items-center gap-2 pt-1">
|
<div className="flex items-center gap-3 pt-2">
|
||||||
<button
|
<button
|
||||||
onClick={handleSave}
|
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>
|
||||||
<button
|
<button
|
||||||
onClick={handleCancel}
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
) : (
|
) : (
|
||||||
<motion.div key="viewing" className="space-y-2.5">
|
<motion.div key="viewing" className="space-y-4">
|
||||||
{/* Narrasyon */}
|
{/* Narrasyon */}
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-3">
|
||||||
<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)]">
|
<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={11} className="text-neutral-400" />
|
<Mic size={14} className="text-violet-400" />
|
||||||
</div>
|
</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">
|
||||||
{scene.narrationText}
|
<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">
|
||||||
</p>
|
{scene.narrationText}
|
||||||
</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" />
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 group/prompt relative">
|
|
||||||
<p className="text-xs text-[var(--color-text-ghost)] leading-relaxed italic pr-6">
|
|
||||||
{scene.visualPrompt}
|
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
navigator.clipboard.writeText(scene.visualPrompt);
|
navigator.clipboard.writeText(scene.narrationText);
|
||||||
}}
|
}}
|
||||||
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/narration:opacity-100 transition-opacity bg-violet-500/20 rounded-md text-violet-300 hover:text-violet-100 hover:bg-violet-500/40"
|
||||||
title="Prompt'u Kopyala"
|
title="Metni Kopyala"
|
||||||
>
|
>
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
<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" />
|
<rect width="14" height="14" x="8" y="8" rx="2" ry="2" />
|
||||||
@@ -205,62 +195,89 @@ export function SceneCard({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Görsel / Upscale Alanı */}
|
{/* Görsel Prompt ve Görsel Alanı */}
|
||||||
<div className="flex flex-col gap-2 pt-2">
|
<div className="flex flex-col md:flex-row gap-4 pt-2">
|
||||||
{thumbnailAsset?.url && !isGeneratingImage ? (
|
{/* Sol: Prompt */}
|
||||||
<div className="relative group/thumb rounded-lg overflow-hidden border border-[var(--color-border-faint)] aspect-video max-w-sm">
|
<div className="flex gap-3 flex-1">
|
||||||
<img
|
<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">
|
||||||
src={thumbnailAsset.url}
|
<ImageIcon size={14} className="text-cyan-400" />
|
||||||
alt="Scene Thumbnail"
|
|
||||||
className="w-full h-full object-cover cursor-pointer hover:scale-105 transition-transform duration-500"
|
|
||||||
onClick={() => setLightboxOpen(true)}
|
|
||||||
/>
|
|
||||||
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover/thumb:opacity-100 transition-opacity flex items-center justify-center pointer-events-none">
|
|
||||||
<Maximize2 size={24} className="text-white" />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
<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">
|
||||||
<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">
|
<p className="text-sm font-medium text-cyan-50 leading-relaxed pr-8">
|
||||||
{isGeneratingImage ? (
|
{scene.visualPrompt}
|
||||||
<div className="flex flex-col items-center justify-center animate-in fade-in zoom-in duration-300">
|
</p>
|
||||||
<div className="relative w-12 h-12 mb-3">
|
<button
|
||||||
<div className="absolute inset-0 rounded-full border-2 border-emerald-500/20"></div>
|
onClick={() => {
|
||||||
<div className="absolute inset-0 rounded-full border-2 border-emerald-500 border-t-transparent animate-spin"></div>
|
navigator.clipboard.writeText(scene.visualPrompt);
|
||||||
<Sparkles size={16} className="absolute inset-0 m-auto text-emerald-400 animate-pulse" />
|
}}
|
||||||
</div>
|
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"
|
||||||
<p className="text-xs font-medium text-emerald-400 text-center animate-pulse">
|
title="Prompt'u Kopyala"
|
||||||
AI Görsel Üretiyor...
|
>
|
||||||
</p>
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<rect width="14" height="14" x="8" y="8" rx="2" ry="2" />
|
||||||
|
<path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sağ: Görsel Önizleme ve Butonlar */}
|
||||||
|
<div className="flex flex-col gap-2 w-full md:w-64 shrink-0">
|
||||||
|
{thumbnailAsset?.url && !isGeneratingImage ? (
|
||||||
|
<div className="relative group/thumb rounded-lg overflow-hidden border border-[var(--color-border-faint)] aspect-video w-full">
|
||||||
|
<img
|
||||||
|
src={thumbnailAsset.url}
|
||||||
|
alt="Scene Thumbnail"
|
||||||
|
className="w-full h-full object-cover cursor-pointer hover:scale-105 transition-transform duration-500"
|
||||||
|
onClick={() => setLightboxOpen(true)}
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover/thumb:opacity-100 transition-opacity flex items-center justify-center pointer-events-none">
|
||||||
|
<Maximize2 size={24} className="text-white" />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
</div>
|
||||||
<>
|
) : (
|
||||||
<ImageIcon size={24} className="text-[var(--color-text-ghost)] mb-2" />
|
<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">
|
||||||
<p className="text-xs text-[var(--color-text-muted)] text-center">Görsel Henüz Üretilmedi</p>
|
{isGeneratingImage ? (
|
||||||
</>
|
<div className="flex flex-col items-center justify-center animate-in fade-in zoom-in duration-300">
|
||||||
|
<div className="relative w-12 h-12 mb-3">
|
||||||
|
<div className="absolute inset-0 rounded-full border-2 border-emerald-500/20"></div>
|
||||||
|
<div className="absolute inset-0 rounded-full border-2 border-emerald-500 border-t-transparent animate-spin"></div>
|
||||||
|
<Sparkles size={16} className="absolute inset-0 m-auto text-emerald-400 animate-pulse" />
|
||||||
|
</div>
|
||||||
|
<p className="text-xs font-medium text-emerald-400 text-center animate-pulse">
|
||||||
|
AI Görsel Üretiyor...
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<ImageIcon size={24} className="text-[var(--color-text-ghost)] mb-2" />
|
||||||
|
<p className="text-xs text-[var(--color-text-muted)] text-center">Görsel Henüz Üretilmedi</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Görsel üretim butonları */}
|
||||||
|
<div className="flex items-center gap-2 mt-1 flex-wrap">
|
||||||
|
<button
|
||||||
|
onClick={() => onGenerateImage?.(scene.id, scene.visualPrompt)}
|
||||||
|
disabled={isGeneratingImage || isUpscalingImage}
|
||||||
|
className="flex-1 flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-lg bg-emerald-500/15 text-emerald-400 text-xs font-medium hover:bg-emerald-500/25 transition-colors disabled:opacity-50 disabled:cursor-not-allowed whitespace-nowrap"
|
||||||
|
>
|
||||||
|
{isGeneratingImage ? <RefreshCw size={13} className="animate-spin" /> : <ImageIcon size={13} />}
|
||||||
|
{thumbnailAsset ? "Yeniden Üret" : "Görsel Üret"}
|
||||||
|
</button>
|
||||||
|
{thumbnailAsset?.url && (
|
||||||
|
<button
|
||||||
|
onClick={() => onUpscaleImage?.(scene.id)}
|
||||||
|
disabled={isUpscalingImage || isGeneratingImage}
|
||||||
|
className="flex-1 flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-lg bg-orange-500/15 text-orange-400 text-xs font-medium hover:bg-orange-500/25 transition-colors disabled:opacity-50 disabled:cursor-not-allowed whitespace-nowrap"
|
||||||
|
>
|
||||||
|
{isUpscalingImage ? <RefreshCw size={13} className="animate-spin" /> : <Wand2 size={13} />}
|
||||||
|
Upscale (4K)
|
||||||
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 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">
|
|
||||||
<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"
|
|
||||||
>
|
|
||||||
{isGeneratingImage ? <RefreshCw size={13} className="animate-spin" /> : <ImageIcon size={13} />}
|
|
||||||
{thumbnailAsset ? "Görseli 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"
|
|
||||||
>
|
|
||||||
{isUpscalingImage ? <RefreshCw size={13} className="animate-spin" /> : <Wand2 size={13} />}
|
|
||||||
Upscale (4K)
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|||||||
@@ -304,25 +304,26 @@ export function DurationSelector({
|
|||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
min={15}
|
min={15}
|
||||||
max={180}
|
max={900}
|
||||||
step={5}
|
step={5}
|
||||||
value={value}
|
value={value}
|
||||||
onChange={(e) => onChange(Number(e.target.value))}
|
onChange={(e) => onChange(Number(e.target.value))}
|
||||||
className={cn(
|
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]: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]:rounded-full",
|
||||||
"[&::-webkit-slider-thumb]:bg-[var(--color-bg-inverted)]",
|
"[&::-webkit-slider-thumb]:bg-cyan-400",
|
||||||
"[&::-webkit-slider-thumb]:shadow-md",
|
"[&::-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"
|
"[&::-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>15s</span>
|
||||||
<span>60s</span>
|
<span>60s</span>
|
||||||
<span>120s</span>
|
<span>120s</span>
|
||||||
<span>180s</span>
|
<span>900s</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -348,19 +349,19 @@ export function AspectRatioSelector({
|
|||||||
key={ar.id}
|
key={ar.id}
|
||||||
onClick={() => onChange(ar.id)}
|
onClick={() => onChange(ar.id)}
|
||||||
className={cn(
|
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
|
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)]"
|
: "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} />
|
<Icon size={24} className={value === ar.id ? "drop-shadow-[0_0_8px_rgba(34,211,238,0.5)]" : ""} />
|
||||||
<span className="font-semibold">{ar.label}</span>
|
<span className="font-semibold text-[13px]">{ar.label}</span>
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-[10px]",
|
"text-[10px]",
|
||||||
value === ar.id
|
value === ar.id
|
||||||
? "text-[var(--color-text-inverted)]/70"
|
? "text-cyan-400/70"
|
||||||
: "text-[var(--color-text-ghost)]"
|
: "text-[var(--color-text-ghost)]"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
+21
-3
@@ -160,9 +160,10 @@ export function useGenerateScript() {
|
|||||||
export function useApproveAndQueue() {
|
export function useApproveAndQueue() {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (projectId: string) => projectsApi.approveAndQueue(projectId),
|
mutationFn: ({ projectId, data }: { projectId: string; data?: { ttsProvider?: string; visualEffect?: string } }) =>
|
||||||
onSuccess: (_data, projectId) => {
|
projectsApi.approveAndQueue(projectId, data),
|
||||||
qc.invalidateQueries({ queryKey: queryKeys.projects.detail(projectId) });
|
onSuccess: (_data, variables) => {
|
||||||
|
qc.invalidateQueries({ queryKey: queryKeys.projects.detail(variables.projectId) });
|
||||||
qc.invalidateQueries({ queryKey: queryKeys.projects.all });
|
qc.invalidateQueries({ queryKey: queryKeys.projects.all });
|
||||||
qc.invalidateQueries({ queryKey: queryKeys.credits.balance });
|
qc.invalidateQueries({ queryKey: queryKeys.credits.balance });
|
||||||
qc.invalidateQueries({ queryKey: queryKeys.dashboard.stats });
|
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 */
|
/** Alternatif SEO başlıklarından birini seç ve proje başlığını güncelle */
|
||||||
export function useSelectSeoTitle() {
|
export function useSelectSeoTitle() {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
|
|||||||
@@ -370,9 +370,10 @@ export const projectsApi = {
|
|||||||
generateScript: (id: string) =>
|
generateScript: (id: string) =>
|
||||||
apiClient.post<Project>(`/projects/${id}/generate-script`).then((r) => r.data),
|
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 }>(
|
apiClient.post<{ projectId: string; renderJobId: string; bullJobId: string }>(
|
||||||
`/projects/${id}/approve`,
|
`/projects/${id}/approve`,
|
||||||
|
data || {}
|
||||||
).then((r) => r.data),
|
).then((r) => r.data),
|
||||||
|
|
||||||
cancelRender: (id: string) =>
|
cancelRender: (id: string) =>
|
||||||
@@ -385,6 +386,9 @@ export const projectsApi = {
|
|||||||
`/projects/${id}/generate-seo-titles`,
|
`/projects/${id}/generate-seo-titles`,
|
||||||
).then((r) => r.data),
|
).then((r) => r.data),
|
||||||
|
|
||||||
|
generateSocialContent: (id: string) =>
|
||||||
|
apiClient.post<any>(`/projects/${id}/generate-social-content`).then((r) => r.data),
|
||||||
|
|
||||||
selectSeoTitle: (id: string, title: string) =>
|
selectSeoTitle: (id: string, title: string) =>
|
||||||
apiClient.patch<Project>(
|
apiClient.patch<Project>(
|
||||||
`/projects/${id}/select-title`,
|
`/projects/${id}/select-title`,
|
||||||
@@ -445,6 +449,25 @@ export const projectsApi = {
|
|||||||
apiClient.post<Scene>(`/projects/${projectId}/scenes/${sceneId}/upscale-image`).then((r) => r.data),
|
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)
|
// Backend path: /billing/credits/balance (billing controller prefix)
|
||||||
export const creditsApi = {
|
export const creditsApi = {
|
||||||
getBalance: () =>
|
getBalance: () =>
|
||||||
|
|||||||
@@ -55,14 +55,26 @@ export function createApiClient(baseURL: string): AxiosInstance {
|
|||||||
client.interceptors.response.use(
|
client.interceptors.response.use(
|
||||||
(response) => {
|
(response) => {
|
||||||
// Backend ResponseInterceptor tüm yanıtları { success, status, message, data } ile sarıyor.
|
// 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 (
|
if (
|
||||||
response.data &&
|
response.data &&
|
||||||
typeof response.data === 'object' &&
|
typeof response.data === 'object' &&
|
||||||
'success' in response.data &&
|
'success' in response.data
|
||||||
'data' in response.data
|
|
||||||
) {
|
) {
|
||||||
response.data = response.data.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;
|
return response;
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user