generated from fahricansecer/boilerplate-fe
main
Some checks failed
UI Deploy (Next-Auth Support) 🎨 / build-and-deploy (push) Has been cancelled
Some checks failed
UI Deploy (Next-Auth Support) 🎨 / build-and-deploy (push) Has been cancelled
This commit is contained in:
15
.env
Normal file
15
.env
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
# NextAuth Configuration
|
||||||
|
NEXTAUTH_URL=http://localhost:3001
|
||||||
|
NEXTAUTH_SECRET=local-dev-secret-key-contgen-ai-2026
|
||||||
|
|
||||||
|
# Backend API URL
|
||||||
|
NEXT_PUBLIC_API_URL=http://localhost:3000/api
|
||||||
|
|
||||||
|
# Auth Mode: true = login required, false = public access
|
||||||
|
NEXT_PUBLIC_AUTH_REQUIRED=false
|
||||||
|
|
||||||
|
# Mock Mode: true = skip backend auth, use local mock
|
||||||
|
NEXT_PUBLIC_ENABLE_MOCK_MODE=true
|
||||||
|
|
||||||
|
# Google API (optional)
|
||||||
|
NEXT_PUBLIC_GOOGLE_API_KEY=
|
||||||
2
next-env.d.ts
vendored
2
next-env.d.ts
vendored
@@ -1,6 +1,6 @@
|
|||||||
/// <reference types="next" />
|
/// <reference types="next" />
|
||||||
/// <reference types="next/image-types/global" />
|
/// <reference types="next/image-types/global" />
|
||||||
import "./.next/types/routes.d.ts";
|
import "./.next/dev/types/routes.d.ts";
|
||||||
|
|
||||||
// NOTE: This file should not be edited
|
// NOTE: This file should not be edited
|
||||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||||
|
|||||||
@@ -3,9 +3,6 @@ import createNextIntlPlugin from "next-intl/plugin";
|
|||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
output: 'standalone',
|
output: 'standalone',
|
||||||
experimental: {
|
|
||||||
optimizePackageImports: ["@chakra-ui/react"],
|
|
||||||
},
|
|
||||||
reactCompiler: true,
|
reactCompiler: true,
|
||||||
async rewrites() {
|
async rewrites() {
|
||||||
return [
|
return [
|
||||||
|
|||||||
10
package.json
10
package.json
@@ -13,18 +13,28 @@
|
|||||||
"@emotion/react": "^11.14.0",
|
"@emotion/react": "^11.14.0",
|
||||||
"@google/genai": "^1.35.0",
|
"@google/genai": "^1.35.0",
|
||||||
"@hookform/resolvers": "^5.2.2",
|
"@hookform/resolvers": "^5.2.2",
|
||||||
|
"@tailwindcss/postcss": "^4.2.2",
|
||||||
"@tanstack/react-query": "^5.90.16",
|
"@tanstack/react-query": "^5.90.16",
|
||||||
|
"autoprefixer": "^10.4.27",
|
||||||
"axios": "^1.13.1",
|
"axios": "^1.13.1",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"framer-motion": "^12.38.0",
|
||||||
"i18next": "^25.6.0",
|
"i18next": "^25.6.0",
|
||||||
|
"lucide-react": "^1.7.0",
|
||||||
"next": "16.0.0",
|
"next": "16.0.0",
|
||||||
"next-auth": "^4.24.13",
|
"next-auth": "^4.24.13",
|
||||||
"next-intl": "^4.4.0",
|
"next-intl": "^4.4.0",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"nextjs-toploader": "^3.9.17",
|
"nextjs-toploader": "^3.9.17",
|
||||||
|
"postcss": "^8.5.8",
|
||||||
"react": "19.2.0",
|
"react": "19.2.0",
|
||||||
"react-dom": "19.2.0",
|
"react-dom": "19.2.0",
|
||||||
"react-hook-form": "^7.65.0",
|
"react-hook-form": "^7.65.0",
|
||||||
"react-icons": "^5.5.0",
|
"react-icons": "^5.5.0",
|
||||||
|
"recharts": "^3.8.1",
|
||||||
|
"tailwind-merge": "^3.5.0",
|
||||||
|
"tailwindcss": "^4.2.2",
|
||||||
"yup": "^1.7.1"
|
"yup": "^1.7.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
7417
pnpm-lock.yaml
generated
Normal file
7417
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
8
postcss.config.mjs
Normal file
8
postcss.config.mjs
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
/** @type {import('postcss-load-config').Config} */
|
||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
"@tailwindcss/postcss": {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
241
src/app/[locale]/(dashboard)/dashboard/page.tsx
Normal file
241
src/app/[locale]/(dashboard)/dashboard/page.tsx
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import {
|
||||||
|
FolderOpen,
|
||||||
|
PlayCircle,
|
||||||
|
CheckCircle2,
|
||||||
|
Coins,
|
||||||
|
Plus,
|
||||||
|
ArrowUpRight,
|
||||||
|
Clock,
|
||||||
|
Sparkles,
|
||||||
|
TrendingUp,
|
||||||
|
Loader2,
|
||||||
|
Twitter,
|
||||||
|
} from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { DashboardCharts } from "@/components/dashboard/dashboard-charts";
|
||||||
|
import { RecentProjects } from "@/components/dashboard/recent-projects";
|
||||||
|
import { TweetImportCard } from "@/components/dashboard/tweet-import-card";
|
||||||
|
import { useDashboardStats, useCreditBalance } from "@/hooks/use-api";
|
||||||
|
|
||||||
|
const stagger = {
|
||||||
|
hidden: { opacity: 0 },
|
||||||
|
show: {
|
||||||
|
opacity: 1,
|
||||||
|
transition: { staggerChildren: 0.08 },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const fadeUp = {
|
||||||
|
hidden: { opacity: 0, y: 16 },
|
||||||
|
show: { opacity: 1, y: 0, transition: { duration: 0.5, ease: [0.16, 1, 0.3, 1] as const } },
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mock-mode: Backend yokken statik veri kullan
|
||||||
|
const isMockMode = process.env.NEXT_PUBLIC_ENABLE_MOCK_MODE === "true";
|
||||||
|
|
||||||
|
const MOCK_STATS = {
|
||||||
|
totalProjects: 12,
|
||||||
|
completedVideos: 8,
|
||||||
|
activeRenderJobs: 2,
|
||||||
|
totalCreditsUsed: 27,
|
||||||
|
creditsRemaining: 47,
|
||||||
|
};
|
||||||
|
|
||||||
|
function getStatCards(data?: typeof MOCK_STATS, creditBalance?: { balance: number; monthlyLimit: number }) {
|
||||||
|
const stats = data ?? MOCK_STATS;
|
||||||
|
const credits = creditBalance ?? { balance: stats.creditsRemaining, monthlyLimit: 50 };
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: "Toplam Proje",
|
||||||
|
value: String(stats.totalProjects),
|
||||||
|
change: `${stats.completedVideos} tamamlandı`,
|
||||||
|
icon: FolderOpen,
|
||||||
|
gradient: "from-violet-500/12 to-violet-600/5",
|
||||||
|
iconBg: "bg-violet-500/12",
|
||||||
|
iconColor: "text-violet-400",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Devam Eden",
|
||||||
|
value: String(stats.activeRenderJobs),
|
||||||
|
change: "İşleniyor",
|
||||||
|
icon: PlayCircle,
|
||||||
|
gradient: "from-cyan-500/12 to-cyan-600/5",
|
||||||
|
iconBg: "bg-cyan-500/12",
|
||||||
|
iconColor: "text-cyan-400",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Tamamlanan",
|
||||||
|
value: String(stats.completedVideos),
|
||||||
|
change: "Bu ay",
|
||||||
|
icon: CheckCircle2,
|
||||||
|
gradient: "from-emerald-500/12 to-emerald-600/5",
|
||||||
|
iconBg: "bg-emerald-500/12",
|
||||||
|
iconColor: "text-emerald-400",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Kalan Kredi",
|
||||||
|
value: String(credits.balance),
|
||||||
|
change: `${credits.monthlyLimit} üzerinden`,
|
||||||
|
icon: Coins,
|
||||||
|
gradient: "from-amber-500/12 to-amber-600/5",
|
||||||
|
iconBg: "bg-amber-500/12",
|
||||||
|
iconColor: "text-amber-400",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DashboardPage() {
|
||||||
|
// Real API hook'ları — mock modunda çağrılmaz
|
||||||
|
const statsQuery = useDashboardStats();
|
||||||
|
const creditQuery = useCreditBalance();
|
||||||
|
|
||||||
|
// Mock/Real veri birleştir
|
||||||
|
const isLoading = !isMockMode && (statsQuery.isLoading || creditQuery.isLoading);
|
||||||
|
const statsData = isMockMode ? MOCK_STATS : (statsQuery.data as typeof MOCK_STATS | undefined);
|
||||||
|
const creditData = isMockMode ? undefined : (creditQuery.data as { balance: number; monthlyLimit: number } | undefined);
|
||||||
|
|
||||||
|
const statCards = getStatCards(statsData, creditData);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
variants={stagger}
|
||||||
|
initial="hidden"
|
||||||
|
animate="show"
|
||||||
|
className="space-y-6 max-w-7xl mx-auto"
|
||||||
|
>
|
||||||
|
{/* ── Başlık + CTA ── */}
|
||||||
|
<motion.div variants={fadeUp} className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="font-[family-name:var(--font-display)] text-2xl md:text-3xl font-bold tracking-tight">
|
||||||
|
Hoş geldin 👋
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-[var(--color-text-muted)] mt-1">
|
||||||
|
AI ile videolarını oluşturmaya devam et
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
href="/dashboard/projects/new"
|
||||||
|
className="btn-primary flex items-center gap-2 text-sm"
|
||||||
|
>
|
||||||
|
<Plus size={16} />
|
||||||
|
<span className="hidden sm:inline">Yeni Proje</span>
|
||||||
|
</Link>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* ── İstatistik Kartları ── */}
|
||||||
|
<motion.div variants={fadeUp} className="grid grid-cols-2 lg:grid-cols-4 gap-3 md:gap-4">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="col-span-4 flex items-center justify-center py-12">
|
||||||
|
<Loader2 size={28} className="animate-spin text-violet-400" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
statCards.map((stat) => {
|
||||||
|
const Icon = stat.icon;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={stat.label}
|
||||||
|
className={`card-surface p-4 md:p-5 bg-gradient-to-br ${stat.gradient}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<div className={`w-9 h-9 rounded-xl ${stat.iconBg} flex items-center justify-center`}>
|
||||||
|
<Icon size={18} className={stat.iconColor} />
|
||||||
|
</div>
|
||||||
|
<ArrowUpRight size={14} className="text-[var(--color-text-ghost)]" />
|
||||||
|
</div>
|
||||||
|
<div className="font-[family-name:var(--font-display)] text-2xl md:text-3xl font-bold">
|
||||||
|
{stat.value}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between mt-1">
|
||||||
|
<span className="text-xs text-[var(--color-text-muted)]">{stat.label}</span>
|
||||||
|
<span className="text-[10px] text-[var(--color-text-ghost)]">{stat.change}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* ── Hızlı Eylemler ── */}
|
||||||
|
<motion.div variants={fadeUp} className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
|
||||||
|
<Link
|
||||||
|
href="/dashboard/projects/new"
|
||||||
|
className="group card-surface p-4 flex items-center gap-4 hover:border-violet-500/30"
|
||||||
|
>
|
||||||
|
<div className="w-11 h-11 rounded-xl bg-gradient-to-br from-violet-500 to-violet-700 flex items-center justify-center shrink-0 shadow-lg group-hover:shadow-violet-500/20 transition-shadow">
|
||||||
|
<Sparkles size={20} className="text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold">AI ile Video Oluştur</h3>
|
||||||
|
<p className="text-xs text-[var(--color-text-muted)] mt-0.5">Bir konu gir, gerisini AI halletsin</p>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
href="/dashboard/templates"
|
||||||
|
className="group card-surface p-4 flex items-center gap-4 hover:border-cyan-500/30"
|
||||||
|
>
|
||||||
|
<div className="w-11 h-11 rounded-xl bg-gradient-to-br from-cyan-500 to-cyan-700 flex items-center justify-center shrink-0 shadow-lg group-hover:shadow-cyan-500/20 transition-shadow">
|
||||||
|
<TrendingUp size={20} className="text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold">Şablon Keşfet</h3>
|
||||||
|
<p className="text-xs text-[var(--color-text-muted)] mt-0.5">Topluluk şablonlarını kullan</p>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
href="/dashboard/projects"
|
||||||
|
className="group card-surface p-4 flex items-center gap-4 hover:border-emerald-500/30"
|
||||||
|
>
|
||||||
|
<div className="w-11 h-11 rounded-xl bg-gradient-to-br from-emerald-500 to-emerald-700 flex items-center justify-center shrink-0 shadow-lg group-hover:shadow-emerald-500/20 transition-shadow">
|
||||||
|
<Clock size={20} className="text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold">Devam Eden İşler</h3>
|
||||||
|
<p className="text-xs text-[var(--color-text-muted)] mt-0.5">İşlenen videolarını takip et</p>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
href="#tweet-import"
|
||||||
|
className="group card-surface p-4 flex items-center gap-4 hover:border-sky-500/30"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
document.getElementById('tweet-import')?.scrollIntoView({ behavior: 'smooth' });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<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" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold">Tweet → Video</h3>
|
||||||
|
<p className="text-xs text-[var(--color-text-muted)] mt-0.5">Viral tweet'leri videoya dönüştür</p>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* ── Tweet Import + Grafikler ── */}
|
||||||
|
<motion.div variants={fadeUp} className="grid grid-cols-1 lg:grid-cols-5 gap-4">
|
||||||
|
<div id="tweet-import" className="lg:col-span-2">
|
||||||
|
<TweetImportCard
|
||||||
|
onProjectCreated={(id) => {
|
||||||
|
window.location.href = `/dashboard/projects/${id}`;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="lg:col-span-3">
|
||||||
|
<DashboardCharts />
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* ── Son Projeler ── */}
|
||||||
|
<motion.div variants={fadeUp}>
|
||||||
|
<RecentProjects />
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
251
src/app/[locale]/(dashboard)/dashboard/pricing/page.tsx
Normal file
251
src/app/[locale]/(dashboard)/dashboard/pricing/page.tsx
Normal file
@@ -0,0 +1,251 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import {
|
||||||
|
Check,
|
||||||
|
X,
|
||||||
|
Zap,
|
||||||
|
Crown,
|
||||||
|
Rocket,
|
||||||
|
Sparkles,
|
||||||
|
ArrowRight,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
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",
|
||||||
|
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,
|
||||||
|
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 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "business",
|
||||||
|
name: "Business",
|
||||||
|
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",
|
||||||
|
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 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
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>
|
||||||
|
</motion.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";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
key={plan.id}
|
||||||
|
initial={{ opacity: 0, y: 24 }}
|
||||||
|
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"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{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>
|
||||||
|
</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>
|
||||||
|
|
||||||
|
{/* Fiyat */}
|
||||||
|
<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>
|
||||||
|
{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>
|
||||||
|
</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>
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
370
src/app/[locale]/(dashboard)/dashboard/projects/new/page.tsx
Normal file
370
src/app/[locale]/(dashboard)/dashboard/projects/new/page.tsx
Normal file
@@ -0,0 +1,370 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
ArrowRight,
|
||||||
|
Sparkles,
|
||||||
|
Languages,
|
||||||
|
Clock,
|
||||||
|
Palette,
|
||||||
|
Monitor,
|
||||||
|
Smartphone,
|
||||||
|
Square,
|
||||||
|
Loader2,
|
||||||
|
Check,
|
||||||
|
Wand2,
|
||||||
|
} from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const steps = ["Konu & Dil", "Stil & Süre", "AI Senaryo"];
|
||||||
|
|
||||||
|
const languages = [
|
||||||
|
{ code: "tr", label: "Türkçe", flag: "🇹🇷" },
|
||||||
|
{ code: "en", label: "English", flag: "🇺🇸" },
|
||||||
|
{ code: "es", label: "Español", flag: "🇪🇸" },
|
||||||
|
{ code: "de", label: "Deutsch", flag: "🇩🇪" },
|
||||||
|
{ code: "fr", label: "Français", flag: "🇫🇷" },
|
||||||
|
{ code: "ar", label: "العربية", flag: "🇸🇦" },
|
||||||
|
{ code: "pt", label: "Português", flag: "🇧🇷" },
|
||||||
|
{ code: "ja", label: "日本語", flag: "🇯🇵" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const videoStyles = [
|
||||||
|
{ id: "CINEMATIC", label: "Sinematik", emoji: "🎬", desc: "Film kalitesinde görseller" },
|
||||||
|
{ id: "DOCUMENTARY", label: "Belgesel", emoji: "📹", desc: "Bilimsel ve ciddi ton" },
|
||||||
|
{ id: "EDUCATIONAL", label: "Eğitim", emoji: "📚", desc: "Öğretici ve açıklayıcı" },
|
||||||
|
{ id: "STORYTELLING", label: "Hikaye", emoji: "📖", desc: "Anlatı odaklı, sürükleyici" },
|
||||||
|
{ id: "NEWS", label: "Haber", emoji: "📰", desc: "Güncel ve bilgilendirici" },
|
||||||
|
{ id: "ARTISTIC", label: "Sanatsal", emoji: "🎨", desc: "Yaratıcı ve sıra dışı" },
|
||||||
|
];
|
||||||
|
|
||||||
|
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" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function NewProjectPage() {
|
||||||
|
const [currentStep, setCurrentStep] = useState(0);
|
||||||
|
const [isGenerating, setIsGenerating] = useState(false);
|
||||||
|
|
||||||
|
// Form state
|
||||||
|
const [topic, setTopic] = useState("");
|
||||||
|
const [language, setLanguage] = useState("tr");
|
||||||
|
const [style, setStyle] = useState("CINEMATIC");
|
||||||
|
const [duration, setDuration] = useState(60);
|
||||||
|
const [aspectRatio, setAspectRatio] = useState("PORTRAIT_9_16");
|
||||||
|
|
||||||
|
const canProceed = currentStep === 0 ? topic.trim().length >= 5 : true;
|
||||||
|
|
||||||
|
const handleGenerate = async () => {
|
||||||
|
setIsGenerating(true);
|
||||||
|
// API çağrısı burada yapılacak
|
||||||
|
setTimeout(() => setIsGenerating(false), 3000);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-2xl mx-auto">
|
||||||
|
{/* Geri + Başlık */}
|
||||||
|
<div className="flex items-center gap-3 mb-6">
|
||||||
|
<Link
|
||||||
|
href="/dashboard/projects"
|
||||||
|
className="w-9 h-9 rounded-xl flex items-center justify-center text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)] hover:bg-[var(--color-bg-elevated)] transition-colors"
|
||||||
|
>
|
||||||
|
<ArrowLeft size={18} />
|
||||||
|
</Link>
|
||||||
|
<div>
|
||||||
|
<h1 className="font-[family-name:var(--font-display)] text-xl font-bold">Yeni Proje</h1>
|
||||||
|
<p className="text-xs text-[var(--color-text-muted)]">AI ile video oluştur</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Step Indicator */}
|
||||||
|
<div className="flex items-center gap-2 mb-8">
|
||||||
|
{steps.map((step, i) => (
|
||||||
|
<div key={step} className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => i < currentStep && setCurrentStep(i)}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 px-3 py-1.5 rounded-full text-xs font-medium transition-all",
|
||||||
|
i === currentStep
|
||||||
|
? "bg-violet-500/15 text-violet-400 border border-violet-500/25"
|
||||||
|
: i < currentStep
|
||||||
|
? "bg-emerald-500/10 text-emerald-400 border border-emerald-500/20 cursor-pointer"
|
||||||
|
: "text-[var(--color-text-ghost)] border border-[var(--color-border-faint)]"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{i < currentStep ? (
|
||||||
|
<Check size={12} />
|
||||||
|
) : (
|
||||||
|
<span className="w-4 h-4 rounded-full border border-current flex items-center justify-center text-[10px]">
|
||||||
|
{i + 1}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="hidden sm:inline">{step}</span>
|
||||||
|
</button>
|
||||||
|
{i < steps.length - 1 && (
|
||||||
|
<div className={cn(
|
||||||
|
"w-6 h-px",
|
||||||
|
i < currentStep ? "bg-emerald-500/40" : "bg-[var(--color-border-faint)]"
|
||||||
|
)} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Step Content */}
|
||||||
|
<AnimatePresence mode="wait">
|
||||||
|
{currentStep === 0 && (
|
||||||
|
<motion.div
|
||||||
|
key="step-0"
|
||||||
|
initial={{ opacity: 0, x: 20 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
exit={{ opacity: 0, x: -20 }}
|
||||||
|
transition={{ duration: 0.3, ease: [0.16, 1, 0.3, 1] }}
|
||||||
|
className="space-y-6"
|
||||||
|
>
|
||||||
|
{/* Konu */}
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium text-[var(--color-text-secondary)] mb-2 block">
|
||||||
|
<Sparkles size={14} className="inline mr-1.5 text-violet-400" />
|
||||||
|
Videonun Konusu
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={topic}
|
||||||
|
onChange={(e) => setTopic(e.target.value)}
|
||||||
|
placeholder="Örn: Boötes Boşluğu — evrendeki en büyük boşluk ve gizemi..."
|
||||||
|
className="w-full h-32 px-4 py-3 rounded-xl bg-[var(--color-bg-surface)] border border-[var(--color-border-faint)] text-[var(--color-text-primary)] text-sm placeholder:text-[var(--color-text-ghost)] focus:outline-none focus:border-violet-500/40 focus:ring-1 focus:ring-violet-500/20 transition-all resize-none"
|
||||||
|
/>
|
||||||
|
<p className="text-[11px] text-[var(--color-text-ghost)] mt-1.5">
|
||||||
|
Ne kadar detaylı yazarsan, AI o kadar iyi senaryo üretir ({topic.length} karakter)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Dil Seçimi */}
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium text-[var(--color-text-secondary)] mb-3 block">
|
||||||
|
<Languages size={14} className="inline mr-1.5 text-cyan-400" />
|
||||||
|
Video Dili
|
||||||
|
</label>
|
||||||
|
<div className="grid grid-cols-4 gap-2">
|
||||||
|
{languages.map((lang) => (
|
||||||
|
<button
|
||||||
|
key={lang.code}
|
||||||
|
onClick={() => setLanguage(lang.code)}
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col items-center gap-1 py-3 px-2 rounded-xl text-xs transition-all",
|
||||||
|
language === lang.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)] hover:border-[var(--color-border-default)]"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="text-lg">{lang.flag}</span>
|
||||||
|
<span className="font-medium">{lang.label}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{currentStep === 1 && (
|
||||||
|
<motion.div
|
||||||
|
key="step-1"
|
||||||
|
initial={{ opacity: 0, x: 20 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
exit={{ opacity: 0, x: -20 }}
|
||||||
|
transition={{ duration: 0.3, ease: [0.16, 1, 0.3, 1] }}
|
||||||
|
className="space-y-6"
|
||||||
|
>
|
||||||
|
{/* Video Stili */}
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium text-[var(--color-text-secondary)] mb-3 block">
|
||||||
|
<Palette size={14} className="inline mr-1.5 text-violet-400" />
|
||||||
|
Video Stili
|
||||||
|
</label>
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
|
||||||
|
{videoStyles.map((s) => (
|
||||||
|
<button
|
||||||
|
key={s.id}
|
||||||
|
onClick={() => setStyle(s.id)}
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col items-start gap-1 p-3 rounded-xl text-left transition-all",
|
||||||
|
style === s.id
|
||||||
|
? "bg-violet-500/12 border border-violet-500/30 glow-violet"
|
||||||
|
: "bg-[var(--color-bg-surface)] border border-[var(--color-border-faint)] hover:border-[var(--color-border-default)]"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="text-xl mb-0.5">{s.emoji}</span>
|
||||||
|
<span className="text-sm font-medium">{s.label}</span>
|
||||||
|
<span className="text-[10px] text-[var(--color-text-ghost)]">{s.desc}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Süre */}
|
||||||
|
<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={180}
|
||||||
|
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 className="flex justify-between text-[10px] text-[var(--color-text-ghost)] mt-1">
|
||||||
|
<span>15s</span>
|
||||||
|
<span>60s</span>
|
||||||
|
<span>120s</span>
|
||||||
|
<span>180s</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* En-Boy Oranı */}
|
||||||
|
<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)] hover:border-[var(--color-border-default)]"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{currentStep === 2 && (
|
||||||
|
<motion.div
|
||||||
|
key="step-2"
|
||||||
|
initial={{ opacity: 0, x: 20 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
exit={{ opacity: 0, x: -20 }}
|
||||||
|
transition={{ duration: 0.3, ease: [0.16, 1, 0.3, 1] }}
|
||||||
|
className="space-y-6"
|
||||||
|
>
|
||||||
|
{/* Özet */}
|
||||||
|
<div className="card-surface p-5 space-y-4">
|
||||||
|
<h3 className="font-[family-name:var(--font-display)] text-base font-semibold">Proje Özeti</h3>
|
||||||
|
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||||
|
<div>
|
||||||
|
<span className="text-[var(--color-text-muted)] text-xs">Konu</span>
|
||||||
|
<p className="font-medium mt-0.5 line-clamp-2">{topic || "—"}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-[var(--color-text-muted)] text-xs">Dil</span>
|
||||||
|
<p className="font-medium mt-0.5">
|
||||||
|
{languages.find((l) => l.code === language)?.flag}{" "}
|
||||||
|
{languages.find((l) => l.code === language)?.label}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-[var(--color-text-muted)] text-xs">Stil</span>
|
||||||
|
<p className="font-medium mt-0.5">
|
||||||
|
{videoStyles.find((s) => s.id === style)?.emoji}{" "}
|
||||||
|
{videoStyles.find((s) => s.id === style)?.label}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-[var(--color-text-muted)] text-xs">Süre / Oran</span>
|
||||||
|
<p className="font-medium mt-0.5">{duration}s • {aspectRatios.find((a) => a.id === aspectRatio)?.label}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Generate butonu */}
|
||||||
|
<button
|
||||||
|
onClick={handleGenerate}
|
||||||
|
disabled={isGenerating}
|
||||||
|
className={cn(
|
||||||
|
"w-full py-4 rounded-xl font-semibold text-base flex items-center justify-center gap-2 transition-all",
|
||||||
|
isGenerating
|
||||||
|
? "bg-violet-500/20 text-violet-300 cursor-wait"
|
||||||
|
: "btn-primary text-lg"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isGenerating ? (
|
||||||
|
<>
|
||||||
|
<Loader2 size={20} className="animate-spin" />
|
||||||
|
<span>AI Senaryo Üretiliyor...</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Wand2 size={20} />
|
||||||
|
<span>AI ile Senaryo Üret</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<p className="text-center text-[11px] text-[var(--color-text-ghost)]">
|
||||||
|
Bu işlem 1 kredi kullanır • Tahmini süre: ~15 saniye
|
||||||
|
</p>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
{/* Navigation Buttons */}
|
||||||
|
<div className="flex items-center justify-between mt-8 pt-4 border-t border-[var(--color-border-faint)]">
|
||||||
|
<button
|
||||||
|
onClick={() => setCurrentStep((s) => Math.max(0, s - 1))}
|
||||||
|
disabled={currentStep === 0}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-1.5 text-sm font-medium transition-colors",
|
||||||
|
currentStep === 0
|
||||||
|
? "text-[var(--color-text-ghost)] cursor-not-allowed"
|
||||||
|
: "text-[var(--color-text-muted)] hover:text-[var(--color-text-primary)]"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<ArrowLeft size={16} />
|
||||||
|
Geri
|
||||||
|
</button>
|
||||||
|
{currentStep < steps.length - 1 && (
|
||||||
|
<button
|
||||||
|
onClick={() => setCurrentStep((s) => Math.min(steps.length - 1, s + 1))}
|
||||||
|
disabled={!canProceed}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-1.5 px-5 py-2 rounded-xl text-sm font-semibold transition-all",
|
||||||
|
canProceed
|
||||||
|
? "btn-primary"
|
||||||
|
: "bg-[var(--color-bg-elevated)] text-[var(--color-text-ghost)] cursor-not-allowed"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
İleri
|
||||||
|
<ArrowRight size={16} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
80
src/app/[locale]/(dashboard)/dashboard/projects/page.tsx
Normal file
80
src/app/[locale]/(dashboard)/dashboard/projects/page.tsx
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import { Plus, Search, Filter, Grid3X3, List } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { RecentProjects } from "@/components/dashboard/recent-projects";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
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" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function ProjectsPage() {
|
||||||
|
const [activeFilter, setActiveFilter] = useState("all");
|
||||||
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
|
||||||
|
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>
|
||||||
|
<p className="text-sm text-[var(--color-text-muted)] mt-0.5">
|
||||||
|
Tüm video projelerini yönet
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Link href="/dashboard/projects/new" className="btn-primary flex items-center gap-2 text-sm">
|
||||||
|
<Plus size={16} />
|
||||||
|
<span>Yeni Proje</span>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 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)]" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
placeholder="Proje ara..."
|
||||||
|
className="w-full pl-9 pr-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 transition-colors"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status Tabs */}
|
||||||
|
<div className="flex items-center gap-1.5 overflow-x-auto pb-1 scrollbar-none">
|
||||||
|
{statusFilters.map((filter) => (
|
||||||
|
<button
|
||||||
|
key={filter.id}
|
||||||
|
onClick={() => setActiveFilter(filter.id)}
|
||||||
|
className={cn(
|
||||||
|
"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)]"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{filter.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</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>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
78
src/app/[locale]/(dashboard)/dashboard/settings/page.tsx
Normal file
78
src/app/[locale]/(dashboard)/dashboard/settings/page.tsx
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import {
|
||||||
|
User,
|
||||||
|
CreditCard,
|
||||||
|
Bell,
|
||||||
|
Palette,
|
||||||
|
Globe,
|
||||||
|
Shield,
|
||||||
|
LogOut,
|
||||||
|
ChevronRight,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
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" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function SettingsPage() {
|
||||||
|
const [activeSection, setActiveSection] = useState("profile");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-3xl mx-auto space-y-6">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
{sections.map((section) => {
|
||||||
|
const Icon = section.icon;
|
||||||
|
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)]"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</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>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
272
src/app/[locale]/(dashboard)/dashboard/templates/page.tsx
Normal file
272
src/app/[locale]/(dashboard)/dashboard/templates/page.tsx
Normal file
@@ -0,0 +1,272 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import {
|
||||||
|
Search,
|
||||||
|
Star,
|
||||||
|
Copy,
|
||||||
|
Eye,
|
||||||
|
TrendingUp,
|
||||||
|
Clock,
|
||||||
|
Sparkles,
|
||||||
|
Filter,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
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", "Education", "Tarih", "Teknoloji", "Ciencia", "Geschichte"];
|
||||||
|
const sortOptions = [
|
||||||
|
{ id: "popular", label: "En Popüler", icon: TrendingUp },
|
||||||
|
{ id: "newest", label: "En Yeni", icon: Clock },
|
||||||
|
{ id: "rating", label: "En İyi Puan", icon: Star },
|
||||||
|
];
|
||||||
|
|
||||||
|
const flagEmoji: Record<string, string> = {
|
||||||
|
tr: "🇹🇷",
|
||||||
|
en: "🇺🇸",
|
||||||
|
es: "🇪🇸",
|
||||||
|
de: "🇩🇪",
|
||||||
|
fr: "🇫🇷",
|
||||||
|
};
|
||||||
|
|
||||||
|
const stagger = {
|
||||||
|
hidden: { opacity: 0 },
|
||||||
|
show: { opacity: 1, transition: { staggerChildren: 0.06 } },
|
||||||
|
};
|
||||||
|
|
||||||
|
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 } },
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function TemplatesPage() {
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [activeCategory, setActiveCategory] = useState("Tümü");
|
||||||
|
const [activeSort, setActiveSort] = useState("popular");
|
||||||
|
|
||||||
|
const filtered = mockTemplates.filter((t) => {
|
||||||
|
if (activeCategory !== "Tümü" && t.category !== activeCategory) return false;
|
||||||
|
if (search && !t.title.toLowerCase().includes(search.toLowerCase())) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-6xl mx-auto space-y-6">
|
||||||
|
{/* ── Başlık ── */}
|
||||||
|
<div>
|
||||||
|
<h1 className="font-[family-name:var(--font-display)] text-2xl md:text-3xl font-bold">
|
||||||
|
<Sparkles size={24} className="inline mr-2 text-violet-400" />
|
||||||
|
Şablon Galerisi
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-[var(--color-text-muted)] mt-1">
|
||||||
|
Topluluk şablonlarını keşfet, tek tıkla kendi projene klonla
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── 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)]" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
placeholder="Şablon ara..."
|
||||||
|
className="w-full pl-9 pr-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 transition-colors"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1.5">
|
||||||
|
{sortOptions.map((opt) => {
|
||||||
|
const Icon = opt.icon;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={opt.id}
|
||||||
|
onClick={() => setActiveSort(opt.id)}
|
||||||
|
className={cn(
|
||||||
|
"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)]"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon size={13} />
|
||||||
|
<span className="hidden sm:inline">{opt.label}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Kategori Tabs */}
|
||||||
|
<div className="flex items-center gap-1.5 overflow-x-auto pb-1 scrollbar-none">
|
||||||
|
{categories.map((cat) => (
|
||||||
|
<button
|
||||||
|
key={cat}
|
||||||
|
onClick={() => setActiveCategory(cat)}
|
||||||
|
className={cn(
|
||||||
|
"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)]"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{cat}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{filtered.length === 0 && (
|
||||||
|
<div className="text-center py-16">
|
||||||
|
<p className="text-[var(--color-text-muted)]">Aramanızla eşleşen şablon bulunamadı</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
9
src/app/[locale]/(dashboard)/layout.tsx
Normal file
9
src/app/[locale]/(dashboard)/layout.tsx
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { AppShell } from "@/components/layout/app-shell";
|
||||||
|
|
||||||
|
export default function DashboardLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return <AppShell>{children}</AppShell>;
|
||||||
|
}
|
||||||
@@ -1,7 +1,320 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
/* ================================================
|
||||||
|
ContentGen AI — Design System
|
||||||
|
Aesthetic Direction: Cinematic Dark + Violet Neon
|
||||||
|
Frontend Design Skill: Bold, intentional, unforgettable
|
||||||
|
================================================ */
|
||||||
|
|
||||||
|
@theme {
|
||||||
|
/* ── Color Palette ── */
|
||||||
|
--color-bg-void: #000000;
|
||||||
|
--color-bg-deep: #050509;
|
||||||
|
--color-bg-base: #0a0a12;
|
||||||
|
--color-bg-surface: #111120;
|
||||||
|
--color-bg-elevated: #1a1a2e;
|
||||||
|
--color-bg-subtle: #232340;
|
||||||
|
|
||||||
|
--color-border-faint: #1e1e3a;
|
||||||
|
--color-border-default: #2a2a4a;
|
||||||
|
--color-border-strong: #3a3a5a;
|
||||||
|
|
||||||
|
--color-text-primary: #f0f0ff;
|
||||||
|
--color-text-secondary: #a0a0c0;
|
||||||
|
--color-text-muted: #6a6a8a;
|
||||||
|
--color-text-ghost: #4a4a6a;
|
||||||
|
|
||||||
|
/* Brand */
|
||||||
|
--color-violet-400: #a78bfa;
|
||||||
|
--color-violet-500: #8b5cf6;
|
||||||
|
--color-violet-600: #7c3aed;
|
||||||
|
--color-violet-700: #6d28d9;
|
||||||
|
--color-violet-glow: rgba(139, 92, 246, 0.15);
|
||||||
|
|
||||||
|
--color-cyan-400: #22d3ee;
|
||||||
|
--color-cyan-500: #06b6d4;
|
||||||
|
--color-cyan-glow: rgba(6, 182, 212, 0.12);
|
||||||
|
|
||||||
|
--color-emerald-400: #34d399;
|
||||||
|
--color-emerald-500: #10b981;
|
||||||
|
|
||||||
|
--color-amber-400: #fbbf24;
|
||||||
|
--color-amber-500: #f59e0b;
|
||||||
|
|
||||||
|
--color-rose-400: #fb7185;
|
||||||
|
--color-rose-500: #f43f5e;
|
||||||
|
|
||||||
|
/* ── Spacing ── */
|
||||||
|
--spacing-page: clamp(1rem, 4vw, 2.5rem);
|
||||||
|
|
||||||
|
/* ── Radius ── */
|
||||||
|
--radius-sm: 0.375rem;
|
||||||
|
--radius-md: 0.625rem;
|
||||||
|
--radius-lg: 0.875rem;
|
||||||
|
--radius-xl: 1.25rem;
|
||||||
|
--radius-2xl: 1.5rem;
|
||||||
|
--radius-full: 9999px;
|
||||||
|
|
||||||
|
/* ── Shadows ── */
|
||||||
|
--shadow-glow-sm: 0 0 12px rgba(139, 92, 246, 0.08);
|
||||||
|
--shadow-glow-md: 0 0 24px rgba(139, 92, 246, 0.12);
|
||||||
|
--shadow-glow-lg: 0 0 48px rgba(139, 92, 246, 0.18);
|
||||||
|
--shadow-card: 0 1px 3px rgba(0, 0, 0, 0.4), 0 4px 12px rgba(0, 0, 0, 0.2);
|
||||||
|
--shadow-elevated: 0 8px 32px rgba(0, 0, 0, 0.5);
|
||||||
|
|
||||||
|
/* ── Animations ── */
|
||||||
|
--ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||||
|
--duration-fast: 150ms;
|
||||||
|
--duration-normal: 250ms;
|
||||||
|
--duration-slow: 400ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Base ── */
|
||||||
html {
|
html {
|
||||||
scroll-behavior: smooth;
|
scroll-behavior: smooth;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
|
background-color: var(--color-bg-void);
|
||||||
|
color: var(--color-text-primary);
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
|
min-height: 100dvh;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Selection ── */
|
||||||
|
::selection {
|
||||||
|
background-color: rgba(139, 92, 246, 0.3);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Scrollbar ── */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--color-border-default);
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: var(--color-border-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Glass Effect ── */
|
||||||
|
.glass {
|
||||||
|
background: rgba(17, 17, 32, 0.6);
|
||||||
|
backdrop-filter: blur(16px) saturate(180%);
|
||||||
|
-webkit-backdrop-filter: blur(16px) saturate(180%);
|
||||||
|
border: 1px solid var(--color-border-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-subtle {
|
||||||
|
background: rgba(17, 17, 32, 0.35);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
-webkit-backdrop-filter: blur(8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Glow Effects ── */
|
||||||
|
.glow-violet {
|
||||||
|
box-shadow: 0 0 20px rgba(139, 92, 246, 0.15),
|
||||||
|
0 0 40px rgba(139, 92, 246, 0.08),
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glow-cyan {
|
||||||
|
box-shadow: 0 0 20px rgba(6, 182, 212, 0.15),
|
||||||
|
0 0 40px rgba(6, 182, 212, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Gradient Borders ── */
|
||||||
|
.gradient-border {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.gradient-border::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
border-radius: inherit;
|
||||||
|
padding: 1px;
|
||||||
|
background: linear-gradient(135deg, var(--color-violet-500), var(--color-cyan-400));
|
||||||
|
mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
|
||||||
|
mask-composite: exclude;
|
||||||
|
-webkit-mask-composite: xor;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Animated Gradient Background ── */
|
||||||
|
.gradient-mesh {
|
||||||
|
background-image:
|
||||||
|
radial-gradient(ellipse at 20% 50%, var(--color-violet-glow) 0%, transparent 50%),
|
||||||
|
radial-gradient(ellipse at 80% 20%, var(--color-cyan-glow) 0%, transparent 50%),
|
||||||
|
radial-gradient(ellipse at 50% 80%, rgba(16, 185, 129, 0.06) 0%, transparent 50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Card Styles ── */
|
||||||
|
.card-surface {
|
||||||
|
background: var(--color-bg-surface);
|
||||||
|
border: 1px solid var(--color-border-faint);
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
transition: all var(--duration-normal) var(--ease-out-expo);
|
||||||
|
}
|
||||||
|
.card-surface:hover {
|
||||||
|
border-color: var(--color-border-default);
|
||||||
|
box-shadow: var(--shadow-glow-sm);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Badge Styles ── */
|
||||||
|
.badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.125rem 0.625rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
.badge-violet {
|
||||||
|
background: rgba(139, 92, 246, 0.12);
|
||||||
|
color: var(--color-violet-400);
|
||||||
|
border: 1px solid rgba(139, 92, 246, 0.2);
|
||||||
|
}
|
||||||
|
.badge-cyan {
|
||||||
|
background: rgba(6, 182, 212, 0.12);
|
||||||
|
color: var(--color-cyan-400);
|
||||||
|
border: 1px solid rgba(6, 182, 212, 0.2);
|
||||||
|
}
|
||||||
|
.badge-emerald {
|
||||||
|
background: rgba(16, 185, 129, 0.12);
|
||||||
|
color: var(--color-emerald-400);
|
||||||
|
border: 1px solid rgba(16, 185, 129, 0.2);
|
||||||
|
}
|
||||||
|
.badge-amber {
|
||||||
|
background: rgba(245, 158, 11, 0.12);
|
||||||
|
color: var(--color-amber-400);
|
||||||
|
border: 1px solid rgba(245, 158, 11, 0.2);
|
||||||
|
}
|
||||||
|
.badge-rose {
|
||||||
|
background: rgba(244, 63, 94, 0.12);
|
||||||
|
color: var(--color-rose-400);
|
||||||
|
border: 1px solid rgba(244, 63, 94, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Button Styles ── */
|
||||||
|
.btn-primary {
|
||||||
|
background: linear-gradient(135deg, var(--color-violet-600), var(--color-violet-500));
|
||||||
|
color: white;
|
||||||
|
font-weight: 600;
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: 0.625rem 1.25rem;
|
||||||
|
transition: all var(--duration-normal) var(--ease-out-expo);
|
||||||
|
box-shadow: 0 2px 8px rgba(139, 92, 246, 0.25);
|
||||||
|
}
|
||||||
|
.btn-primary:hover {
|
||||||
|
box-shadow: 0 4px 16px rgba(139, 92, 246, 0.4);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
.btn-primary:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-ghost {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
border: 1px solid var(--color-border-faint);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: 0.625rem 1.25rem;
|
||||||
|
transition: all var(--duration-fast) var(--ease-out-expo);
|
||||||
|
}
|
||||||
|
.btn-ghost:hover {
|
||||||
|
background: var(--color-bg-elevated);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
border-color: var(--color-border-default);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Mobile Navigation ── */
|
||||||
|
.mobile-nav {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 50;
|
||||||
|
padding: 0.5rem 0 calc(0.5rem + env(safe-area-inset-bottom));
|
||||||
|
background: rgba(5, 5, 9, 0.85);
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
-webkit-backdrop-filter: blur(20px);
|
||||||
|
border-top: 1px solid var(--color-border-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Progress Bar ── */
|
||||||
|
.progress-bar {
|
||||||
|
height: 6px;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
background: var(--color-bg-elevated);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.progress-bar-fill {
|
||||||
|
height: 100%;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
background: linear-gradient(90deg, var(--color-violet-500), var(--color-cyan-400));
|
||||||
|
transition: width var(--duration-slow) var(--ease-out-expo);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Skeleton Loader ── */
|
||||||
|
@keyframes shimmer {
|
||||||
|
0% { background-position: -200% 0; }
|
||||||
|
100% { background-position: 200% 0; }
|
||||||
|
}
|
||||||
|
.skeleton {
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
var(--color-bg-surface) 25%,
|
||||||
|
var(--color-bg-elevated) 50%,
|
||||||
|
var(--color-bg-surface) 75%
|
||||||
|
);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
animation: shimmer 1.5s ease-in-out infinite;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Noise Texture Overlay ── */
|
||||||
|
.noise-overlay::after {
|
||||||
|
content: "";
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0.015;
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
|
||||||
|
z-index: 9999;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Page Transition ── */
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from { opacity: 0; transform: translateY(8px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
.page-enter {
|
||||||
|
animation: fadeIn var(--duration-slow) var(--ease-out-expo) forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Touch Optimization ── */
|
||||||
|
@media (hover: none) {
|
||||||
|
.card-surface:hover {
|
||||||
|
transform: none;
|
||||||
|
box-shadow: var(--shadow-card);
|
||||||
|
}
|
||||||
|
.btn-primary:hover {
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Safe Area ── */
|
||||||
|
.pb-safe {
|
||||||
|
padding-bottom: calc(5rem + env(safe-area-inset-bottom));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,34 @@
|
|||||||
import { Provider } from '@/components/ui/provider';
|
import { Bricolage_Grotesque, DM_Sans } from 'next/font/google';
|
||||||
import { Bricolage_Grotesque } from 'next/font/google';
|
|
||||||
import { hasLocale, NextIntlClientProvider } from 'next-intl';
|
import { hasLocale, NextIntlClientProvider } from 'next-intl';
|
||||||
import { notFound } from 'next/navigation';
|
import { notFound } from 'next/navigation';
|
||||||
import { routing } from '@/i18n/routing';
|
import { routing } from '@/i18n/routing';
|
||||||
import { dir } from 'i18next';
|
import { dir } from 'i18next';
|
||||||
|
import { Provider } from '@/components/ui/provider';
|
||||||
import './global.css';
|
import './global.css';
|
||||||
|
|
||||||
const bricolage = Bricolage_Grotesque({
|
const bricolage = Bricolage_Grotesque({
|
||||||
variable: '--font-bricolage',
|
variable: '--font-display',
|
||||||
subsets: ['latin'],
|
subsets: ['latin'],
|
||||||
|
display: 'swap',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const dmSans = DM_Sans({
|
||||||
|
variable: '--font-body',
|
||||||
|
subsets: ['latin'],
|
||||||
|
display: 'swap',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const metadata = {
|
||||||
|
title: 'ContentGen AI — AI ile YouTube Shorts Üret',
|
||||||
|
description: 'Yapay zeka ile saniyeler içinde profesyonel YouTube Shorts videoları üretin. Konu girin, AI senaryoyu yazsın, videonuz hazır.',
|
||||||
|
keywords: 'ai video generator, youtube shorts maker, ai short video, yapay zeka video',
|
||||||
|
openGraph: {
|
||||||
|
title: 'ContentGen AI — AI Video Generator',
|
||||||
|
description: 'Create stunning YouTube Shorts with AI in seconds.',
|
||||||
|
type: 'website',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
export default async function RootLayout({
|
export default async function RootLayout({
|
||||||
children,
|
children,
|
||||||
params,
|
params,
|
||||||
@@ -24,14 +42,21 @@ export default async function RootLayout({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html lang={locale} dir={dir(locale)} suppressHydrationWarning data-scroll-behavior='smooth'>
|
<html
|
||||||
|
lang={locale}
|
||||||
|
dir={dir(locale)}
|
||||||
|
suppressHydrationWarning
|
||||||
|
className="dark"
|
||||||
|
>
|
||||||
<head>
|
<head>
|
||||||
<link rel='apple-touch-icon' sizes='180x180' href='/favicon/apple-touch-icon.png' />
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||||
<link rel='icon' type='image/png' sizes='32x32' href='/favicon/favicon-32x32.png' />
|
<meta name="theme-color" content="#000000" />
|
||||||
<link rel='icon' type='image/png' sizes='16x16' href='/favicon/favicon-16x16.png' />
|
<link rel="apple-touch-icon" sizes="180x180" href="/favicon/apple-touch-icon.png" />
|
||||||
<link rel='manifest' href='/favicon/site.webmanifest' />
|
<link rel="icon" type="image/png" sizes="32x32" href="/favicon/favicon-32x32.png" />
|
||||||
|
<link rel="icon" type="image/png" sizes="16x16" href="/favicon/favicon-16x16.png" />
|
||||||
|
<link rel="manifest" href="/favicon/site.webmanifest" />
|
||||||
</head>
|
</head>
|
||||||
<body className={bricolage.variable}>
|
<body className={`${bricolage.variable} ${dmSans.variable} font-[family-name:var(--font-body)] noise-overlay`}>
|
||||||
<NextIntlClientProvider>
|
<NextIntlClientProvider>
|
||||||
<Provider>{children}</Provider>
|
<Provider>{children}</Provider>
|
||||||
</NextIntlClientProvider>
|
</NextIntlClientProvider>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { redirect } from 'next/navigation';
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
export default async function Page() {
|
export default function LocaleHomePage() {
|
||||||
redirect('/home');
|
redirect("/dashboard");
|
||||||
}
|
}
|
||||||
|
|||||||
161
src/components/dashboard/dashboard-charts.tsx
Normal file
161
src/components/dashboard/dashboard-charts.tsx
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
AreaChart,
|
||||||
|
Area,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
Tooltip,
|
||||||
|
ResponsiveContainer,
|
||||||
|
PieChart,
|
||||||
|
Pie,
|
||||||
|
Cell,
|
||||||
|
} from "recharts";
|
||||||
|
|
||||||
|
const weeklyData = [
|
||||||
|
{ name: "Pzt", videos: 1, credits: 4 },
|
||||||
|
{ name: "Sal", videos: 2, credits: 8 },
|
||||||
|
{ name: "Çar", videos: 0, credits: 0 },
|
||||||
|
{ name: "Per", videos: 3, credits: 12 },
|
||||||
|
{ name: "Cum", videos: 1, credits: 4 },
|
||||||
|
{ name: "Cmt", videos: 2, credits: 8 },
|
||||||
|
{ name: "Paz", videos: 1, credits: 4 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const statusData = [
|
||||||
|
{ name: "Tamamlanan", value: 8, color: "#34d399" },
|
||||||
|
{ name: "Devam Eden", value: 2, color: "#8b5cf6" },
|
||||||
|
{ name: "Taslak", value: 2, color: "#4a4a6a" },
|
||||||
|
];
|
||||||
|
|
||||||
|
function CustomTooltip({
|
||||||
|
active,
|
||||||
|
payload,
|
||||||
|
label,
|
||||||
|
}: {
|
||||||
|
active?: boolean;
|
||||||
|
payload?: Array<{ value: number; dataKey: string; color: string }>;
|
||||||
|
label?: string;
|
||||||
|
}) {
|
||||||
|
if (!active || !payload?.length) return null;
|
||||||
|
return (
|
||||||
|
<div className="glass rounded-lg px-3 py-2 text-xs shadow-xl">
|
||||||
|
<p className="text-[var(--color-text-muted)] mb-1">{label}</p>
|
||||||
|
{payload.map((entry) => (
|
||||||
|
<p key={entry.dataKey} className="text-[var(--color-text-primary)] font-medium">
|
||||||
|
{entry.dataKey === "videos" ? "Video" : "Kredi"}: {entry.value}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DashboardCharts() {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||||
|
{/* ── Haftalık Video Üretimi (Area Chart) ── */}
|
||||||
|
<div className="lg:col-span-2 card-surface p-5">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h2 className="font-[family-name:var(--font-display)] text-base font-semibold">
|
||||||
|
Haftalık Üretim
|
||||||
|
</h2>
|
||||||
|
<span className="badge badge-violet">Bu Hafta</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-48 md:h-56">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<AreaChart data={weeklyData} margin={{ top: 4, right: 4, left: -20, bottom: 0 }}>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="gradientViolet" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stopColor="#8b5cf6" stopOpacity={0.25} />
|
||||||
|
<stop offset="100%" stopColor="#8b5cf6" stopOpacity={0} />
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="gradientCyan" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stopColor="#06b6d4" stopOpacity={0.2} />
|
||||||
|
<stop offset="100%" stopColor="#06b6d4" stopOpacity={0} />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<XAxis
|
||||||
|
dataKey="name"
|
||||||
|
axisLine={false}
|
||||||
|
tickLine={false}
|
||||||
|
tick={{ fill: "#6a6a8a", fontSize: 11 }}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
axisLine={false}
|
||||||
|
tickLine={false}
|
||||||
|
tick={{ fill: "#6a6a8a", fontSize: 11 }}
|
||||||
|
/>
|
||||||
|
<Tooltip content={<CustomTooltip />} />
|
||||||
|
<Area
|
||||||
|
type="monotone"
|
||||||
|
dataKey="videos"
|
||||||
|
stroke="#8b5cf6"
|
||||||
|
strokeWidth={2}
|
||||||
|
fill="url(#gradientViolet)"
|
||||||
|
/>
|
||||||
|
<Area
|
||||||
|
type="monotone"
|
||||||
|
dataKey="credits"
|
||||||
|
stroke="#06b6d4"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
fill="url(#gradientCyan)"
|
||||||
|
strokeDasharray="4 4"
|
||||||
|
/>
|
||||||
|
</AreaChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4 mt-2 text-xs text-[var(--color-text-muted)]">
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<span className="w-2.5 h-2.5 rounded-full bg-violet-500" />
|
||||||
|
Video
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<span className="w-2.5 h-2.5 rounded-full bg-cyan-500" />
|
||||||
|
Kredi Kullanımı
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Proje Durumu (Donut Chart) ── */}
|
||||||
|
<div className="card-surface p-5">
|
||||||
|
<h2 className="font-[family-name:var(--font-display)] text-base font-semibold mb-4">
|
||||||
|
Proje Durumu
|
||||||
|
</h2>
|
||||||
|
<div className="h-40 flex items-center justify-center">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<PieChart>
|
||||||
|
<Pie
|
||||||
|
data={statusData}
|
||||||
|
cx="50%"
|
||||||
|
cy="50%"
|
||||||
|
innerRadius={45}
|
||||||
|
outerRadius={65}
|
||||||
|
paddingAngle={3}
|
||||||
|
dataKey="value"
|
||||||
|
stroke="none"
|
||||||
|
>
|
||||||
|
{statusData.map((entry, i) => (
|
||||||
|
<Cell key={i} fill={entry.color} />
|
||||||
|
))}
|
||||||
|
</Pie>
|
||||||
|
</PieChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2 mt-2">
|
||||||
|
{statusData.map((item) => (
|
||||||
|
<div key={item.name} className="flex items-center justify-between text-xs">
|
||||||
|
<span className="flex items-center gap-2 text-[var(--color-text-secondary)]">
|
||||||
|
<span
|
||||||
|
className="w-2 h-2 rounded-full"
|
||||||
|
style={{ backgroundColor: item.color }}
|
||||||
|
/>
|
||||||
|
{item.name}
|
||||||
|
</span>
|
||||||
|
<span className="font-medium text-[var(--color-text-primary)]">{item.value}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
196
src/components/dashboard/recent-projects.tsx
Normal file
196
src/components/dashboard/recent-projects.tsx
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import {
|
||||||
|
MoreHorizontal,
|
||||||
|
Play,
|
||||||
|
CheckCircle2,
|
||||||
|
Clock,
|
||||||
|
AlertCircle,
|
||||||
|
FileText,
|
||||||
|
Download,
|
||||||
|
Trash2,
|
||||||
|
} from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
type ProjectStatus = "COMPLETED" | "RENDERING" | "GENERATING_MEDIA" | "DRAFT" | "FAILED";
|
||||||
|
|
||||||
|
interface Project {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
status: ProjectStatus;
|
||||||
|
progress: number;
|
||||||
|
language: string;
|
||||||
|
duration: number;
|
||||||
|
createdAt: string;
|
||||||
|
thumbnailUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockProjects: Project[] = [
|
||||||
|
{
|
||||||
|
id: "1",
|
||||||
|
title: "Boötes Boşluğu — Evrenin En Büyük Gizemi",
|
||||||
|
status: "COMPLETED",
|
||||||
|
progress: 100,
|
||||||
|
language: "tr",
|
||||||
|
duration: 45,
|
||||||
|
createdAt: "2 saat önce",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "2",
|
||||||
|
title: "Kuantum Dolanıklık Nedir?",
|
||||||
|
status: "RENDERING",
|
||||||
|
progress: 72,
|
||||||
|
language: "tr",
|
||||||
|
duration: 60,
|
||||||
|
createdAt: "30 dk önce",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "3",
|
||||||
|
title: "The Dark History of the Bermuda Triangle",
|
||||||
|
status: "GENERATING_MEDIA",
|
||||||
|
progress: 35,
|
||||||
|
language: "en",
|
||||||
|
duration: 40,
|
||||||
|
createdAt: "15 dk önce",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "4",
|
||||||
|
title: "Yapay Zeka Sanatı Öldürecek mi?",
|
||||||
|
status: "DRAFT",
|
||||||
|
progress: 0,
|
||||||
|
language: "tr",
|
||||||
|
duration: 55,
|
||||||
|
createdAt: "1 gün önce",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "5",
|
||||||
|
title: "5 Misterios sin Resolver de la Historia",
|
||||||
|
status: "FAILED",
|
||||||
|
progress: 48,
|
||||||
|
language: "es",
|
||||||
|
duration: 50,
|
||||||
|
createdAt: "3 saat önce",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const statusConfig: Record<
|
||||||
|
ProjectStatus,
|
||||||
|
{ label: string; icon: React.ElementType; badgeClass: string }
|
||||||
|
> = {
|
||||||
|
COMPLETED: { label: "Tamamlandı", icon: CheckCircle2, badgeClass: "badge-emerald" },
|
||||||
|
RENDERING: { label: "Render", icon: Play, badgeClass: "badge-violet" },
|
||||||
|
GENERATING_MEDIA: { label: "Medya Üretimi", icon: Clock, badgeClass: "badge-cyan" },
|
||||||
|
DRAFT: { label: "Taslak", icon: FileText, badgeClass: "badge-amber" },
|
||||||
|
FAILED: { label: "Başarısız", icon: AlertCircle, badgeClass: "badge-rose" },
|
||||||
|
};
|
||||||
|
|
||||||
|
const flagEmoji: Record<string, string> = {
|
||||||
|
tr: "🇹🇷",
|
||||||
|
en: "🇺🇸",
|
||||||
|
es: "🇪🇸",
|
||||||
|
de: "🇩🇪",
|
||||||
|
fr: "🇫🇷",
|
||||||
|
ar: "🇸🇦",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function RecentProjects() {
|
||||||
|
return (
|
||||||
|
<div className="card-surface overflow-hidden">
|
||||||
|
<div className="flex items-center justify-between p-4 md:p-5 border-b border-[var(--color-border-faint)]">
|
||||||
|
<h2 className="font-[family-name:var(--font-display)] text-base font-semibold">
|
||||||
|
Son Projeler
|
||||||
|
</h2>
|
||||||
|
<Link
|
||||||
|
href="/dashboard/projects"
|
||||||
|
className="text-xs text-violet-400 hover:text-violet-300 transition-colors"
|
||||||
|
>
|
||||||
|
Tümünü gör →
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="divide-y divide-[var(--color-border-faint)]">
|
||||||
|
{mockProjects.map((project, i) => {
|
||||||
|
const config = statusConfig[project.status];
|
||||||
|
const StatusIcon = config.icon;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
key={project.id}
|
||||||
|
initial={{ opacity: 0, x: -8 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
transition={{ delay: i * 0.06, duration: 0.4, ease: [0.16, 1, 0.3, 1] }}
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
href={`/dashboard/projects/${project.id}`}
|
||||||
|
className="group flex items-center gap-3 md:gap-4 px-4 md:px-5 py-3.5 hover:bg-[var(--color-bg-elevated)]/50 transition-colors"
|
||||||
|
>
|
||||||
|
{/* Thumbnail placeholder */}
|
||||||
|
<div className="w-12 h-12 md:w-14 md:h-14 rounded-lg bg-[var(--color-bg-elevated)] border border-[var(--color-border-faint)] flex items-center justify-center shrink-0 overflow-hidden">
|
||||||
|
<span className="text-xl">{flagEmoji[project.language] || "🌍"}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h3 className="text-sm font-medium truncate group-hover:text-violet-300 transition-colors">
|
||||||
|
{project.title}
|
||||||
|
</h3>
|
||||||
|
<div className="flex items-center gap-2 mt-1">
|
||||||
|
<span className={cn("badge text-[10px]", config.badgeClass)}>
|
||||||
|
<StatusIcon size={10} className="mr-1" />
|
||||||
|
{config.label}
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] text-[var(--color-text-ghost)]">
|
||||||
|
{project.duration}s
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] text-[var(--color-text-ghost)]">
|
||||||
|
{project.createdAt}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress bar */}
|
||||||
|
{project.progress > 0 && project.progress < 100 && (
|
||||||
|
<div className="progress-bar mt-2 w-full max-w-[200px]">
|
||||||
|
<div
|
||||||
|
className="progress-bar-fill"
|
||||||
|
style={{ width: `${project.progress}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="hidden md:flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
{project.status === "COMPLETED" && (
|
||||||
|
<button
|
||||||
|
className="w-8 h-8 rounded-lg flex items-center justify-center text-[var(--color-text-muted)] hover:text-emerald-400 hover:bg-emerald-500/10 transition-colors"
|
||||||
|
aria-label="İndir"
|
||||||
|
onClick={(e) => e.preventDefault()}
|
||||||
|
>
|
||||||
|
<Download size={15} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
className="w-8 h-8 rounded-lg flex items-center justify-center text-[var(--color-text-muted)] hover:text-rose-400 hover:bg-rose-500/10 transition-colors"
|
||||||
|
aria-label="Sil"
|
||||||
|
onClick={(e) => e.preventDefault()}
|
||||||
|
>
|
||||||
|
<Trash2 size={15} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="w-8 h-8 rounded-lg flex items-center justify-center text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)] hover:bg-[var(--color-bg-surface)] transition-colors"
|
||||||
|
aria-label="Daha fazla"
|
||||||
|
onClick={(e) => e.preventDefault()}
|
||||||
|
>
|
||||||
|
<MoreHorizontal size={15} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
386
src/components/dashboard/tweet-import-card.tsx
Normal file
386
src/components/dashboard/tweet-import-card.tsx
Normal file
@@ -0,0 +1,386 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useCallback } from "react";
|
||||||
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
|
import {
|
||||||
|
Twitter,
|
||||||
|
Loader2,
|
||||||
|
Heart,
|
||||||
|
Repeat2,
|
||||||
|
Eye,
|
||||||
|
MessageCircle,
|
||||||
|
Sparkles,
|
||||||
|
ArrowRight,
|
||||||
|
CheckCircle2,
|
||||||
|
XCircle,
|
||||||
|
Flame,
|
||||||
|
Image as ImageIcon,
|
||||||
|
Link2,
|
||||||
|
Zap,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useTweet } from "@/hooks/use-tweet";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
interface TweetImportCardProps {
|
||||||
|
onProjectCreated?: (projectId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TweetImportCard({ onProjectCreated }: TweetImportCardProps) {
|
||||||
|
const {
|
||||||
|
preview,
|
||||||
|
isLoadingPreview,
|
||||||
|
isCreatingProject,
|
||||||
|
error,
|
||||||
|
createdProject,
|
||||||
|
isValidTweetUrl,
|
||||||
|
previewTweet,
|
||||||
|
createProjectFromTweet,
|
||||||
|
reset,
|
||||||
|
} = useTweet();
|
||||||
|
|
||||||
|
const [tweetUrl, setTweetUrl] = useState("");
|
||||||
|
const [customTitle, setCustomTitle] = useState("");
|
||||||
|
|
||||||
|
const handlePreview = useCallback(async () => {
|
||||||
|
if (!tweetUrl.trim()) return;
|
||||||
|
await previewTweet(tweetUrl.trim());
|
||||||
|
}, [tweetUrl, previewTweet]);
|
||||||
|
|
||||||
|
const handleCreate = useCallback(async () => {
|
||||||
|
if (!tweetUrl.trim()) return;
|
||||||
|
|
||||||
|
const project = await createProjectFromTweet({
|
||||||
|
tweetUrl: tweetUrl.trim(),
|
||||||
|
title: customTitle.trim() || undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (project && onProjectCreated) {
|
||||||
|
onProjectCreated(project.id);
|
||||||
|
}
|
||||||
|
}, [tweetUrl, customTitle, createProjectFromTweet, onProjectCreated]);
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === "Enter" && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!preview) {
|
||||||
|
handlePreview();
|
||||||
|
} else {
|
||||||
|
handleCreate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isUrlValid = tweetUrl.trim().length > 0 && isValidTweetUrl(tweetUrl.trim());
|
||||||
|
|
||||||
|
const formatNumber = (num: number): string => {
|
||||||
|
if (num >= 1_000_000) return `${(num / 1_000_000).toFixed(1)}M`;
|
||||||
|
if (num >= 1_000) return `${(num / 1_000).toFixed(1)}K`;
|
||||||
|
return num.toString();
|
||||||
|
};
|
||||||
|
|
||||||
|
const viralColor = (score: number) => {
|
||||||
|
if (score >= 70) return "text-emerald-400";
|
||||||
|
if (score >= 40) return "text-amber-400";
|
||||||
|
return "text-[var(--color-text-muted)]";
|
||||||
|
};
|
||||||
|
|
||||||
|
const viralBg = (score: number) => {
|
||||||
|
if (score >= 70) return "bg-emerald-500/10 border-emerald-500/30";
|
||||||
|
if (score >= 40) return "bg-amber-500/10 border-amber-500/30";
|
||||||
|
return "bg-[var(--color-bg-elevated)] border-[var(--color-border-faint)]";
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card-surface overflow-hidden">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center gap-3 p-4 md:p-5 border-b border-[var(--color-border-faint)]">
|
||||||
|
<div className="w-9 h-9 rounded-xl bg-sky-500/15 flex items-center justify-center">
|
||||||
|
<Twitter size={18} className="text-sky-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-[family-name:var(--font-display)] text-sm font-semibold">
|
||||||
|
X/Twitter Import
|
||||||
|
</h3>
|
||||||
|
<p className="text-[11px] text-[var(--color-text-ghost)]">
|
||||||
|
Tweet → Video pipeline
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* URL Input */}
|
||||||
|
<div className="p-4 md:p-5 space-y-4">
|
||||||
|
<div className="relative">
|
||||||
|
<div className="absolute left-3 top-1/2 -translate-y-1/2 text-[var(--color-text-ghost)]">
|
||||||
|
<Link2 size={16} />
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
value={tweetUrl}
|
||||||
|
onChange={(e) => {
|
||||||
|
setTweetUrl(e.target.value);
|
||||||
|
if (preview) reset();
|
||||||
|
}}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder="https://x.com/user/status/123..."
|
||||||
|
className="w-full h-11 pl-10 pr-24 rounded-xl bg-[var(--color-bg-elevated)] border border-[var(--color-border-faint)] text-sm text-[var(--color-text-primary)] placeholder:text-[var(--color-text-ghost)] focus:border-sky-500/50 focus:ring-1 focus:ring-sky-500/25 outline-none transition-all"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={preview ? handleCreate : handlePreview}
|
||||||
|
disabled={!isUrlValid || isLoadingPreview || isCreatingProject}
|
||||||
|
className={cn(
|
||||||
|
"absolute right-1.5 top-1/2 -translate-y-1/2 h-8 px-3 rounded-lg text-xs font-medium transition-all flex items-center gap-1.5",
|
||||||
|
isUrlValid && !isLoadingPreview && !isCreatingProject
|
||||||
|
? "bg-sky-500 text-white hover:bg-sky-400 shadow-sm"
|
||||||
|
: "bg-[var(--color-bg-surface)] text-[var(--color-text-ghost)] cursor-not-allowed"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isLoadingPreview ? (
|
||||||
|
<>
|
||||||
|
<Loader2 size={13} className="animate-spin" />
|
||||||
|
Çekiliyor
|
||||||
|
</>
|
||||||
|
) : isCreatingProject ? (
|
||||||
|
<>
|
||||||
|
<Loader2 size={13} className="animate-spin" />
|
||||||
|
Üretiliyor
|
||||||
|
</>
|
||||||
|
) : preview ? (
|
||||||
|
<>
|
||||||
|
<Zap size={13} />
|
||||||
|
Oluştur
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<ArrowRight size={13} />
|
||||||
|
Ön İzle
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error */}
|
||||||
|
<AnimatePresence mode="wait">
|
||||||
|
{error && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: -8, height: 0 }}
|
||||||
|
animate={{ opacity: 1, y: 0, height: "auto" }}
|
||||||
|
exit={{ opacity: 0, y: -8, height: 0 }}
|
||||||
|
className="flex items-center gap-2 text-xs text-rose-400 bg-rose-500/10 border border-rose-500/20 rounded-lg px-3 py-2"
|
||||||
|
>
|
||||||
|
<XCircle size={14} />
|
||||||
|
{error}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
{/* Preview Card */}
|
||||||
|
<AnimatePresence mode="wait">
|
||||||
|
{preview && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 12, scale: 0.97 }}
|
||||||
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||||
|
exit={{ opacity: 0, y: 12, scale: 0.97 }}
|
||||||
|
transition={{ duration: 0.35, ease: [0.16, 1, 0.3, 1] }}
|
||||||
|
className="space-y-3"
|
||||||
|
>
|
||||||
|
{/* Author + Viral Score */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<div className="w-9 h-9 rounded-full bg-[var(--color-bg-elevated)] border border-[var(--color-border-faint)] overflow-hidden flex items-center justify-center">
|
||||||
|
{preview.tweet.author.avatarUrl ? (
|
||||||
|
<img
|
||||||
|
src={preview.tweet.author.avatarUrl}
|
||||||
|
alt={preview.tweet.author.name}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Twitter size={16} className="text-sky-400" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
{preview.tweet.author.name}
|
||||||
|
</span>
|
||||||
|
{preview.tweet.author.verified && (
|
||||||
|
<CheckCircle2
|
||||||
|
size={13}
|
||||||
|
className="text-sky-400 fill-sky-400"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="text-[11px] text-[var(--color-text-ghost)]">
|
||||||
|
@{preview.tweet.author.username}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Viral Score Badge */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg border text-xs font-medium",
|
||||||
|
viralBg(preview.viralScore)
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Flame size={13} className={viralColor(preview.viralScore)} />
|
||||||
|
<span className={viralColor(preview.viralScore)}>
|
||||||
|
{preview.viralScore}/100
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tweet Text */}
|
||||||
|
<div className="bg-[var(--color-bg-elevated)] rounded-xl p-3 border border-[var(--color-border-faint)]">
|
||||||
|
<p className="text-[13px] text-[var(--color-text-secondary)] leading-relaxed line-clamp-4">
|
||||||
|
{preview.tweet.text}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Media preview */}
|
||||||
|
{preview.tweet.media.length > 0 && (
|
||||||
|
<div className="flex gap-1.5 overflow-x-auto pb-1">
|
||||||
|
{preview.tweet.media.slice(0, 4).map((m, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="w-16 h-16 rounded-lg bg-[var(--color-bg-elevated)] border border-[var(--color-border-faint)] overflow-hidden flex-shrink-0"
|
||||||
|
>
|
||||||
|
{m.type === "photo" ? (
|
||||||
|
<img
|
||||||
|
src={m.url}
|
||||||
|
alt={`Media ${i + 1}`}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-full h-full flex items-center justify-center text-[var(--color-text-ghost)]">
|
||||||
|
<ImageIcon size={20} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Metrics */}
|
||||||
|
<div className="grid grid-cols-4 gap-2">
|
||||||
|
{[
|
||||||
|
{
|
||||||
|
icon: Heart,
|
||||||
|
value: preview.tweet.metrics.likes,
|
||||||
|
color: "text-rose-400",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Repeat2,
|
||||||
|
value: preview.tweet.metrics.retweets,
|
||||||
|
color: "text-emerald-400",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Eye,
|
||||||
|
value: preview.tweet.metrics.views,
|
||||||
|
color: "text-sky-400",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: MessageCircle,
|
||||||
|
value: preview.tweet.metrics.replies,
|
||||||
|
color: "text-amber-400",
|
||||||
|
},
|
||||||
|
].map(({ icon: Icon, value, color }, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="flex items-center justify-center gap-1.5 py-2 rounded-lg bg-[var(--color-bg-elevated)]/50 border border-[var(--color-border-faint)]"
|
||||||
|
>
|
||||||
|
<Icon size={13} className={color} />
|
||||||
|
<span className="text-[11px] font-medium">
|
||||||
|
{formatNumber(value)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info badges */}
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
<span className="badge badge-cyan text-[10px]">
|
||||||
|
{preview.contentType === "thread"
|
||||||
|
? "Thread"
|
||||||
|
: preview.contentType === "quote_tweet"
|
||||||
|
? "Alıntı"
|
||||||
|
: "Tweet"}
|
||||||
|
</span>
|
||||||
|
<span className="badge badge-violet text-[10px]">
|
||||||
|
~{preview.estimatedDuration}s video
|
||||||
|
</span>
|
||||||
|
{preview.tweet.media.length > 0 && (
|
||||||
|
<span className="badge badge-amber text-[10px]">
|
||||||
|
{preview.tweet.media.length} medya
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Custom Title */}
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={customTitle}
|
||||||
|
onChange={(e) => setCustomTitle(e.target.value)}
|
||||||
|
placeholder={preview.suggestedTitle}
|
||||||
|
className="w-full h-10 px-3 rounded-xl bg-[var(--color-bg-elevated)] border border-[var(--color-border-faint)] text-sm text-[var(--color-text-primary)] placeholder:text-[var(--color-text-ghost)]/50 focus:border-violet-500/50 focus:ring-1 focus:ring-violet-500/25 outline-none transition-all"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Create Button */}
|
||||||
|
<button
|
||||||
|
onClick={handleCreate}
|
||||||
|
disabled={isCreatingProject}
|
||||||
|
className={cn(
|
||||||
|
"w-full h-11 rounded-xl text-sm font-semibold shadow-sm transition-all flex items-center justify-center gap-2",
|
||||||
|
isCreatingProject
|
||||||
|
? "bg-violet-600/50 text-violet-200 cursor-not-allowed"
|
||||||
|
: "bg-gradient-to-r from-violet-600 to-purple-600 text-white hover:from-violet-500 hover:to-purple-500 hover:shadow-lg hover:shadow-violet-500/25 active:scale-[0.98]"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isCreatingProject ? (
|
||||||
|
<>
|
||||||
|
<Loader2 size={16} className="animate-spin" />
|
||||||
|
Senaryo üretiliyor...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Sparkles size={16} />
|
||||||
|
Tweet'ten Video Oluştur
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
{/* Success */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{createdProject && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.95 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
exit={{ opacity: 0, scale: 0.95 }}
|
||||||
|
className="flex items-center gap-3 p-3 rounded-xl bg-emerald-500/10 border border-emerald-500/25"
|
||||||
|
>
|
||||||
|
<CheckCircle2 size={18} className="text-emerald-400 shrink-0" />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium text-emerald-300">
|
||||||
|
Proje oluşturuldu!
|
||||||
|
</p>
|
||||||
|
<p className="text-[11px] text-emerald-400/70 truncate">
|
||||||
|
{createdProject.title}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (onProjectCreated) onProjectCreated(createdProject.id);
|
||||||
|
}}
|
||||||
|
className="text-[11px] text-emerald-400 hover:text-emerald-300 font-medium transition-colors whitespace-nowrap"
|
||||||
|
>
|
||||||
|
Görüntüle →
|
||||||
|
</button>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
192
src/components/layout/app-shell.tsx
Normal file
192
src/components/layout/app-shell.tsx
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
|
import { Home, FolderOpen, LayoutGrid, Settings, Sparkles } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const navItems = [
|
||||||
|
{ href: "/dashboard", icon: Home, label: "Ana Sayfa" },
|
||||||
|
{ href: "/dashboard/projects", icon: FolderOpen, label: "Projeler" },
|
||||||
|
{ href: "/dashboard/templates", icon: LayoutGrid, label: "Şablonlar" },
|
||||||
|
{ href: "/dashboard/settings", icon: Settings, label: "Ayarlar" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function MobileNav() {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const localePath = pathname.replace(/^\/[a-z]{2}/, "");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav className="mobile-nav md:hidden">
|
||||||
|
<div className="flex items-center justify-around px-2">
|
||||||
|
{navItems.map((item) => {
|
||||||
|
const isActive =
|
||||||
|
localePath === item.href ||
|
||||||
|
(item.href !== "/dashboard" && localePath.startsWith(item.href));
|
||||||
|
const Icon = item.icon;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={item.href}
|
||||||
|
href={item.href}
|
||||||
|
className={cn(
|
||||||
|
"relative flex flex-col items-center gap-0.5 py-1.5 px-3 rounded-xl min-w-[4rem] transition-colors",
|
||||||
|
isActive
|
||||||
|
? "text-violet-400"
|
||||||
|
: "text-[var(--color-text-muted)] active:text-[var(--color-text-secondary)]"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="relative">
|
||||||
|
<Icon size={22} strokeWidth={isActive ? 2.2 : 1.8} />
|
||||||
|
{isActive && (
|
||||||
|
<motion.div
|
||||||
|
layoutId="nav-indicator"
|
||||||
|
className="absolute -inset-2 rounded-xl bg-violet-500/10"
|
||||||
|
transition={{ type: "spring", bounce: 0.2, duration: 0.5 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] font-medium tracking-wide">
|
||||||
|
{item.label}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DesktopSidebar() {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const localePath = pathname.replace(/^\/[a-z]{2}/, "");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside className="hidden md:flex md:w-64 lg:w-72 flex-col h-screen sticky top-0 border-r border-[var(--color-border-faint)] bg-[var(--color-bg-deep)]">
|
||||||
|
{/* Logo */}
|
||||||
|
<div className="flex items-center gap-3 px-6 py-5 border-b border-[var(--color-border-faint)]">
|
||||||
|
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-violet-500 to-cyan-400 flex items-center justify-center shadow-lg">
|
||||||
|
<Sparkles size={18} className="text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="font-[family-name:var(--font-display)] text-lg font-bold tracking-tight text-[var(--color-text-primary)]">
|
||||||
|
ContentGen
|
||||||
|
</h1>
|
||||||
|
<p className="text-[10px] text-[var(--color-text-muted)] uppercase tracking-widest">
|
||||||
|
AI Studio
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Navigation */}
|
||||||
|
<nav className="flex-1 px-3 py-4 space-y-1">
|
||||||
|
{navItems.map((item) => {
|
||||||
|
const isActive =
|
||||||
|
localePath === item.href ||
|
||||||
|
(item.href !== "/dashboard" && localePath.startsWith(item.href));
|
||||||
|
const Icon = item.icon;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={item.href}
|
||||||
|
href={item.href}
|
||||||
|
className={cn(
|
||||||
|
"relative flex items-center gap-3 px-4 py-2.5 rounded-xl text-sm font-medium transition-all duration-200",
|
||||||
|
isActive
|
||||||
|
? "text-white bg-violet-500/12 shadow-[inset_0_1px_0_rgba(255,255,255,0.04)]"
|
||||||
|
: "text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)] hover:bg-[var(--color-bg-surface)]"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isActive && (
|
||||||
|
<motion.div
|
||||||
|
layoutId="sidebar-active"
|
||||||
|
className="absolute left-0 top-1/2 -translate-y-1/2 w-[3px] h-6 rounded-r-full bg-gradient-to-b from-violet-400 to-violet-600"
|
||||||
|
transition={{ type: "spring", bounce: 0.2, duration: 0.5 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Icon size={18} strokeWidth={isActive ? 2.2 : 1.6} />
|
||||||
|
<span>{item.label}</span>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* Credits Card */}
|
||||||
|
<div className="mx-3 mb-4 p-4 rounded-xl bg-gradient-to-br from-violet-500/8 to-cyan-400/5 border border-[var(--color-border-faint)]">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<span className="text-xs text-[var(--color-text-muted)]">Kalan Kredi</span>
|
||||||
|
<span className="badge badge-violet">Pro</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-bold font-[family-name:var(--font-display)] text-[var(--color-text-primary)]">
|
||||||
|
47
|
||||||
|
</div>
|
||||||
|
<div className="progress-bar mt-2">
|
||||||
|
<div className="progress-bar-fill" style={{ width: "94%" }} />
|
||||||
|
</div>
|
||||||
|
<p className="text-[10px] text-[var(--color-text-ghost)] mt-1.5">
|
||||||
|
50 kredilik planınızın 47'si kaldı
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TopBar() {
|
||||||
|
return (
|
||||||
|
<header className="sticky top-0 z-40 glass">
|
||||||
|
<div className="flex items-center justify-between px-4 md:px-6 h-14 md:h-16">
|
||||||
|
{/* Mobil logo */}
|
||||||
|
<div className="flex items-center gap-2.5 md:hidden">
|
||||||
|
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-violet-500 to-cyan-400 flex items-center justify-center">
|
||||||
|
<Sparkles size={16} className="text-white" />
|
||||||
|
</div>
|
||||||
|
<span className="font-[family-name:var(--font-display)] font-bold text-base">
|
||||||
|
ContentGen
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Spacer — desktop */}
|
||||||
|
<div className="hidden md:block" />
|
||||||
|
|
||||||
|
{/* Sağ taraf */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{/* Bildirim */}
|
||||||
|
<button
|
||||||
|
className="relative w-9 h-9 rounded-xl flex items-center justify-center text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)] hover:bg-[var(--color-bg-elevated)] transition-colors"
|
||||||
|
aria-label="Bildirimler"
|
||||||
|
>
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9" />
|
||||||
|
<path d="M10.3 21a1.94 1.94 0 0 0 3.4 0" />
|
||||||
|
</svg>
|
||||||
|
<span className="absolute top-1.5 right-1.5 w-2 h-2 bg-violet-500 rounded-full" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Avatar */}
|
||||||
|
<button
|
||||||
|
className="w-9 h-9 rounded-xl bg-gradient-to-br from-violet-600 to-cyan-500 flex items-center justify-center text-white text-sm font-semibold shadow-md"
|
||||||
|
aria-label="Profil"
|
||||||
|
>
|
||||||
|
H
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AppShell({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen">
|
||||||
|
<DesktopSidebar />
|
||||||
|
<div className="flex-1 flex flex-col min-h-screen">
|
||||||
|
<TopBar />
|
||||||
|
<main className="flex-1 px-[var(--spacing-page)] py-5 pb-safe page-enter">
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
<MobileNav />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,22 +1,21 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { ChakraProvider } from "@chakra-ui/react";
|
|
||||||
import { SessionProvider } from "next-auth/react";
|
import { SessionProvider } from "next-auth/react";
|
||||||
import { ColorModeProvider, type ColorModeProviderProps } from "./color-mode";
|
import { ThemeProvider } from "next-themes";
|
||||||
import { system } from "../../theme/theme";
|
|
||||||
import { Toaster } from "./feedback/toaster";
|
|
||||||
import TopLoader from "./top-loader";
|
|
||||||
import ReactQueryProvider from "@/provider/react-query-provider";
|
import ReactQueryProvider from "@/provider/react-query-provider";
|
||||||
|
|
||||||
export function Provider(props: ColorModeProviderProps) {
|
export function Provider({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<SessionProvider>
|
<SessionProvider>
|
||||||
<ReactQueryProvider>
|
<ReactQueryProvider>
|
||||||
<ChakraProvider value={system}>
|
<ThemeProvider
|
||||||
<TopLoader />
|
attribute="class"
|
||||||
<ColorModeProvider {...props} />
|
defaultTheme="dark"
|
||||||
<Toaster />
|
enableSystem={false}
|
||||||
</ChakraProvider>
|
disableTransitionOnChange
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ThemeProvider>
|
||||||
</ReactQueryProvider>
|
</ReactQueryProvider>
|
||||||
</SessionProvider>
|
</SessionProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
197
src/hooks/use-api.ts
Normal file
197
src/hooks/use-api.ts
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import {
|
||||||
|
projectsApi,
|
||||||
|
creditsApi,
|
||||||
|
templatesApi,
|
||||||
|
dashboardApi,
|
||||||
|
type Project,
|
||||||
|
type CreateProjectPayload,
|
||||||
|
type PaginatedResponse,
|
||||||
|
} from '@/lib/api/api-service';
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════
|
||||||
|
// Query Keys — React Query cache yönetimi
|
||||||
|
// ═══════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
export const queryKeys = {
|
||||||
|
projects: {
|
||||||
|
all: ['projects'] as const,
|
||||||
|
list: (params?: Record<string, unknown>) => ['projects', 'list', params] as const,
|
||||||
|
detail: (id: string) => ['projects', 'detail', id] as const,
|
||||||
|
},
|
||||||
|
credits: {
|
||||||
|
balance: ['credits', 'balance'] as const,
|
||||||
|
history: (params?: Record<string, unknown>) => ['credits', 'history', params] as const,
|
||||||
|
},
|
||||||
|
templates: {
|
||||||
|
all: ['templates'] as const,
|
||||||
|
list: (params?: Record<string, unknown>) => ['templates', 'list', params] as const,
|
||||||
|
detail: (id: string) => ['templates', 'detail', id] as const,
|
||||||
|
},
|
||||||
|
dashboard: {
|
||||||
|
stats: ['dashboard', 'stats'] as const,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════
|
||||||
|
// PROJECTS — Hook'ları
|
||||||
|
// ═══════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
/** Proje listesi — sayfalı, filtreleme destekli */
|
||||||
|
export function useProjects(params?: { page?: number; limit?: number; status?: string }) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: queryKeys.projects.list(params),
|
||||||
|
queryFn: () => projectsApi.list(params),
|
||||||
|
staleTime: 30_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tek proje detayı — sahneler, medya, render job dahil */
|
||||||
|
export function useProject(id: string) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: queryKeys.projects.detail(id),
|
||||||
|
queryFn: () => projectsApi.get(id),
|
||||||
|
enabled: !!id,
|
||||||
|
staleTime: 10_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Yeni proje oluştur */
|
||||||
|
export function useCreateProject() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (data: CreateProjectPayload) => projectsApi.create(data),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: queryKeys.projects.all });
|
||||||
|
qc.invalidateQueries({ queryKey: queryKeys.dashboard.stats });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Proje güncelle */
|
||||||
|
export function useUpdateProject() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ id, data }: { id: string; data: Partial<CreateProjectPayload> }) =>
|
||||||
|
projectsApi.update(id, data),
|
||||||
|
onSuccess: (_data, variables) => {
|
||||||
|
qc.invalidateQueries({ queryKey: queryKeys.projects.detail(variables.id) });
|
||||||
|
qc.invalidateQueries({ queryKey: queryKeys.projects.all });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Proje sil (soft delete) */
|
||||||
|
export function useDeleteProject() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: string) => projectsApi.delete(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: queryKeys.projects.all });
|
||||||
|
qc.invalidateQueries({ queryKey: queryKeys.dashboard.stats });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** AI senaryo üret — SEO + Humanizer-enhanced */
|
||||||
|
export function useGenerateScript() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (projectId: string) => projectsApi.generateScript(projectId),
|
||||||
|
onSuccess: (data) => {
|
||||||
|
qc.setQueryData(queryKeys.projects.detail(data.id), data);
|
||||||
|
qc.invalidateQueries({ queryKey: queryKeys.projects.all });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Onay ver ve video üretimine gönder (BullMQ kuyruğu) */
|
||||||
|
export function useApproveAndQueue() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (projectId: string) => projectsApi.approveAndQueue(projectId),
|
||||||
|
onSuccess: (_data, projectId) => {
|
||||||
|
qc.invalidateQueries({ queryKey: queryKeys.projects.detail(projectId) });
|
||||||
|
qc.invalidateQueries({ queryKey: queryKeys.projects.all });
|
||||||
|
qc.invalidateQueries({ queryKey: queryKeys.credits.balance });
|
||||||
|
qc.invalidateQueries({ queryKey: queryKeys.dashboard.stats });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════
|
||||||
|
// CREDITS — Kredi hook'ları
|
||||||
|
// ═══════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
/** Mevcut kredi bakiyesi */
|
||||||
|
export function useCreditBalance() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: queryKeys.credits.balance,
|
||||||
|
queryFn: () => creditsApi.getBalance(),
|
||||||
|
staleTime: 60_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Kredi işlem geçmişi */
|
||||||
|
export function useCreditHistory(params?: { page?: number; limit?: number }) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: queryKeys.credits.history(params),
|
||||||
|
queryFn: () => creditsApi.getHistory(params),
|
||||||
|
staleTime: 30_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════
|
||||||
|
// TEMPLATES — Şablon hook'ları
|
||||||
|
// ═══════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
/** Şablon galerisi */
|
||||||
|
export function useTemplates(params?: {
|
||||||
|
category?: string;
|
||||||
|
language?: string;
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
}) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: queryKeys.templates.list(params),
|
||||||
|
queryFn: () => templatesApi.list(params),
|
||||||
|
staleTime: 120_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tek şablon detayı */
|
||||||
|
export function useTemplate(id: string) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: queryKeys.templates.detail(id),
|
||||||
|
queryFn: () => templatesApi.get(id),
|
||||||
|
enabled: !!id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Şablondan proje klonla */
|
||||||
|
export function useCloneTemplate() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (templateId: string) => templatesApi.clone(templateId),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: queryKeys.projects.all });
|
||||||
|
qc.invalidateQueries({ queryKey: queryKeys.dashboard.stats });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════
|
||||||
|
// DASHBOARD — İstatistik hook'ları
|
||||||
|
// ═══════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
/** Dashboard istatistikleri */
|
||||||
|
export function useDashboardStats() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: queryKeys.dashboard.stats,
|
||||||
|
queryFn: () => dashboardApi.getStats(),
|
||||||
|
staleTime: 30_000,
|
||||||
|
refetchInterval: 60_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
147
src/hooks/use-tweet.ts
Normal file
147
src/hooks/use-tweet.ts
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
import {
|
||||||
|
xTwitterApi,
|
||||||
|
projectsApi,
|
||||||
|
TweetPreview,
|
||||||
|
CreateFromTweetPayload,
|
||||||
|
Project,
|
||||||
|
} from '@/lib/api/api-service';
|
||||||
|
|
||||||
|
interface UseTweetState {
|
||||||
|
preview: TweetPreview | null;
|
||||||
|
isLoadingPreview: boolean;
|
||||||
|
isCreatingProject: boolean;
|
||||||
|
error: string | null;
|
||||||
|
createdProject: Project | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* X/Twitter tweet import hook.
|
||||||
|
* 1. URL ön izleme: tweet çeker, viral skor hesaplar, prompt önerir
|
||||||
|
* 2. Proje oluşturma: tweet'ten otomatik proje + senaryo üretir
|
||||||
|
*/
|
||||||
|
export function useTweet() {
|
||||||
|
const [state, setState] = useState<UseTweetState>({
|
||||||
|
preview: null,
|
||||||
|
isLoadingPreview: false,
|
||||||
|
isCreatingProject: false,
|
||||||
|
error: null,
|
||||||
|
createdProject: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tweet URL validasyonu (client-side)
|
||||||
|
*/
|
||||||
|
const isValidTweetUrl = useCallback((url: string): boolean => {
|
||||||
|
return /^https?:\/\/(x\.com|twitter\.com)\/([\w]+\/status\/\d+|i\/status\/\d+)/.test(url);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tweet ön izleme — URL'yi backend'e gönder, tweet verisi + viral skor al
|
||||||
|
*/
|
||||||
|
const previewTweet = useCallback(async (tweetUrl: string) => {
|
||||||
|
if (!isValidTweetUrl(tweetUrl)) {
|
||||||
|
setState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
error: 'Geçerli bir X/Twitter URL\'si girin (https://x.com/user/status/123)',
|
||||||
|
preview: null,
|
||||||
|
}));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
isLoadingPreview: true,
|
||||||
|
error: null,
|
||||||
|
preview: null,
|
||||||
|
createdProject: null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await xTwitterApi.preview(tweetUrl);
|
||||||
|
const preview = response && typeof response === 'object' && 'data' in response
|
||||||
|
? (response as { data: TweetPreview }).data
|
||||||
|
: response;
|
||||||
|
|
||||||
|
setState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
preview,
|
||||||
|
isLoadingPreview: false,
|
||||||
|
}));
|
||||||
|
return preview;
|
||||||
|
} catch (err) {
|
||||||
|
const message =
|
||||||
|
err instanceof Error
|
||||||
|
? err.message
|
||||||
|
: 'Tweet çekilemedi. URL\'yi kontrol edin.';
|
||||||
|
setState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
error: message,
|
||||||
|
isLoadingPreview: false,
|
||||||
|
}));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}, [isValidTweetUrl]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tweet'ten proje oluştur — ön izleme sonrasında
|
||||||
|
*/
|
||||||
|
const createProjectFromTweet = useCallback(
|
||||||
|
async (payload: CreateFromTweetPayload) => {
|
||||||
|
setState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
isCreatingProject: true,
|
||||||
|
error: null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await projectsApi.createFromTweet(payload);
|
||||||
|
const project = response && typeof response === 'object' && 'data' in response
|
||||||
|
? (response as { data: Project }).data
|
||||||
|
: response;
|
||||||
|
|
||||||
|
setState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
createdProject: project,
|
||||||
|
isCreatingProject: false,
|
||||||
|
}));
|
||||||
|
return project;
|
||||||
|
} catch (err) {
|
||||||
|
const message =
|
||||||
|
err instanceof Error
|
||||||
|
? err.message
|
||||||
|
: 'Tweet\'ten proje oluşturulamadı.';
|
||||||
|
setState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
error: message,
|
||||||
|
isCreatingProject: false,
|
||||||
|
}));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* State'i temizle — yeni import için
|
||||||
|
*/
|
||||||
|
const reset = useCallback(() => {
|
||||||
|
setState({
|
||||||
|
preview: null,
|
||||||
|
isLoadingPreview: false,
|
||||||
|
isCreatingProject: false,
|
||||||
|
error: null,
|
||||||
|
createdProject: null,
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
isValidTweetUrl,
|
||||||
|
previewTweet,
|
||||||
|
createProjectFromTweet,
|
||||||
|
reset,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,22 +1,293 @@
|
|||||||
import { clientMap } from '@/lib/api/client-map';
|
import { createApiClient } from './create-api-client';
|
||||||
import { Method } from 'axios';
|
|
||||||
|
|
||||||
interface ApiRequestOptions {
|
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000/api';
|
||||||
|
|
||||||
|
export const apiClient = createApiClient(API_URL);
|
||||||
|
|
||||||
|
// ── Type Definitions ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface Project {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
prompt: string;
|
||||||
|
status: ProjectStatus;
|
||||||
|
progress: number;
|
||||||
|
language: string;
|
||||||
|
aspectRatio: string;
|
||||||
|
videoStyle: string;
|
||||||
|
targetDuration: number;
|
||||||
|
creditsUsed: number;
|
||||||
|
thumbnailUrl?: string;
|
||||||
|
finalVideoUrl?: string;
|
||||||
|
errorMessage?: string;
|
||||||
|
scriptJson?: ScriptJson;
|
||||||
|
scriptVersion: number;
|
||||||
|
scenes?: Scene[];
|
||||||
|
renderJobs?: RenderJob[];
|
||||||
|
sourceType?: 'MANUAL' | 'X_TWEET' | 'YOUTUBE';
|
||||||
|
sourceTweetData?: Record<string, unknown>;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
completedAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ProjectStatus =
|
||||||
|
| 'DRAFT'
|
||||||
|
| 'GENERATING_SCRIPT'
|
||||||
|
| 'PENDING'
|
||||||
|
| 'GENERATING_MEDIA'
|
||||||
|
| 'RENDERING'
|
||||||
|
| 'COMPLETED'
|
||||||
|
| 'FAILED';
|
||||||
|
|
||||||
|
export interface Scene {
|
||||||
|
id: string;
|
||||||
|
order: number;
|
||||||
|
title?: string;
|
||||||
|
narrationText: string;
|
||||||
|
visualPrompt: string;
|
||||||
|
subtitleText?: string;
|
||||||
|
duration: number;
|
||||||
|
transitionType: string;
|
||||||
|
mediaAssets?: MediaAsset[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MediaAsset {
|
||||||
|
id: string;
|
||||||
|
type: string;
|
||||||
|
url?: string;
|
||||||
|
fileName?: string;
|
||||||
|
mimeType?: string;
|
||||||
|
sizeBytes?: number;
|
||||||
|
durationMs?: number;
|
||||||
|
aiProvider?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RenderJob {
|
||||||
|
id: string;
|
||||||
|
status: string;
|
||||||
|
currentStage?: string;
|
||||||
|
attemptNumber: number;
|
||||||
|
processingTimeMs?: number;
|
||||||
|
errorMessage?: string;
|
||||||
|
finalVideoUrl?: string;
|
||||||
|
createdAt: string;
|
||||||
|
startedAt?: string;
|
||||||
|
completedAt?: string;
|
||||||
|
logs?: RenderLog[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RenderLog {
|
||||||
|
id: string;
|
||||||
|
stage: string;
|
||||||
|
level: string;
|
||||||
|
message: string;
|
||||||
|
durationMs?: number;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScriptJson {
|
||||||
|
metadata: {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
totalDurationSeconds: number;
|
||||||
|
language: string;
|
||||||
|
hashtags: string[];
|
||||||
|
};
|
||||||
|
seo?: {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
keywords: string[];
|
||||||
|
hashtags: string[];
|
||||||
|
schemaMarkup: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
scenes: Array<{
|
||||||
|
order: number;
|
||||||
|
title?: string;
|
||||||
|
narrationText: string;
|
||||||
|
visualPrompt: string;
|
||||||
|
subtitleText: string;
|
||||||
|
durationSeconds: number;
|
||||||
|
transitionType: string;
|
||||||
|
}>;
|
||||||
|
musicPrompt: string;
|
||||||
|
voiceStyle: string;
|
||||||
|
socialContent?: {
|
||||||
|
youtubeTitle: string;
|
||||||
|
youtubeDescription: string;
|
||||||
|
tiktokCaption: string;
|
||||||
|
instagramCaption: string;
|
||||||
|
twitterText: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaginatedResponse<T> {
|
||||||
|
data: T[];
|
||||||
|
meta: {
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
limit: number;
|
||||||
|
totalPages: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateProjectPayload {
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
prompt: string;
|
||||||
|
language?: string;
|
||||||
|
aspectRatio?: string;
|
||||||
|
videoStyle?: string;
|
||||||
|
targetDuration?: number;
|
||||||
|
seoKeywords?: string[];
|
||||||
|
referenceUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreditBalance {
|
||||||
|
balance: number;
|
||||||
|
monthlyUsed: number;
|
||||||
|
monthlyLimit: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Template {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
thumbnailUrl?: string;
|
||||||
|
previewVideoUrl?: string;
|
||||||
|
category: string;
|
||||||
|
tags: string[];
|
||||||
|
language: string;
|
||||||
|
usageCount: number;
|
||||||
|
rating: number;
|
||||||
|
isFeatured: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardStats {
|
||||||
|
totalProjects: number;
|
||||||
|
completedVideos: number;
|
||||||
|
totalCreditsUsed: number;
|
||||||
|
creditsRemaining: number;
|
||||||
|
activeRenderJobs: number;
|
||||||
|
recentProjects: Project[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tweet Types
|
||||||
|
export interface TweetAuthor {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
username: string;
|
||||||
|
avatarUrl: string;
|
||||||
|
followersCount: number;
|
||||||
|
verified: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TweetMetrics {
|
||||||
|
replies: number;
|
||||||
|
retweets: number;
|
||||||
|
likes: number;
|
||||||
|
views: number;
|
||||||
|
engagementRate: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TweetMedia {
|
||||||
|
type: 'photo' | 'video' | 'gif';
|
||||||
url: string;
|
url: string;
|
||||||
client: keyof typeof clientMap;
|
thumbnailUrl?: string;
|
||||||
method?: Method;
|
width: number;
|
||||||
data?: any;
|
height: number;
|
||||||
params?: Record<string, any>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function apiRequest<T = any>(options: ApiRequestOptions): Promise<T> {
|
export interface ParsedTweet {
|
||||||
const { url, client, method = 'get', data, params } = options;
|
id: string;
|
||||||
const clientInstance = clientMap[client];
|
url: string;
|
||||||
|
text: string;
|
||||||
if (!url || !clientInstance) {
|
createdAt: string;
|
||||||
throw new Error(`Invalid API request: ${client} - ${url}`);
|
author: TweetAuthor;
|
||||||
}
|
metrics: TweetMetrics;
|
||||||
|
media: TweetMedia[];
|
||||||
const response = await clientInstance.request<T>({ method, url, data, params });
|
quotedTweet?: ParsedTweet;
|
||||||
return response.data;
|
isThread: boolean;
|
||||||
|
threadTweets?: ParsedTweet[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TweetPreview {
|
||||||
|
tweet: ParsedTweet;
|
||||||
|
suggestedTitle: string;
|
||||||
|
suggestedPrompt: string;
|
||||||
|
viralScore: number;
|
||||||
|
contentType: 'tweet' | 'thread' | 'quote_tweet';
|
||||||
|
estimatedDuration: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateFromTweetPayload {
|
||||||
|
tweetUrl: string;
|
||||||
|
title?: string;
|
||||||
|
language?: string;
|
||||||
|
aspectRatio?: string;
|
||||||
|
videoStyle?: string;
|
||||||
|
targetDuration?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── API Functions ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const projectsApi = {
|
||||||
|
list: (params?: { page?: number; limit?: number; status?: string }) =>
|
||||||
|
apiClient.get<PaginatedResponse<Project>>('/projects', { params }).then((r) => r.data),
|
||||||
|
|
||||||
|
get: (id: string) =>
|
||||||
|
apiClient.get<Project>(`/projects/${id}`).then((r) => r.data),
|
||||||
|
|
||||||
|
create: (data: CreateProjectPayload) =>
|
||||||
|
apiClient.post<Project>('/projects', data).then((r) => r.data),
|
||||||
|
|
||||||
|
update: (id: string, data: Partial<CreateProjectPayload>) =>
|
||||||
|
apiClient.patch<Project>(`/projects/${id}`, data).then((r) => r.data),
|
||||||
|
|
||||||
|
delete: (id: string) =>
|
||||||
|
apiClient.delete(`/projects/${id}`).then((r) => r.data),
|
||||||
|
|
||||||
|
generateScript: (id: string) =>
|
||||||
|
apiClient.post<Project>(`/projects/${id}/generate-script`).then((r) => r.data),
|
||||||
|
|
||||||
|
approveAndQueue: (id: string) =>
|
||||||
|
apiClient.post<{ projectId: string; renderJobId: string; bullJobId: string }>(
|
||||||
|
`/projects/${id}/approve-and-queue`,
|
||||||
|
).then((r) => r.data),
|
||||||
|
|
||||||
|
createFromTweet: (data: CreateFromTweetPayload) =>
|
||||||
|
apiClient.post<Project>('/projects/from-tweet', data).then((r) => r.data),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const creditsApi = {
|
||||||
|
getBalance: () =>
|
||||||
|
apiClient.get<CreditBalance>('/credits/balance').then((r) => r.data),
|
||||||
|
|
||||||
|
getHistory: (params?: { page?: number; limit?: number }) =>
|
||||||
|
apiClient.get('/credits/history', { params }).then((r) => r.data),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const templatesApi = {
|
||||||
|
list: (params?: { category?: string; language?: string; page?: number; limit?: number }) =>
|
||||||
|
apiClient.get<PaginatedResponse<Template>>('/templates', { params }).then((r) => r.data),
|
||||||
|
|
||||||
|
get: (id: string) =>
|
||||||
|
apiClient.get<Template>(`/templates/${id}`).then((r) => r.data),
|
||||||
|
|
||||||
|
clone: (id: string) =>
|
||||||
|
apiClient.post<Project>(`/templates/${id}/clone`).then((r) => r.data),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const dashboardApi = {
|
||||||
|
getStats: () =>
|
||||||
|
apiClient.get<DashboardStats>('/dashboard/stats').then((r) => r.data),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const xTwitterApi = {
|
||||||
|
preview: (tweetUrl: string) =>
|
||||||
|
apiClient.post<TweetPreview>('/x-twitter/preview', { tweetUrl }).then((r) => r.data),
|
||||||
|
|
||||||
|
fetch: (tweetUrl: string) =>
|
||||||
|
apiClient.post<ParsedTweet>('/x-twitter/fetch', { tweetUrl }).then((r) => r.data),
|
||||||
|
};
|
||||||
|
|||||||
6
src/lib/utils.ts
Normal file
6
src/lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { type ClassValue, clsx } from "clsx";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
||||||
63
src/proxy.ts
63
src/proxy.ts
@@ -1,55 +1,34 @@
|
|||||||
import { NAV_ITEMS } from "@/config/navigation";
|
|
||||||
import { withAuth } from "next-auth/middleware";
|
|
||||||
import createMiddleware from "next-intl/middleware";
|
import createMiddleware from "next-intl/middleware";
|
||||||
import { NextRequest } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { routing } from "./i18n/routing";
|
import { routing } from "./i18n/routing";
|
||||||
|
|
||||||
const publicPages = NAV_ITEMS.flatMap((item) => [
|
const intlMiddleware = createMiddleware(routing);
|
||||||
...(!item.protected ? [item.href] : []),
|
|
||||||
...(item.children
|
|
||||||
?.filter((child) => !child.protected)
|
|
||||||
.map((child) => child.href) ?? []),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const handleI18nRouting = createMiddleware(routing);
|
// Geliştirme sırasında tüm dashboard rotaları public
|
||||||
|
// Production'da auth kontrolü eklenecek
|
||||||
const authMiddleware = withAuth(
|
const publicPaths = [
|
||||||
// Note that this callback is only invoked if
|
"/",
|
||||||
// the `authorized` callback has returned `true`
|
"/home",
|
||||||
// and not for pages listed in `pages`.
|
"/signin",
|
||||||
function onSuccess(req) {
|
"/signup",
|
||||||
return handleI18nRouting(req);
|
"/forgot-password",
|
||||||
},
|
"/dashboard",
|
||||||
{
|
"/predictions",
|
||||||
callbacks: {
|
"/about",
|
||||||
authorized: ({ token }) => token != null,
|
];
|
||||||
},
|
|
||||||
pages: {
|
|
||||||
signIn: "/home",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
export default function proxy(req: NextRequest) {
|
export default function proxy(req: NextRequest) {
|
||||||
// CRITICAL: Skip API routes entirely - they should not go through i18n or auth middleware
|
const { pathname } = req.nextUrl;
|
||||||
if (req.nextUrl.pathname.startsWith("/api/")) {
|
|
||||||
return; // Return undefined to pass through without modification
|
// API rotaları: middleware'den geç
|
||||||
|
if (pathname.startsWith("/api/")) {
|
||||||
|
return NextResponse.next();
|
||||||
}
|
}
|
||||||
|
|
||||||
const publicPathnameRegex = RegExp(
|
// i18n routing uygula — tüm sayfalar için
|
||||||
`^(/(${routing.locales.join("|")}))?(${publicPages.flatMap((p) => (p === "/" ? ["", "/"] : p)).join("|")})/?$`,
|
return intlMiddleware(req);
|
||||||
"i",
|
|
||||||
);
|
|
||||||
const isPublicPage = publicPathnameRegex.test(req.nextUrl.pathname);
|
|
||||||
|
|
||||||
if (isPublicPage) {
|
|
||||||
return handleI18nRouting(req);
|
|
||||||
} else {
|
|
||||||
return (authMiddleware as any)(req);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const config = {
|
export const config = {
|
||||||
matcher: "/((?!api|trpc|_next|_vercel|.*\\..*).*)",
|
matcher: "/((?!api|trpc|_next|_vercel|.*\\..*).*)",
|
||||||
// matcher: ['/', '/(de|en|tr)/:path*'],
|
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user