generated from fahricansecer/boilerplate-fe
This commit is contained in:
@@ -12,8 +12,14 @@ import {
|
||||
Sparkles,
|
||||
TrendingUp,
|
||||
Loader2,
|
||||
Twitter,
|
||||
} from "lucide-react";
|
||||
|
||||
// X (Twitter) logosu — lucide-react'ta mevcut değil
|
||||
const XIcon = ({ size = 20, className = "" }: { size?: number; className?: string }) => (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor" className={className}>
|
||||
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
|
||||
</svg>
|
||||
);
|
||||
import Link from "next/link";
|
||||
import { DashboardCharts } from "@/components/dashboard/dashboard-charts";
|
||||
import { RecentProjects } from "@/components/dashboard/recent-projects";
|
||||
@@ -209,7 +215,7 @@ export default function DashboardPage() {
|
||||
}}
|
||||
>
|
||||
<div className="w-11 h-11 rounded-xl bg-gradient-to-br from-sky-500 to-sky-700 flex items-center justify-center shrink-0 shadow-lg group-hover:shadow-sky-500/20 transition-shadow">
|
||||
<Twitter size={20} className="text-white" />
|
||||
<XIcon size={20} className="text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold">Tweet → Video</h3>
|
||||
|
||||
@@ -4,248 +4,245 @@ import { useState } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import {
|
||||
Check,
|
||||
X,
|
||||
Zap,
|
||||
Crown,
|
||||
Rocket,
|
||||
Loader2,
|
||||
Sparkles,
|
||||
ArrowRight,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useCreateCheckout, useSubscription } from "@/hooks/use-api";
|
||||
import { useToast } from "@/components/ui/toast";
|
||||
|
||||
const plans = [
|
||||
{
|
||||
id: "free",
|
||||
name: "Free",
|
||||
icon: Sparkles,
|
||||
monthlyPrice: 0,
|
||||
yearlyPrice: 0,
|
||||
credits: 3,
|
||||
description: "AI video üretimini keşfet",
|
||||
color: "emerald",
|
||||
gradient: "from-emerald-500/15 to-emerald-600/5",
|
||||
borderActive: "border-emerald-500/30",
|
||||
buttonClass: "btn-ghost",
|
||||
buttonLabel: "Mevcut Plan",
|
||||
features: [
|
||||
{ label: "3 kredi / ay", included: true },
|
||||
{ label: "720p video kalitesi", included: true },
|
||||
{ label: "Max 30 saniye", included: true },
|
||||
{ label: "5 proje limiti", included: true },
|
||||
{ label: "Temel şablonlar", included: true },
|
||||
{ label: "Öncelikli kuyruk", included: false },
|
||||
{ label: "Marka kaldırma", included: false },
|
||||
{ label: "API erişimi", included: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "pro",
|
||||
name: "Pro",
|
||||
price: { monthly: 0, yearly: 0 },
|
||||
description: "Başlangıç için ideal",
|
||||
icon: Zap,
|
||||
monthlyPrice: 19,
|
||||
yearlyPrice: 190,
|
||||
credits: 50,
|
||||
description: "İçerik üreticileri için güçlü araçlar",
|
||||
color: "violet",
|
||||
gradient: "from-violet-500/20 to-violet-600/8",
|
||||
borderActive: "border-violet-500/40",
|
||||
buttonClass: "btn-primary",
|
||||
buttonLabel: "Pro'ya Yükselt",
|
||||
recommended: true,
|
||||
gradient: "from-gray-500/20 to-gray-600/10",
|
||||
iconColor: "text-gray-400",
|
||||
features: [
|
||||
{ label: "50 kredi / ay", included: true },
|
||||
{ label: "1080p video kalitesi", included: true },
|
||||
{ label: "Max 120 saniye", included: true },
|
||||
{ label: "50 proje limiti", included: true },
|
||||
{ label: "Tüm şablonlar", included: true },
|
||||
{ label: "Öncelikli kuyruk", included: true },
|
||||
{ label: "Marka kaldırma", included: true },
|
||||
{ label: "API erişimi", included: false },
|
||||
"Ayda 3 video",
|
||||
"720p kalite",
|
||||
"Temel AI senaryo",
|
||||
"Topluluk desteği",
|
||||
],
|
||||
cta: "Mevcut Plan",
|
||||
popular: false,
|
||||
},
|
||||
{
|
||||
id: "business",
|
||||
name: "Business",
|
||||
name: "Pro",
|
||||
price: { monthly: 29, yearly: 290 },
|
||||
description: "İçerik üreticileri için",
|
||||
icon: Crown,
|
||||
monthlyPrice: 49,
|
||||
yearlyPrice: 490,
|
||||
credits: -1,
|
||||
description: "Ajanslar ve profesyonel ekipler",
|
||||
color: "cyan",
|
||||
gradient: "from-cyan-500/15 to-cyan-600/5",
|
||||
borderActive: "border-cyan-500/30",
|
||||
buttonClass: "btn-primary",
|
||||
buttonLabel: "Business'a Yükselt",
|
||||
gradient: "from-violet-500/20 to-cyan-400/10",
|
||||
iconColor: "text-violet-400",
|
||||
features: [
|
||||
{ label: "Sınırsız kredi", included: true },
|
||||
{ label: "1080p video kalitesi", included: true },
|
||||
{ label: "Max 180 saniye", included: true },
|
||||
{ label: "Sınırsız proje", included: true },
|
||||
{ label: "Tüm şablonlar + Özel", included: true },
|
||||
{ label: "Öncelikli kuyruk", included: true },
|
||||
{ label: "Marka kaldırma", included: true },
|
||||
{ label: "API erişimi", included: true },
|
||||
"Ayda 50 video",
|
||||
"1080p Full HD",
|
||||
"Gelişmiş AI senaryo",
|
||||
"Tweet → Video dönüşümü",
|
||||
"Öncelikli render",
|
||||
"E-posta desteği",
|
||||
],
|
||||
cta: "Pro'ya Geç",
|
||||
popular: true,
|
||||
},
|
||||
{
|
||||
name: "Enterprise",
|
||||
price: { monthly: 99, yearly: 990 },
|
||||
description: "Ajanslar ve büyük ekipler",
|
||||
icon: Rocket,
|
||||
gradient: "from-amber-500/20 to-orange-400/10",
|
||||
iconColor: "text-amber-400",
|
||||
features: [
|
||||
"Sınırsız video",
|
||||
"4K Ultra HD",
|
||||
"Premium AI + özel model",
|
||||
"API erişimi",
|
||||
"Özel şablonlar",
|
||||
"Öncelikli 7/24 destek",
|
||||
"Beyaz etiket seçeneği",
|
||||
],
|
||||
cta: "Enterprise'a Geç",
|
||||
popular: false,
|
||||
},
|
||||
];
|
||||
|
||||
const fadeUp = {
|
||||
hidden: { opacity: 0, y: 20 },
|
||||
show: { opacity: 1, y: 0, transition: { duration: 0.6, ease: [0.16, 1, 0.3, 1] as const } },
|
||||
};
|
||||
|
||||
export default function PricingPage() {
|
||||
const [isYearly, setIsYearly] = useState(false);
|
||||
const [billingCycle, setBillingCycle] = useState<"monthly" | "yearly">(
|
||||
"monthly",
|
||||
);
|
||||
const checkout = useCreateCheckout();
|
||||
const { data: subData } = useSubscription();
|
||||
const toast = useToast();
|
||||
|
||||
const currentPlan = subData?.data?.plan ?? subData?.plan ?? "Free";
|
||||
|
||||
const handleCheckout = async (planName: string) => {
|
||||
if (planName === "Free") {
|
||||
toast.info("Free plan zaten aktif");
|
||||
return;
|
||||
}
|
||||
if (planName === currentPlan) {
|
||||
toast.info("Bu plan zaten aktif");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await checkout.mutateAsync({ planName, billingCycle });
|
||||
} catch {
|
||||
toast.error("Ödeme sayfası açılamadı. Lütfen tekrar deneyin.");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-5xl mx-auto space-y-10 py-4">
|
||||
{/* ── Başlık ── */}
|
||||
<motion.div
|
||||
variants={fadeUp}
|
||||
initial="hidden"
|
||||
animate="show"
|
||||
className="text-center space-y-3"
|
||||
>
|
||||
<h1 className="font-[family-name:var(--font-display)] text-3xl md:text-4xl font-bold tracking-tight">
|
||||
Planını Seç, Üretmeye Başla
|
||||
</h1>
|
||||
<p className="text-[var(--color-text-muted)] text-sm md:text-base max-w-md mx-auto">
|
||||
Her plan ücretsiz deneme ile başlar. İstediğin zaman yükselt veya iptal et.
|
||||
</p>
|
||||
|
||||
{/* Aylık / Yıllık Toggle */}
|
||||
<div className="flex items-center justify-center gap-3 pt-2">
|
||||
<span className={cn("text-sm", !isYearly ? "text-[var(--color-text-primary)]" : "text-[var(--color-text-muted)]")}>
|
||||
Aylık
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setIsYearly(!isYearly)}
|
||||
className={cn(
|
||||
"relative w-14 h-7 rounded-full transition-colors",
|
||||
isYearly ? "bg-violet-500" : "bg-[var(--color-bg-elevated)]"
|
||||
)}
|
||||
>
|
||||
<motion.div
|
||||
className="absolute top-0.5 w-6 h-6 rounded-full bg-white shadow-md"
|
||||
animate={{ left: isYearly ? "calc(100% - 1.625rem)" : "0.125rem" }}
|
||||
transition={{ type: "spring", stiffness: 500, damping: 30 }}
|
||||
/>
|
||||
</button>
|
||||
<span className={cn("text-sm", isYearly ? "text-[var(--color-text-primary)]" : "text-[var(--color-text-muted)]")}>
|
||||
Yıllık
|
||||
</span>
|
||||
{isYearly && (
|
||||
<motion.span
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
className="badge badge-emerald text-[10px]"
|
||||
>
|
||||
%17 tasarruf
|
||||
</motion.span>
|
||||
)}
|
||||
<div className="max-w-5xl mx-auto space-y-8">
|
||||
{/* Header */}
|
||||
<div className="text-center space-y-3">
|
||||
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-violet-500/10 border border-violet-500/20 text-violet-300 text-xs font-medium">
|
||||
<Sparkles size={12} />
|
||||
Fiyatlandırma
|
||||
</div>
|
||||
</motion.div>
|
||||
<h1 className="text-3xl md:text-4xl font-[family-name:var(--font-display)] font-bold text-[var(--color-text-primary)]">
|
||||
İhtiyacınıza Uygun Plan
|
||||
</h1>
|
||||
<p className="text-sm text-[var(--color-text-muted)] max-w-lg mx-auto">
|
||||
İster hobby, ister profesyonel — her seviyeye uygun planlarımızla AI
|
||||
video üretimine başlayın.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* ── Plan Kartları ── */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 md:gap-5">
|
||||
{plans.map((plan, i) => {
|
||||
const Icon = plan.icon;
|
||||
const price = isYearly ? plan.yearlyPrice : plan.monthlyPrice;
|
||||
const period = isYearly ? "/yıl" : "/ay";
|
||||
{/* Billing Toggle */}
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
<span
|
||||
className={`text-sm ${
|
||||
billingCycle === "monthly"
|
||||
? "text-[var(--color-text-primary)]"
|
||||
: "text-[var(--color-text-muted)]"
|
||||
}`}
|
||||
>
|
||||
Aylık
|
||||
</span>
|
||||
<button
|
||||
onClick={() =>
|
||||
setBillingCycle((p) => (p === "monthly" ? "yearly" : "monthly"))
|
||||
}
|
||||
className={`relative w-12 h-6 rounded-full transition-colors ${
|
||||
billingCycle === "yearly"
|
||||
? "bg-violet-500"
|
||||
: "bg-[var(--color-bg-elevated)]"
|
||||
}`}
|
||||
>
|
||||
<motion.div
|
||||
className="absolute top-0.5 left-0.5 w-5 h-5 rounded-full bg-white shadow"
|
||||
animate={{ x: billingCycle === "yearly" ? 24 : 0 }}
|
||||
transition={{ type: "spring", bounce: 0.25, duration: 0.3 }}
|
||||
/>
|
||||
</button>
|
||||
<span
|
||||
className={`text-sm ${
|
||||
billingCycle === "yearly"
|
||||
? "text-[var(--color-text-primary)]"
|
||||
: "text-[var(--color-text-muted)]"
|
||||
}`}
|
||||
>
|
||||
Yıllık
|
||||
<span className="ml-1 text-xs text-emerald-400 font-medium">
|
||||
%17 tasarruf
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Plans Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-5">
|
||||
{plans.map((plan, idx) => {
|
||||
const isCurrentPlan =
|
||||
plan.name.toLowerCase() === currentPlan.toLowerCase();
|
||||
const PlanIcon = plan.icon;
|
||||
const price = plan.price[billingCycle];
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={plan.id}
|
||||
initial={{ opacity: 0, y: 24 }}
|
||||
key={plan.name}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: i * 0.1, duration: 0.6, ease: [0.16, 1, 0.3, 1] }}
|
||||
className={cn(
|
||||
"relative card-surface p-6 flex flex-col bg-gradient-to-br",
|
||||
plan.gradient,
|
||||
plan.recommended && "glow-violet md:-translate-y-2"
|
||||
)}
|
||||
transition={{ delay: idx * 0.1 }}
|
||||
className={`relative card p-6 flex flex-col ${
|
||||
plan.popular
|
||||
? "border-violet-500/30 shadow-lg shadow-violet-500/5"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
{plan.recommended && (
|
||||
<div className="absolute -top-3 left-1/2 -translate-x-1/2">
|
||||
<span className="badge bg-violet-500 text-white text-[10px] px-3 py-1 shadow-lg shadow-violet-500/30">
|
||||
⚡ Önerilen
|
||||
</span>
|
||||
{plan.popular && (
|
||||
<div className="absolute -top-3 left-1/2 -translate-x-1/2 px-3 py-0.5 rounded-full bg-violet-500 text-white text-[10px] font-bold uppercase tracking-wider">
|
||||
En Popüler
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className={cn(
|
||||
"w-10 h-10 rounded-xl flex items-center justify-center",
|
||||
plan.color === "violet" && "bg-violet-500/15 text-violet-400",
|
||||
plan.color === "emerald" && "bg-emerald-500/15 text-emerald-400",
|
||||
plan.color === "cyan" && "bg-cyan-500/15 text-cyan-400"
|
||||
)}>
|
||||
<Icon size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-[family-name:var(--font-display)] text-lg font-bold">{plan.name}</h3>
|
||||
<p className="text-[11px] text-[var(--color-text-muted)]">{plan.description}</p>
|
||||
</div>
|
||||
<div
|
||||
className={`w-10 h-10 rounded-xl bg-gradient-to-br ${plan.gradient} flex items-center justify-center mb-4`}
|
||||
>
|
||||
<PlanIcon size={20} className={plan.iconColor} />
|
||||
</div>
|
||||
|
||||
{/* Fiyat */}
|
||||
<h3 className="text-lg font-bold text-[var(--color-text-primary)]">
|
||||
{plan.name}
|
||||
</h3>
|
||||
<p className="text-xs text-[var(--color-text-muted)] mb-4">
|
||||
{plan.description}
|
||||
</p>
|
||||
|
||||
<div className="mb-5">
|
||||
<div className="flex items-baseline gap-1">
|
||||
<span className="font-[family-name:var(--font-display)] text-4xl font-bold">
|
||||
${price}
|
||||
<span className="text-3xl font-[family-name:var(--font-display)] font-bold text-[var(--color-text-primary)]">
|
||||
${price}
|
||||
</span>
|
||||
{price > 0 && (
|
||||
<span className="text-sm text-[var(--color-text-muted)]">
|
||||
/{billingCycle === "monthly" ? "ay" : "yıl"}
|
||||
</span>
|
||||
{price > 0 && (
|
||||
<span className="text-sm text-[var(--color-text-muted)]">{period}</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-[var(--color-text-ghost)] mt-1">
|
||||
{plan.credits === -1 ? "Sınırsız video üretimi" : `${plan.credits} kredi dahil`}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Features */}
|
||||
<ul className="space-y-2.5 flex-1 mb-6">
|
||||
{plan.features.map((feat) => (
|
||||
<li key={feat.label} className="flex items-center gap-2.5 text-sm">
|
||||
{feat.included ? (
|
||||
<Check size={14} className="text-emerald-400 shrink-0" />
|
||||
) : (
|
||||
<X size={14} className="text-[var(--color-text-ghost)] shrink-0" />
|
||||
)}
|
||||
<span className={cn(
|
||||
feat.included ? "text-[var(--color-text-secondary)]" : "text-[var(--color-text-ghost)]"
|
||||
)}>
|
||||
{feat.label}
|
||||
</span>
|
||||
{plan.features.map((f) => (
|
||||
<li
|
||||
key={f}
|
||||
className="flex items-start gap-2 text-sm text-[var(--color-text-secondary)]"
|
||||
>
|
||||
<Check
|
||||
size={14}
|
||||
className="shrink-0 mt-0.5 text-emerald-400"
|
||||
/>
|
||||
{f}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{/* CTA */}
|
||||
<button className={cn("w-full py-3 rounded-xl font-semibold text-sm flex items-center justify-center gap-2", plan.buttonClass)}>
|
||||
{plan.buttonLabel}
|
||||
{price > 0 && <ArrowRight size={14} />}
|
||||
<button
|
||||
onClick={() => handleCheckout(plan.name)}
|
||||
disabled={
|
||||
isCurrentPlan || (checkout.isPending && !isCurrentPlan)
|
||||
}
|
||||
className={`w-full py-2.5 rounded-xl text-sm font-semibold transition-all ${
|
||||
isCurrentPlan
|
||||
? "bg-[var(--color-bg-elevated)] text-[var(--color-text-ghost)] cursor-default"
|
||||
: plan.popular
|
||||
? "btn-primary"
|
||||
: "bg-[var(--color-bg-elevated)] text-[var(--color-text-secondary)] hover:bg-[var(--color-bg-surface)] border border-[var(--color-border-faint)]"
|
||||
}`}
|
||||
>
|
||||
{checkout.isPending && !isCurrentPlan ? (
|
||||
<Loader2 size={16} className="animate-spin mx-auto" />
|
||||
) : isCurrentPlan ? (
|
||||
"Mevcut Plan"
|
||||
) : (
|
||||
plan.cta
|
||||
)}
|
||||
</button>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* ── Trust ── */}
|
||||
<motion.div
|
||||
variants={fadeUp}
|
||||
initial="hidden"
|
||||
animate="show"
|
||||
className="text-center space-y-2 pt-4"
|
||||
>
|
||||
<p className="text-xs text-[var(--color-text-ghost)]">
|
||||
🔒 Güvenli ödeme • İstediğin zaman iptal • 7 gün para iade garantisi
|
||||
</p>
|
||||
<p className="text-[10px] text-[var(--color-text-ghost)]">
|
||||
Stripe ile güvenli ödeme altyapısı
|
||||
</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,451 @@
|
||||
'use client';
|
||||
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import { motion } from 'framer-motion';
|
||||
import {
|
||||
ArrowLeft,
|
||||
Play,
|
||||
Sparkles,
|
||||
RefreshCw,
|
||||
Clock,
|
||||
CheckCircle2,
|
||||
AlertCircle,
|
||||
Loader2,
|
||||
FileText,
|
||||
Film,
|
||||
Trash2,
|
||||
MoreVertical,
|
||||
} from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { useState } from 'react';
|
||||
import { useProject, useGenerateScript, useApproveAndQueue, useUpdateProject, useDeleteProject } from '@/hooks/use-api';
|
||||
import { useRenderProgress } from '@/hooks/use-render-progress';
|
||||
import { SceneCard } from '@/components/project/scene-card';
|
||||
import { RenderProgress } from '@/components/project/render-progress';
|
||||
import { VideoPlayer } from '@/components/project/video-player';
|
||||
import { projectsApi } from '@/lib/api/api-service';
|
||||
|
||||
// X (Twitter) ikonunu burada da tanımlıyoruz
|
||||
const XIcon = ({ size = 16 }: { size?: number }) => (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const STATUS_MAP: Record<string, { label: string; color: string; icon: React.ElementType; bgClass: string }> = {
|
||||
DRAFT: { label: 'Taslak', color: 'text-slate-400', icon: FileText, bgClass: 'bg-slate-500/10 border-slate-500/20' },
|
||||
GENERATING_SCRIPT: { label: 'Senaryo Üretiliyor', color: 'text-violet-400', icon: Sparkles, bgClass: 'bg-violet-500/10 border-violet-500/20' },
|
||||
PENDING: { label: 'Kuyrukta', color: 'text-amber-400', icon: Clock, bgClass: 'bg-amber-500/10 border-amber-500/20' },
|
||||
GENERATING_MEDIA: { label: 'Medya Üretiliyor', color: 'text-cyan-400', icon: Sparkles, bgClass: 'bg-cyan-500/10 border-cyan-500/20' },
|
||||
RENDERING: { label: 'Video İşleniyor', color: 'text-blue-400', icon: Film, bgClass: 'bg-blue-500/10 border-blue-500/20' },
|
||||
COMPLETED: { label: 'Tamamlandı', color: 'text-emerald-400', icon: CheckCircle2, bgClass: 'bg-emerald-500/10 border-emerald-500/20' },
|
||||
FAILED: { label: 'Başarısız', color: 'text-red-400', icon: AlertCircle, bgClass: 'bg-red-500/10 border-red-500/20' },
|
||||
};
|
||||
|
||||
const STYLE_LABELS: Record<string, string> = {
|
||||
CINEMATIC: '🎬 Sinematik',
|
||||
DOCUMENTARY: '📹 Belgesel',
|
||||
EDUCATIONAL: '📚 Eğitici',
|
||||
STORYTELLING: '📖 Hikaye',
|
||||
NEWS: '📰 Haber',
|
||||
PROMOTIONAL: '📢 Tanıtım',
|
||||
ARTISTIC: '🎨 Sanatsal',
|
||||
MINIMALIST: '✨ Minimalist',
|
||||
};
|
||||
|
||||
const stagger = {
|
||||
hidden: { opacity: 0 },
|
||||
show: { opacity: 1, transition: { staggerChildren: 0.06 } },
|
||||
};
|
||||
|
||||
const fadeUp = {
|
||||
hidden: { opacity: 0, y: 12 },
|
||||
show: { opacity: 1, y: 0, transition: { duration: 0.4, ease: [0.16, 1, 0.3, 1] as const } },
|
||||
};
|
||||
|
||||
export default function ProjectDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const router = useRouter();
|
||||
const [showMenu, setShowMenu] = useState(false);
|
||||
const [regeneratingSceneId, setRegeneratingSceneId] = useState<string | null>(null);
|
||||
|
||||
// Veri hook'ları
|
||||
const { data: project, isLoading, error, refetch } = useProject(id);
|
||||
const generateScriptMutation = useGenerateScript();
|
||||
const approveMutation = useApproveAndQueue();
|
||||
const deleteMutation = useDeleteProject();
|
||||
|
||||
// WebSocket progress
|
||||
const renderState = useRenderProgress(
|
||||
project?.status && ['PENDING', 'GENERATING_MEDIA', 'RENDERING'].includes(project.status) ? id : undefined,
|
||||
);
|
||||
|
||||
// Sahne güncelleme
|
||||
const handleSceneUpdate = async (sceneId: string, data: { narrationText?: string; visualPrompt?: string; subtitleText?: string }) => {
|
||||
try {
|
||||
await projectsApi.update(`${id}/scenes/${sceneId}` as unknown as string, data as any);
|
||||
refetch();
|
||||
} catch (err) {
|
||||
console.error('Sahne güncelleme hatası:', err);
|
||||
}
|
||||
};
|
||||
|
||||
// Sahne yeniden üretim
|
||||
const handleSceneRegenerate = async (sceneId: string) => {
|
||||
setRegeneratingSceneId(sceneId);
|
||||
try {
|
||||
await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000/api'}/projects/${id}/scenes/${sceneId}/regenerate`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
refetch();
|
||||
} catch (err) {
|
||||
console.error('Sahne yeniden üretim hatası:', err);
|
||||
} finally {
|
||||
setRegeneratingSceneId(null);
|
||||
}
|
||||
};
|
||||
|
||||
// Senaryo üret
|
||||
const handleGenerateScript = () => {
|
||||
generateScriptMutation.mutate(id, {
|
||||
onSuccess: () => refetch(),
|
||||
});
|
||||
};
|
||||
|
||||
// Onayla ve gönder
|
||||
const handleApprove = () => {
|
||||
approveMutation.mutate(id, {
|
||||
onSuccess: () => refetch(),
|
||||
});
|
||||
};
|
||||
|
||||
// Sil
|
||||
const handleDelete = () => {
|
||||
if (confirm('Bu projeyi silmek istediğinize emin misiniz?')) {
|
||||
deleteMutation.mutate(id, {
|
||||
onSuccess: () => router.push('/dashboard/projects'),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// ── Loading ──
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[60vh]">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<Loader2 size={32} className="animate-spin text-violet-400" />
|
||||
<p className="text-sm text-[var(--color-text-muted)]">Proje yükleniyor...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Error ──
|
||||
if (error || !project) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[60vh]">
|
||||
<div className="card-surface p-8 text-center max-w-md">
|
||||
<AlertCircle size={40} className="text-red-400 mx-auto mb-3" />
|
||||
<h2 className="text-lg font-semibold mb-2">Proje Bulunamadı</h2>
|
||||
<p className="text-sm text-[var(--color-text-muted)] mb-4">
|
||||
Bu proje silinmiş veya erişim izniniz yok.
|
||||
</p>
|
||||
<Link href="/dashboard/projects" className="btn-primary text-sm">
|
||||
Projelere Dön
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const statusInfo = STATUS_MAP[project.status] || STATUS_MAP.DRAFT;
|
||||
const StatusIcon = statusInfo.icon;
|
||||
const isEditable = project.status === 'DRAFT' || project.status === 'FAILED';
|
||||
const hasScript = project.scenes && project.scenes.length > 0;
|
||||
const isRendering = ['PENDING', 'GENERATING_MEDIA', 'RENDERING'].includes(project.status);
|
||||
const isCompleted = project.status === 'COMPLETED';
|
||||
const tweetData = project.sourceTweetData as Record<string, any> | undefined;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
variants={stagger}
|
||||
initial="hidden"
|
||||
animate="show"
|
||||
className="space-y-5 max-w-4xl mx-auto pb-8"
|
||||
>
|
||||
{/* ── Üst Bar — Geri + Aksiyonlar ── */}
|
||||
<motion.div variants={fadeUp} className="flex items-center justify-between">
|
||||
<Link
|
||||
href="/dashboard/projects"
|
||||
className="flex items-center gap-2 text-sm text-[var(--color-text-muted)] hover:text-[var(--color-text-primary)] transition-colors"
|
||||
>
|
||||
<ArrowLeft size={16} />
|
||||
Projeler
|
||||
</Link>
|
||||
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setShowMenu(!showMenu)}
|
||||
className="w-8 h-8 rounded-lg flex items-center justify-center text-[var(--color-text-muted)] hover:bg-[var(--color-bg-elevated)] transition-colors"
|
||||
>
|
||||
<MoreVertical size={16} />
|
||||
</button>
|
||||
|
||||
{showMenu && (
|
||||
<div className="absolute right-0 top-10 card-surface p-1.5 min-w-[160px] z-50 shadow-xl">
|
||||
<button
|
||||
onClick={() => { refetch(); setShowMenu(false); }}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-[var(--color-text-secondary)] hover:bg-[var(--color-bg-elevated)] rounded-lg transition-colors"
|
||||
>
|
||||
<RefreshCw size={14} /> Yenile
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { handleDelete(); setShowMenu(false); }}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-red-400 hover:bg-red-500/10 rounded-lg transition-colors"
|
||||
>
|
||||
<Trash2 size={14} /> Sil
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* ── Proje Header ── */}
|
||||
<motion.div variants={fadeUp} className="card-surface p-5 md:p-6">
|
||||
<div className="flex flex-col md:flex-row md:items-start md:justify-between gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
{/* Durum badge */}
|
||||
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-medium border ${statusInfo.bgClass} ${statusInfo.color}`}>
|
||||
<StatusIcon size={12} />
|
||||
{statusInfo.label}
|
||||
</span>
|
||||
|
||||
{/* Tweet kaynak badge */}
|
||||
{project.sourceType === 'X_TWEET' && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-1 rounded-lg text-[10px] font-medium bg-sky-500/10 border border-sky-500/20 text-sky-400">
|
||||
<XIcon size={10} /> Tweet
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<h1 className="font-[family-name:var(--font-display)] text-xl md:text-2xl font-bold tracking-tight text-[var(--color-text-primary)] truncate">
|
||||
{project.title}
|
||||
</h1>
|
||||
|
||||
{project.description && (
|
||||
<p className="text-sm text-[var(--color-text-muted)] mt-1.5 line-clamp-2">
|
||||
{project.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Meta bilgiler */}
|
||||
<div className="flex flex-wrap items-center gap-3 text-xs text-[var(--color-text-ghost)] shrink-0">
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock size={12} /> {project.targetDuration}s
|
||||
</span>
|
||||
<span>{STYLE_LABELS[project.videoStyle] || project.videoStyle}</span>
|
||||
<span className="uppercase text-[10px] tracking-wider">{project.language}</span>
|
||||
<span className="text-[10px]">
|
||||
{new Date(project.createdAt).toLocaleDateString('tr-TR', {
|
||||
day: 'numeric', month: 'short', year: 'numeric',
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tweet kaynak bilgisi */}
|
||||
{tweetData && (
|
||||
<div className="mt-4 p-3 rounded-xl bg-sky-500/5 border border-sky-500/10">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<XIcon size={14} />
|
||||
<span className="text-xs font-medium text-sky-400">
|
||||
@{tweetData.authorUsername as string}
|
||||
</span>
|
||||
{tweetData.viralScore && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded-md bg-amber-500/15 text-amber-400 font-medium">
|
||||
🔥 {String(tweetData.viralScore)}/100
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-[var(--color-text-muted)] line-clamp-2">
|
||||
{tweetData.text as string}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Aksiyon butonları */}
|
||||
<div className="flex flex-wrap items-center gap-2 mt-4 pt-4 border-t border-[var(--color-border-faint)]">
|
||||
{/* Senaryo üret (draft, senaryo yok) */}
|
||||
{isEditable && !hasScript && (
|
||||
<button
|
||||
onClick={handleGenerateScript}
|
||||
disabled={generateScriptMutation.isPending}
|
||||
className="flex items-center gap-2 px-4 py-2.5 rounded-xl bg-gradient-to-r from-violet-500 to-violet-600 text-white text-sm font-medium shadow-lg shadow-violet-500/20 hover:shadow-violet-500/30 transition-shadow disabled:opacity-50"
|
||||
>
|
||||
{generateScriptMutation.isPending ? (
|
||||
<Loader2 size={15} className="animate-spin" />
|
||||
) : (
|
||||
<Sparkles size={15} />
|
||||
)}
|
||||
AI ile Senaryo Üret
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Senaryo yeniden üret (draft/failed, senaryo var) */}
|
||||
{isEditable && hasScript && (
|
||||
<button
|
||||
onClick={handleGenerateScript}
|
||||
disabled={generateScriptMutation.isPending}
|
||||
className="flex items-center gap-2 px-4 py-2.5 rounded-xl bg-[var(--color-bg-elevated)] text-[var(--color-text-secondary)] text-sm font-medium hover:bg-[var(--color-bg-surface)] transition-colors disabled:opacity-50"
|
||||
>
|
||||
{generateScriptMutation.isPending ? (
|
||||
<Loader2 size={15} className="animate-spin" />
|
||||
) : (
|
||||
<RefreshCw size={15} />
|
||||
)}
|
||||
Yeniden Üret
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Onayla ve video üretimini başlat */}
|
||||
{isEditable && hasScript && (
|
||||
<button
|
||||
onClick={handleApprove}
|
||||
disabled={approveMutation.isPending}
|
||||
className="flex items-center gap-2 px-4 py-2.5 rounded-xl bg-gradient-to-r from-emerald-500 to-emerald-600 text-white text-sm font-medium shadow-lg shadow-emerald-500/20 hover:shadow-emerald-500/30 transition-shadow disabled:opacity-50"
|
||||
>
|
||||
{approveMutation.isPending ? (
|
||||
<Loader2 size={15} className="animate-spin" />
|
||||
) : (
|
||||
<Play size={15} />
|
||||
)}
|
||||
Onayla & Video Üret
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Hata mesajı */}
|
||||
{project.errorMessage && (
|
||||
<div className="mt-3 p-3 rounded-lg bg-red-500/10 border border-red-500/20">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<AlertCircle size={14} className="text-red-400" />
|
||||
<span className="text-xs font-medium text-red-400">Hata</span>
|
||||
</div>
|
||||
<p className="text-xs text-red-400/80">{project.errorMessage}</p>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
|
||||
{/* ── Render Progress (WebSocket) ── */}
|
||||
{isRendering && (
|
||||
<motion.div variants={fadeUp}>
|
||||
<RenderProgress renderState={renderState} />
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* ── Video Player (tamamlandıysa) ── */}
|
||||
{isCompleted && project.finalVideoUrl && (
|
||||
<motion.div variants={fadeUp}>
|
||||
<VideoPlayer
|
||||
videoUrl={project.finalVideoUrl}
|
||||
thumbnailUrl={project.thumbnailUrl}
|
||||
title={project.title}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* ── Sahneler ── */}
|
||||
{hasScript && (
|
||||
<motion.div variants={fadeUp}>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="text-sm font-semibold text-[var(--color-text-primary)] flex items-center gap-2">
|
||||
<Film size={15} className="text-violet-400" />
|
||||
Senaryo — {project.scenes!.length} sahne
|
||||
</h2>
|
||||
<span className="text-[10px] text-[var(--color-text-ghost)]">
|
||||
Toplam: {project.scenes!.reduce((sum, s) => sum + s.duration, 0)}s
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{project.scenes!.map((scene) => (
|
||||
<SceneCard
|
||||
key={scene.id}
|
||||
scene={scene}
|
||||
isEditable={isEditable}
|
||||
onUpdate={handleSceneUpdate}
|
||||
onRegenerate={handleSceneRegenerate}
|
||||
isRegenerating={regeneratingSceneId === scene.id}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* ── Boş durum (senaryo yok) ── */}
|
||||
{!hasScript && isEditable && (
|
||||
<motion.div variants={fadeUp} className="card-surface p-8 text-center">
|
||||
<div className="w-16 h-16 rounded-2xl bg-gradient-to-br from-violet-500/15 to-cyan-400/10 mx-auto mb-4 flex items-center justify-center">
|
||||
<Sparkles size={28} className="text-violet-400" />
|
||||
</div>
|
||||
<h3 className="text-base font-semibold mb-1.5">Henüz senaryo üretilmedi</h3>
|
||||
<p className="text-sm text-[var(--color-text-muted)] mb-4 max-w-sm mx-auto">
|
||||
AI'ın projeniz için etkileyici bir video senaryosu oluşturmasını sağlayın.
|
||||
</p>
|
||||
<button
|
||||
onClick={handleGenerateScript}
|
||||
disabled={generateScriptMutation.isPending}
|
||||
className="inline-flex items-center gap-2 px-5 py-2.5 rounded-xl bg-gradient-to-r from-violet-500 to-violet-600 text-white text-sm font-medium shadow-lg shadow-violet-500/20 hover:shadow-violet-500/30 transition-shadow disabled:opacity-50"
|
||||
>
|
||||
{generateScriptMutation.isPending ? (
|
||||
<Loader2 size={15} className="animate-spin" />
|
||||
) : (
|
||||
<Sparkles size={15} />
|
||||
)}
|
||||
Senaryo Üret
|
||||
</button>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* ── Render Geçmişi ── */}
|
||||
{project.renderJobs && project.renderJobs.length > 0 && (
|
||||
<motion.div variants={fadeUp}>
|
||||
<h2 className="text-sm font-semibold text-[var(--color-text-primary)] mb-3 flex items-center gap-2">
|
||||
<Clock size={15} className="text-cyan-400" />
|
||||
Render Geçmişi
|
||||
</h2>
|
||||
<div className="space-y-2">
|
||||
{project.renderJobs.map((job) => (
|
||||
<div key={job.id} className="card-surface p-3 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className={`w-2 h-2 rounded-full ${
|
||||
job.status === 'COMPLETED' ? 'bg-emerald-400' :
|
||||
job.status === 'FAILED' ? 'bg-red-400' :
|
||||
'bg-amber-400 animate-pulse'
|
||||
}`} />
|
||||
<span className="text-xs text-[var(--color-text-secondary)]">
|
||||
Deneme #{job.attemptNumber}
|
||||
</span>
|
||||
<span className="text-[10px] text-[var(--color-text-ghost)]">
|
||||
{job.status}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-[10px] text-[var(--color-text-ghost)]">
|
||||
{job.processingTimeMs && (
|
||||
<span>{(job.processingTimeMs / 1000).toFixed(1)}s</span>
|
||||
)}
|
||||
<span>
|
||||
{new Date(job.createdAt).toLocaleTimeString('tr-TR', { hour: '2-digit', minute: '2-digit' })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import {
|
||||
ArrowLeft,
|
||||
@@ -18,6 +19,8 @@ import {
|
||||
} 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";
|
||||
|
||||
const steps = ["Konu & Dil", "Stil & Süre", "AI Senaryo"];
|
||||
|
||||
@@ -48,8 +51,11 @@ const aspectRatios = [
|
||||
];
|
||||
|
||||
export default function NewProjectPage() {
|
||||
const router = useRouter();
|
||||
const createProject = useCreateProject();
|
||||
const toast = useToast();
|
||||
|
||||
const [currentStep, setCurrentStep] = useState(0);
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
|
||||
// Form state
|
||||
const [topic, setTopic] = useState("");
|
||||
@@ -59,11 +65,28 @@ export default function NewProjectPage() {
|
||||
const [aspectRatio, setAspectRatio] = useState("PORTRAIT_9_16");
|
||||
|
||||
const canProceed = currentStep === 0 ? topic.trim().length >= 5 : true;
|
||||
const isGenerating = createProject.isPending;
|
||||
|
||||
const handleGenerate = async () => {
|
||||
setIsGenerating(true);
|
||||
// API çağrısı burada yapılacak
|
||||
setTimeout(() => setIsGenerating(false), 3000);
|
||||
try {
|
||||
const result = await createProject.mutateAsync({
|
||||
title: topic.slice(0, 80),
|
||||
topic,
|
||||
language,
|
||||
style,
|
||||
targetDuration: duration,
|
||||
aspectRatio,
|
||||
});
|
||||
toast.success("Proje başarıyla oluşturuldu! AI senaryo üretiliyor...");
|
||||
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. Lütfen tekrar deneyin.");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,35 +1,129 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useState, useMemo } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { Plus, Search, Filter, Grid3X3, List } from "lucide-react";
|
||||
import {
|
||||
Plus,
|
||||
Search,
|
||||
FolderOpen,
|
||||
Clock,
|
||||
CheckCircle,
|
||||
Video,
|
||||
AlertCircle,
|
||||
ExternalLink,
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { RecentProjects } from "@/components/dashboard/recent-projects";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useProjects } from "@/hooks/use-api";
|
||||
|
||||
const statusFilters = [
|
||||
{ id: "all", label: "Tümü" },
|
||||
{ id: "DRAFT", label: "Taslak" },
|
||||
{ id: "RENDERING", label: "İşleniyor" },
|
||||
{ id: "COMPLETED", label: "Tamamlanan" },
|
||||
{ id: "FAILED", label: "Başarısız" },
|
||||
{ id: "draft", label: "Taslak" },
|
||||
{ id: "scripting", label: "Senaryo" },
|
||||
{ id: "rendering", label: "İşleniyor" },
|
||||
{ id: "completed", label: "Tamamlanan" },
|
||||
{ id: "failed", label: "Başarısız" },
|
||||
];
|
||||
|
||||
const statusMap: Record<
|
||||
string,
|
||||
{ icon: typeof Clock; color: string; label: string; bgColor: string }
|
||||
> = {
|
||||
draft: {
|
||||
icon: Clock,
|
||||
color: "text-amber-400",
|
||||
bgColor: "bg-amber-500/10",
|
||||
label: "Taslak",
|
||||
},
|
||||
scripting: {
|
||||
icon: Clock,
|
||||
color: "text-blue-400",
|
||||
bgColor: "bg-blue-500/10",
|
||||
label: "Senaryo",
|
||||
},
|
||||
reviewing: {
|
||||
icon: Clock,
|
||||
color: "text-purple-400",
|
||||
bgColor: "bg-purple-500/10",
|
||||
label: "İnceleme",
|
||||
},
|
||||
rendering: {
|
||||
icon: Video,
|
||||
color: "text-cyan-400",
|
||||
bgColor: "bg-cyan-500/10",
|
||||
label: "Render",
|
||||
},
|
||||
completed: {
|
||||
icon: CheckCircle,
|
||||
color: "text-emerald-400",
|
||||
bgColor: "bg-emerald-500/10",
|
||||
label: "Tamamlandı",
|
||||
},
|
||||
failed: {
|
||||
icon: AlertCircle,
|
||||
color: "text-red-400",
|
||||
bgColor: "bg-red-500/10",
|
||||
label: "Hatalı",
|
||||
},
|
||||
};
|
||||
|
||||
interface ProjectItem {
|
||||
id: string;
|
||||
title: string;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
updatedAt?: string;
|
||||
language?: string;
|
||||
progress?: number;
|
||||
creditsUsed?: number;
|
||||
}
|
||||
|
||||
export default function ProjectsPage() {
|
||||
const [activeFilter, setActiveFilter] = useState("all");
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
|
||||
const { data, isLoading } = useProjects({ limit: 100 });
|
||||
// useProjects returns PaginatedResponse<Project> which has .data as Project[]
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const raw = data as any;
|
||||
const rawProjects: ProjectItem[] = raw?.data ?? raw ?? [];
|
||||
const projects = Array.isArray(rawProjects) ? rawProjects : [];
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
return projects.filter((p) => {
|
||||
if (activeFilter !== "all" && p.status?.toLowerCase() !== activeFilter)
|
||||
return false;
|
||||
if (
|
||||
searchQuery &&
|
||||
!p.title.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
)
|
||||
return false;
|
||||
return true;
|
||||
});
|
||||
}, [projects, activeFilter, searchQuery]);
|
||||
|
||||
return (
|
||||
<div className="max-w-5xl mx-auto space-y-6">
|
||||
{/* Başlık */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="font-[family-name:var(--font-display)] text-2xl font-bold">Projeler</h1>
|
||||
<h1 className="font-[family-name:var(--font-display)] text-2xl font-bold">
|
||||
Projeler
|
||||
</h1>
|
||||
<p className="text-sm text-[var(--color-text-muted)] mt-0.5">
|
||||
Tüm video projelerini yönet
|
||||
{projects.length > 0 && (
|
||||
<span className="ml-1 text-violet-400">
|
||||
({projects.length} proje)
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<Link href="/dashboard/projects/new" className="btn-primary flex items-center gap-2 text-sm">
|
||||
<Link
|
||||
href="/dashboard/projects/new"
|
||||
className="btn-primary flex items-center gap-2 text-sm"
|
||||
>
|
||||
<Plus size={16} />
|
||||
<span>Yeni Proje</span>
|
||||
</Link>
|
||||
@@ -38,7 +132,10 @@ export default function ProjectsPage() {
|
||||
{/* Arama + Filtreler */}
|
||||
<div className="flex flex-col sm:flex-row gap-3">
|
||||
<div className="relative flex-1">
|
||||
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-[var(--color-text-ghost)]" />
|
||||
<Search
|
||||
size={16}
|
||||
className="absolute left-3 top-1/2 -translate-y-1/2 text-[var(--color-text-ghost)]"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
@@ -59,7 +156,7 @@ export default function ProjectsPage() {
|
||||
"px-3.5 py-1.5 rounded-full text-xs font-medium whitespace-nowrap transition-all",
|
||||
activeFilter === filter.id
|
||||
? "bg-violet-500/15 text-violet-400 border border-violet-500/25"
|
||||
: "text-[var(--color-text-muted)] border border-[var(--color-border-faint)] hover:border-[var(--color-border-default)]"
|
||||
: "text-[var(--color-text-muted)] border border-[var(--color-border-faint)] hover:border-[var(--color-border-default)]",
|
||||
)}
|
||||
>
|
||||
{filter.label}
|
||||
@@ -67,14 +164,99 @@ export default function ProjectsPage() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Loading */}
|
||||
{isLoading && (
|
||||
<div className="flex flex-col items-center justify-center py-16">
|
||||
<Loader2
|
||||
size={32}
|
||||
className="animate-spin text-violet-400 mb-3"
|
||||
/>
|
||||
<p className="text-sm text-[var(--color-text-muted)]">
|
||||
Projeler yükleniyor...
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Proje Listesi */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.4, ease: [0.16, 1, 0.3, 1] }}
|
||||
>
|
||||
<RecentProjects />
|
||||
</motion.div>
|
||||
{!isLoading && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.4, ease: [0.16, 1, 0.3, 1] }}
|
||||
className="space-y-2"
|
||||
>
|
||||
{filtered.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-center">
|
||||
<FolderOpen
|
||||
size={40}
|
||||
className="text-[var(--color-text-ghost)] mb-3"
|
||||
/>
|
||||
<p className="text-sm text-[var(--color-text-muted)] mb-1">
|
||||
{searchQuery || activeFilter !== "all"
|
||||
? "Filtrenizle eşleşen proje bulunamadı"
|
||||
: "Henüz proje bulunmuyor"}
|
||||
</p>
|
||||
{!searchQuery && activeFilter === "all" && (
|
||||
<Link
|
||||
href="/dashboard/projects/new"
|
||||
className="mt-3 text-xs text-violet-400 hover:text-violet-300"
|
||||
>
|
||||
İlk projenizi oluşturun →
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
filtered.map((project) => {
|
||||
const st = statusMap[project.status] ?? statusMap.draft;
|
||||
const StIcon = st.icon;
|
||||
return (
|
||||
<Link
|
||||
key={project.id}
|
||||
href={`/dashboard/projects/${project.id}`}
|
||||
className="flex items-center gap-4 p-4 rounded-xl card hover:border-violet-500/20 transition-all group"
|
||||
>
|
||||
<div
|
||||
className={`w-10 h-10 rounded-xl ${st.bgColor} flex items-center justify-center shrink-0 ${st.color}`}
|
||||
>
|
||||
<StIcon size={18} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-[var(--color-text-primary)] truncate group-hover:text-violet-300 transition-colors">
|
||||
{project.title}
|
||||
</p>
|
||||
<div className="flex items-center gap-3 mt-0.5 text-[11px] text-[var(--color-text-ghost)]">
|
||||
<span>
|
||||
{new Date(project.createdAt).toLocaleDateString(
|
||||
"tr-TR",
|
||||
{
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
},
|
||||
)}
|
||||
</span>
|
||||
{project.language && <span>• {project.language}</span>}
|
||||
{typeof project.creditsUsed === "number" &&
|
||||
project.creditsUsed > 0 && (
|
||||
<span>• {project.creditsUsed} kredi</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
className={`text-[10px] font-medium px-2.5 py-1 rounded-full border ${st.color} border-current/20 ${st.bgColor} shrink-0`}
|
||||
>
|
||||
{st.label}
|
||||
</span>
|
||||
<ExternalLink
|
||||
size={14}
|
||||
className="text-[var(--color-text-ghost)] group-hover:text-violet-400 transition-colors shrink-0"
|
||||
/>
|
||||
</Link>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,78 +1,490 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { useState, useEffect } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import {
|
||||
User,
|
||||
Shield,
|
||||
CreditCard,
|
||||
Bell,
|
||||
Palette,
|
||||
Globe,
|
||||
Shield,
|
||||
LogOut,
|
||||
ChevronRight,
|
||||
Save,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Loader2,
|
||||
CheckCircle,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
useCurrentUser,
|
||||
useUpdateProfile,
|
||||
useChangePassword,
|
||||
useCreditBalance,
|
||||
useCreditHistory,
|
||||
useSubscription,
|
||||
} from "@/hooks/use-api";
|
||||
import { useToast } from "@/components/ui/toast";
|
||||
|
||||
const sections = [
|
||||
{ id: "profile", label: "Profil", icon: User, desc: "Ad, e-posta ve avatar" },
|
||||
{ id: "billing", label: "Abonelik & Fatura", icon: CreditCard, desc: "Plan, kredi ve ödeme bilgileri" },
|
||||
{ id: "notifications", label: "Bildirimler", icon: Bell, desc: "E-posta ve push bildirimleri" },
|
||||
{ id: "appearance", label: "Görünüm", icon: Palette, desc: "Tema ve dil tercihleri" },
|
||||
{ id: "language", label: "Dil", icon: Globe, desc: "Varsayılan video ve arayüz dili" },
|
||||
{ id: "security", label: "Güvenlik", icon: Shield, desc: "Şifre ve iki faktörlü doğrulama" },
|
||||
];
|
||||
const tabs = [
|
||||
{ id: "profile", label: "Profil", icon: User },
|
||||
{ id: "security", label: "Güvenlik", icon: Shield },
|
||||
{ id: "billing", label: "Abonelik", icon: CreditCard },
|
||||
{ id: "notifications", label: "Bildirimler", icon: Bell },
|
||||
] as const;
|
||||
|
||||
type TabId = (typeof tabs)[number]["id"];
|
||||
|
||||
export default function SettingsPage() {
|
||||
const [activeSection, setActiveSection] = useState("profile");
|
||||
const [activeTab, setActiveTab] = useState<TabId>("profile");
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto space-y-6">
|
||||
<div className="max-w-4xl mx-auto space-y-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="font-[family-name:var(--font-display)] text-2xl font-bold">Ayarlar</h1>
|
||||
<p className="text-sm text-[var(--color-text-muted)] mt-0.5">Hesap ve uygulama ayarlarını yönet</p>
|
||||
<h1 className="text-2xl md:text-3xl font-[family-name:var(--font-display)] font-bold text-[var(--color-text-primary)]">
|
||||
Ayarlar
|
||||
</h1>
|
||||
<p className="text-sm text-[var(--color-text-muted)] mt-1">
|
||||
Hesap ayarlarınızı ve tercihlerinizi yönetin
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{sections.map((section) => {
|
||||
const Icon = section.icon;
|
||||
{/* Tab Navigation */}
|
||||
<div className="flex gap-1 p-1 rounded-xl bg-[var(--color-bg-surface)] border border-[var(--color-border-faint)] overflow-x-auto">
|
||||
{tabs.map((tab) => {
|
||||
const Icon = tab.icon;
|
||||
const isActive = activeTab === tab.id;
|
||||
return (
|
||||
<motion.button
|
||||
key={section.id}
|
||||
onClick={() => setActiveSection(section.id)}
|
||||
whileTap={{ scale: 0.99 }}
|
||||
className={cn(
|
||||
"w-full flex items-center gap-4 p-4 rounded-xl text-left transition-all",
|
||||
activeSection === section.id
|
||||
? "bg-violet-500/8 border border-violet-500/20"
|
||||
: "card-surface hover:border-[var(--color-border-default)]"
|
||||
)}
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`relative flex items-center gap-2 px-4 py-2.5 rounded-lg text-sm font-medium transition-colors whitespace-nowrap ${
|
||||
isActive
|
||||
? "text-white"
|
||||
: "text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]"
|
||||
}`}
|
||||
>
|
||||
<div className={cn(
|
||||
"w-10 h-10 rounded-xl flex items-center justify-center shrink-0",
|
||||
activeSection === section.id
|
||||
? "bg-violet-500/15 text-violet-400"
|
||||
: "bg-[var(--color-bg-elevated)] text-[var(--color-text-muted)]"
|
||||
)}>
|
||||
<Icon size={18} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-sm font-semibold">{section.label}</h3>
|
||||
<p className="text-xs text-[var(--color-text-muted)] mt-0.5">{section.desc}</p>
|
||||
</div>
|
||||
<ChevronRight size={16} className="text-[var(--color-text-ghost)]" />
|
||||
</motion.button>
|
||||
{isActive && (
|
||||
<motion.div
|
||||
layoutId="settings-tab"
|
||||
className="absolute inset-0 rounded-lg bg-violet-500/15 border border-violet-500/20"
|
||||
transition={{ type: "spring", bounce: 0.2, duration: 0.5 }}
|
||||
/>
|
||||
)}
|
||||
<Icon size={16} className="relative z-10" />
|
||||
<span className="relative z-10">{tab.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Çıkış */}
|
||||
<button className="w-full flex items-center gap-4 p-4 rounded-xl text-left bg-rose-500/5 border border-rose-500/15 text-rose-400 hover:bg-rose-500/10 transition-colors">
|
||||
<div className="w-10 h-10 rounded-xl bg-rose-500/10 flex items-center justify-center">
|
||||
<LogOut size={18} />
|
||||
</div>
|
||||
<span className="text-sm font-semibold">Çıkış Yap</span>
|
||||
</button>
|
||||
{/* Tab Content */}
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={activeTab}
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -8 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
{activeTab === "profile" && <ProfileTab />}
|
||||
{activeTab === "security" && <SecurityTab />}
|
||||
{activeTab === "billing" && <BillingTab />}
|
||||
{activeTab === "notifications" && <NotificationsTab />}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── PROFILE TAB ─── */
|
||||
function ProfileTab() {
|
||||
const { data: userData, isLoading } = useCurrentUser();
|
||||
const updateProfile = useUpdateProfile();
|
||||
const toast = useToast();
|
||||
|
||||
const user = userData?.data ?? userData;
|
||||
|
||||
const [firstName, setFirstName] = useState("");
|
||||
const [lastName, setLastName] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
setFirstName(user.firstName ?? "");
|
||||
setLastName(user.lastName ?? "");
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
await updateProfile.mutateAsync({ firstName, lastName });
|
||||
toast.success("Profil başarıyla güncellendi");
|
||||
} catch {
|
||||
toast.error("Profil güncellenirken bir hata oluştu");
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="card p-12 flex items-center justify-center">
|
||||
<Loader2 className="animate-spin text-violet-400" size={24} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card p-6 space-y-6">
|
||||
<h2 className="text-lg font-semibold text-[var(--color-text-primary)]">
|
||||
Profil Bilgileri
|
||||
</h2>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-[var(--color-text-muted)] mb-1.5">
|
||||
Ad
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={firstName}
|
||||
onChange={(e) => setFirstName(e.target.value)}
|
||||
className="w-full px-4 py-2.5 rounded-xl bg-[var(--color-bg-deep)] border border-[var(--color-border-faint)] text-[var(--color-text-primary)] text-sm focus:outline-none focus:border-violet-500/50 focus:ring-1 focus:ring-violet-500/25 transition-all"
|
||||
placeholder="Adınız"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-[var(--color-text-muted)] mb-1.5">
|
||||
Soyad
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={lastName}
|
||||
onChange={(e) => setLastName(e.target.value)}
|
||||
className="w-full px-4 py-2.5 rounded-xl bg-[var(--color-bg-deep)] border border-[var(--color-border-faint)] text-[var(--color-text-primary)] text-sm focus:outline-none focus:border-violet-500/50 focus:ring-1 focus:ring-violet-500/25 transition-all"
|
||||
placeholder="Soyadınız"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-[var(--color-text-muted)] mb-1.5">
|
||||
E-posta
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={user?.email ?? ""}
|
||||
disabled
|
||||
className="w-full px-4 py-2.5 rounded-xl bg-[var(--color-bg-deep)] border border-[var(--color-border-faint)] text-[var(--color-text-ghost)] text-sm cursor-not-allowed"
|
||||
/>
|
||||
<p className="text-[10px] text-[var(--color-text-ghost)] mt-1">
|
||||
E-posta adresi değiştirilemez
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end pt-2">
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={updateProfile.isPending}
|
||||
className="btn-primary flex items-center gap-2 px-6 py-2.5 text-sm"
|
||||
>
|
||||
{updateProfile.isPending ? (
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
) : (
|
||||
<Save size={16} />
|
||||
)}
|
||||
Kaydet
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── SECURITY TAB ─── */
|
||||
function SecurityTab() {
|
||||
const changePassword = useChangePassword();
|
||||
const toast = useToast();
|
||||
|
||||
const [current, setCurrent] = useState("");
|
||||
const [newPw, setNewPw] = useState("");
|
||||
const [confirm, setConfirm] = useState("");
|
||||
const [showCurrent, setShowCurrent] = useState(false);
|
||||
const [showNew, setShowNew] = useState(false);
|
||||
|
||||
const handleChange = async () => {
|
||||
if (newPw !== confirm) {
|
||||
toast.warning("Yeni şifre ve onay eşleşmiyor");
|
||||
return;
|
||||
}
|
||||
if (newPw.length < 8) {
|
||||
toast.warning("Yeni şifre en az 8 karakter olmalı");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await changePassword.mutateAsync({
|
||||
currentPassword: current,
|
||||
newPassword: newPw,
|
||||
});
|
||||
toast.success("Şifre başarıyla güncellendi");
|
||||
setCurrent("");
|
||||
setNewPw("");
|
||||
setConfirm("");
|
||||
} catch {
|
||||
toast.error("Mevcut şifre hatalı veya bir sorun oluştu");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="card p-6 space-y-6">
|
||||
<h2 className="text-lg font-semibold text-[var(--color-text-primary)]">
|
||||
Şifre Değiştir
|
||||
</h2>
|
||||
|
||||
<div className="space-y-4 max-w-md">
|
||||
{/* Current */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-[var(--color-text-muted)] mb-1.5">
|
||||
Mevcut Şifre
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showCurrent ? "text" : "password"}
|
||||
value={current}
|
||||
onChange={(e) => setCurrent(e.target.value)}
|
||||
className="w-full px-4 py-2.5 pr-10 rounded-xl bg-[var(--color-bg-deep)] border border-[var(--color-border-faint)] text-[var(--color-text-primary)] text-sm focus:outline-none focus:border-violet-500/50 focus:ring-1 focus:ring-violet-500/25 transition-all"
|
||||
/>
|
||||
<button
|
||||
onClick={() => setShowCurrent(!showCurrent)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-[var(--color-text-ghost)]"
|
||||
type="button"
|
||||
>
|
||||
{showCurrent ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* New */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-[var(--color-text-muted)] mb-1.5">
|
||||
Yeni Şifre
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showNew ? "text" : "password"}
|
||||
value={newPw}
|
||||
onChange={(e) => setNewPw(e.target.value)}
|
||||
className="w-full px-4 py-2.5 pr-10 rounded-xl bg-[var(--color-bg-deep)] border border-[var(--color-border-faint)] text-[var(--color-text-primary)] text-sm focus:outline-none focus:border-violet-500/50 focus:ring-1 focus:ring-violet-500/25 transition-all"
|
||||
/>
|
||||
<button
|
||||
onClick={() => setShowNew(!showNew)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-[var(--color-text-ghost)]"
|
||||
type="button"
|
||||
>
|
||||
{showNew ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Confirm */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-[var(--color-text-muted)] mb-1.5">
|
||||
Yeni Şifre (Tekrar)
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={confirm}
|
||||
onChange={(e) => setConfirm(e.target.value)}
|
||||
className="w-full px-4 py-2.5 rounded-xl bg-[var(--color-bg-deep)] border border-[var(--color-border-faint)] text-[var(--color-text-primary)] text-sm focus:outline-none focus:border-violet-500/50 focus:ring-1 focus:ring-violet-500/25 transition-all"
|
||||
/>
|
||||
{newPw && confirm && newPw !== confirm && (
|
||||
<p className="text-[10px] text-red-400 mt-1">Şifreler eşleşmiyor</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end pt-2">
|
||||
<button
|
||||
onClick={handleChange}
|
||||
disabled={changePassword.isPending || !current || !newPw || !confirm}
|
||||
className="btn-primary flex items-center gap-2 px-6 py-2.5 text-sm disabled:opacity-40"
|
||||
>
|
||||
{changePassword.isPending ? (
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
) : (
|
||||
<Shield size={16} />
|
||||
)}
|
||||
Şifreyi Güncelle
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── BILLING TAB ─── */
|
||||
function BillingTab() {
|
||||
const { data: creditData } = useCreditBalance();
|
||||
const { data: subData } = useSubscription();
|
||||
const { data: historyData } = useCreditHistory();
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const credits = (creditData as any)?.data ?? creditData;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const subscription = (subData as any)?.data ?? subData;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const transactions = (historyData as any)?.data?.transactions ?? (historyData as any)?.transactions ?? [];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Abonelik Kartı */}
|
||||
<div className="card p-6">
|
||||
<h2 className="text-lg font-semibold text-[var(--color-text-primary)] mb-4">
|
||||
Abonelik Durumu
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="p-4 rounded-xl bg-[var(--color-bg-deep)] border border-[var(--color-border-faint)]">
|
||||
<p className="text-xs text-[var(--color-text-muted)] mb-1">Plan</p>
|
||||
<p className="text-lg font-bold text-[var(--color-text-primary)]">
|
||||
{subscription?.plan ?? "Free"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-4 rounded-xl bg-[var(--color-bg-deep)] border border-[var(--color-border-faint)]">
|
||||
<p className="text-xs text-[var(--color-text-muted)] mb-1">Kalan Kredi</p>
|
||||
<p className="text-lg font-bold text-emerald-400">
|
||||
{credits?.remaining ?? credits?.balance ?? 0}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-4 rounded-xl bg-[var(--color-bg-deep)] border border-[var(--color-border-faint)]">
|
||||
<p className="text-xs text-[var(--color-text-muted)] mb-1">Aylık Limit</p>
|
||||
<p className="text-lg font-bold text-[var(--color-text-primary)]">
|
||||
{subscription?.monthlyCredits ?? credits?.total ?? 3}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Kredi Geçmişi */}
|
||||
<div className="card p-6">
|
||||
<h2 className="text-lg font-semibold text-[var(--color-text-primary)] mb-4">
|
||||
Kredi İşlem Geçmişi
|
||||
</h2>
|
||||
{transactions.length === 0 ? (
|
||||
<p className="text-sm text-[var(--color-text-muted)] text-center py-8">
|
||||
Henüz işlem geçmişi bulunmuyor
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{transactions.slice(0, 10).map((tx: { id: string; amount: number; type: string; description: string; createdAt: string }) => (
|
||||
<div
|
||||
key={tx.id}
|
||||
className="flex items-center justify-between p-3 rounded-lg bg-[var(--color-bg-deep)] border border-[var(--color-border-faint)]"
|
||||
>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--color-text-primary)]">
|
||||
{tx.description || tx.type}
|
||||
</p>
|
||||
<p className="text-[10px] text-[var(--color-text-ghost)]">
|
||||
{new Date(tx.createdAt).toLocaleDateString("tr-TR")}
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
className={`text-sm font-bold ${
|
||||
tx.amount > 0 ? "text-emerald-400" : "text-red-400"
|
||||
}`}
|
||||
>
|
||||
{tx.amount > 0 ? `+${tx.amount}` : tx.amount}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── NOTIFICATIONS TAB ─── */
|
||||
function NotificationsTab() {
|
||||
const toast = useToast();
|
||||
const [prefs, setPrefs] = useState({
|
||||
projectComplete: true,
|
||||
creditLow: true,
|
||||
weeklyReport: false,
|
||||
marketingEmails: false,
|
||||
});
|
||||
|
||||
const toggle = (key: keyof typeof prefs) => {
|
||||
setPrefs((p) => ({ ...p, [key]: !p[key] }));
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
toast.success("Bildirim tercihleri kaydedildi");
|
||||
};
|
||||
|
||||
const notifItems = [
|
||||
{
|
||||
key: "projectComplete" as const,
|
||||
label: "Proje Tamamlandı",
|
||||
desc: "Video render işlemi tamamlandığında bildirim al",
|
||||
},
|
||||
{
|
||||
key: "creditLow" as const,
|
||||
label: "Düşük Kredi Uyarısı",
|
||||
desc: "Kredileriniz %20'nin altına düştüğünde uyarı al",
|
||||
},
|
||||
{
|
||||
key: "weeklyReport" as const,
|
||||
label: "Haftalık Rapor",
|
||||
desc: "Haftalık kullanım raporunu e-posta ile al",
|
||||
},
|
||||
{
|
||||
key: "marketingEmails" as const,
|
||||
label: "Pazarlama E-postaları",
|
||||
desc: "Yeni özellikler ve kampanyalar hakkında bilgi al",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="card p-6 space-y-6">
|
||||
<h2 className="text-lg font-semibold text-[var(--color-text-primary)]">
|
||||
Bildirim Tercihleri
|
||||
</h2>
|
||||
|
||||
<div className="space-y-3">
|
||||
{notifItems.map((item) => (
|
||||
<div
|
||||
key={item.key}
|
||||
className="flex items-center justify-between p-4 rounded-xl bg-[var(--color-bg-deep)] border border-[var(--color-border-faint)]"
|
||||
>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--color-text-primary)]">
|
||||
{item.label}
|
||||
</p>
|
||||
<p className="text-[11px] text-[var(--color-text-ghost)] mt-0.5">
|
||||
{item.desc}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => toggle(item.key)}
|
||||
className={`relative w-11 h-6 rounded-full transition-colors ${
|
||||
prefs[item.key] ? "bg-violet-500" : "bg-[var(--color-bg-elevated)]"
|
||||
}`}
|
||||
>
|
||||
<motion.div
|
||||
className="absolute top-0.5 left-0.5 w-5 h-5 rounded-full bg-white shadow-md"
|
||||
animate={{ x: prefs[item.key] ? 20 : 0 }}
|
||||
transition={{ type: "spring", bounce: 0.25, duration: 0.3 }}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end pt-2">
|
||||
<button
|
||||
onClick={handleSave}
|
||||
className="btn-primary flex items-center gap-2 px-6 py-2.5 text-sm"
|
||||
>
|
||||
<CheckCircle size={16} />
|
||||
Tercihleri Kaydet
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,99 +10,23 @@ import {
|
||||
TrendingUp,
|
||||
Clock,
|
||||
Sparkles,
|
||||
Filter,
|
||||
Loader2,
|
||||
FolderOpen,
|
||||
} from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTemplates, useCloneTemplate } from "@/hooks/use-api";
|
||||
import { useToast } from "@/components/ui/toast";
|
||||
|
||||
interface Template {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
category: string;
|
||||
language: string;
|
||||
usageCount: number;
|
||||
rating: number;
|
||||
duration: number;
|
||||
style: string;
|
||||
featured: boolean;
|
||||
}
|
||||
|
||||
const mockTemplates: Template[] = [
|
||||
{
|
||||
id: "t1",
|
||||
title: "Evrenin Gizemli Boşlukları",
|
||||
description: "Uzaydaki devasa boşlukları ve karanlık maddeyi keşfet",
|
||||
category: "Bilim",
|
||||
language: "tr",
|
||||
usageCount: 342,
|
||||
rating: 4.8,
|
||||
duration: 45,
|
||||
style: "CINEMATIC",
|
||||
featured: true,
|
||||
},
|
||||
{
|
||||
id: "t2",
|
||||
title: "5 Mind-Blowing Physics Facts",
|
||||
description: "Quantum mechanics to relativity in 60 seconds",
|
||||
category: "Education",
|
||||
language: "en",
|
||||
usageCount: 1205,
|
||||
rating: 4.9,
|
||||
duration: 60,
|
||||
style: "EDUCATIONAL",
|
||||
featured: true,
|
||||
},
|
||||
{
|
||||
id: "t3",
|
||||
title: "Mitolojik Yaratıklar",
|
||||
description: "Antik medeniyetlerin efsanevi canlıları",
|
||||
category: "Tarih",
|
||||
language: "tr",
|
||||
usageCount: 189,
|
||||
rating: 4.6,
|
||||
duration: 50,
|
||||
style: "STORYTELLING",
|
||||
featured: false,
|
||||
},
|
||||
{
|
||||
id: "t4",
|
||||
title: "Secretos del Océano Profundo",
|
||||
description: "Criaturas bioluminiscentes y volcanes submarinos",
|
||||
category: "Ciencia",
|
||||
language: "es",
|
||||
usageCount: 567,
|
||||
rating: 4.7,
|
||||
duration: 55,
|
||||
style: "DOCUMENTARY",
|
||||
featured: false,
|
||||
},
|
||||
{
|
||||
id: "t5",
|
||||
title: "AI Tüm Meslekleri Yok Edecek mi?",
|
||||
description: "Yapay zekanın iş dünyasına etkisi ve gelecek senaryoları",
|
||||
category: "Teknoloji",
|
||||
language: "tr",
|
||||
usageCount: 891,
|
||||
rating: 4.5,
|
||||
duration: 60,
|
||||
style: "NEWS",
|
||||
featured: true,
|
||||
},
|
||||
{
|
||||
id: "t6",
|
||||
title: "Die Geheimnisse der Pyramiden",
|
||||
description: "Ägyptische Pyramiden und ihre versteckten Kammern",
|
||||
category: "Geschichte",
|
||||
language: "de",
|
||||
usageCount: 234,
|
||||
rating: 4.4,
|
||||
duration: 40,
|
||||
style: "DOCUMENTARY",
|
||||
featured: false,
|
||||
},
|
||||
const categories = [
|
||||
"Tümü",
|
||||
"Bilim",
|
||||
"Teknoloji",
|
||||
"Eğitim",
|
||||
"Haber",
|
||||
"Tarih",
|
||||
"Sanat",
|
||||
];
|
||||
|
||||
const categories = ["Tümü", "Bilim", "Education", "Tarih", "Teknoloji", "Ciencia", "Geschichte"];
|
||||
const sortOptions = [
|
||||
{ id: "popular", label: "En Popüler", icon: TrendingUp },
|
||||
{ id: "newest", label: "En Yeni", icon: Clock },
|
||||
@@ -115,6 +39,9 @@ const flagEmoji: Record<string, string> = {
|
||||
es: "🇪🇸",
|
||||
de: "🇩🇪",
|
||||
fr: "🇫🇷",
|
||||
ar: "🇸🇦",
|
||||
pt: "🇧🇷",
|
||||
ja: "🇯🇵",
|
||||
};
|
||||
|
||||
const stagger = {
|
||||
@@ -124,20 +51,80 @@ const stagger = {
|
||||
|
||||
const fadeUp = {
|
||||
hidden: { opacity: 0, y: 16, scale: 0.97 },
|
||||
show: { opacity: 1, y: 0, scale: 1, transition: { duration: 0.5, ease: [0.16, 1, 0.3, 1] as const } },
|
||||
show: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
scale: 1,
|
||||
transition: { duration: 0.5, ease: [0.16, 1, 0.3, 1] as const },
|
||||
},
|
||||
};
|
||||
|
||||
interface TemplateItem {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
category: string;
|
||||
language: string;
|
||||
usageCount: number;
|
||||
rating: number;
|
||||
duration?: number;
|
||||
targetDuration?: number;
|
||||
style?: string;
|
||||
isFeatured?: boolean;
|
||||
featured?: boolean;
|
||||
thumbnailUrl?: string;
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
export default function TemplatesPage() {
|
||||
const router = useRouter();
|
||||
const toast = useToast();
|
||||
const [search, setSearch] = useState("");
|
||||
const [activeCategory, setActiveCategory] = useState("Tümü");
|
||||
const [activeSort, setActiveSort] = useState("popular");
|
||||
|
||||
const filtered = mockTemplates.filter((t) => {
|
||||
const { data, isLoading } = useTemplates({ limit: 50 });
|
||||
const cloneTemplate = useCloneTemplate();
|
||||
|
||||
// API'den gelen veriyi çıkar
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const rawTemplates: TemplateItem[] =
|
||||
(data as any)?.data?.items ?? (data as any)?.data ?? (data as any)?.items ?? data ?? [];
|
||||
const templates = Array.isArray(rawTemplates) ? rawTemplates : [];
|
||||
|
||||
// Filtreleme
|
||||
const filtered = templates.filter((t) => {
|
||||
if (activeCategory !== "Tümü" && t.category !== activeCategory) return false;
|
||||
if (search && !t.title.toLowerCase().includes(search.toLowerCase())) return false;
|
||||
if (
|
||||
search &&
|
||||
!t.title.toLowerCase().includes(search.toLowerCase()) &&
|
||||
!(t.description ?? "").toLowerCase().includes(search.toLowerCase())
|
||||
)
|
||||
return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
// Sıralama
|
||||
const sorted = [...filtered].sort((a, b) => {
|
||||
if (activeSort === "popular") return (b.usageCount ?? 0) - (a.usageCount ?? 0);
|
||||
if (activeSort === "rating") return (b.rating ?? 0) - (a.rating ?? 0);
|
||||
return 0;
|
||||
});
|
||||
|
||||
const handleClone = async (templateId: string) => {
|
||||
try {
|
||||
const result = await cloneTemplate.mutateAsync(templateId);
|
||||
toast.success("Şablon başarıyla klonlandı!");
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const projectId = (result as any)?.id;
|
||||
if (projectId) {
|
||||
router.push(`/dashboard/projects/${projectId}`);
|
||||
}
|
||||
} catch {
|
||||
toast.error("Klonlama sırasında bir hata oluştu.");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto space-y-6">
|
||||
{/* ── Başlık ── */}
|
||||
@@ -154,7 +141,10 @@ export default function TemplatesPage() {
|
||||
{/* ── Arama + Filtreler ── */}
|
||||
<div className="flex flex-col sm:flex-row gap-3">
|
||||
<div className="relative flex-1">
|
||||
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-[var(--color-text-ghost)]" />
|
||||
<Search
|
||||
size={16}
|
||||
className="absolute left-3 top-1/2 -translate-y-1/2 text-[var(--color-text-ghost)]"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
@@ -174,7 +164,7 @@ export default function TemplatesPage() {
|
||||
"flex items-center gap-1.5 px-3 py-2 rounded-xl text-xs font-medium transition-all",
|
||||
activeSort === opt.id
|
||||
? "bg-violet-500/12 text-violet-400 border border-violet-500/25"
|
||||
: "text-[var(--color-text-muted)] border border-[var(--color-border-faint)] hover:border-[var(--color-border-default)]"
|
||||
: "text-[var(--color-text-muted)] border border-[var(--color-border-faint)] hover:border-[var(--color-border-default)]",
|
||||
)}
|
||||
>
|
||||
<Icon size={13} />
|
||||
@@ -195,7 +185,7 @@ export default function TemplatesPage() {
|
||||
"px-3.5 py-1.5 rounded-full text-xs font-medium whitespace-nowrap transition-all",
|
||||
activeCategory === cat
|
||||
? "bg-violet-500/15 text-violet-400 border border-violet-500/25"
|
||||
: "text-[var(--color-text-muted)] border border-[var(--color-border-faint)] hover:border-[var(--color-border-default)]"
|
||||
: "text-[var(--color-text-muted)] border border-[var(--color-border-faint)] hover:border-[var(--color-border-default)]",
|
||||
)}
|
||||
>
|
||||
{cat}
|
||||
@@ -203,68 +193,123 @@ export default function TemplatesPage() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* ── Grid ── */}
|
||||
<motion.div
|
||||
variants={stagger}
|
||||
initial="hidden"
|
||||
animate="show"
|
||||
className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4"
|
||||
>
|
||||
{filtered.map((template) => (
|
||||
<motion.div key={template.id} variants={fadeUp}>
|
||||
<div className="group card-surface overflow-hidden hover:border-violet-500/20">
|
||||
{/* Cover */}
|
||||
<div className="relative h-36 bg-gradient-to-br from-[var(--color-bg-elevated)] to-[var(--color-bg-surface)] flex items-center justify-center overflow-hidden">
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-violet-500/5 to-cyan-500/5" />
|
||||
<span className="text-4xl opacity-70">{flagEmoji[template.language] || "🌍"}</span>
|
||||
{template.featured && (
|
||||
<span className="absolute top-3 left-3 badge badge-violet text-[9px]">
|
||||
✨ Öne Çıkan
|
||||
</span>
|
||||
)}
|
||||
<div className="absolute top-3 right-3 flex items-center gap-1 badge badge-amber text-[10px]">
|
||||
<Star size={10} className="fill-amber-400" />
|
||||
{template.rating}
|
||||
</div>
|
||||
{/* Hover overlay */}
|
||||
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-3">
|
||||
<button className="w-10 h-10 rounded-xl bg-white/10 backdrop-blur flex items-center justify-center text-white hover:bg-white/20 transition-colors">
|
||||
<Eye size={18} />
|
||||
</button>
|
||||
<button className="w-10 h-10 rounded-xl bg-violet-500/80 backdrop-blur flex items-center justify-center text-white hover:bg-violet-500 transition-colors">
|
||||
<Copy size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="p-4">
|
||||
<h3 className="text-sm font-semibold line-clamp-1 group-hover:text-violet-300 transition-colors">
|
||||
{template.title}
|
||||
</h3>
|
||||
<p className="text-xs text-[var(--color-text-muted)] mt-1 line-clamp-2">
|
||||
{template.description}
|
||||
</p>
|
||||
<div className="flex items-center justify-between mt-3">
|
||||
<div className="flex items-center gap-2 text-[10px] text-[var(--color-text-ghost)]">
|
||||
<span>{template.duration}s</span>
|
||||
<span>•</span>
|
||||
<span>{template.usageCount} kullanım</span>
|
||||
</div>
|
||||
<button className="flex items-center gap-1 text-[11px] font-medium text-violet-400 hover:text-violet-300 transition-colors">
|
||||
<Copy size={12} />
|
||||
Klonla
|
||||
</button>
|
||||
</div>
|
||||
{/* ── Loading ── */}
|
||||
{isLoading && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{[1, 2, 3, 4, 5, 6].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="card-surface overflow-hidden animate-pulse"
|
||||
>
|
||||
<div className="h-36 bg-[var(--color-bg-elevated)]" />
|
||||
<div className="p-4 space-y-2">
|
||||
<div className="h-4 bg-[var(--color-bg-elevated)] rounded w-3/4" />
|
||||
<div className="h-3 bg-[var(--color-bg-elevated)] rounded w-1/2" />
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filtered.length === 0 && (
|
||||
{/* ── Grid ── */}
|
||||
{!isLoading && (
|
||||
<motion.div
|
||||
variants={stagger}
|
||||
initial="hidden"
|
||||
animate="show"
|
||||
className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4"
|
||||
>
|
||||
{sorted.map((template) => (
|
||||
<motion.div key={template.id} variants={fadeUp}>
|
||||
<div className="group card-surface overflow-hidden hover:border-violet-500/20">
|
||||
{/* Cover */}
|
||||
<div className="relative h-36 bg-gradient-to-br from-[var(--color-bg-elevated)] to-[var(--color-bg-surface)] flex items-center justify-center overflow-hidden">
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-violet-500/5 to-cyan-500/5" />
|
||||
{template.thumbnailUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={template.thumbnailUrl}
|
||||
alt={template.title}
|
||||
className="absolute inset-0 w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-4xl opacity-70">
|
||||
{flagEmoji[template.language] || "🌍"}
|
||||
</span>
|
||||
)}
|
||||
{(template.isFeatured || template.featured) && (
|
||||
<span className="absolute top-3 left-3 badge badge-violet text-[9px]">
|
||||
✨ Öne Çıkan
|
||||
</span>
|
||||
)}
|
||||
{template.rating > 0 && (
|
||||
<div className="absolute top-3 right-3 flex items-center gap-1 badge badge-amber text-[10px]">
|
||||
<Star size={10} className="fill-amber-400" />
|
||||
{template.rating.toFixed(1)}
|
||||
</div>
|
||||
)}
|
||||
{/* Hover overlay */}
|
||||
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-3">
|
||||
<button className="w-10 h-10 rounded-xl bg-white/10 backdrop-blur flex items-center justify-center text-white hover:bg-white/20 transition-colors">
|
||||
<Eye size={18} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleClone(template.id)}
|
||||
disabled={cloneTemplate.isPending}
|
||||
className="w-10 h-10 rounded-xl bg-violet-500/80 backdrop-blur flex items-center justify-center text-white hover:bg-violet-500 transition-colors"
|
||||
>
|
||||
{cloneTemplate.isPending ? (
|
||||
<Loader2 size={18} className="animate-spin" />
|
||||
) : (
|
||||
<Copy size={18} />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="p-4">
|
||||
<h3 className="text-sm font-semibold line-clamp-1 group-hover:text-violet-300 transition-colors">
|
||||
{template.title}
|
||||
</h3>
|
||||
<p className="text-xs text-[var(--color-text-muted)] mt-1 line-clamp-2">
|
||||
{template.description || "—"}
|
||||
</p>
|
||||
<div className="flex items-center justify-between mt-3">
|
||||
<div className="flex items-center gap-2 text-[10px] text-[var(--color-text-ghost)]">
|
||||
<span>
|
||||
{template.duration ?? template.targetDuration ?? "—"}s
|
||||
</span>
|
||||
<span>•</span>
|
||||
<span>{template.usageCount} kullanım</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleClone(template.id)}
|
||||
disabled={cloneTemplate.isPending}
|
||||
className="flex items-center gap-1 text-[11px] font-medium text-violet-400 hover:text-violet-300 transition-colors"
|
||||
>
|
||||
<Copy size={12} />
|
||||
Klonla
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{!isLoading && sorted.length === 0 && (
|
||||
<div className="text-center py-16">
|
||||
<p className="text-[var(--color-text-muted)]">Aramanızla eşleşen şablon bulunamadı</p>
|
||||
<FolderOpen
|
||||
size={40}
|
||||
className="mx-auto text-[var(--color-text-ghost)] mb-3"
|
||||
/>
|
||||
<p className="text-[var(--color-text-muted)]">
|
||||
{search || activeCategory !== "Tümü"
|
||||
? "Aramanızla eşleşen şablon bulunamadı"
|
||||
: "Henüz şablon eklenmemiş"}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,418 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import {
|
||||
AtSign,
|
||||
Link2,
|
||||
Loader2,
|
||||
ArrowRight,
|
||||
Clock,
|
||||
Palette,
|
||||
Monitor,
|
||||
Smartphone,
|
||||
Square,
|
||||
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";
|
||||
|
||||
const videoStyles = [
|
||||
{ id: "CINEMATIC", label: "Sinematik", emoji: "🎬" },
|
||||
{ id: "DOCUMENTARY", label: "Belgesel", emoji: "📹" },
|
||||
{ id: "EDUCATIONAL", label: "Eğitim", emoji: "📚" },
|
||||
{ id: "STORYTELLING", label: "Hikâye", emoji: "📖" },
|
||||
{ id: "NEWS", label: "Haber", emoji: "📰" },
|
||||
];
|
||||
|
||||
const aspectRatios = [
|
||||
{ id: "PORTRAIT_9_16", label: "9:16", icon: Smartphone, desc: "Shorts / Reels" },
|
||||
{ id: "SQUARE_1_1", label: "1:1", icon: Square, desc: "Instagram" },
|
||||
{ id: "LANDSCAPE_16_9", label: "16:9", icon: Monitor, desc: "YouTube" },
|
||||
];
|
||||
|
||||
const languages = [
|
||||
{ code: "tr", label: "Türkçe", flag: "🇹🇷" },
|
||||
{ code: "en", label: "English", flag: "🇺🇸" },
|
||||
{ code: "de", label: "Deutsch", flag: "🇩🇪" },
|
||||
{ code: "es", label: "Español", flag: "🇪🇸" },
|
||||
];
|
||||
|
||||
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 [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,
|
||||
targetDuration: duration,
|
||||
});
|
||||
toast.success("Tweet → Video projesi oluşturuldu!");
|
||||
const projectId = result?.id ?? result?.data?.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">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-violet-500/10 border border-violet-500/20 text-violet-300 text-xs font-medium mb-3">
|
||||
<AtSign size={12} />
|
||||
X → Video
|
||||
</div>
|
||||
<h1 className="font-[family-name:var(--font-display)] text-2xl md:text-3xl font-bold">
|
||||
Tweet'ten Video Oluştur
|
||||
</h1>
|
||||
<p className="text-sm text-[var(--color-text-muted)] mt-1">
|
||||
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-violet-500/40 focus:ring-1 focus:ring-violet-500/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.author?.name ?? previewData.authorName ?? "X")?.[0]}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-bold text-[var(--color-text-primary)]">
|
||||
{previewData.author?.name ?? previewData.authorName ?? "Kullanıcı"}
|
||||
</p>
|
||||
<p className="text-[11px] text-[var(--color-text-ghost)]">
|
||||
@{previewData.author?.handle ?? previewData.authorHandle ?? "handle"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<p className="text-sm text-[var(--color-text-secondary)] whitespace-pre-line">
|
||||
{previewData.text ?? previewData.content ?? ""}
|
||||
</p>
|
||||
|
||||
{/* Images */}
|
||||
{(previewData.images?.length > 0 || previewData.mediaUrls?.length > 0) && (
|
||||
<div className="flex gap-2 overflow-x-auto">
|
||||
{(previewData.images ?? previewData.mediaUrls ?? []).map(
|
||||
(url: string, 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={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.likes ?? previewData.stats?.likes ?? 0}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Repeat2 size={12} />
|
||||
{previewData.retweets ?? previewData.stats?.retweets ?? 0}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Eye size={12} />
|
||||
{previewData.views ?? previewData.stats?.views ?? 0}
|
||||
</span>
|
||||
{previewData.threadLength > 1 && (
|
||||
<span className="flex items-center gap-1 text-violet-400">
|
||||
<MessageSquare size={12} />
|
||||
{previewData.threadLength} tweet thread
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Images tag */}
|
||||
{(previewData.images?.length > 0 || previewData.mediaUrls?.length > 0) && (
|
||||
<div className="flex items-center gap-1.5 text-[11px] text-cyan-400">
|
||||
<ImageIcon size={12} />
|
||||
{(previewData.images ?? previewData.mediaUrls ?? []).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"
|
||||
>
|
||||
{/* Language */}
|
||||
<div className="card p-5 space-y-3">
|
||||
<label className="text-sm font-medium text-[var(--color-text-secondary)]">
|
||||
Video Dili
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
{languages.map((l) => (
|
||||
<button
|
||||
key={l.code}
|
||||
onClick={() => setLanguage(l.code)}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 px-3 py-2 rounded-xl text-xs transition-all",
|
||||
language === l.code
|
||||
? "bg-violet-500/12 border border-violet-500/30 text-violet-300"
|
||||
: "bg-[var(--color-bg-surface)] border border-[var(--color-border-faint)] text-[var(--color-text-muted)]",
|
||||
)}
|
||||
>
|
||||
<span>{l.flag}</span>
|
||||
{l.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Style */}
|
||||
<div className="card p-5 space-y-3">
|
||||
<label className="text-sm font-medium text-[var(--color-text-secondary)]">
|
||||
<Palette size={14} className="inline mr-1.5 text-violet-400" />
|
||||
Video Stili
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{videoStyles.map((s) => (
|
||||
<button
|
||||
key={s.id}
|
||||
onClick={() => setStyle(s.id)}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 px-3 py-2 rounded-xl text-xs transition-all",
|
||||
style === s.id
|
||||
? "bg-violet-500/12 border border-violet-500/30 text-violet-300"
|
||||
: "bg-[var(--color-bg-surface)] border border-[var(--color-border-faint)] text-[var(--color-text-muted)]",
|
||||
)}
|
||||
>
|
||||
<span>{s.emoji}</span>
|
||||
{s.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Duration + Aspect Ratio */}
|
||||
<div className="card p-5 space-y-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-[var(--color-text-secondary)] mb-3 block">
|
||||
<Clock size={14} className="inline mr-1.5 text-cyan-400" />
|
||||
Hedef Süre: <span className="text-violet-400">{duration}s</span>
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min={15}
|
||||
max={120}
|
||||
step={5}
|
||||
value={duration}
|
||||
onChange={(e) => setDuration(Number(e.target.value))}
|
||||
className="w-full h-1.5 rounded-full bg-[var(--color-bg-elevated)] appearance-none cursor-pointer
|
||||
[&::-webkit-slider-thumb]:appearance-none
|
||||
[&::-webkit-slider-thumb]:w-5 [&::-webkit-slider-thumb]:h-5
|
||||
[&::-webkit-slider-thumb]:rounded-full
|
||||
[&::-webkit-slider-thumb]:bg-violet-500
|
||||
[&::-webkit-slider-thumb]:shadow-[0_0_12px_rgba(139,92,246,0.4)]
|
||||
[&::-webkit-slider-thumb]:cursor-grab"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-medium text-[var(--color-text-secondary)] mb-3 block">
|
||||
En-Boy Oranı
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
{aspectRatios.map((ar) => {
|
||||
const Icon = ar.icon;
|
||||
return (
|
||||
<button
|
||||
key={ar.id}
|
||||
onClick={() => setAspectRatio(ar.id)}
|
||||
className={cn(
|
||||
"flex-1 flex flex-col items-center gap-1.5 py-3 rounded-xl text-xs transition-all",
|
||||
aspectRatio === ar.id
|
||||
? "bg-violet-500/12 border border-violet-500/30 text-violet-300"
|
||||
: "bg-[var(--color-bg-surface)] border border-[var(--color-border-faint)] text-[var(--color-text-muted)]",
|
||||
)}
|
||||
>
|
||||
<Icon size={20} />
|
||||
<span className="font-semibold">{ar.label}</span>
|
||||
<span className="text-[10px] text-[var(--color-text-ghost)]">
|
||||
{ar.desc}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Generate Button */}
|
||||
<button
|
||||
onClick={handleGenerate}
|
||||
disabled={createFromTweet.isPending}
|
||||
className={cn(
|
||||
"w-full py-4 rounded-xl font-semibold text-base flex items-center justify-center gap-2 transition-all",
|
||||
createFromTweet.isPending
|
||||
? "bg-violet-500/20 text-violet-300 cursor-wait"
|
||||
: "btn-primary text-lg",
|
||||
)}
|
||||
>
|
||||
{createFromTweet.isPending ? (
|
||||
<>
|
||||
<Loader2 size={20} className="animate-spin" />
|
||||
<span>Video Projesi Oluşturuluyor...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Wand2 size={20} />
|
||||
<span>Tweet → Video Oluştur</span>
|
||||
<ArrowRight size={16} />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<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-gradient-to-br from-violet-500/5 to-cyan-500/5">
|
||||
<div className="flex items-start gap-3">
|
||||
<Sparkles size={20} className="text-violet-400 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user