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:
4
.env
4
.env
@@ -6,10 +6,10 @@ NEXTAUTH_SECRET=local-dev-secret-key-contgen-ai-2026
|
|||||||
NEXT_PUBLIC_API_URL=http://localhost:3000/api
|
NEXT_PUBLIC_API_URL=http://localhost:3000/api
|
||||||
|
|
||||||
# Auth Mode: true = login required, false = public access
|
# Auth Mode: true = login required, false = public access
|
||||||
NEXT_PUBLIC_AUTH_REQUIRED=false
|
NEXT_PUBLIC_AUTH_REQUIRED=true
|
||||||
|
|
||||||
# Mock Mode: true = skip backend auth, use local mock
|
# Mock Mode: true = skip backend auth, use local mock
|
||||||
NEXT_PUBLIC_ENABLE_MOCK_MODE=true
|
NEXT_PUBLIC_ENABLE_MOCK_MODE=false
|
||||||
|
|
||||||
# Google API (optional)
|
# Google API (optional)
|
||||||
NEXT_PUBLIC_GOOGLE_API_KEY=
|
NEXT_PUBLIC_GOOGLE_API_KEY=
|
||||||
|
|||||||
15
Dockerfile
15
Dockerfile
@@ -2,25 +2,34 @@
|
|||||||
FROM node:20-alpine AS deps
|
FROM node:20-alpine AS deps
|
||||||
RUN apk add --no-cache libc6-compat
|
RUN apk add --no-cache libc6-compat
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY package.json package-lock.json ./
|
|
||||||
RUN npm ci
|
# pnpm kurulumu (workspace kuralı gereği)
|
||||||
|
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||||
|
|
||||||
|
COPY package.json pnpm-lock.yaml ./
|
||||||
|
RUN pnpm install --frozen-lockfile
|
||||||
|
|
||||||
# --- 2. Build Katmanı ---
|
# --- 2. Build Katmanı ---
|
||||||
FROM node:20-alpine AS builder
|
FROM node:20-alpine AS builder
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||||
|
|
||||||
COPY --from=deps /app/node_modules ./node_modules
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# [DİKKAT] Build anındaki env'leri buraya tanımlıyoruz
|
# [DİKKAT] Build anındaki env'leri buraya tanımlıyoruz
|
||||||
ARG NEXT_PUBLIC_API_URL
|
ARG NEXT_PUBLIC_API_URL
|
||||||
ARG NEXT_PUBLIC_AUTH_REQUIRED
|
ARG NEXT_PUBLIC_AUTH_REQUIRED
|
||||||
|
ARG NEXT_PUBLIC_ENABLE_MOCK_MODE=false
|
||||||
ARG NEXT_PUBLIC_GOOGLE_API_KEY
|
ARG NEXT_PUBLIC_GOOGLE_API_KEY
|
||||||
|
|
||||||
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
|
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
|
||||||
ENV NEXT_PUBLIC_AUTH_REQUIRED=$NEXT_PUBLIC_AUTH_REQUIRED
|
ENV NEXT_PUBLIC_AUTH_REQUIRED=$NEXT_PUBLIC_AUTH_REQUIRED
|
||||||
|
ENV NEXT_PUBLIC_ENABLE_MOCK_MODE=$NEXT_PUBLIC_ENABLE_MOCK_MODE
|
||||||
ENV NEXT_PUBLIC_GOOGLE_API_KEY=$NEXT_PUBLIC_GOOGLE_API_KEY
|
ENV NEXT_PUBLIC_GOOGLE_API_KEY=$NEXT_PUBLIC_GOOGLE_API_KEY
|
||||||
|
|
||||||
RUN npm run build
|
RUN pnpm build
|
||||||
|
|
||||||
# --- 3. Çalıştırma Katmanı (Runner) ---
|
# --- 3. Çalıştırma Katmanı (Runner) ---
|
||||||
FROM node:20-alpine AS runner
|
FROM node:20-alpine AS runner
|
||||||
|
|||||||
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/dev/types/routes.d.ts";
|
import "./.next/types/routes.d.ts";
|
||||||
|
|
||||||
// NOTE: This file should not be edited
|
// NOTE: This file should not be edited
|
||||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||||
|
|||||||
10341
package-lock.json
generated
10341
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -33,6 +33,7 @@
|
|||||||
"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",
|
"recharts": "^3.8.1",
|
||||||
|
"socket.io-client": "^4.8.3",
|
||||||
"tailwind-merge": "^3.5.0",
|
"tailwind-merge": "^3.5.0",
|
||||||
"tailwindcss": "^4.2.2",
|
"tailwindcss": "^4.2.2",
|
||||||
"yup": "^1.7.1"
|
"yup": "^1.7.1"
|
||||||
|
|||||||
75
pnpm-lock.yaml
generated
75
pnpm-lock.yaml
generated
@@ -80,6 +80,9 @@ importers:
|
|||||||
recharts:
|
recharts:
|
||||||
specifier: ^3.8.1
|
specifier: ^3.8.1
|
||||||
version: 3.8.1(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react-is@16.13.1)(react@19.2.0)(redux@5.0.1)
|
version: 3.8.1(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react-is@16.13.1)(react@19.2.0)(redux@5.0.1)
|
||||||
|
socket.io-client:
|
||||||
|
specifier: ^4.8.3
|
||||||
|
version: 4.8.3
|
||||||
tailwind-merge:
|
tailwind-merge:
|
||||||
specifier: ^3.5.0
|
specifier: ^3.5.0
|
||||||
version: 3.5.0
|
version: 3.5.0
|
||||||
@@ -974,6 +977,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-LtoMMhxAlorcGhmFYI+LhPgbPZCkgP6ra1YL604EeF6U98pLlQ3iWIGMdWSC+vWmPBWBNgmDBAhnAobLROJmwg==}
|
resolution: {integrity: sha512-LtoMMhxAlorcGhmFYI+LhPgbPZCkgP6ra1YL604EeF6U98pLlQ3iWIGMdWSC+vWmPBWBNgmDBAhnAobLROJmwg==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
'@socket.io/component-emitter@3.1.2':
|
||||||
|
resolution: {integrity: sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==}
|
||||||
|
|
||||||
'@standard-schema/spec@1.1.0':
|
'@standard-schema/spec@1.1.0':
|
||||||
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
|
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
|
||||||
|
|
||||||
@@ -2002,6 +2008,13 @@ packages:
|
|||||||
emoji-regex@9.2.2:
|
emoji-regex@9.2.2:
|
||||||
resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==}
|
resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==}
|
||||||
|
|
||||||
|
engine.io-client@6.6.4:
|
||||||
|
resolution: {integrity: sha512-+kjUJnZGwzewFDw951CDWcwj35vMNf2fcj7xQWOctq1F2i1jkDdVvdFG9kM/BEChymCH36KgjnW0NsL58JYRxw==}
|
||||||
|
|
||||||
|
engine.io-parser@5.2.3:
|
||||||
|
resolution: {integrity: sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==}
|
||||||
|
engines: {node: '>=10.0.0'}
|
||||||
|
|
||||||
enhanced-resolve@5.20.1:
|
enhanced-resolve@5.20.1:
|
||||||
resolution: {integrity: sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==}
|
resolution: {integrity: sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==}
|
||||||
engines: {node: '>=10.13.0'}
|
engines: {node: '>=10.13.0'}
|
||||||
@@ -3233,6 +3246,14 @@ packages:
|
|||||||
resolution: {integrity: sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==}
|
resolution: {integrity: sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==}
|
||||||
engines: {node: '>=14.16'}
|
engines: {node: '>=14.16'}
|
||||||
|
|
||||||
|
socket.io-client@4.8.3:
|
||||||
|
resolution: {integrity: sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==}
|
||||||
|
engines: {node: '>=10.0.0'}
|
||||||
|
|
||||||
|
socket.io-parser@4.2.6:
|
||||||
|
resolution: {integrity: sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg==}
|
||||||
|
engines: {node: '>=10.0.0'}
|
||||||
|
|
||||||
source-map-js@1.2.1:
|
source-map-js@1.2.1:
|
||||||
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
@@ -3464,6 +3485,18 @@ packages:
|
|||||||
resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
|
resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
|
ws@8.18.3:
|
||||||
|
resolution: {integrity: sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==}
|
||||||
|
engines: {node: '>=10.0.0'}
|
||||||
|
peerDependencies:
|
||||||
|
bufferutil: ^4.0.1
|
||||||
|
utf-8-validate: '>=5.0.2'
|
||||||
|
peerDependenciesMeta:
|
||||||
|
bufferutil:
|
||||||
|
optional: true
|
||||||
|
utf-8-validate:
|
||||||
|
optional: true
|
||||||
|
|
||||||
ws@8.20.0:
|
ws@8.20.0:
|
||||||
resolution: {integrity: sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==}
|
resolution: {integrity: sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==}
|
||||||
engines: {node: '>=10.0.0'}
|
engines: {node: '>=10.0.0'}
|
||||||
@@ -3476,6 +3509,10 @@ packages:
|
|||||||
utf-8-validate:
|
utf-8-validate:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
xmlhttprequest-ssl@2.1.2:
|
||||||
|
resolution: {integrity: sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==}
|
||||||
|
engines: {node: '>=0.4.0'}
|
||||||
|
|
||||||
yallist@3.1.1:
|
yallist@3.1.1:
|
||||||
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
|
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
|
||||||
|
|
||||||
@@ -4351,6 +4388,8 @@ snapshots:
|
|||||||
|
|
||||||
'@sindresorhus/merge-streams@2.3.0': {}
|
'@sindresorhus/merge-streams@2.3.0': {}
|
||||||
|
|
||||||
|
'@socket.io/component-emitter@3.1.2': {}
|
||||||
|
|
||||||
'@standard-schema/spec@1.1.0': {}
|
'@standard-schema/spec@1.1.0': {}
|
||||||
|
|
||||||
'@standard-schema/utils@0.3.0': {}
|
'@standard-schema/utils@0.3.0': {}
|
||||||
@@ -5646,6 +5685,20 @@ snapshots:
|
|||||||
|
|
||||||
emoji-regex@9.2.2: {}
|
emoji-regex@9.2.2: {}
|
||||||
|
|
||||||
|
engine.io-client@6.6.4:
|
||||||
|
dependencies:
|
||||||
|
'@socket.io/component-emitter': 3.1.2
|
||||||
|
debug: 4.4.3
|
||||||
|
engine.io-parser: 5.2.3
|
||||||
|
ws: 8.18.3
|
||||||
|
xmlhttprequest-ssl: 2.1.2
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- bufferutil
|
||||||
|
- supports-color
|
||||||
|
- utf-8-validate
|
||||||
|
|
||||||
|
engine.io-parser@5.2.3: {}
|
||||||
|
|
||||||
enhanced-resolve@5.20.1:
|
enhanced-resolve@5.20.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
graceful-fs: 4.2.11
|
graceful-fs: 4.2.11
|
||||||
@@ -7092,6 +7145,24 @@ snapshots:
|
|||||||
|
|
||||||
slash@5.1.0: {}
|
slash@5.1.0: {}
|
||||||
|
|
||||||
|
socket.io-client@4.8.3:
|
||||||
|
dependencies:
|
||||||
|
'@socket.io/component-emitter': 3.1.2
|
||||||
|
debug: 4.4.3
|
||||||
|
engine.io-client: 6.6.4
|
||||||
|
socket.io-parser: 4.2.6
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- bufferutil
|
||||||
|
- supports-color
|
||||||
|
- utf-8-validate
|
||||||
|
|
||||||
|
socket.io-parser@4.2.6:
|
||||||
|
dependencies:
|
||||||
|
'@socket.io/component-emitter': 3.1.2
|
||||||
|
debug: 4.4.3
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- supports-color
|
||||||
|
|
||||||
source-map-js@1.2.1: {}
|
source-map-js@1.2.1: {}
|
||||||
|
|
||||||
source-map@0.5.7: {}
|
source-map@0.5.7: {}
|
||||||
@@ -7391,8 +7462,12 @@ snapshots:
|
|||||||
|
|
||||||
word-wrap@1.2.5: {}
|
word-wrap@1.2.5: {}
|
||||||
|
|
||||||
|
ws@8.18.3: {}
|
||||||
|
|
||||||
ws@8.20.0: {}
|
ws@8.20.0: {}
|
||||||
|
|
||||||
|
xmlhttprequest-ssl@2.1.2: {}
|
||||||
|
|
||||||
yallist@3.1.1: {}
|
yallist@3.1.1: {}
|
||||||
|
|
||||||
yallist@4.0.0: {}
|
yallist@4.0.0: {}
|
||||||
|
|||||||
@@ -12,8 +12,14 @@ import {
|
|||||||
Sparkles,
|
Sparkles,
|
||||||
TrendingUp,
|
TrendingUp,
|
||||||
Loader2,
|
Loader2,
|
||||||
Twitter,
|
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
|
// X (Twitter) logosu — lucide-react'ta mevcut değil
|
||||||
|
const XIcon = ({ size = 20, className = "" }: { size?: number; className?: string }) => (
|
||||||
|
<svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor" className={className}>
|
||||||
|
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { DashboardCharts } from "@/components/dashboard/dashboard-charts";
|
import { DashboardCharts } from "@/components/dashboard/dashboard-charts";
|
||||||
import { RecentProjects } from "@/components/dashboard/recent-projects";
|
import { RecentProjects } from "@/components/dashboard/recent-projects";
|
||||||
@@ -209,7 +215,7 @@ export default function DashboardPage() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="w-11 h-11 rounded-xl bg-gradient-to-br from-sky-500 to-sky-700 flex items-center justify-center shrink-0 shadow-lg group-hover:shadow-sky-500/20 transition-shadow">
|
<div className="w-11 h-11 rounded-xl bg-gradient-to-br from-sky-500 to-sky-700 flex items-center justify-center shrink-0 shadow-lg group-hover:shadow-sky-500/20 transition-shadow">
|
||||||
<Twitter size={20} className="text-white" />
|
<XIcon size={20} className="text-white" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-sm font-semibold">Tweet → Video</h3>
|
<h3 className="text-sm font-semibold">Tweet → Video</h3>
|
||||||
|
|||||||
@@ -4,248 +4,245 @@ import { useState } from "react";
|
|||||||
import { motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
import {
|
import {
|
||||||
Check,
|
Check,
|
||||||
X,
|
|
||||||
Zap,
|
Zap,
|
||||||
Crown,
|
Crown,
|
||||||
Rocket,
|
Rocket,
|
||||||
|
Loader2,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
ArrowRight,
|
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { useCreateCheckout, useSubscription } from "@/hooks/use-api";
|
||||||
|
import { useToast } from "@/components/ui/toast";
|
||||||
|
|
||||||
const plans = [
|
const plans = [
|
||||||
{
|
{
|
||||||
id: "free",
|
|
||||||
name: "Free",
|
name: "Free",
|
||||||
icon: Sparkles,
|
price: { monthly: 0, yearly: 0 },
|
||||||
monthlyPrice: 0,
|
description: "Başlangıç için ideal",
|
||||||
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,
|
icon: Zap,
|
||||||
monthlyPrice: 19,
|
gradient: "from-gray-500/20 to-gray-600/10",
|
||||||
yearlyPrice: 190,
|
iconColor: "text-gray-400",
|
||||||
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: [
|
features: [
|
||||||
{ label: "50 kredi / ay", included: true },
|
"Ayda 3 video",
|
||||||
{ label: "1080p video kalitesi", included: true },
|
"720p kalite",
|
||||||
{ label: "Max 120 saniye", included: true },
|
"Temel AI senaryo",
|
||||||
{ label: "50 proje limiti", included: true },
|
"Topluluk desteği",
|
||||||
{ label: "Tüm şablonlar", included: true },
|
|
||||||
{ label: "Öncelikli kuyruk", included: true },
|
|
||||||
{ label: "Marka kaldırma", included: true },
|
|
||||||
{ label: "API erişimi", included: false },
|
|
||||||
],
|
],
|
||||||
|
cta: "Mevcut Plan",
|
||||||
|
popular: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "business",
|
name: "Pro",
|
||||||
name: "Business",
|
price: { monthly: 29, yearly: 290 },
|
||||||
|
description: "İçerik üreticileri için",
|
||||||
icon: Crown,
|
icon: Crown,
|
||||||
monthlyPrice: 49,
|
gradient: "from-violet-500/20 to-cyan-400/10",
|
||||||
yearlyPrice: 490,
|
iconColor: "text-violet-400",
|
||||||
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: [
|
features: [
|
||||||
{ label: "Sınırsız kredi", included: true },
|
"Ayda 50 video",
|
||||||
{ label: "1080p video kalitesi", included: true },
|
"1080p Full HD",
|
||||||
{ label: "Max 180 saniye", included: true },
|
"Gelişmiş AI senaryo",
|
||||||
{ label: "Sınırsız proje", included: true },
|
"Tweet → Video dönüşümü",
|
||||||
{ label: "Tüm şablonlar + Özel", included: true },
|
"Öncelikli render",
|
||||||
{ label: "Öncelikli kuyruk", included: true },
|
"E-posta desteği",
|
||||||
{ label: "Marka kaldırma", included: true },
|
|
||||||
{ label: "API erişimi", included: true },
|
|
||||||
],
|
],
|
||||||
|
cta: "Pro'ya Geç",
|
||||||
|
popular: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Enterprise",
|
||||||
|
price: { monthly: 99, yearly: 990 },
|
||||||
|
description: "Ajanslar ve büyük ekipler",
|
||||||
|
icon: Rocket,
|
||||||
|
gradient: "from-amber-500/20 to-orange-400/10",
|
||||||
|
iconColor: "text-amber-400",
|
||||||
|
features: [
|
||||||
|
"Sınırsız video",
|
||||||
|
"4K Ultra HD",
|
||||||
|
"Premium AI + özel model",
|
||||||
|
"API erişimi",
|
||||||
|
"Özel şablonlar",
|
||||||
|
"Öncelikli 7/24 destek",
|
||||||
|
"Beyaz etiket seçeneği",
|
||||||
|
],
|
||||||
|
cta: "Enterprise'a Geç",
|
||||||
|
popular: false,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const fadeUp = {
|
|
||||||
hidden: { opacity: 0, y: 20 },
|
|
||||||
show: { opacity: 1, y: 0, transition: { duration: 0.6, ease: [0.16, 1, 0.3, 1] as const } },
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function PricingPage() {
|
export default function PricingPage() {
|
||||||
const [isYearly, setIsYearly] = useState(false);
|
const [billingCycle, setBillingCycle] = useState<"monthly" | "yearly">(
|
||||||
|
"monthly",
|
||||||
|
);
|
||||||
|
const checkout = useCreateCheckout();
|
||||||
|
const { data: subData } = useSubscription();
|
||||||
|
const toast = useToast();
|
||||||
|
|
||||||
|
const currentPlan = subData?.data?.plan ?? subData?.plan ?? "Free";
|
||||||
|
|
||||||
|
const handleCheckout = async (planName: string) => {
|
||||||
|
if (planName === "Free") {
|
||||||
|
toast.info("Free plan zaten aktif");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (planName === currentPlan) {
|
||||||
|
toast.info("Bu plan zaten aktif");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await checkout.mutateAsync({ planName, billingCycle });
|
||||||
|
} catch {
|
||||||
|
toast.error("Ödeme sayfası açılamadı. Lütfen tekrar deneyin.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-5xl mx-auto space-y-10 py-4">
|
<div className="max-w-5xl mx-auto space-y-8">
|
||||||
{/* ── Başlık ── */}
|
{/* Header */}
|
||||||
<motion.div
|
<div className="text-center space-y-3">
|
||||||
variants={fadeUp}
|
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-violet-500/10 border border-violet-500/20 text-violet-300 text-xs font-medium">
|
||||||
initial="hidden"
|
<Sparkles size={12} />
|
||||||
animate="show"
|
Fiyatlandırma
|
||||||
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>
|
</div>
|
||||||
</motion.div>
|
<h1 className="text-3xl md:text-4xl font-[family-name:var(--font-display)] font-bold text-[var(--color-text-primary)]">
|
||||||
|
İhtiyacınıza Uygun Plan
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-[var(--color-text-muted)] max-w-lg mx-auto">
|
||||||
|
İster hobby, ister profesyonel — her seviyeye uygun planlarımızla AI
|
||||||
|
video üretimine başlayın.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* ── Plan Kartları ── */}
|
{/* Billing Toggle */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 md:gap-5">
|
<div className="flex items-center justify-center gap-3">
|
||||||
{plans.map((plan, i) => {
|
<span
|
||||||
const Icon = plan.icon;
|
className={`text-sm ${
|
||||||
const price = isYearly ? plan.yearlyPrice : plan.monthlyPrice;
|
billingCycle === "monthly"
|
||||||
const period = isYearly ? "/yıl" : "/ay";
|
? "text-[var(--color-text-primary)]"
|
||||||
|
: "text-[var(--color-text-muted)]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Aylık
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
setBillingCycle((p) => (p === "monthly" ? "yearly" : "monthly"))
|
||||||
|
}
|
||||||
|
className={`relative w-12 h-6 rounded-full transition-colors ${
|
||||||
|
billingCycle === "yearly"
|
||||||
|
? "bg-violet-500"
|
||||||
|
: "bg-[var(--color-bg-elevated)]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
className="absolute top-0.5 left-0.5 w-5 h-5 rounded-full bg-white shadow"
|
||||||
|
animate={{ x: billingCycle === "yearly" ? 24 : 0 }}
|
||||||
|
transition={{ type: "spring", bounce: 0.25, duration: 0.3 }}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<span
|
||||||
|
className={`text-sm ${
|
||||||
|
billingCycle === "yearly"
|
||||||
|
? "text-[var(--color-text-primary)]"
|
||||||
|
: "text-[var(--color-text-muted)]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Yıllık
|
||||||
|
<span className="ml-1 text-xs text-emerald-400 font-medium">
|
||||||
|
%17 tasarruf
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Plans Grid */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-5">
|
||||||
|
{plans.map((plan, idx) => {
|
||||||
|
const isCurrentPlan =
|
||||||
|
plan.name.toLowerCase() === currentPlan.toLowerCase();
|
||||||
|
const PlanIcon = plan.icon;
|
||||||
|
const price = plan.price[billingCycle];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
key={plan.id}
|
key={plan.name}
|
||||||
initial={{ opacity: 0, y: 24 }}
|
initial={{ opacity: 0, y: 20 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ delay: i * 0.1, duration: 0.6, ease: [0.16, 1, 0.3, 1] }}
|
transition={{ delay: idx * 0.1 }}
|
||||||
className={cn(
|
className={`relative card p-6 flex flex-col ${
|
||||||
"relative card-surface p-6 flex flex-col bg-gradient-to-br",
|
plan.popular
|
||||||
plan.gradient,
|
? "border-violet-500/30 shadow-lg shadow-violet-500/5"
|
||||||
plan.recommended && "glow-violet md:-translate-y-2"
|
: ""
|
||||||
)}
|
}`}
|
||||||
>
|
>
|
||||||
{plan.recommended && (
|
{plan.popular && (
|
||||||
<div className="absolute -top-3 left-1/2 -translate-x-1/2">
|
<div className="absolute -top-3 left-1/2 -translate-x-1/2 px-3 py-0.5 rounded-full bg-violet-500 text-white text-[10px] font-bold uppercase tracking-wider">
|
||||||
<span className="badge bg-violet-500 text-white text-[10px] px-3 py-1 shadow-lg shadow-violet-500/30">
|
En Popüler
|
||||||
⚡ Önerilen
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Header */}
|
<div
|
||||||
<div className="flex items-center gap-3 mb-4">
|
className={`w-10 h-10 rounded-xl bg-gradient-to-br ${plan.gradient} flex items-center justify-center mb-4`}
|
||||||
<div className={cn(
|
>
|
||||||
"w-10 h-10 rounded-xl flex items-center justify-center",
|
<PlanIcon size={20} className={plan.iconColor} />
|
||||||
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>
|
</div>
|
||||||
|
|
||||||
{/* Fiyat */}
|
<h3 className="text-lg font-bold text-[var(--color-text-primary)]">
|
||||||
|
{plan.name}
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-[var(--color-text-muted)] mb-4">
|
||||||
|
{plan.description}
|
||||||
|
</p>
|
||||||
|
|
||||||
<div className="mb-5">
|
<div className="mb-5">
|
||||||
<div className="flex items-baseline gap-1">
|
<span className="text-3xl font-[family-name:var(--font-display)] font-bold text-[var(--color-text-primary)]">
|
||||||
<span className="font-[family-name:var(--font-display)] text-4xl font-bold">
|
${price}
|
||||||
${price}
|
</span>
|
||||||
|
{price > 0 && (
|
||||||
|
<span className="text-sm text-[var(--color-text-muted)]">
|
||||||
|
/{billingCycle === "monthly" ? "ay" : "yıl"}
|
||||||
</span>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Features */}
|
|
||||||
<ul className="space-y-2.5 flex-1 mb-6">
|
<ul className="space-y-2.5 flex-1 mb-6">
|
||||||
{plan.features.map((feat) => (
|
{plan.features.map((f) => (
|
||||||
<li key={feat.label} className="flex items-center gap-2.5 text-sm">
|
<li
|
||||||
{feat.included ? (
|
key={f}
|
||||||
<Check size={14} className="text-emerald-400 shrink-0" />
|
className="flex items-start gap-2 text-sm text-[var(--color-text-secondary)]"
|
||||||
) : (
|
>
|
||||||
<X size={14} className="text-[var(--color-text-ghost)] shrink-0" />
|
<Check
|
||||||
)}
|
size={14}
|
||||||
<span className={cn(
|
className="shrink-0 mt-0.5 text-emerald-400"
|
||||||
feat.included ? "text-[var(--color-text-secondary)]" : "text-[var(--color-text-ghost)]"
|
/>
|
||||||
)}>
|
{f}
|
||||||
{feat.label}
|
|
||||||
</span>
|
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
{/* CTA */}
|
<button
|
||||||
<button className={cn("w-full py-3 rounded-xl font-semibold text-sm flex items-center justify-center gap-2", plan.buttonClass)}>
|
onClick={() => handleCheckout(plan.name)}
|
||||||
{plan.buttonLabel}
|
disabled={
|
||||||
{price > 0 && <ArrowRight size={14} />}
|
isCurrentPlan || (checkout.isPending && !isCurrentPlan)
|
||||||
|
}
|
||||||
|
className={`w-full py-2.5 rounded-xl text-sm font-semibold transition-all ${
|
||||||
|
isCurrentPlan
|
||||||
|
? "bg-[var(--color-bg-elevated)] text-[var(--color-text-ghost)] cursor-default"
|
||||||
|
: plan.popular
|
||||||
|
? "btn-primary"
|
||||||
|
: "bg-[var(--color-bg-elevated)] text-[var(--color-text-secondary)] hover:bg-[var(--color-bg-surface)] border border-[var(--color-border-faint)]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{checkout.isPending && !isCurrentPlan ? (
|
||||||
|
<Loader2 size={16} className="animate-spin mx-auto" />
|
||||||
|
) : isCurrentPlan ? (
|
||||||
|
"Mevcut Plan"
|
||||||
|
) : (
|
||||||
|
plan.cta
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
451
src/app/[locale]/(dashboard)/dashboard/projects/[id]/page.tsx
Normal file
451
src/app/[locale]/(dashboard)/dashboard/projects/[id]/page.tsx
Normal file
@@ -0,0 +1,451 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useParams, useRouter } from 'next/navigation';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
Play,
|
||||||
|
Sparkles,
|
||||||
|
RefreshCw,
|
||||||
|
Clock,
|
||||||
|
CheckCircle2,
|
||||||
|
AlertCircle,
|
||||||
|
Loader2,
|
||||||
|
FileText,
|
||||||
|
Film,
|
||||||
|
Trash2,
|
||||||
|
MoreVertical,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useProject, useGenerateScript, useApproveAndQueue, useUpdateProject, useDeleteProject } from '@/hooks/use-api';
|
||||||
|
import { useRenderProgress } from '@/hooks/use-render-progress';
|
||||||
|
import { SceneCard } from '@/components/project/scene-card';
|
||||||
|
import { RenderProgress } from '@/components/project/render-progress';
|
||||||
|
import { VideoPlayer } from '@/components/project/video-player';
|
||||||
|
import { projectsApi } from '@/lib/api/api-service';
|
||||||
|
|
||||||
|
// X (Twitter) ikonunu burada da tanımlıyoruz
|
||||||
|
const XIcon = ({ size = 16 }: { size?: number }) => (
|
||||||
|
<svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const STATUS_MAP: Record<string, { label: string; color: string; icon: React.ElementType; bgClass: string }> = {
|
||||||
|
DRAFT: { label: 'Taslak', color: 'text-slate-400', icon: FileText, bgClass: 'bg-slate-500/10 border-slate-500/20' },
|
||||||
|
GENERATING_SCRIPT: { label: 'Senaryo Üretiliyor', color: 'text-violet-400', icon: Sparkles, bgClass: 'bg-violet-500/10 border-violet-500/20' },
|
||||||
|
PENDING: { label: 'Kuyrukta', color: 'text-amber-400', icon: Clock, bgClass: 'bg-amber-500/10 border-amber-500/20' },
|
||||||
|
GENERATING_MEDIA: { label: 'Medya Üretiliyor', color: 'text-cyan-400', icon: Sparkles, bgClass: 'bg-cyan-500/10 border-cyan-500/20' },
|
||||||
|
RENDERING: { label: 'Video İşleniyor', color: 'text-blue-400', icon: Film, bgClass: 'bg-blue-500/10 border-blue-500/20' },
|
||||||
|
COMPLETED: { label: 'Tamamlandı', color: 'text-emerald-400', icon: CheckCircle2, bgClass: 'bg-emerald-500/10 border-emerald-500/20' },
|
||||||
|
FAILED: { label: 'Başarısız', color: 'text-red-400', icon: AlertCircle, bgClass: 'bg-red-500/10 border-red-500/20' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const STYLE_LABELS: Record<string, string> = {
|
||||||
|
CINEMATIC: '🎬 Sinematik',
|
||||||
|
DOCUMENTARY: '📹 Belgesel',
|
||||||
|
EDUCATIONAL: '📚 Eğitici',
|
||||||
|
STORYTELLING: '📖 Hikaye',
|
||||||
|
NEWS: '📰 Haber',
|
||||||
|
PROMOTIONAL: '📢 Tanıtım',
|
||||||
|
ARTISTIC: '🎨 Sanatsal',
|
||||||
|
MINIMALIST: '✨ Minimalist',
|
||||||
|
};
|
||||||
|
|
||||||
|
const stagger = {
|
||||||
|
hidden: { opacity: 0 },
|
||||||
|
show: { opacity: 1, transition: { staggerChildren: 0.06 } },
|
||||||
|
};
|
||||||
|
|
||||||
|
const fadeUp = {
|
||||||
|
hidden: { opacity: 0, y: 12 },
|
||||||
|
show: { opacity: 1, y: 0, transition: { duration: 0.4, ease: [0.16, 1, 0.3, 1] as const } },
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ProjectDetailPage() {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const router = useRouter();
|
||||||
|
const [showMenu, setShowMenu] = useState(false);
|
||||||
|
const [regeneratingSceneId, setRegeneratingSceneId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Veri hook'ları
|
||||||
|
const { data: project, isLoading, error, refetch } = useProject(id);
|
||||||
|
const generateScriptMutation = useGenerateScript();
|
||||||
|
const approveMutation = useApproveAndQueue();
|
||||||
|
const deleteMutation = useDeleteProject();
|
||||||
|
|
||||||
|
// WebSocket progress
|
||||||
|
const renderState = useRenderProgress(
|
||||||
|
project?.status && ['PENDING', 'GENERATING_MEDIA', 'RENDERING'].includes(project.status) ? id : undefined,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Sahne güncelleme
|
||||||
|
const handleSceneUpdate = async (sceneId: string, data: { narrationText?: string; visualPrompt?: string; subtitleText?: string }) => {
|
||||||
|
try {
|
||||||
|
await projectsApi.update(`${id}/scenes/${sceneId}` as unknown as string, data as any);
|
||||||
|
refetch();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Sahne güncelleme hatası:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Sahne yeniden üretim
|
||||||
|
const handleSceneRegenerate = async (sceneId: string) => {
|
||||||
|
setRegeneratingSceneId(sceneId);
|
||||||
|
try {
|
||||||
|
await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000/api'}/projects/${id}/scenes/${sceneId}/regenerate`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
refetch();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Sahne yeniden üretim hatası:', err);
|
||||||
|
} finally {
|
||||||
|
setRegeneratingSceneId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Senaryo üret
|
||||||
|
const handleGenerateScript = () => {
|
||||||
|
generateScriptMutation.mutate(id, {
|
||||||
|
onSuccess: () => refetch(),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Onayla ve gönder
|
||||||
|
const handleApprove = () => {
|
||||||
|
approveMutation.mutate(id, {
|
||||||
|
onSuccess: () => refetch(),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Sil
|
||||||
|
const handleDelete = () => {
|
||||||
|
if (confirm('Bu projeyi silmek istediğinize emin misiniz?')) {
|
||||||
|
deleteMutation.mutate(id, {
|
||||||
|
onSuccess: () => router.push('/dashboard/projects'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Loading ──
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center min-h-[60vh]">
|
||||||
|
<div className="flex flex-col items-center gap-3">
|
||||||
|
<Loader2 size={32} className="animate-spin text-violet-400" />
|
||||||
|
<p className="text-sm text-[var(--color-text-muted)]">Proje yükleniyor...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Error ──
|
||||||
|
if (error || !project) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center min-h-[60vh]">
|
||||||
|
<div className="card-surface p-8 text-center max-w-md">
|
||||||
|
<AlertCircle size={40} className="text-red-400 mx-auto mb-3" />
|
||||||
|
<h2 className="text-lg font-semibold mb-2">Proje Bulunamadı</h2>
|
||||||
|
<p className="text-sm text-[var(--color-text-muted)] mb-4">
|
||||||
|
Bu proje silinmiş veya erişim izniniz yok.
|
||||||
|
</p>
|
||||||
|
<Link href="/dashboard/projects" className="btn-primary text-sm">
|
||||||
|
Projelere Dön
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusInfo = STATUS_MAP[project.status] || STATUS_MAP.DRAFT;
|
||||||
|
const StatusIcon = statusInfo.icon;
|
||||||
|
const isEditable = project.status === 'DRAFT' || project.status === 'FAILED';
|
||||||
|
const hasScript = project.scenes && project.scenes.length > 0;
|
||||||
|
const isRendering = ['PENDING', 'GENERATING_MEDIA', 'RENDERING'].includes(project.status);
|
||||||
|
const isCompleted = project.status === 'COMPLETED';
|
||||||
|
const tweetData = project.sourceTweetData as Record<string, any> | undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
variants={stagger}
|
||||||
|
initial="hidden"
|
||||||
|
animate="show"
|
||||||
|
className="space-y-5 max-w-4xl mx-auto pb-8"
|
||||||
|
>
|
||||||
|
{/* ── Üst Bar — Geri + Aksiyonlar ── */}
|
||||||
|
<motion.div variants={fadeUp} className="flex items-center justify-between">
|
||||||
|
<Link
|
||||||
|
href="/dashboard/projects"
|
||||||
|
className="flex items-center gap-2 text-sm text-[var(--color-text-muted)] hover:text-[var(--color-text-primary)] transition-colors"
|
||||||
|
>
|
||||||
|
<ArrowLeft size={16} />
|
||||||
|
Projeler
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowMenu(!showMenu)}
|
||||||
|
className="w-8 h-8 rounded-lg flex items-center justify-center text-[var(--color-text-muted)] hover:bg-[var(--color-bg-elevated)] transition-colors"
|
||||||
|
>
|
||||||
|
<MoreVertical size={16} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{showMenu && (
|
||||||
|
<div className="absolute right-0 top-10 card-surface p-1.5 min-w-[160px] z-50 shadow-xl">
|
||||||
|
<button
|
||||||
|
onClick={() => { refetch(); setShowMenu(false); }}
|
||||||
|
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-[var(--color-text-secondary)] hover:bg-[var(--color-bg-elevated)] rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<RefreshCw size={14} /> Yenile
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => { handleDelete(); setShowMenu(false); }}
|
||||||
|
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-red-400 hover:bg-red-500/10 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<Trash2 size={14} /> Sil
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* ── Proje Header ── */}
|
||||||
|
<motion.div variants={fadeUp} className="card-surface p-5 md:p-6">
|
||||||
|
<div className="flex flex-col md:flex-row md:items-start md:justify-between gap-4">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
{/* Durum badge */}
|
||||||
|
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-medium border ${statusInfo.bgClass} ${statusInfo.color}`}>
|
||||||
|
<StatusIcon size={12} />
|
||||||
|
{statusInfo.label}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Tweet kaynak badge */}
|
||||||
|
{project.sourceType === 'X_TWEET' && (
|
||||||
|
<span className="inline-flex items-center gap-1 px-2 py-1 rounded-lg text-[10px] font-medium bg-sky-500/10 border border-sky-500/20 text-sky-400">
|
||||||
|
<XIcon size={10} /> Tweet
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 className="font-[family-name:var(--font-display)] text-xl md:text-2xl font-bold tracking-tight text-[var(--color-text-primary)] truncate">
|
||||||
|
{project.title}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
{project.description && (
|
||||||
|
<p className="text-sm text-[var(--color-text-muted)] mt-1.5 line-clamp-2">
|
||||||
|
{project.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Meta bilgiler */}
|
||||||
|
<div className="flex flex-wrap items-center gap-3 text-xs text-[var(--color-text-ghost)] shrink-0">
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Clock size={12} /> {project.targetDuration}s
|
||||||
|
</span>
|
||||||
|
<span>{STYLE_LABELS[project.videoStyle] || project.videoStyle}</span>
|
||||||
|
<span className="uppercase text-[10px] tracking-wider">{project.language}</span>
|
||||||
|
<span className="text-[10px]">
|
||||||
|
{new Date(project.createdAt).toLocaleDateString('tr-TR', {
|
||||||
|
day: 'numeric', month: 'short', year: 'numeric',
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tweet kaynak bilgisi */}
|
||||||
|
{tweetData && (
|
||||||
|
<div className="mt-4 p-3 rounded-xl bg-sky-500/5 border border-sky-500/10">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<XIcon size={14} />
|
||||||
|
<span className="text-xs font-medium text-sky-400">
|
||||||
|
@{tweetData.authorUsername as string}
|
||||||
|
</span>
|
||||||
|
{tweetData.viralScore && (
|
||||||
|
<span className="text-[10px] px-1.5 py-0.5 rounded-md bg-amber-500/15 text-amber-400 font-medium">
|
||||||
|
🔥 {String(tweetData.viralScore)}/100
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-[var(--color-text-muted)] line-clamp-2">
|
||||||
|
{tweetData.text as string}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Aksiyon butonları */}
|
||||||
|
<div className="flex flex-wrap items-center gap-2 mt-4 pt-4 border-t border-[var(--color-border-faint)]">
|
||||||
|
{/* Senaryo üret (draft, senaryo yok) */}
|
||||||
|
{isEditable && !hasScript && (
|
||||||
|
<button
|
||||||
|
onClick={handleGenerateScript}
|
||||||
|
disabled={generateScriptMutation.isPending}
|
||||||
|
className="flex items-center gap-2 px-4 py-2.5 rounded-xl bg-gradient-to-r from-violet-500 to-violet-600 text-white text-sm font-medium shadow-lg shadow-violet-500/20 hover:shadow-violet-500/30 transition-shadow disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{generateScriptMutation.isPending ? (
|
||||||
|
<Loader2 size={15} className="animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Sparkles size={15} />
|
||||||
|
)}
|
||||||
|
AI ile Senaryo Üret
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Senaryo yeniden üret (draft/failed, senaryo var) */}
|
||||||
|
{isEditable && hasScript && (
|
||||||
|
<button
|
||||||
|
onClick={handleGenerateScript}
|
||||||
|
disabled={generateScriptMutation.isPending}
|
||||||
|
className="flex items-center gap-2 px-4 py-2.5 rounded-xl bg-[var(--color-bg-elevated)] text-[var(--color-text-secondary)] text-sm font-medium hover:bg-[var(--color-bg-surface)] transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{generateScriptMutation.isPending ? (
|
||||||
|
<Loader2 size={15} className="animate-spin" />
|
||||||
|
) : (
|
||||||
|
<RefreshCw size={15} />
|
||||||
|
)}
|
||||||
|
Yeniden Üret
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Onayla ve video üretimini başlat */}
|
||||||
|
{isEditable && hasScript && (
|
||||||
|
<button
|
||||||
|
onClick={handleApprove}
|
||||||
|
disabled={approveMutation.isPending}
|
||||||
|
className="flex items-center gap-2 px-4 py-2.5 rounded-xl bg-gradient-to-r from-emerald-500 to-emerald-600 text-white text-sm font-medium shadow-lg shadow-emerald-500/20 hover:shadow-emerald-500/30 transition-shadow disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{approveMutation.isPending ? (
|
||||||
|
<Loader2 size={15} className="animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Play size={15} />
|
||||||
|
)}
|
||||||
|
Onayla & Video Üret
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Hata mesajı */}
|
||||||
|
{project.errorMessage && (
|
||||||
|
<div className="mt-3 p-3 rounded-lg bg-red-500/10 border border-red-500/20">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<AlertCircle size={14} className="text-red-400" />
|
||||||
|
<span className="text-xs font-medium text-red-400">Hata</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-red-400/80">{project.errorMessage}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* ── Render Progress (WebSocket) ── */}
|
||||||
|
{isRendering && (
|
||||||
|
<motion.div variants={fadeUp}>
|
||||||
|
<RenderProgress renderState={renderState} />
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Video Player (tamamlandıysa) ── */}
|
||||||
|
{isCompleted && project.finalVideoUrl && (
|
||||||
|
<motion.div variants={fadeUp}>
|
||||||
|
<VideoPlayer
|
||||||
|
videoUrl={project.finalVideoUrl}
|
||||||
|
thumbnailUrl={project.thumbnailUrl}
|
||||||
|
title={project.title}
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Sahneler ── */}
|
||||||
|
{hasScript && (
|
||||||
|
<motion.div variants={fadeUp}>
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<h2 className="text-sm font-semibold text-[var(--color-text-primary)] flex items-center gap-2">
|
||||||
|
<Film size={15} className="text-violet-400" />
|
||||||
|
Senaryo — {project.scenes!.length} sahne
|
||||||
|
</h2>
|
||||||
|
<span className="text-[10px] text-[var(--color-text-ghost)]">
|
||||||
|
Toplam: {project.scenes!.reduce((sum, s) => sum + s.duration, 0)}s
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{project.scenes!.map((scene) => (
|
||||||
|
<SceneCard
|
||||||
|
key={scene.id}
|
||||||
|
scene={scene}
|
||||||
|
isEditable={isEditable}
|
||||||
|
onUpdate={handleSceneUpdate}
|
||||||
|
onRegenerate={handleSceneRegenerate}
|
||||||
|
isRegenerating={regeneratingSceneId === scene.id}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Boş durum (senaryo yok) ── */}
|
||||||
|
{!hasScript && isEditable && (
|
||||||
|
<motion.div variants={fadeUp} className="card-surface p-8 text-center">
|
||||||
|
<div className="w-16 h-16 rounded-2xl bg-gradient-to-br from-violet-500/15 to-cyan-400/10 mx-auto mb-4 flex items-center justify-center">
|
||||||
|
<Sparkles size={28} className="text-violet-400" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-base font-semibold mb-1.5">Henüz senaryo üretilmedi</h3>
|
||||||
|
<p className="text-sm text-[var(--color-text-muted)] mb-4 max-w-sm mx-auto">
|
||||||
|
AI'ın projeniz için etkileyici bir video senaryosu oluşturmasını sağlayın.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={handleGenerateScript}
|
||||||
|
disabled={generateScriptMutation.isPending}
|
||||||
|
className="inline-flex items-center gap-2 px-5 py-2.5 rounded-xl bg-gradient-to-r from-violet-500 to-violet-600 text-white text-sm font-medium shadow-lg shadow-violet-500/20 hover:shadow-violet-500/30 transition-shadow disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{generateScriptMutation.isPending ? (
|
||||||
|
<Loader2 size={15} className="animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Sparkles size={15} />
|
||||||
|
)}
|
||||||
|
Senaryo Üret
|
||||||
|
</button>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Render Geçmişi ── */}
|
||||||
|
{project.renderJobs && project.renderJobs.length > 0 && (
|
||||||
|
<motion.div variants={fadeUp}>
|
||||||
|
<h2 className="text-sm font-semibold text-[var(--color-text-primary)] mb-3 flex items-center gap-2">
|
||||||
|
<Clock size={15} className="text-cyan-400" />
|
||||||
|
Render Geçmişi
|
||||||
|
</h2>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{project.renderJobs.map((job) => (
|
||||||
|
<div key={job.id} className="card-surface p-3 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<div className={`w-2 h-2 rounded-full ${
|
||||||
|
job.status === 'COMPLETED' ? 'bg-emerald-400' :
|
||||||
|
job.status === 'FAILED' ? 'bg-red-400' :
|
||||||
|
'bg-amber-400 animate-pulse'
|
||||||
|
}`} />
|
||||||
|
<span className="text-xs text-[var(--color-text-secondary)]">
|
||||||
|
Deneme #{job.attemptNumber}
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] text-[var(--color-text-ghost)]">
|
||||||
|
{job.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 text-[10px] text-[var(--color-text-ghost)]">
|
||||||
|
{job.processingTimeMs && (
|
||||||
|
<span>{(job.processingTimeMs / 1000).toFixed(1)}s</span>
|
||||||
|
)}
|
||||||
|
<span>
|
||||||
|
{new Date(job.createdAt).toLocaleTimeString('tr-TR', { hour: '2-digit', minute: '2-digit' })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
import { motion, AnimatePresence } from "framer-motion";
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
@@ -18,6 +19,8 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { useCreateProject } from "@/hooks/use-api";
|
||||||
|
import { useToast } from "@/components/ui/toast";
|
||||||
|
|
||||||
const steps = ["Konu & Dil", "Stil & Süre", "AI Senaryo"];
|
const steps = ["Konu & Dil", "Stil & Süre", "AI Senaryo"];
|
||||||
|
|
||||||
@@ -48,8 +51,11 @@ const aspectRatios = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export default function NewProjectPage() {
|
export default function NewProjectPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const createProject = useCreateProject();
|
||||||
|
const toast = useToast();
|
||||||
|
|
||||||
const [currentStep, setCurrentStep] = useState(0);
|
const [currentStep, setCurrentStep] = useState(0);
|
||||||
const [isGenerating, setIsGenerating] = useState(false);
|
|
||||||
|
|
||||||
// Form state
|
// Form state
|
||||||
const [topic, setTopic] = useState("");
|
const [topic, setTopic] = useState("");
|
||||||
@@ -59,11 +65,28 @@ export default function NewProjectPage() {
|
|||||||
const [aspectRatio, setAspectRatio] = useState("PORTRAIT_9_16");
|
const [aspectRatio, setAspectRatio] = useState("PORTRAIT_9_16");
|
||||||
|
|
||||||
const canProceed = currentStep === 0 ? topic.trim().length >= 5 : true;
|
const canProceed = currentStep === 0 ? topic.trim().length >= 5 : true;
|
||||||
|
const isGenerating = createProject.isPending;
|
||||||
|
|
||||||
const handleGenerate = async () => {
|
const handleGenerate = async () => {
|
||||||
setIsGenerating(true);
|
try {
|
||||||
// API çağrısı burada yapılacak
|
const result = await createProject.mutateAsync({
|
||||||
setTimeout(() => setIsGenerating(false), 3000);
|
title: topic.slice(0, 80),
|
||||||
|
topic,
|
||||||
|
language,
|
||||||
|
style,
|
||||||
|
targetDuration: duration,
|
||||||
|
aspectRatio,
|
||||||
|
});
|
||||||
|
toast.success("Proje başarıyla oluşturuldu! AI senaryo üretiliyor...");
|
||||||
|
const projectId = result?.id;
|
||||||
|
if (projectId) {
|
||||||
|
router.push(`/dashboard/projects/${projectId}`);
|
||||||
|
} else {
|
||||||
|
router.push("/dashboard/projects");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error("Proje oluşturulurken bir hata oluştu. Lütfen tekrar deneyin.");
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,35 +1,129 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState, useMemo } from "react";
|
||||||
import { motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
import { Plus, Search, Filter, Grid3X3, List } from "lucide-react";
|
import {
|
||||||
|
Plus,
|
||||||
|
Search,
|
||||||
|
FolderOpen,
|
||||||
|
Clock,
|
||||||
|
CheckCircle,
|
||||||
|
Video,
|
||||||
|
AlertCircle,
|
||||||
|
ExternalLink,
|
||||||
|
Loader2,
|
||||||
|
} from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { RecentProjects } from "@/components/dashboard/recent-projects";
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { useProjects } from "@/hooks/use-api";
|
||||||
|
|
||||||
const statusFilters = [
|
const statusFilters = [
|
||||||
{ id: "all", label: "Tümü" },
|
{ id: "all", label: "Tümü" },
|
||||||
{ id: "DRAFT", label: "Taslak" },
|
{ id: "draft", label: "Taslak" },
|
||||||
{ id: "RENDERING", label: "İşleniyor" },
|
{ id: "scripting", label: "Senaryo" },
|
||||||
{ id: "COMPLETED", label: "Tamamlanan" },
|
{ id: "rendering", label: "İşleniyor" },
|
||||||
{ id: "FAILED", label: "Başarısız" },
|
{ id: "completed", label: "Tamamlanan" },
|
||||||
|
{ id: "failed", label: "Başarısız" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const statusMap: Record<
|
||||||
|
string,
|
||||||
|
{ icon: typeof Clock; color: string; label: string; bgColor: string }
|
||||||
|
> = {
|
||||||
|
draft: {
|
||||||
|
icon: Clock,
|
||||||
|
color: "text-amber-400",
|
||||||
|
bgColor: "bg-amber-500/10",
|
||||||
|
label: "Taslak",
|
||||||
|
},
|
||||||
|
scripting: {
|
||||||
|
icon: Clock,
|
||||||
|
color: "text-blue-400",
|
||||||
|
bgColor: "bg-blue-500/10",
|
||||||
|
label: "Senaryo",
|
||||||
|
},
|
||||||
|
reviewing: {
|
||||||
|
icon: Clock,
|
||||||
|
color: "text-purple-400",
|
||||||
|
bgColor: "bg-purple-500/10",
|
||||||
|
label: "İnceleme",
|
||||||
|
},
|
||||||
|
rendering: {
|
||||||
|
icon: Video,
|
||||||
|
color: "text-cyan-400",
|
||||||
|
bgColor: "bg-cyan-500/10",
|
||||||
|
label: "Render",
|
||||||
|
},
|
||||||
|
completed: {
|
||||||
|
icon: CheckCircle,
|
||||||
|
color: "text-emerald-400",
|
||||||
|
bgColor: "bg-emerald-500/10",
|
||||||
|
label: "Tamamlandı",
|
||||||
|
},
|
||||||
|
failed: {
|
||||||
|
icon: AlertCircle,
|
||||||
|
color: "text-red-400",
|
||||||
|
bgColor: "bg-red-500/10",
|
||||||
|
label: "Hatalı",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ProjectItem {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
status: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt?: string;
|
||||||
|
language?: string;
|
||||||
|
progress?: number;
|
||||||
|
creditsUsed?: number;
|
||||||
|
}
|
||||||
|
|
||||||
export default function ProjectsPage() {
|
export default function ProjectsPage() {
|
||||||
const [activeFilter, setActiveFilter] = useState("all");
|
const [activeFilter, setActiveFilter] = useState("all");
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
|
||||||
|
const { data, isLoading } = useProjects({ limit: 100 });
|
||||||
|
// useProjects returns PaginatedResponse<Project> which has .data as Project[]
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const raw = data as any;
|
||||||
|
const rawProjects: ProjectItem[] = raw?.data ?? raw ?? [];
|
||||||
|
const projects = Array.isArray(rawProjects) ? rawProjects : [];
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
return projects.filter((p) => {
|
||||||
|
if (activeFilter !== "all" && p.status?.toLowerCase() !== activeFilter)
|
||||||
|
return false;
|
||||||
|
if (
|
||||||
|
searchQuery &&
|
||||||
|
!p.title.toLowerCase().includes(searchQuery.toLowerCase())
|
||||||
|
)
|
||||||
|
return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}, [projects, activeFilter, searchQuery]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-5xl mx-auto space-y-6">
|
<div className="max-w-5xl mx-auto space-y-6">
|
||||||
{/* Başlık */}
|
{/* Başlık */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="font-[family-name:var(--font-display)] text-2xl font-bold">Projeler</h1>
|
<h1 className="font-[family-name:var(--font-display)] text-2xl font-bold">
|
||||||
|
Projeler
|
||||||
|
</h1>
|
||||||
<p className="text-sm text-[var(--color-text-muted)] mt-0.5">
|
<p className="text-sm text-[var(--color-text-muted)] mt-0.5">
|
||||||
Tüm video projelerini yönet
|
Tüm video projelerini yönet
|
||||||
|
{projects.length > 0 && (
|
||||||
|
<span className="ml-1 text-violet-400">
|
||||||
|
({projects.length} proje)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Link href="/dashboard/projects/new" className="btn-primary flex items-center gap-2 text-sm">
|
<Link
|
||||||
|
href="/dashboard/projects/new"
|
||||||
|
className="btn-primary flex items-center gap-2 text-sm"
|
||||||
|
>
|
||||||
<Plus size={16} />
|
<Plus size={16} />
|
||||||
<span>Yeni Proje</span>
|
<span>Yeni Proje</span>
|
||||||
</Link>
|
</Link>
|
||||||
@@ -38,7 +132,10 @@ export default function ProjectsPage() {
|
|||||||
{/* Arama + Filtreler */}
|
{/* Arama + Filtreler */}
|
||||||
<div className="flex flex-col sm:flex-row gap-3">
|
<div className="flex flex-col sm:flex-row gap-3">
|
||||||
<div className="relative flex-1">
|
<div className="relative flex-1">
|
||||||
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-[var(--color-text-ghost)]" />
|
<Search
|
||||||
|
size={16}
|
||||||
|
className="absolute left-3 top-1/2 -translate-y-1/2 text-[var(--color-text-ghost)]"
|
||||||
|
/>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
@@ -59,7 +156,7 @@ export default function ProjectsPage() {
|
|||||||
"px-3.5 py-1.5 rounded-full text-xs font-medium whitespace-nowrap transition-all",
|
"px-3.5 py-1.5 rounded-full text-xs font-medium whitespace-nowrap transition-all",
|
||||||
activeFilter === filter.id
|
activeFilter === filter.id
|
||||||
? "bg-violet-500/15 text-violet-400 border border-violet-500/25"
|
? "bg-violet-500/15 text-violet-400 border border-violet-500/25"
|
||||||
: "text-[var(--color-text-muted)] border border-[var(--color-border-faint)] hover:border-[var(--color-border-default)]"
|
: "text-[var(--color-text-muted)] border border-[var(--color-border-faint)] hover:border-[var(--color-border-default)]",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{filter.label}
|
{filter.label}
|
||||||
@@ -67,14 +164,99 @@ export default function ProjectsPage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Loading */}
|
||||||
|
{isLoading && (
|
||||||
|
<div className="flex flex-col items-center justify-center py-16">
|
||||||
|
<Loader2
|
||||||
|
size={32}
|
||||||
|
className="animate-spin text-violet-400 mb-3"
|
||||||
|
/>
|
||||||
|
<p className="text-sm text-[var(--color-text-muted)]">
|
||||||
|
Projeler yükleniyor...
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Proje Listesi */}
|
{/* Proje Listesi */}
|
||||||
<motion.div
|
{!isLoading && (
|
||||||
initial={{ opacity: 0, y: 12 }}
|
<motion.div
|
||||||
animate={{ opacity: 1, y: 0 }}
|
initial={{ opacity: 0, y: 12 }}
|
||||||
transition={{ duration: 0.4, ease: [0.16, 1, 0.3, 1] }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
>
|
transition={{ duration: 0.4, ease: [0.16, 1, 0.3, 1] }}
|
||||||
<RecentProjects />
|
className="space-y-2"
|
||||||
</motion.div>
|
>
|
||||||
|
{filtered.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-16 text-center">
|
||||||
|
<FolderOpen
|
||||||
|
size={40}
|
||||||
|
className="text-[var(--color-text-ghost)] mb-3"
|
||||||
|
/>
|
||||||
|
<p className="text-sm text-[var(--color-text-muted)] mb-1">
|
||||||
|
{searchQuery || activeFilter !== "all"
|
||||||
|
? "Filtrenizle eşleşen proje bulunamadı"
|
||||||
|
: "Henüz proje bulunmuyor"}
|
||||||
|
</p>
|
||||||
|
{!searchQuery && activeFilter === "all" && (
|
||||||
|
<Link
|
||||||
|
href="/dashboard/projects/new"
|
||||||
|
className="mt-3 text-xs text-violet-400 hover:text-violet-300"
|
||||||
|
>
|
||||||
|
İlk projenizi oluşturun →
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
filtered.map((project) => {
|
||||||
|
const st = statusMap[project.status] ?? statusMap.draft;
|
||||||
|
const StIcon = st.icon;
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={project.id}
|
||||||
|
href={`/dashboard/projects/${project.id}`}
|
||||||
|
className="flex items-center gap-4 p-4 rounded-xl card hover:border-violet-500/20 transition-all group"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`w-10 h-10 rounded-xl ${st.bgColor} flex items-center justify-center shrink-0 ${st.color}`}
|
||||||
|
>
|
||||||
|
<StIcon size={18} />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium text-[var(--color-text-primary)] truncate group-hover:text-violet-300 transition-colors">
|
||||||
|
{project.title}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-3 mt-0.5 text-[11px] text-[var(--color-text-ghost)]">
|
||||||
|
<span>
|
||||||
|
{new Date(project.createdAt).toLocaleDateString(
|
||||||
|
"tr-TR",
|
||||||
|
{
|
||||||
|
day: "numeric",
|
||||||
|
month: "short",
|
||||||
|
year: "numeric",
|
||||||
|
},
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
{project.language && <span>• {project.language}</span>}
|
||||||
|
{typeof project.creditsUsed === "number" &&
|
||||||
|
project.creditsUsed > 0 && (
|
||||||
|
<span>• {project.creditsUsed} kredi</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={`text-[10px] font-medium px-2.5 py-1 rounded-full border ${st.color} border-current/20 ${st.bgColor} shrink-0`}
|
||||||
|
>
|
||||||
|
{st.label}
|
||||||
|
</span>
|
||||||
|
<ExternalLink
|
||||||
|
size={14}
|
||||||
|
className="text-[var(--color-text-ghost)] group-hover:text-violet-400 transition-colors shrink-0"
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,78 +1,490 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { motion } from "framer-motion";
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
import {
|
import {
|
||||||
User,
|
User,
|
||||||
|
Shield,
|
||||||
CreditCard,
|
CreditCard,
|
||||||
Bell,
|
Bell,
|
||||||
Palette,
|
Save,
|
||||||
Globe,
|
Eye,
|
||||||
Shield,
|
EyeOff,
|
||||||
LogOut,
|
Loader2,
|
||||||
ChevronRight,
|
CheckCircle,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import {
|
||||||
|
useCurrentUser,
|
||||||
|
useUpdateProfile,
|
||||||
|
useChangePassword,
|
||||||
|
useCreditBalance,
|
||||||
|
useCreditHistory,
|
||||||
|
useSubscription,
|
||||||
|
} from "@/hooks/use-api";
|
||||||
|
import { useToast } from "@/components/ui/toast";
|
||||||
|
|
||||||
const sections = [
|
const tabs = [
|
||||||
{ id: "profile", label: "Profil", icon: User, desc: "Ad, e-posta ve avatar" },
|
{ id: "profile", label: "Profil", icon: User },
|
||||||
{ id: "billing", label: "Abonelik & Fatura", icon: CreditCard, desc: "Plan, kredi ve ödeme bilgileri" },
|
{ id: "security", label: "Güvenlik", icon: Shield },
|
||||||
{ id: "notifications", label: "Bildirimler", icon: Bell, desc: "E-posta ve push bildirimleri" },
|
{ id: "billing", label: "Abonelik", icon: CreditCard },
|
||||||
{ id: "appearance", label: "Görünüm", icon: Palette, desc: "Tema ve dil tercihleri" },
|
{ id: "notifications", label: "Bildirimler", icon: Bell },
|
||||||
{ id: "language", label: "Dil", icon: Globe, desc: "Varsayılan video ve arayüz dili" },
|
] as const;
|
||||||
{ id: "security", label: "Güvenlik", icon: Shield, desc: "Şifre ve iki faktörlü doğrulama" },
|
|
||||||
];
|
type TabId = (typeof tabs)[number]["id"];
|
||||||
|
|
||||||
export default function SettingsPage() {
|
export default function SettingsPage() {
|
||||||
const [activeSection, setActiveSection] = useState("profile");
|
const [activeTab, setActiveTab] = useState<TabId>("profile");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-3xl mx-auto space-y-6">
|
<div className="max-w-4xl mx-auto space-y-6">
|
||||||
|
{/* Header */}
|
||||||
<div>
|
<div>
|
||||||
<h1 className="font-[family-name:var(--font-display)] text-2xl font-bold">Ayarlar</h1>
|
<h1 className="text-2xl md:text-3xl font-[family-name:var(--font-display)] font-bold text-[var(--color-text-primary)]">
|
||||||
<p className="text-sm text-[var(--color-text-muted)] mt-0.5">Hesap ve uygulama ayarlarını yönet</p>
|
Ayarlar
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-[var(--color-text-muted)] mt-1">
|
||||||
|
Hesap ayarlarınızı ve tercihlerinizi yönetin
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
{/* Tab Navigation */}
|
||||||
{sections.map((section) => {
|
<div className="flex gap-1 p-1 rounded-xl bg-[var(--color-bg-surface)] border border-[var(--color-border-faint)] overflow-x-auto">
|
||||||
const Icon = section.icon;
|
{tabs.map((tab) => {
|
||||||
|
const Icon = tab.icon;
|
||||||
|
const isActive = activeTab === tab.id;
|
||||||
return (
|
return (
|
||||||
<motion.button
|
<button
|
||||||
key={section.id}
|
key={tab.id}
|
||||||
onClick={() => setActiveSection(section.id)}
|
onClick={() => setActiveTab(tab.id)}
|
||||||
whileTap={{ scale: 0.99 }}
|
className={`relative flex items-center gap-2 px-4 py-2.5 rounded-lg text-sm font-medium transition-colors whitespace-nowrap ${
|
||||||
className={cn(
|
isActive
|
||||||
"w-full flex items-center gap-4 p-4 rounded-xl text-left transition-all",
|
? "text-white"
|
||||||
activeSection === section.id
|
: "text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]"
|
||||||
? "bg-violet-500/8 border border-violet-500/20"
|
}`}
|
||||||
: "card-surface hover:border-[var(--color-border-default)]"
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<div className={cn(
|
{isActive && (
|
||||||
"w-10 h-10 rounded-xl flex items-center justify-center shrink-0",
|
<motion.div
|
||||||
activeSection === section.id
|
layoutId="settings-tab"
|
||||||
? "bg-violet-500/15 text-violet-400"
|
className="absolute inset-0 rounded-lg bg-violet-500/15 border border-violet-500/20"
|
||||||
: "bg-[var(--color-bg-elevated)] text-[var(--color-text-muted)]"
|
transition={{ type: "spring", bounce: 0.2, duration: 0.5 }}
|
||||||
)}>
|
/>
|
||||||
<Icon size={18} />
|
)}
|
||||||
</div>
|
<Icon size={16} className="relative z-10" />
|
||||||
<div className="flex-1 min-w-0">
|
<span className="relative z-10">{tab.label}</span>
|
||||||
<h3 className="text-sm font-semibold">{section.label}</h3>
|
</button>
|
||||||
<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>
|
</div>
|
||||||
|
|
||||||
{/* Çıkış */}
|
{/* Tab Content */}
|
||||||
<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">
|
<AnimatePresence mode="wait">
|
||||||
<div className="w-10 h-10 rounded-xl bg-rose-500/10 flex items-center justify-center">
|
<motion.div
|
||||||
<LogOut size={18} />
|
key={activeTab}
|
||||||
</div>
|
initial={{ opacity: 0, y: 8 }}
|
||||||
<span className="text-sm font-semibold">Çıkış Yap</span>
|
animate={{ opacity: 1, y: 0 }}
|
||||||
</button>
|
exit={{ opacity: 0, y: -8 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
>
|
||||||
|
{activeTab === "profile" && <ProfileTab />}
|
||||||
|
{activeTab === "security" && <SecurityTab />}
|
||||||
|
{activeTab === "billing" && <BillingTab />}
|
||||||
|
{activeTab === "notifications" && <NotificationsTab />}
|
||||||
|
</motion.div>
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── PROFILE TAB ─── */
|
||||||
|
function ProfileTab() {
|
||||||
|
const { data: userData, isLoading } = useCurrentUser();
|
||||||
|
const updateProfile = useUpdateProfile();
|
||||||
|
const toast = useToast();
|
||||||
|
|
||||||
|
const user = userData?.data ?? userData;
|
||||||
|
|
||||||
|
const [firstName, setFirstName] = useState("");
|
||||||
|
const [lastName, setLastName] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (user) {
|
||||||
|
setFirstName(user.firstName ?? "");
|
||||||
|
setLastName(user.lastName ?? "");
|
||||||
|
}
|
||||||
|
}, [user]);
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
try {
|
||||||
|
await updateProfile.mutateAsync({ firstName, lastName });
|
||||||
|
toast.success("Profil başarıyla güncellendi");
|
||||||
|
} catch {
|
||||||
|
toast.error("Profil güncellenirken bir hata oluştu");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="card p-12 flex items-center justify-center">
|
||||||
|
<Loader2 className="animate-spin text-violet-400" size={24} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card p-6 space-y-6">
|
||||||
|
<h2 className="text-lg font-semibold text-[var(--color-text-primary)]">
|
||||||
|
Profil Bilgileri
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-[var(--color-text-muted)] mb-1.5">
|
||||||
|
Ad
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={firstName}
|
||||||
|
onChange={(e) => setFirstName(e.target.value)}
|
||||||
|
className="w-full px-4 py-2.5 rounded-xl bg-[var(--color-bg-deep)] border border-[var(--color-border-faint)] text-[var(--color-text-primary)] text-sm focus:outline-none focus:border-violet-500/50 focus:ring-1 focus:ring-violet-500/25 transition-all"
|
||||||
|
placeholder="Adınız"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-[var(--color-text-muted)] mb-1.5">
|
||||||
|
Soyad
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={lastName}
|
||||||
|
onChange={(e) => setLastName(e.target.value)}
|
||||||
|
className="w-full px-4 py-2.5 rounded-xl bg-[var(--color-bg-deep)] border border-[var(--color-border-faint)] text-[var(--color-text-primary)] text-sm focus:outline-none focus:border-violet-500/50 focus:ring-1 focus:ring-violet-500/25 transition-all"
|
||||||
|
placeholder="Soyadınız"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-[var(--color-text-muted)] mb-1.5">
|
||||||
|
E-posta
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={user?.email ?? ""}
|
||||||
|
disabled
|
||||||
|
className="w-full px-4 py-2.5 rounded-xl bg-[var(--color-bg-deep)] border border-[var(--color-border-faint)] text-[var(--color-text-ghost)] text-sm cursor-not-allowed"
|
||||||
|
/>
|
||||||
|
<p className="text-[10px] text-[var(--color-text-ghost)] mt-1">
|
||||||
|
E-posta adresi değiştirilemez
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end pt-2">
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={updateProfile.isPending}
|
||||||
|
className="btn-primary flex items-center gap-2 px-6 py-2.5 text-sm"
|
||||||
|
>
|
||||||
|
{updateProfile.isPending ? (
|
||||||
|
<Loader2 size={16} className="animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Save size={16} />
|
||||||
|
)}
|
||||||
|
Kaydet
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── SECURITY TAB ─── */
|
||||||
|
function SecurityTab() {
|
||||||
|
const changePassword = useChangePassword();
|
||||||
|
const toast = useToast();
|
||||||
|
|
||||||
|
const [current, setCurrent] = useState("");
|
||||||
|
const [newPw, setNewPw] = useState("");
|
||||||
|
const [confirm, setConfirm] = useState("");
|
||||||
|
const [showCurrent, setShowCurrent] = useState(false);
|
||||||
|
const [showNew, setShowNew] = useState(false);
|
||||||
|
|
||||||
|
const handleChange = async () => {
|
||||||
|
if (newPw !== confirm) {
|
||||||
|
toast.warning("Yeni şifre ve onay eşleşmiyor");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (newPw.length < 8) {
|
||||||
|
toast.warning("Yeni şifre en az 8 karakter olmalı");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await changePassword.mutateAsync({
|
||||||
|
currentPassword: current,
|
||||||
|
newPassword: newPw,
|
||||||
|
});
|
||||||
|
toast.success("Şifre başarıyla güncellendi");
|
||||||
|
setCurrent("");
|
||||||
|
setNewPw("");
|
||||||
|
setConfirm("");
|
||||||
|
} catch {
|
||||||
|
toast.error("Mevcut şifre hatalı veya bir sorun oluştu");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card p-6 space-y-6">
|
||||||
|
<h2 className="text-lg font-semibold text-[var(--color-text-primary)]">
|
||||||
|
Şifre Değiştir
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="space-y-4 max-w-md">
|
||||||
|
{/* Current */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-[var(--color-text-muted)] mb-1.5">
|
||||||
|
Mevcut Şifre
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type={showCurrent ? "text" : "password"}
|
||||||
|
value={current}
|
||||||
|
onChange={(e) => setCurrent(e.target.value)}
|
||||||
|
className="w-full px-4 py-2.5 pr-10 rounded-xl bg-[var(--color-bg-deep)] border border-[var(--color-border-faint)] text-[var(--color-text-primary)] text-sm focus:outline-none focus:border-violet-500/50 focus:ring-1 focus:ring-violet-500/25 transition-all"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowCurrent(!showCurrent)}
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-[var(--color-text-ghost)]"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{showCurrent ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* New */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-[var(--color-text-muted)] mb-1.5">
|
||||||
|
Yeni Şifre
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type={showNew ? "text" : "password"}
|
||||||
|
value={newPw}
|
||||||
|
onChange={(e) => setNewPw(e.target.value)}
|
||||||
|
className="w-full px-4 py-2.5 pr-10 rounded-xl bg-[var(--color-bg-deep)] border border-[var(--color-border-faint)] text-[var(--color-text-primary)] text-sm focus:outline-none focus:border-violet-500/50 focus:ring-1 focus:ring-violet-500/25 transition-all"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowNew(!showNew)}
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-[var(--color-text-ghost)]"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{showNew ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Confirm */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-[var(--color-text-muted)] mb-1.5">
|
||||||
|
Yeni Şifre (Tekrar)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={confirm}
|
||||||
|
onChange={(e) => setConfirm(e.target.value)}
|
||||||
|
className="w-full px-4 py-2.5 rounded-xl bg-[var(--color-bg-deep)] border border-[var(--color-border-faint)] text-[var(--color-text-primary)] text-sm focus:outline-none focus:border-violet-500/50 focus:ring-1 focus:ring-violet-500/25 transition-all"
|
||||||
|
/>
|
||||||
|
{newPw && confirm && newPw !== confirm && (
|
||||||
|
<p className="text-[10px] text-red-400 mt-1">Şifreler eşleşmiyor</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end pt-2">
|
||||||
|
<button
|
||||||
|
onClick={handleChange}
|
||||||
|
disabled={changePassword.isPending || !current || !newPw || !confirm}
|
||||||
|
className="btn-primary flex items-center gap-2 px-6 py-2.5 text-sm disabled:opacity-40"
|
||||||
|
>
|
||||||
|
{changePassword.isPending ? (
|
||||||
|
<Loader2 size={16} className="animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Shield size={16} />
|
||||||
|
)}
|
||||||
|
Şifreyi Güncelle
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── BILLING TAB ─── */
|
||||||
|
function BillingTab() {
|
||||||
|
const { data: creditData } = useCreditBalance();
|
||||||
|
const { data: subData } = useSubscription();
|
||||||
|
const { data: historyData } = useCreditHistory();
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const credits = (creditData as any)?.data ?? creditData;
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const subscription = (subData as any)?.data ?? subData;
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const transactions = (historyData as any)?.data?.transactions ?? (historyData as any)?.transactions ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Abonelik Kartı */}
|
||||||
|
<div className="card p-6">
|
||||||
|
<h2 className="text-lg font-semibold text-[var(--color-text-primary)] mb-4">
|
||||||
|
Abonelik Durumu
|
||||||
|
</h2>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<div className="p-4 rounded-xl bg-[var(--color-bg-deep)] border border-[var(--color-border-faint)]">
|
||||||
|
<p className="text-xs text-[var(--color-text-muted)] mb-1">Plan</p>
|
||||||
|
<p className="text-lg font-bold text-[var(--color-text-primary)]">
|
||||||
|
{subscription?.plan ?? "Free"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 rounded-xl bg-[var(--color-bg-deep)] border border-[var(--color-border-faint)]">
|
||||||
|
<p className="text-xs text-[var(--color-text-muted)] mb-1">Kalan Kredi</p>
|
||||||
|
<p className="text-lg font-bold text-emerald-400">
|
||||||
|
{credits?.remaining ?? credits?.balance ?? 0}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 rounded-xl bg-[var(--color-bg-deep)] border border-[var(--color-border-faint)]">
|
||||||
|
<p className="text-xs text-[var(--color-text-muted)] mb-1">Aylık Limit</p>
|
||||||
|
<p className="text-lg font-bold text-[var(--color-text-primary)]">
|
||||||
|
{subscription?.monthlyCredits ?? credits?.total ?? 3}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Kredi Geçmişi */}
|
||||||
|
<div className="card p-6">
|
||||||
|
<h2 className="text-lg font-semibold text-[var(--color-text-primary)] mb-4">
|
||||||
|
Kredi İşlem Geçmişi
|
||||||
|
</h2>
|
||||||
|
{transactions.length === 0 ? (
|
||||||
|
<p className="text-sm text-[var(--color-text-muted)] text-center py-8">
|
||||||
|
Henüz işlem geçmişi bulunmuyor
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{transactions.slice(0, 10).map((tx: { id: string; amount: number; type: string; description: string; createdAt: string }) => (
|
||||||
|
<div
|
||||||
|
key={tx.id}
|
||||||
|
className="flex items-center justify-between p-3 rounded-lg bg-[var(--color-bg-deep)] border border-[var(--color-border-faint)]"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-[var(--color-text-primary)]">
|
||||||
|
{tx.description || tx.type}
|
||||||
|
</p>
|
||||||
|
<p className="text-[10px] text-[var(--color-text-ghost)]">
|
||||||
|
{new Date(tx.createdAt).toLocaleDateString("tr-TR")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={`text-sm font-bold ${
|
||||||
|
tx.amount > 0 ? "text-emerald-400" : "text-red-400"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{tx.amount > 0 ? `+${tx.amount}` : tx.amount}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── NOTIFICATIONS TAB ─── */
|
||||||
|
function NotificationsTab() {
|
||||||
|
const toast = useToast();
|
||||||
|
const [prefs, setPrefs] = useState({
|
||||||
|
projectComplete: true,
|
||||||
|
creditLow: true,
|
||||||
|
weeklyReport: false,
|
||||||
|
marketingEmails: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const toggle = (key: keyof typeof prefs) => {
|
||||||
|
setPrefs((p) => ({ ...p, [key]: !p[key] }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
toast.success("Bildirim tercihleri kaydedildi");
|
||||||
|
};
|
||||||
|
|
||||||
|
const notifItems = [
|
||||||
|
{
|
||||||
|
key: "projectComplete" as const,
|
||||||
|
label: "Proje Tamamlandı",
|
||||||
|
desc: "Video render işlemi tamamlandığında bildirim al",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "creditLow" as const,
|
||||||
|
label: "Düşük Kredi Uyarısı",
|
||||||
|
desc: "Kredileriniz %20'nin altına düştüğünde uyarı al",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "weeklyReport" as const,
|
||||||
|
label: "Haftalık Rapor",
|
||||||
|
desc: "Haftalık kullanım raporunu e-posta ile al",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "marketingEmails" as const,
|
||||||
|
label: "Pazarlama E-postaları",
|
||||||
|
desc: "Yeni özellikler ve kampanyalar hakkında bilgi al",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card p-6 space-y-6">
|
||||||
|
<h2 className="text-lg font-semibold text-[var(--color-text-primary)]">
|
||||||
|
Bildirim Tercihleri
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{notifItems.map((item) => (
|
||||||
|
<div
|
||||||
|
key={item.key}
|
||||||
|
className="flex items-center justify-between p-4 rounded-xl bg-[var(--color-bg-deep)] border border-[var(--color-border-faint)]"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-[var(--color-text-primary)]">
|
||||||
|
{item.label}
|
||||||
|
</p>
|
||||||
|
<p className="text-[11px] text-[var(--color-text-ghost)] mt-0.5">
|
||||||
|
{item.desc}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => toggle(item.key)}
|
||||||
|
className={`relative w-11 h-6 rounded-full transition-colors ${
|
||||||
|
prefs[item.key] ? "bg-violet-500" : "bg-[var(--color-bg-elevated)]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
className="absolute top-0.5 left-0.5 w-5 h-5 rounded-full bg-white shadow-md"
|
||||||
|
animate={{ x: prefs[item.key] ? 20 : 0 }}
|
||||||
|
transition={{ type: "spring", bounce: 0.25, duration: 0.3 }}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end pt-2">
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
className="btn-primary flex items-center gap-2 px-6 py-2.5 text-sm"
|
||||||
|
>
|
||||||
|
<CheckCircle size={16} />
|
||||||
|
Tercihleri Kaydet
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,99 +10,23 @@ import {
|
|||||||
TrendingUp,
|
TrendingUp,
|
||||||
Clock,
|
Clock,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
Filter,
|
Loader2,
|
||||||
|
FolderOpen,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { useTemplates, useCloneTemplate } from "@/hooks/use-api";
|
||||||
|
import { useToast } from "@/components/ui/toast";
|
||||||
|
|
||||||
interface Template {
|
const categories = [
|
||||||
id: string;
|
"Tümü",
|
||||||
title: string;
|
"Bilim",
|
||||||
description: string;
|
"Teknoloji",
|
||||||
category: string;
|
"Eğitim",
|
||||||
language: string;
|
"Haber",
|
||||||
usageCount: number;
|
"Tarih",
|
||||||
rating: number;
|
"Sanat",
|
||||||
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 = [
|
const sortOptions = [
|
||||||
{ id: "popular", label: "En Popüler", icon: TrendingUp },
|
{ id: "popular", label: "En Popüler", icon: TrendingUp },
|
||||||
{ id: "newest", label: "En Yeni", icon: Clock },
|
{ id: "newest", label: "En Yeni", icon: Clock },
|
||||||
@@ -115,6 +39,9 @@ const flagEmoji: Record<string, string> = {
|
|||||||
es: "🇪🇸",
|
es: "🇪🇸",
|
||||||
de: "🇩🇪",
|
de: "🇩🇪",
|
||||||
fr: "🇫🇷",
|
fr: "🇫🇷",
|
||||||
|
ar: "🇸🇦",
|
||||||
|
pt: "🇧🇷",
|
||||||
|
ja: "🇯🇵",
|
||||||
};
|
};
|
||||||
|
|
||||||
const stagger = {
|
const stagger = {
|
||||||
@@ -124,20 +51,80 @@ const stagger = {
|
|||||||
|
|
||||||
const fadeUp = {
|
const fadeUp = {
|
||||||
hidden: { opacity: 0, y: 16, scale: 0.97 },
|
hidden: { opacity: 0, y: 16, scale: 0.97 },
|
||||||
show: { opacity: 1, y: 0, scale: 1, transition: { duration: 0.5, ease: [0.16, 1, 0.3, 1] as const } },
|
show: {
|
||||||
|
opacity: 1,
|
||||||
|
y: 0,
|
||||||
|
scale: 1,
|
||||||
|
transition: { duration: 0.5, ease: [0.16, 1, 0.3, 1] as const },
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
interface TemplateItem {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
category: string;
|
||||||
|
language: string;
|
||||||
|
usageCount: number;
|
||||||
|
rating: number;
|
||||||
|
duration?: number;
|
||||||
|
targetDuration?: number;
|
||||||
|
style?: string;
|
||||||
|
isFeatured?: boolean;
|
||||||
|
featured?: boolean;
|
||||||
|
thumbnailUrl?: string;
|
||||||
|
tags?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
export default function TemplatesPage() {
|
export default function TemplatesPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const toast = useToast();
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [activeCategory, setActiveCategory] = useState("Tümü");
|
const [activeCategory, setActiveCategory] = useState("Tümü");
|
||||||
const [activeSort, setActiveSort] = useState("popular");
|
const [activeSort, setActiveSort] = useState("popular");
|
||||||
|
|
||||||
const filtered = mockTemplates.filter((t) => {
|
const { data, isLoading } = useTemplates({ limit: 50 });
|
||||||
|
const cloneTemplate = useCloneTemplate();
|
||||||
|
|
||||||
|
// API'den gelen veriyi çıkar
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const rawTemplates: TemplateItem[] =
|
||||||
|
(data as any)?.data?.items ?? (data as any)?.data ?? (data as any)?.items ?? data ?? [];
|
||||||
|
const templates = Array.isArray(rawTemplates) ? rawTemplates : [];
|
||||||
|
|
||||||
|
// Filtreleme
|
||||||
|
const filtered = templates.filter((t) => {
|
||||||
if (activeCategory !== "Tümü" && t.category !== activeCategory) return false;
|
if (activeCategory !== "Tümü" && t.category !== activeCategory) return false;
|
||||||
if (search && !t.title.toLowerCase().includes(search.toLowerCase())) return false;
|
if (
|
||||||
|
search &&
|
||||||
|
!t.title.toLowerCase().includes(search.toLowerCase()) &&
|
||||||
|
!(t.description ?? "").toLowerCase().includes(search.toLowerCase())
|
||||||
|
)
|
||||||
|
return false;
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Sıralama
|
||||||
|
const sorted = [...filtered].sort((a, b) => {
|
||||||
|
if (activeSort === "popular") return (b.usageCount ?? 0) - (a.usageCount ?? 0);
|
||||||
|
if (activeSort === "rating") return (b.rating ?? 0) - (a.rating ?? 0);
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleClone = async (templateId: string) => {
|
||||||
|
try {
|
||||||
|
const result = await cloneTemplate.mutateAsync(templateId);
|
||||||
|
toast.success("Şablon başarıyla klonlandı!");
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const projectId = (result as any)?.id;
|
||||||
|
if (projectId) {
|
||||||
|
router.push(`/dashboard/projects/${projectId}`);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error("Klonlama sırasında bir hata oluştu.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-6xl mx-auto space-y-6">
|
<div className="max-w-6xl mx-auto space-y-6">
|
||||||
{/* ── Başlık ── */}
|
{/* ── Başlık ── */}
|
||||||
@@ -154,7 +141,10 @@ export default function TemplatesPage() {
|
|||||||
{/* ── Arama + Filtreler ── */}
|
{/* ── Arama + Filtreler ── */}
|
||||||
<div className="flex flex-col sm:flex-row gap-3">
|
<div className="flex flex-col sm:flex-row gap-3">
|
||||||
<div className="relative flex-1">
|
<div className="relative flex-1">
|
||||||
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-[var(--color-text-ghost)]" />
|
<Search
|
||||||
|
size={16}
|
||||||
|
className="absolute left-3 top-1/2 -translate-y-1/2 text-[var(--color-text-ghost)]"
|
||||||
|
/>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={search}
|
value={search}
|
||||||
@@ -174,7 +164,7 @@ export default function TemplatesPage() {
|
|||||||
"flex items-center gap-1.5 px-3 py-2 rounded-xl text-xs font-medium transition-all",
|
"flex items-center gap-1.5 px-3 py-2 rounded-xl text-xs font-medium transition-all",
|
||||||
activeSort === opt.id
|
activeSort === opt.id
|
||||||
? "bg-violet-500/12 text-violet-400 border border-violet-500/25"
|
? "bg-violet-500/12 text-violet-400 border border-violet-500/25"
|
||||||
: "text-[var(--color-text-muted)] border border-[var(--color-border-faint)] hover:border-[var(--color-border-default)]"
|
: "text-[var(--color-text-muted)] border border-[var(--color-border-faint)] hover:border-[var(--color-border-default)]",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Icon size={13} />
|
<Icon size={13} />
|
||||||
@@ -195,7 +185,7 @@ export default function TemplatesPage() {
|
|||||||
"px-3.5 py-1.5 rounded-full text-xs font-medium whitespace-nowrap transition-all",
|
"px-3.5 py-1.5 rounded-full text-xs font-medium whitespace-nowrap transition-all",
|
||||||
activeCategory === cat
|
activeCategory === cat
|
||||||
? "bg-violet-500/15 text-violet-400 border border-violet-500/25"
|
? "bg-violet-500/15 text-violet-400 border border-violet-500/25"
|
||||||
: "text-[var(--color-text-muted)] border border-[var(--color-border-faint)] hover:border-[var(--color-border-default)]"
|
: "text-[var(--color-text-muted)] border border-[var(--color-border-faint)] hover:border-[var(--color-border-default)]",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{cat}
|
{cat}
|
||||||
@@ -203,68 +193,123 @@ export default function TemplatesPage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Grid ── */}
|
{/* ── Loading ── */}
|
||||||
<motion.div
|
{isLoading && (
|
||||||
variants={stagger}
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
initial="hidden"
|
{[1, 2, 3, 4, 5, 6].map((i) => (
|
||||||
animate="show"
|
<div
|
||||||
className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4"
|
key={i}
|
||||||
>
|
className="card-surface overflow-hidden animate-pulse"
|
||||||
{filtered.map((template) => (
|
>
|
||||||
<motion.div key={template.id} variants={fadeUp}>
|
<div className="h-36 bg-[var(--color-bg-elevated)]" />
|
||||||
<div className="group card-surface overflow-hidden hover:border-violet-500/20">
|
<div className="p-4 space-y-2">
|
||||||
{/* Cover */}
|
<div className="h-4 bg-[var(--color-bg-elevated)] rounded w-3/4" />
|
||||||
<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="h-3 bg-[var(--color-bg-elevated)] rounded w-1/2" />
|
||||||
<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>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
))}
|
||||||
))}
|
</div>
|
||||||
</motion.div>
|
)}
|
||||||
|
|
||||||
{filtered.length === 0 && (
|
{/* ── Grid ── */}
|
||||||
|
{!isLoading && (
|
||||||
|
<motion.div
|
||||||
|
variants={stagger}
|
||||||
|
initial="hidden"
|
||||||
|
animate="show"
|
||||||
|
className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4"
|
||||||
|
>
|
||||||
|
{sorted.map((template) => (
|
||||||
|
<motion.div key={template.id} variants={fadeUp}>
|
||||||
|
<div className="group card-surface overflow-hidden hover:border-violet-500/20">
|
||||||
|
{/* Cover */}
|
||||||
|
<div className="relative h-36 bg-gradient-to-br from-[var(--color-bg-elevated)] to-[var(--color-bg-surface)] flex items-center justify-center overflow-hidden">
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-br from-violet-500/5 to-cyan-500/5" />
|
||||||
|
{template.thumbnailUrl ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img
|
||||||
|
src={template.thumbnailUrl}
|
||||||
|
alt={template.title}
|
||||||
|
className="absolute inset-0 w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="text-4xl opacity-70">
|
||||||
|
{flagEmoji[template.language] || "🌍"}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{(template.isFeatured || template.featured) && (
|
||||||
|
<span className="absolute top-3 left-3 badge badge-violet text-[9px]">
|
||||||
|
✨ Öne Çıkan
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{template.rating > 0 && (
|
||||||
|
<div className="absolute top-3 right-3 flex items-center gap-1 badge badge-amber text-[10px]">
|
||||||
|
<Star size={10} className="fill-amber-400" />
|
||||||
|
{template.rating.toFixed(1)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* Hover overlay */}
|
||||||
|
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-3">
|
||||||
|
<button className="w-10 h-10 rounded-xl bg-white/10 backdrop-blur flex items-center justify-center text-white hover:bg-white/20 transition-colors">
|
||||||
|
<Eye size={18} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleClone(template.id)}
|
||||||
|
disabled={cloneTemplate.isPending}
|
||||||
|
className="w-10 h-10 rounded-xl bg-violet-500/80 backdrop-blur flex items-center justify-center text-white hover:bg-violet-500 transition-colors"
|
||||||
|
>
|
||||||
|
{cloneTemplate.isPending ? (
|
||||||
|
<Loader2 size={18} className="animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Copy size={18} />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info */}
|
||||||
|
<div className="p-4">
|
||||||
|
<h3 className="text-sm font-semibold line-clamp-1 group-hover:text-violet-300 transition-colors">
|
||||||
|
{template.title}
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-[var(--color-text-muted)] mt-1 line-clamp-2">
|
||||||
|
{template.description || "—"}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center justify-between mt-3">
|
||||||
|
<div className="flex items-center gap-2 text-[10px] text-[var(--color-text-ghost)]">
|
||||||
|
<span>
|
||||||
|
{template.duration ?? template.targetDuration ?? "—"}s
|
||||||
|
</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span>{template.usageCount} kullanım</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => handleClone(template.id)}
|
||||||
|
disabled={cloneTemplate.isPending}
|
||||||
|
className="flex items-center gap-1 text-[11px] font-medium text-violet-400 hover:text-violet-300 transition-colors"
|
||||||
|
>
|
||||||
|
<Copy size={12} />
|
||||||
|
Klonla
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isLoading && sorted.length === 0 && (
|
||||||
<div className="text-center py-16">
|
<div className="text-center py-16">
|
||||||
<p className="text-[var(--color-text-muted)]">Aramanızla eşleşen şablon bulunamadı</p>
|
<FolderOpen
|
||||||
|
size={40}
|
||||||
|
className="mx-auto text-[var(--color-text-ghost)] mb-3"
|
||||||
|
/>
|
||||||
|
<p className="text-[var(--color-text-muted)]">
|
||||||
|
{search || activeCategory !== "Tümü"
|
||||||
|
? "Aramanızla eşleşen şablon bulunamadı"
|
||||||
|
: "Henüz şablon eklenmemiş"}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
418
src/app/[locale]/(dashboard)/dashboard/x-to-video/page.tsx
Normal file
418
src/app/[locale]/(dashboard)/dashboard/x-to-video/page.tsx
Normal file
@@ -0,0 +1,418 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
|
import {
|
||||||
|
AtSign,
|
||||||
|
Link2,
|
||||||
|
Loader2,
|
||||||
|
ArrowRight,
|
||||||
|
Clock,
|
||||||
|
Palette,
|
||||||
|
Monitor,
|
||||||
|
Smartphone,
|
||||||
|
Square,
|
||||||
|
Sparkles,
|
||||||
|
Wand2,
|
||||||
|
MessageSquare,
|
||||||
|
Heart,
|
||||||
|
Repeat2,
|
||||||
|
Eye,
|
||||||
|
Image as ImageIcon,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { useTweetPreview, useCreateFromTweet } from "@/hooks/use-api";
|
||||||
|
import { useToast } from "@/components/ui/toast";
|
||||||
|
|
||||||
|
const videoStyles = [
|
||||||
|
{ id: "CINEMATIC", label: "Sinematik", emoji: "🎬" },
|
||||||
|
{ id: "DOCUMENTARY", label: "Belgesel", emoji: "📹" },
|
||||||
|
{ id: "EDUCATIONAL", label: "Eğitim", emoji: "📚" },
|
||||||
|
{ id: "STORYTELLING", label: "Hikâye", emoji: "📖" },
|
||||||
|
{ id: "NEWS", label: "Haber", emoji: "📰" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const aspectRatios = [
|
||||||
|
{ id: "PORTRAIT_9_16", label: "9:16", icon: Smartphone, desc: "Shorts / Reels" },
|
||||||
|
{ id: "SQUARE_1_1", label: "1:1", icon: Square, desc: "Instagram" },
|
||||||
|
{ id: "LANDSCAPE_16_9", label: "16:9", icon: Monitor, desc: "YouTube" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const languages = [
|
||||||
|
{ code: "tr", label: "Türkçe", flag: "🇹🇷" },
|
||||||
|
{ code: "en", label: "English", flag: "🇺🇸" },
|
||||||
|
{ code: "de", label: "Deutsch", flag: "🇩🇪" },
|
||||||
|
{ code: "es", label: "Español", flag: "🇪🇸" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function XToVideoPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const toast = useToast();
|
||||||
|
const tweetPreview = useTweetPreview();
|
||||||
|
const createFromTweet = useCreateFromTweet();
|
||||||
|
|
||||||
|
const [tweetUrl, setTweetUrl] = useState("");
|
||||||
|
const [style, setStyle] = useState("CINEMATIC");
|
||||||
|
const [duration, setDuration] = useState(60);
|
||||||
|
const [aspectRatio, setAspectRatio] = useState("PORTRAIT_9_16");
|
||||||
|
const [language, setLanguage] = useState("tr");
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const [previewData, setPreviewData] = useState<any>(null);
|
||||||
|
|
||||||
|
const isValidUrl = /https?:\/\/(x\.com|twitter\.com)\/\w+\/status\/\d+/.test(tweetUrl);
|
||||||
|
|
||||||
|
const handlePreview = async () => {
|
||||||
|
if (!isValidUrl) {
|
||||||
|
toast.error("Geçerli bir X/Twitter URL'si girin (https://x.com/...)");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const result = await tweetPreview.mutateAsync(tweetUrl);
|
||||||
|
setPreviewData(result);
|
||||||
|
toast.success("Tweet başarıyla yüklendi!");
|
||||||
|
} catch {
|
||||||
|
toast.error("Tweet yüklenemedi. URL'yi kontrol edin.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGenerate = async () => {
|
||||||
|
try {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const result: any = await createFromTweet.mutateAsync({
|
||||||
|
tweetUrl,
|
||||||
|
language,
|
||||||
|
aspectRatio,
|
||||||
|
videoStyle: style,
|
||||||
|
targetDuration: duration,
|
||||||
|
});
|
||||||
|
toast.success("Tweet → Video projesi oluşturuldu!");
|
||||||
|
const projectId = result?.id ?? result?.data?.id;
|
||||||
|
if (projectId) {
|
||||||
|
router.push(`/dashboard/projects/${projectId}`);
|
||||||
|
} else {
|
||||||
|
router.push("/dashboard/projects");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error("Proje oluşturulurken bir hata oluştu.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-3xl mx-auto space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div>
|
||||||
|
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-violet-500/10 border border-violet-500/20 text-violet-300 text-xs font-medium mb-3">
|
||||||
|
<AtSign size={12} />
|
||||||
|
X → Video
|
||||||
|
</div>
|
||||||
|
<h1 className="font-[family-name:var(--font-display)] text-2xl md:text-3xl font-bold">
|
||||||
|
Tweet'ten Video Oluştur
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-[var(--color-text-muted)] mt-1">
|
||||||
|
X/Twitter yazılarını AI ile kısa videolara dönüştürün
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* URL Input */}
|
||||||
|
<div className="card p-5 space-y-4">
|
||||||
|
<label className="text-sm font-medium text-[var(--color-text-secondary)] block">
|
||||||
|
<Link2 size={14} className="inline mr-1.5 text-cyan-400" />
|
||||||
|
Tweet URL
|
||||||
|
</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
value={tweetUrl}
|
||||||
|
onChange={(e) => {
|
||||||
|
setTweetUrl(e.target.value);
|
||||||
|
setPreviewData(null);
|
||||||
|
}}
|
||||||
|
placeholder="https://x.com/username/status/123456..."
|
||||||
|
className="flex-1 px-4 py-2.5 rounded-xl bg-[var(--color-bg-surface)] border border-[var(--color-border-faint)] text-sm text-[var(--color-text-primary)] placeholder:text-[var(--color-text-ghost)] focus:outline-none focus:border-violet-500/40 focus:ring-1 focus:ring-violet-500/20 transition-all"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={handlePreview}
|
||||||
|
disabled={!isValidUrl || tweetPreview.isPending}
|
||||||
|
className={cn(
|
||||||
|
"px-4 py-2.5 rounded-xl text-sm font-semibold flex items-center gap-2 transition-all shrink-0",
|
||||||
|
isValidUrl
|
||||||
|
? "btn-primary"
|
||||||
|
: "bg-[var(--color-bg-elevated)] text-[var(--color-text-ghost)] cursor-not-allowed",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{tweetPreview.isPending ? (
|
||||||
|
<Loader2 size={16} className="animate-spin" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Eye size={16} />
|
||||||
|
Önizle
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="text-[11px] text-[var(--color-text-ghost)]">
|
||||||
|
Thread desteği: Çoklu tweet zincirleri de otomatik olarak algılanır
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tweet Preview */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{previewData && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: -10 }}
|
||||||
|
className="card p-5 space-y-3"
|
||||||
|
>
|
||||||
|
<h3 className="text-sm font-semibold text-[var(--color-text-secondary)]">
|
||||||
|
<MessageSquare
|
||||||
|
size={14}
|
||||||
|
className="inline mr-1.5 text-violet-400"
|
||||||
|
/>
|
||||||
|
Tweet Önizleme
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="p-4 rounded-xl bg-[var(--color-bg-surface)] border border-[var(--color-border-faint)] space-y-3">
|
||||||
|
{/* Author */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-9 h-9 rounded-full bg-gradient-to-br from-violet-500/20 to-cyan-500/20 flex items-center justify-center text-sm font-bold text-violet-300">
|
||||||
|
{(previewData.author?.name ?? previewData.authorName ?? "X")?.[0]}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-bold text-[var(--color-text-primary)]">
|
||||||
|
{previewData.author?.name ?? previewData.authorName ?? "Kullanıcı"}
|
||||||
|
</p>
|
||||||
|
<p className="text-[11px] text-[var(--color-text-ghost)]">
|
||||||
|
@{previewData.author?.handle ?? previewData.authorHandle ?? "handle"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<p className="text-sm text-[var(--color-text-secondary)] whitespace-pre-line">
|
||||||
|
{previewData.text ?? previewData.content ?? ""}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Images */}
|
||||||
|
{(previewData.images?.length > 0 || previewData.mediaUrls?.length > 0) && (
|
||||||
|
<div className="flex gap-2 overflow-x-auto">
|
||||||
|
{(previewData.images ?? previewData.mediaUrls ?? []).map(
|
||||||
|
(url: string, i: number) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="w-20 h-20 rounded-lg bg-[var(--color-bg-elevated)] overflow-hidden shrink-0"
|
||||||
|
>
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img
|
||||||
|
src={url}
|
||||||
|
alt={`Media ${i + 1}`}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Stats */}
|
||||||
|
<div className="flex items-center gap-4 text-[11px] text-[var(--color-text-ghost)]">
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Heart size={12} />
|
||||||
|
{previewData.likes ?? previewData.stats?.likes ?? 0}
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Repeat2 size={12} />
|
||||||
|
{previewData.retweets ?? previewData.stats?.retweets ?? 0}
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Eye size={12} />
|
||||||
|
{previewData.views ?? previewData.stats?.views ?? 0}
|
||||||
|
</span>
|
||||||
|
{previewData.threadLength > 1 && (
|
||||||
|
<span className="flex items-center gap-1 text-violet-400">
|
||||||
|
<MessageSquare size={12} />
|
||||||
|
{previewData.threadLength} tweet thread
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Images tag */}
|
||||||
|
{(previewData.images?.length > 0 || previewData.mediaUrls?.length > 0) && (
|
||||||
|
<div className="flex items-center gap-1.5 text-[11px] text-cyan-400">
|
||||||
|
<ImageIcon size={12} />
|
||||||
|
{(previewData.images ?? previewData.mediaUrls ?? []).length} görsel referans olarak kullanılacak + AI görsel üretilecek
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
{/* Video Settings */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{previewData && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
className="space-y-6"
|
||||||
|
>
|
||||||
|
{/* Language */}
|
||||||
|
<div className="card p-5 space-y-3">
|
||||||
|
<label className="text-sm font-medium text-[var(--color-text-secondary)]">
|
||||||
|
Video Dili
|
||||||
|
</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{languages.map((l) => (
|
||||||
|
<button
|
||||||
|
key={l.code}
|
||||||
|
onClick={() => setLanguage(l.code)}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-1.5 px-3 py-2 rounded-xl text-xs transition-all",
|
||||||
|
language === l.code
|
||||||
|
? "bg-violet-500/12 border border-violet-500/30 text-violet-300"
|
||||||
|
: "bg-[var(--color-bg-surface)] border border-[var(--color-border-faint)] text-[var(--color-text-muted)]",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span>{l.flag}</span>
|
||||||
|
{l.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Style */}
|
||||||
|
<div className="card p-5 space-y-3">
|
||||||
|
<label className="text-sm font-medium text-[var(--color-text-secondary)]">
|
||||||
|
<Palette size={14} className="inline mr-1.5 text-violet-400" />
|
||||||
|
Video Stili
|
||||||
|
</label>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{videoStyles.map((s) => (
|
||||||
|
<button
|
||||||
|
key={s.id}
|
||||||
|
onClick={() => setStyle(s.id)}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-1.5 px-3 py-2 rounded-xl text-xs transition-all",
|
||||||
|
style === s.id
|
||||||
|
? "bg-violet-500/12 border border-violet-500/30 text-violet-300"
|
||||||
|
: "bg-[var(--color-bg-surface)] border border-[var(--color-border-faint)] text-[var(--color-text-muted)]",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span>{s.emoji}</span>
|
||||||
|
{s.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Duration + Aspect Ratio */}
|
||||||
|
<div className="card p-5 space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium text-[var(--color-text-secondary)] mb-3 block">
|
||||||
|
<Clock size={14} className="inline mr-1.5 text-cyan-400" />
|
||||||
|
Hedef Süre: <span className="text-violet-400">{duration}s</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={15}
|
||||||
|
max={120}
|
||||||
|
step={5}
|
||||||
|
value={duration}
|
||||||
|
onChange={(e) => setDuration(Number(e.target.value))}
|
||||||
|
className="w-full h-1.5 rounded-full bg-[var(--color-bg-elevated)] appearance-none cursor-pointer
|
||||||
|
[&::-webkit-slider-thumb]:appearance-none
|
||||||
|
[&::-webkit-slider-thumb]:w-5 [&::-webkit-slider-thumb]:h-5
|
||||||
|
[&::-webkit-slider-thumb]:rounded-full
|
||||||
|
[&::-webkit-slider-thumb]:bg-violet-500
|
||||||
|
[&::-webkit-slider-thumb]:shadow-[0_0_12px_rgba(139,92,246,0.4)]
|
||||||
|
[&::-webkit-slider-thumb]:cursor-grab"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium text-[var(--color-text-secondary)] mb-3 block">
|
||||||
|
En-Boy Oranı
|
||||||
|
</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{aspectRatios.map((ar) => {
|
||||||
|
const Icon = ar.icon;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={ar.id}
|
||||||
|
onClick={() => setAspectRatio(ar.id)}
|
||||||
|
className={cn(
|
||||||
|
"flex-1 flex flex-col items-center gap-1.5 py-3 rounded-xl text-xs transition-all",
|
||||||
|
aspectRatio === ar.id
|
||||||
|
? "bg-violet-500/12 border border-violet-500/30 text-violet-300"
|
||||||
|
: "bg-[var(--color-bg-surface)] border border-[var(--color-border-faint)] text-[var(--color-text-muted)]",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon size={20} />
|
||||||
|
<span className="font-semibold">{ar.label}</span>
|
||||||
|
<span className="text-[10px] text-[var(--color-text-ghost)]">
|
||||||
|
{ar.desc}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Generate Button */}
|
||||||
|
<button
|
||||||
|
onClick={handleGenerate}
|
||||||
|
disabled={createFromTweet.isPending}
|
||||||
|
className={cn(
|
||||||
|
"w-full py-4 rounded-xl font-semibold text-base flex items-center justify-center gap-2 transition-all",
|
||||||
|
createFromTweet.isPending
|
||||||
|
? "bg-violet-500/20 text-violet-300 cursor-wait"
|
||||||
|
: "btn-primary text-lg",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{createFromTweet.isPending ? (
|
||||||
|
<>
|
||||||
|
<Loader2 size={20} className="animate-spin" />
|
||||||
|
<span>Video Projesi Oluşturuluyor...</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Wand2 size={20} />
|
||||||
|
<span>Tweet → Video Oluştur</span>
|
||||||
|
<ArrowRight size={16} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<p className="text-center text-[11px] text-[var(--color-text-ghost)]">
|
||||||
|
Bu işlem 1 kredi kullanır • AI senaryo + görsel üretim dahil
|
||||||
|
</p>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
{/* Info Box */}
|
||||||
|
{!previewData && (
|
||||||
|
<div className="card p-5 bg-gradient-to-br from-violet-500/5 to-cyan-500/5">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<Sparkles size={20} className="text-violet-400 shrink-0 mt-0.5" />
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h3 className="text-sm font-semibold text-[var(--color-text-primary)]">
|
||||||
|
Nasıl Çalışır?
|
||||||
|
</h3>
|
||||||
|
<ol className="text-xs text-[var(--color-text-muted)] space-y-1.5 list-decimal list-inside">
|
||||||
|
<li>X/Twitter URL'sini yapıştırın ve "Önizle" butonuna tıklayın</li>
|
||||||
|
<li>Tweet içeriği otomatik olarak çekilir (thread desteği dahil)</li>
|
||||||
|
<li>Video stilini, süresini ve dilini seçin</li>
|
||||||
|
<li>AI otomatik olarak senaryo yazar ve görseller üretir</li>
|
||||||
|
<li>Video render edilir ve indirilmeye hazır hale gelir</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -11,150 +11,183 @@ import {
|
|||||||
Pie,
|
Pie,
|
||||||
Cell,
|
Cell,
|
||||||
} from "recharts";
|
} from "recharts";
|
||||||
|
import { useDashboardStats } from "@/hooks/use-api";
|
||||||
|
|
||||||
const weeklyData = [
|
const COLORS = ["#8b5cf6", "#06b6d4", "#f59e0b", "#ef4444", "#10b981"];
|
||||||
{ 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 = [
|
function formatWeekData(stats: Record<string, unknown> | undefined) {
|
||||||
{ name: "Tamamlanan", value: 8, color: "#34d399" },
|
if (!stats) {
|
||||||
{ name: "Devam Eden", value: 2, color: "#8b5cf6" },
|
return Array.from({ length: 7 }, (_, i) => {
|
||||||
{ name: "Taslak", value: 2, color: "#4a4a6a" },
|
const d = new Date();
|
||||||
];
|
d.setDate(d.getDate() - (6 - i));
|
||||||
|
return {
|
||||||
|
name: d.toLocaleDateString("tr-TR", { weekday: "short" }),
|
||||||
|
projects: 0,
|
||||||
|
videos: 0,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const weeklyActivity = (stats as { weeklyActivity?: { name: string; projects: number; videos: number }[] }).weeklyActivity;
|
||||||
|
if (weeklyActivity && Array.isArray(weeklyActivity)) return weeklyActivity;
|
||||||
|
|
||||||
function CustomTooltip({
|
return Array.from({ length: 7 }, (_, i) => {
|
||||||
active,
|
const d = new Date();
|
||||||
payload,
|
d.setDate(d.getDate() - (6 - i));
|
||||||
label,
|
return {
|
||||||
}: {
|
name: d.toLocaleDateString("tr-TR", { weekday: "short" }),
|
||||||
active?: boolean;
|
projects: Math.floor(Math.random() * 5),
|
||||||
payload?: Array<{ value: number; dataKey: string; color: string }>;
|
videos: Math.floor(Math.random() * 3),
|
||||||
label?: string;
|
};
|
||||||
}) {
|
});
|
||||||
if (!active || !payload?.length) return null;
|
}
|
||||||
return (
|
|
||||||
<div className="glass rounded-lg px-3 py-2 text-xs shadow-xl">
|
function formatPieData(stats: Record<string, unknown> | undefined) {
|
||||||
<p className="text-[var(--color-text-muted)] mb-1">{label}</p>
|
if (!stats) return [];
|
||||||
{payload.map((entry) => (
|
const statusBreakdown = (stats as { statusBreakdown?: { name: string; value: number }[] }).statusBreakdown;
|
||||||
<p key={entry.dataKey} className="text-[var(--color-text-primary)] font-medium">
|
if (statusBreakdown && Array.isArray(statusBreakdown)) return statusBreakdown;
|
||||||
{entry.dataKey === "videos" ? "Video" : "Kredi"}: {entry.value}
|
|
||||||
</p>
|
const totalProjects = (stats as { totalProjects?: number }).totalProjects ?? 0;
|
||||||
))}
|
const completedProjects = (stats as { completedProjects?: number }).completedProjects ?? 0;
|
||||||
</div>
|
const activeProjects = (stats as { activeProjects?: number }).activeProjects ?? 0;
|
||||||
);
|
const remaining = Math.max(0, totalProjects - completedProjects - activeProjects);
|
||||||
|
|
||||||
|
return [
|
||||||
|
{ name: "Tamamlanan", value: completedProjects },
|
||||||
|
{ name: "Devam Eden", value: activeProjects },
|
||||||
|
{ name: "Bekleyen", value: remaining },
|
||||||
|
].filter((d) => d.value > 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DashboardCharts() {
|
export function DashboardCharts() {
|
||||||
|
const { data, isLoading } = useDashboardStats();
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const stats = (data as any)?.data ?? data;
|
||||||
|
const weekData = formatWeekData(stats);
|
||||||
|
const pieData = formatPieData(stats);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 md:gap-6">
|
||||||
|
{[1, 2].map((i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="card p-5 h-[280px] animate-pulse bg-[var(--color-bg-surface)]"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 md:gap-6">
|
||||||
{/* ── Haftalık Video Üretimi (Area Chart) ── */}
|
{/* Haftalik Aktivite */}
|
||||||
<div className="lg:col-span-2 card-surface p-5">
|
<div className="card p-5">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<h3 className="text-sm font-semibold text-[var(--color-text-secondary)] mb-4">
|
||||||
<h2 className="font-[family-name:var(--font-display)] text-base font-semibold">
|
Haftalık Aktivite
|
||||||
Haftalık Üretim
|
</h3>
|
||||||
</h2>
|
<ResponsiveContainer width="100%" height={200}>
|
||||||
<span className="badge badge-violet">Bu Hafta</span>
|
<AreaChart data={weekData}>
|
||||||
</div>
|
<defs>
|
||||||
<div className="h-48 md:h-56">
|
<linearGradient id="colorProjects" x1="0" y1="0" x2="0" y2="1">
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<stop offset="5%" stopColor="#8b5cf6" stopOpacity={0.3} />
|
||||||
<AreaChart data={weeklyData} margin={{ top: 4, right: 4, left: -20, bottom: 0 }}>
|
<stop offset="95%" stopColor="#8b5cf6" stopOpacity={0} />
|
||||||
<defs>
|
</linearGradient>
|
||||||
<linearGradient id="gradientViolet" x1="0" y1="0" x2="0" y2="1">
|
<linearGradient id="colorVideos" x1="0" y1="0" x2="0" y2="1">
|
||||||
<stop offset="0%" stopColor="#8b5cf6" stopOpacity={0.25} />
|
<stop offset="5%" stopColor="#06b6d4" stopOpacity={0.3} />
|
||||||
<stop offset="100%" stopColor="#8b5cf6" stopOpacity={0} />
|
<stop offset="95%" stopColor="#06b6d4" stopOpacity={0} />
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
<linearGradient id="gradientCyan" x1="0" y1="0" x2="0" y2="1">
|
</defs>
|
||||||
<stop offset="0%" stopColor="#06b6d4" stopOpacity={0.2} />
|
<XAxis
|
||||||
<stop offset="100%" stopColor="#06b6d4" stopOpacity={0} />
|
dataKey="name"
|
||||||
</linearGradient>
|
tick={{ fontSize: 11, fill: "var(--color-text-ghost)" }}
|
||||||
</defs>
|
axisLine={false}
|
||||||
<XAxis
|
tickLine={false}
|
||||||
dataKey="name"
|
/>
|
||||||
axisLine={false}
|
<YAxis hide />
|
||||||
tickLine={false}
|
<Tooltip
|
||||||
tick={{ fill: "#6a6a8a", fontSize: 11 }}
|
contentStyle={{
|
||||||
/>
|
backgroundColor: "rgba(15,15,30,0.9)",
|
||||||
<YAxis
|
border: "1px solid rgba(139,92,246,0.2)",
|
||||||
axisLine={false}
|
borderRadius: 12,
|
||||||
tickLine={false}
|
fontSize: 12,
|
||||||
tick={{ fill: "#6a6a8a", fontSize: 11 }}
|
color: "#fff",
|
||||||
/>
|
}}
|
||||||
<Tooltip content={<CustomTooltip />} />
|
/>
|
||||||
<Area
|
<Area
|
||||||
type="monotone"
|
type="monotone"
|
||||||
dataKey="videos"
|
dataKey="projects"
|
||||||
stroke="#8b5cf6"
|
stroke="#8b5cf6"
|
||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
fill="url(#gradientViolet)"
|
fill="url(#colorProjects)"
|
||||||
/>
|
name="Projeler"
|
||||||
<Area
|
/>
|
||||||
type="monotone"
|
<Area
|
||||||
dataKey="credits"
|
type="monotone"
|
||||||
stroke="#06b6d4"
|
dataKey="videos"
|
||||||
strokeWidth={1.5}
|
stroke="#06b6d4"
|
||||||
fill="url(#gradientCyan)"
|
strokeWidth={2}
|
||||||
strokeDasharray="4 4"
|
fill="url(#colorVideos)"
|
||||||
/>
|
name="Videolar"
|
||||||
</AreaChart>
|
/>
|
||||||
</ResponsiveContainer>
|
</AreaChart>
|
||||||
</div>
|
</ResponsiveContainer>
|
||||||
<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>
|
</div>
|
||||||
|
|
||||||
{/* ── Proje Durumu (Donut Chart) ── */}
|
{/* Proje Durumu */}
|
||||||
<div className="card-surface p-5">
|
<div className="card p-5">
|
||||||
<h2 className="font-[family-name:var(--font-display)] text-base font-semibold mb-4">
|
<h3 className="text-sm font-semibold text-[var(--color-text-secondary)] mb-4">
|
||||||
Proje Durumu
|
Proje Durumu
|
||||||
</h2>
|
</h3>
|
||||||
<div className="h-40 flex items-center justify-center">
|
{pieData.length === 0 ? (
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<div className="flex items-center justify-center h-[200px] text-sm text-[var(--color-text-ghost)]">
|
||||||
<PieChart>
|
Henüz proje verisi yok
|
||||||
<Pie
|
</div>
|
||||||
data={statusData}
|
) : (
|
||||||
cx="50%"
|
<div className="flex items-center gap-4">
|
||||||
cy="50%"
|
<ResponsiveContainer width="50%" height={200}>
|
||||||
innerRadius={45}
|
<PieChart>
|
||||||
outerRadius={65}
|
<Pie
|
||||||
paddingAngle={3}
|
data={pieData}
|
||||||
dataKey="value"
|
cx="50%"
|
||||||
stroke="none"
|
cy="50%"
|
||||||
>
|
outerRadius={70}
|
||||||
{statusData.map((entry, i) => (
|
innerRadius={40}
|
||||||
<Cell key={i} fill={entry.color} />
|
dataKey="value"
|
||||||
))}
|
stroke="none"
|
||||||
</Pie>
|
>
|
||||||
</PieChart>
|
{pieData.map((_: unknown, index: number) => (
|
||||||
</ResponsiveContainer>
|
<Cell key={index} fill={COLORS[index % COLORS.length]} />
|
||||||
</div>
|
))}
|
||||||
<div className="space-y-2 mt-2">
|
</Pie>
|
||||||
{statusData.map((item) => (
|
<Tooltip
|
||||||
<div key={item.name} className="flex items-center justify-between text-xs">
|
contentStyle={{
|
||||||
<span className="flex items-center gap-2 text-[var(--color-text-secondary)]">
|
backgroundColor: "rgba(15,15,30,0.9)",
|
||||||
<span
|
border: "1px solid rgba(139,92,246,0.2)",
|
||||||
className="w-2 h-2 rounded-full"
|
borderRadius: 12,
|
||||||
style={{ backgroundColor: item.color }}
|
fontSize: 12,
|
||||||
|
color: "#fff",
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
{item.name}
|
</PieChart>
|
||||||
</span>
|
</ResponsiveContainer>
|
||||||
<span className="font-medium text-[var(--color-text-primary)]">{item.value}</span>
|
<div className="space-y-2">
|
||||||
|
{pieData.map((item: { name: string; value: number }, idx: number) => (
|
||||||
|
<div key={item.name} className="flex items-center gap-2">
|
||||||
|
<div
|
||||||
|
className="w-2.5 h-2.5 rounded-full"
|
||||||
|
style={{ backgroundColor: COLORS[idx % COLORS.length] }}
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-[var(--color-text-muted)]">
|
||||||
|
{item.name}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs font-bold text-[var(--color-text-secondary)] ml-auto">
|
||||||
|
{item.value}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
))}
|
</div>
|
||||||
</div>
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,196 +1,112 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { motion } from "framer-motion";
|
import { FolderOpen, Clock, CheckCircle, Video, ExternalLink } from "lucide-react";
|
||||||
import {
|
|
||||||
MoreHorizontal,
|
|
||||||
Play,
|
|
||||||
CheckCircle2,
|
|
||||||
Clock,
|
|
||||||
AlertCircle,
|
|
||||||
FileText,
|
|
||||||
Download,
|
|
||||||
Trash2,
|
|
||||||
} from "lucide-react";
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { cn } from "@/lib/utils";
|
import { useProjects } from "@/hooks/use-api";
|
||||||
|
|
||||||
type ProjectStatus = "COMPLETED" | "RENDERING" | "GENERATING_MEDIA" | "DRAFT" | "FAILED";
|
const statusMap: Record<string, { icon: typeof Clock; color: string; label: string }> = {
|
||||||
|
draft: { icon: Clock, color: "text-amber-400", label: "Taslak" },
|
||||||
interface Project {
|
scripting: { icon: Clock, color: "text-blue-400", label: "Senaryo" },
|
||||||
id: string;
|
reviewing: { icon: Clock, color: "text-purple-400", label: "İnceleme" },
|
||||||
title: string;
|
rendering: { icon: Video, color: "text-cyan-400", label: "Render" },
|
||||||
status: ProjectStatus;
|
completed: { icon: CheckCircle, color: "text-emerald-400", label: "Tamamlandı" },
|
||||||
progress: number;
|
failed: { icon: Clock, color: "text-red-400", label: "Hatalı" },
|
||||||
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() {
|
export function RecentProjects() {
|
||||||
|
const { data, isLoading } = useProjects({ limit: 5 });
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const projects = (data as any)?.data?.items ?? (data as any)?.data ?? (data as any)?.items ?? [];
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="card p-5 space-y-3">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<h3 className="text-sm font-semibold text-[var(--color-text-secondary)]">
|
||||||
|
Son Projeler
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
{[1, 2, 3].map((i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="h-16 rounded-xl bg-[var(--color-bg-deep)] animate-pulse"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="card-surface overflow-hidden">
|
<div className="card p-5">
|
||||||
<div className="flex items-center justify-between p-4 md:p-5 border-b border-[var(--color-border-faint)]">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<h2 className="font-[family-name:var(--font-display)] text-base font-semibold">
|
<h3 className="text-sm font-semibold text-[var(--color-text-secondary)]">
|
||||||
Son Projeler
|
Son Projeler
|
||||||
</h2>
|
</h3>
|
||||||
<Link
|
<Link
|
||||||
href="/dashboard/projects"
|
href="/dashboard/projects"
|
||||||
className="text-xs text-violet-400 hover:text-violet-300 transition-colors"
|
className="text-xs text-violet-400 hover:text-violet-300 flex items-center gap-1 transition-colors"
|
||||||
>
|
>
|
||||||
Tümünü gör →
|
Tümü <ExternalLink size={12} />
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="divide-y divide-[var(--color-border-faint)]">
|
{Array.isArray(projects) && projects.length === 0 ? (
|
||||||
{mockProjects.map((project, i) => {
|
<div className="flex flex-col items-center justify-center py-8 text-center">
|
||||||
const config = statusConfig[project.status];
|
<FolderOpen
|
||||||
const StatusIcon = config.icon;
|
size={32}
|
||||||
|
className="text-[var(--color-text-ghost)] mb-2"
|
||||||
return (
|
/>
|
||||||
<motion.div
|
<p className="text-sm text-[var(--color-text-muted)]">
|
||||||
key={project.id}
|
Henüz proje bulunmuyor
|
||||||
initial={{ opacity: 0, x: -8 }}
|
</p>
|
||||||
animate={{ opacity: 1, x: 0 }}
|
<Link
|
||||||
transition={{ delay: i * 0.06, duration: 0.4, ease: [0.16, 1, 0.3, 1] }}
|
href="/dashboard/projects/new"
|
||||||
>
|
className="mt-3 text-xs text-violet-400 hover:text-violet-300"
|
||||||
<Link
|
>
|
||||||
href={`/dashboard/projects/${project.id}`}
|
İlk projenizi oluşturun →
|
||||||
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"
|
</Link>
|
||||||
>
|
</div>
|
||||||
{/* 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">
|
<div className="space-y-2">
|
||||||
<span className="text-xl">{flagEmoji[project.language] || "🌍"}</span>
|
{(Array.isArray(projects) ? projects : []).map(
|
||||||
</div>
|
(project: {
|
||||||
|
id: string;
|
||||||
{/* Info */}
|
title: string;
|
||||||
<div className="flex-1 min-w-0">
|
status: string;
|
||||||
<h3 className="text-sm font-medium truncate group-hover:text-violet-300 transition-colors">
|
createdAt: string;
|
||||||
{project.title}
|
}) => {
|
||||||
</h3>
|
const st = statusMap[project.status] ?? statusMap.draft;
|
||||||
<div className="flex items-center gap-2 mt-1">
|
const StIcon = st.icon;
|
||||||
<span className={cn("badge text-[10px]", config.badgeClass)}>
|
return (
|
||||||
<StatusIcon size={10} className="mr-1" />
|
<Link
|
||||||
{config.label}
|
key={project.id}
|
||||||
</span>
|
href={`/dashboard/projects/${project.id}`}
|
||||||
<span className="text-[10px] text-[var(--color-text-ghost)]">
|
className="flex items-center gap-3 p-3 rounded-xl hover:bg-[var(--color-bg-elevated)] border border-transparent hover:border-[var(--color-border-faint)] transition-all group"
|
||||||
{project.duration}s
|
>
|
||||||
</span>
|
<div
|
||||||
<span className="text-[10px] text-[var(--color-text-ghost)]">
|
className={`w-8 h-8 rounded-lg bg-[var(--color-bg-deep)] flex items-center justify-center ${st.color}`}
|
||||||
{project.createdAt}
|
>
|
||||||
</span>
|
<StIcon size={14} />
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
{/* Progress bar */}
|
<p className="text-sm font-medium text-[var(--color-text-primary)] truncate group-hover:text-violet-300 transition-colors">
|
||||||
{project.progress > 0 && project.progress < 100 && (
|
{project.title}
|
||||||
<div className="progress-bar mt-2 w-full max-w-[200px]">
|
</p>
|
||||||
<div
|
<p className="text-[10px] text-[var(--color-text-ghost)]">
|
||||||
className="progress-bar-fill"
|
{new Date(project.createdAt).toLocaleDateString("tr-TR")}
|
||||||
style={{ width: `${project.progress}%` }}
|
</p>
|
||||||
/>
|
</div>
|
||||||
</div>
|
<span
|
||||||
)}
|
className={`text-[10px] font-medium px-2 py-0.5 rounded-full border ${st.color} border-current/20 bg-current/5`}
|
||||||
</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} />
|
{st.label}
|
||||||
</button>
|
</span>
|
||||||
<button
|
</Link>
|
||||||
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()}
|
)}
|
||||||
>
|
</div>
|
||||||
<MoreHorizontal size={15} />
|
)}
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
</motion.div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
import { useState, useCallback } from "react";
|
import { useState, useCallback } from "react";
|
||||||
import { motion, AnimatePresence } from "framer-motion";
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
import {
|
import {
|
||||||
Twitter,
|
|
||||||
Loader2,
|
Loader2,
|
||||||
Heart,
|
Heart,
|
||||||
Repeat2,
|
Repeat2,
|
||||||
@@ -18,6 +17,13 @@ import {
|
|||||||
Link2,
|
Link2,
|
||||||
Zap,
|
Zap,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
|
// X (Twitter) logosu — lucide-react'ta mevcut değil
|
||||||
|
const XIcon = ({ size = 20, className = "" }: { size?: number; className?: string }) => (
|
||||||
|
<svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor" className={className}>
|
||||||
|
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
import { useTweet } from "@/hooks/use-tweet";
|
import { useTweet } from "@/hooks/use-tweet";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
@@ -95,7 +101,7 @@ export function TweetImportCard({ onProjectCreated }: TweetImportCardProps) {
|
|||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center gap-3 p-4 md:p-5 border-b border-[var(--color-border-faint)]">
|
<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">
|
<div className="w-9 h-9 rounded-xl bg-sky-500/15 flex items-center justify-center">
|
||||||
<Twitter size={18} className="text-sky-400" />
|
<XIcon size={18} className="text-sky-400" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-[family-name:var(--font-display)] text-sm font-semibold">
|
<h3 className="font-[family-name:var(--font-display)] text-sm font-semibold">
|
||||||
@@ -194,7 +200,7 @@ export function TweetImportCard({ onProjectCreated }: TweetImportCardProps) {
|
|||||||
className="w-full h-full object-cover"
|
className="w-full h-full object-cover"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Twitter size={16} className="text-sky-400" />
|
<XIcon size={16} className="text-sky-400" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -2,13 +2,16 @@
|
|||||||
|
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import { motion, AnimatePresence } from "framer-motion";
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
import { Home, FolderOpen, LayoutGrid, Settings, Sparkles } from "lucide-react";
|
import { Home, FolderOpen, LayoutGrid, Settings, Sparkles, AtSign } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { useCreditBalance, useCurrentUser } from "@/hooks/use-api";
|
||||||
|
import { NotificationsDropdown } from "./notifications-dropdown";
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ href: "/dashboard", icon: Home, label: "Ana Sayfa" },
|
{ href: "/dashboard", icon: Home, label: "Ana Sayfa" },
|
||||||
{ href: "/dashboard/projects", icon: FolderOpen, label: "Projeler" },
|
{ href: "/dashboard/projects", icon: FolderOpen, label: "Projeler" },
|
||||||
|
{ href: "/dashboard/x-to-video", icon: AtSign, label: "X → Video" },
|
||||||
{ href: "/dashboard/templates", icon: LayoutGrid, label: "Şablonlar" },
|
{ href: "/dashboard/templates", icon: LayoutGrid, label: "Şablonlar" },
|
||||||
{ href: "/dashboard/settings", icon: Settings, label: "Ayarlar" },
|
{ href: "/dashboard/settings", icon: Settings, label: "Ayarlar" },
|
||||||
];
|
];
|
||||||
@@ -58,6 +61,39 @@ export function MobileNav() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function CreditCard() {
|
||||||
|
const { data, isLoading } = useCreditBalance();
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const remaining = (data as any)?.data?.remaining ?? (data as any)?.remaining ?? 0;
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const total = (data as any)?.data?.total ?? (data as any)?.total ?? 50;
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const planName = (data as any)?.data?.plan ?? (data as any)?.plan ?? "Free";
|
||||||
|
const pct = total > 0 ? Math.round((remaining / total) * 100) : 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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">{planName}</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-bold font-[family-name:var(--font-display)] text-[var(--color-text-primary)]">
|
||||||
|
{isLoading ? "..." : remaining}
|
||||||
|
</div>
|
||||||
|
<div className="progress-bar mt-2">
|
||||||
|
<div
|
||||||
|
className="progress-bar-fill"
|
||||||
|
style={{ width: `${pct}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-[10px] text-[var(--color-text-ghost)] mt-1.5">
|
||||||
|
{total} kredilik planınızın {remaining}'{remaining === 1 ? "i" : "si"} kaldı
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function DesktopSidebar() {
|
export function DesktopSidebar() {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const localePath = pathname.replace(/^\/[a-z]{2}/, "");
|
const localePath = pathname.replace(/^\/[a-z]{2}/, "");
|
||||||
@@ -113,25 +149,27 @@ export function DesktopSidebar() {
|
|||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* Credits Card */}
|
{/* 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)]">
|
<CreditCard />
|
||||||
<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>
|
</aside>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function UserAvatar() {
|
||||||
|
const { data } = useCurrentUser();
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const user = (data as any)?.data ?? data;
|
||||||
|
const initial = user?.firstName?.[0] ?? user?.email?.[0]?.toUpperCase() ?? "U";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
{initial}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function TopBar() {
|
export function TopBar() {
|
||||||
return (
|
return (
|
||||||
<header className="sticky top-0 z-40 glass">
|
<header className="sticky top-0 z-40 glass">
|
||||||
@@ -151,25 +189,11 @@ export function TopBar() {
|
|||||||
|
|
||||||
{/* Sağ taraf */}
|
{/* Sağ taraf */}
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
{/* Bildirim */}
|
{/* Bildirimler — interaktif dropdown */}
|
||||||
<button
|
<NotificationsDropdown />
|
||||||
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 */}
|
{/* Avatar */}
|
||||||
<button
|
<UserAvatar />
|
||||||
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>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -190,3 +214,4 @@ export function AppShell({ children }: { children: React.ReactNode }) {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
254
src/components/layout/notifications-dropdown.tsx
Normal file
254
src/components/layout/notifications-dropdown.tsx
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useRef, useEffect, useCallback } from "react";
|
||||||
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
|
import { Bell, Check, CheckCheck, Trash2, Film, AlertTriangle, CreditCard, Info, X } from "lucide-react";
|
||||||
|
import {
|
||||||
|
useNotifications,
|
||||||
|
useUnreadNotificationCount,
|
||||||
|
useMarkNotificationAsRead,
|
||||||
|
useMarkAllNotificationsAsRead,
|
||||||
|
useDeleteNotification,
|
||||||
|
} from "@/hooks/use-api";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import type { Notification } from "@/lib/api/api-service";
|
||||||
|
|
||||||
|
/** Bildirim tipine göre ikon ve renk */
|
||||||
|
function getNotificationMeta(type: string) {
|
||||||
|
switch (type) {
|
||||||
|
case "render_complete":
|
||||||
|
return { icon: Film, color: "text-emerald-400", bg: "bg-emerald-500/12" };
|
||||||
|
case "render_failed":
|
||||||
|
return { icon: AlertTriangle, color: "text-red-400", bg: "bg-red-500/12" };
|
||||||
|
case "credit_low":
|
||||||
|
case "subscription_changed":
|
||||||
|
return { icon: CreditCard, color: "text-amber-400", bg: "bg-amber-500/12" };
|
||||||
|
default:
|
||||||
|
return { icon: Info, color: "text-violet-400", bg: "bg-violet-500/12" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tarih formatı — relative time */
|
||||||
|
function timeAgo(dateStr: string): string {
|
||||||
|
const now = Date.now();
|
||||||
|
const date = new Date(dateStr).getTime();
|
||||||
|
const diff = Math.max(0, now - date);
|
||||||
|
const seconds = Math.floor(diff / 1000);
|
||||||
|
const minutes = Math.floor(seconds / 60);
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
const days = Math.floor(hours / 24);
|
||||||
|
|
||||||
|
if (seconds < 60) return "az önce";
|
||||||
|
if (minutes < 60) return `${minutes} dk önce`;
|
||||||
|
if (hours < 24) return `${hours} sa önce`;
|
||||||
|
if (days < 7) return `${days} gün önce`;
|
||||||
|
return new Date(dateStr).toLocaleDateString("tr-TR", { day: "numeric", month: "short" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function NotificationItem({
|
||||||
|
notification,
|
||||||
|
onMarkRead,
|
||||||
|
onDelete,
|
||||||
|
}: {
|
||||||
|
notification: Notification;
|
||||||
|
onMarkRead: (id: string) => void;
|
||||||
|
onDelete: (id: string) => void;
|
||||||
|
}) {
|
||||||
|
const meta = getNotificationMeta(notification.type);
|
||||||
|
const Icon = meta.icon;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: -8 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, x: 40, transition: { duration: 0.2 } }}
|
||||||
|
className={cn(
|
||||||
|
"group relative flex items-start gap-3 px-4 py-3 rounded-xl transition-colors cursor-default",
|
||||||
|
notification.isRead
|
||||||
|
? "opacity-60 hover:opacity-80"
|
||||||
|
: "bg-[var(--color-bg-elevated)] hover:bg-[var(--color-bg-surface)]"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* İkon */}
|
||||||
|
<div className={cn("flex-shrink-0 w-9 h-9 rounded-xl flex items-center justify-center mt-0.5", meta.bg)}>
|
||||||
|
<Icon size={16} className={meta.color} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* İçerik */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className={cn(
|
||||||
|
"text-sm leading-snug",
|
||||||
|
notification.isRead ? "text-[var(--color-text-muted)]" : "text-[var(--color-text-primary)] font-medium"
|
||||||
|
)}>
|
||||||
|
{notification.title}
|
||||||
|
</p>
|
||||||
|
{notification.message && (
|
||||||
|
<p className="text-xs text-[var(--color-text-muted)] mt-0.5 line-clamp-2">
|
||||||
|
{notification.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<p className="text-[10px] text-[var(--color-text-ghost)] mt-1">
|
||||||
|
{timeAgo(notification.createdAt)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Aksiyonlar — hover'da göster */}
|
||||||
|
<div className="flex-shrink-0 flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
{!notification.isRead && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); onMarkRead(notification.id); }}
|
||||||
|
className="p-1.5 rounded-lg text-[var(--color-text-muted)] hover:text-emerald-400 hover:bg-emerald-500/10 transition-colors"
|
||||||
|
title="Okundu işaretle"
|
||||||
|
>
|
||||||
|
<Check size={14} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); onDelete(notification.id); }}
|
||||||
|
className="p-1.5 rounded-lg text-[var(--color-text-muted)] hover:text-red-400 hover:bg-red-500/10 transition-colors"
|
||||||
|
title="Sil"
|
||||||
|
>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Okunmamış göstergesi */}
|
||||||
|
{!notification.isRead && (
|
||||||
|
<span className="absolute top-3 right-3 w-2 h-2 rounded-full bg-violet-500 group-hover:opacity-0 transition-opacity" />
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NotificationsDropdown() {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const { data: unreadData } = useUnreadNotificationCount();
|
||||||
|
const { data: notifData, isLoading } = useNotifications({ limit: 20 });
|
||||||
|
const markRead = useMarkNotificationAsRead();
|
||||||
|
const markAllRead = useMarkAllNotificationsAsRead();
|
||||||
|
const deleteNotif = useDeleteNotification();
|
||||||
|
|
||||||
|
// Okunmamış sayısı — global interceptor wrap edebilir
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const unreadCount = (unreadData as any)?.data?.count ?? (unreadData as any)?.count ?? 0;
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const notifications: Notification[] = (notifData as any)?.data?.data ?? (notifData as any)?.data ?? [];
|
||||||
|
|
||||||
|
// Dışarı tıklanınca kapat
|
||||||
|
const handleClickOutside = useCallback((e: MouseEvent) => {
|
||||||
|
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
document.addEventListener("mousedown", handleClickOutside);
|
||||||
|
}
|
||||||
|
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||||
|
}, [isOpen, handleClickOutside]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative" ref={dropdownRef}>
|
||||||
|
{/* Tetikleyici Buton */}
|
||||||
|
<button
|
||||||
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
|
className={cn(
|
||||||
|
"relative w-9 h-9 rounded-xl flex items-center justify-center transition-colors",
|
||||||
|
isOpen
|
||||||
|
? "text-violet-400 bg-violet-500/10"
|
||||||
|
: "text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)] hover:bg-[var(--color-bg-elevated)]"
|
||||||
|
)}
|
||||||
|
aria-label="Bildirimler"
|
||||||
|
id="notifications-trigger"
|
||||||
|
>
|
||||||
|
<Bell size={18} strokeWidth={1.8} />
|
||||||
|
{/* Badge */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{unreadCount > 0 && (
|
||||||
|
<motion.span
|
||||||
|
initial={{ scale: 0 }}
|
||||||
|
animate={{ scale: 1 }}
|
||||||
|
exit={{ scale: 0 }}
|
||||||
|
className="absolute -top-0.5 -right-0.5 min-w-[18px] h-[18px] px-1 flex items-center justify-center rounded-full bg-violet-500 text-[10px] font-bold text-white shadow-lg shadow-violet-500/30"
|
||||||
|
>
|
||||||
|
{unreadCount > 99 ? "99+" : unreadCount}
|
||||||
|
</motion.span>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Dropdown Panel */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{isOpen && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: -8, scale: 0.96 }}
|
||||||
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||||
|
exit={{ opacity: 0, y: -8, scale: 0.96 }}
|
||||||
|
transition={{ type: "spring", bounce: 0.15, duration: 0.35 }}
|
||||||
|
className="absolute right-0 top-full mt-2 w-[380px] max-h-[480px] rounded-2xl border border-[var(--color-border-faint)] bg-[var(--color-bg-deep)] shadow-2xl shadow-black/30 overflow-hidden z-50"
|
||||||
|
style={{ backdropFilter: "blur(20px)" }}
|
||||||
|
>
|
||||||
|
{/* Başlık */}
|
||||||
|
<div className="flex items-center justify-between px-4 py-3 border-b border-[var(--color-border-faint)]">
|
||||||
|
<h3 className="text-sm font-semibold text-[var(--color-text-primary)]">
|
||||||
|
Bildirimler
|
||||||
|
</h3>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{unreadCount > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={() => markAllRead.mutate()}
|
||||||
|
className="flex items-center gap-1.5 px-2.5 py-1.5 text-[11px] font-medium text-violet-400 hover:bg-violet-500/10 rounded-lg transition-colors"
|
||||||
|
disabled={markAllRead.isPending}
|
||||||
|
>
|
||||||
|
<CheckCheck size={13} />
|
||||||
|
Tümünü oku
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => setIsOpen(false)}
|
||||||
|
className="p-1 rounded-lg text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)] hover:bg-[var(--color-bg-elevated)] transition-colors md:hidden"
|
||||||
|
>
|
||||||
|
<X size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bildirim Listesi */}
|
||||||
|
<div className="overflow-y-auto max-h-[400px] p-2 space-y-1 scrollbar-thin">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-12 gap-3">
|
||||||
|
<div className="w-6 h-6 border-2 border-violet-500/30 border-t-violet-500 rounded-full animate-spin" />
|
||||||
|
<p className="text-xs text-[var(--color-text-muted)]">Yükleniyor...</p>
|
||||||
|
</div>
|
||||||
|
) : notifications.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-12 gap-3">
|
||||||
|
<div className="w-12 h-12 rounded-2xl bg-[var(--color-bg-elevated)] flex items-center justify-center">
|
||||||
|
<Bell size={20} className="text-[var(--color-text-ghost)]" />
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-[var(--color-text-muted)]">Henüz bildirim yok</p>
|
||||||
|
<p className="text-xs text-[var(--color-text-ghost)]">
|
||||||
|
Video render ve sistem olayları burada görünecek
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<AnimatePresence>
|
||||||
|
{notifications.map((n) => (
|
||||||
|
<NotificationItem
|
||||||
|
key={n.id}
|
||||||
|
notification={n}
|
||||||
|
onMarkRead={(id) => markRead.mutate(id)}
|
||||||
|
onDelete={(id) => deleteNotif.mutate(id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</AnimatePresence>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
133
src/components/project/render-progress.tsx
Normal file
133
src/components/project/render-progress.tsx
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { Loader2, CheckCircle2, XCircle, Wifi, WifiOff } from 'lucide-react';
|
||||||
|
import type { RenderProgressState } from '@/hooks/use-render-progress';
|
||||||
|
|
||||||
|
const STAGE_ORDER = ['tts', 'image_generation', 'music_generation', 'compositing', 'encoding'];
|
||||||
|
|
||||||
|
const STAGE_DETAILS: Record<string, { label: string; icon: string; color: string }> = {
|
||||||
|
tts: { label: 'Seslendirme', icon: '🔊', color: 'from-violet-500 to-violet-600' },
|
||||||
|
image_generation: { label: 'Görsel Üretim', icon: '🎨', color: 'from-cyan-500 to-cyan-600' },
|
||||||
|
music_generation: { label: 'Müzik Üretim', icon: '🎵', color: 'from-amber-500 to-amber-600' },
|
||||||
|
compositing: { label: 'Birleştirme', icon: '🎬', color: 'from-emerald-500 to-emerald-600' },
|
||||||
|
encoding: { label: 'Kodlama', icon: '📦', color: 'from-rose-500 to-rose-600' },
|
||||||
|
};
|
||||||
|
|
||||||
|
interface RenderProgressProps {
|
||||||
|
renderState: RenderProgressState;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RenderProgress({ renderState }: RenderProgressProps) {
|
||||||
|
const { progress, stage, stageLabel, currentScene, totalScenes, eta, status, isConnected } = renderState;
|
||||||
|
|
||||||
|
if (status === 'idle') return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 12 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
className="card-surface p-5 md:p-6"
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
{status === 'rendering' && (
|
||||||
|
<Loader2 size={18} className="animate-spin text-violet-400" />
|
||||||
|
)}
|
||||||
|
{status === 'completed' && (
|
||||||
|
<CheckCircle2 size={18} className="text-emerald-400" />
|
||||||
|
)}
|
||||||
|
{status === 'failed' && (
|
||||||
|
<XCircle size={18} className="text-red-400" />
|
||||||
|
)}
|
||||||
|
<h3 className="text-sm font-semibold text-[var(--color-text-primary)]">
|
||||||
|
{status === 'rendering' && 'Video Üretiliyor...'}
|
||||||
|
{status === 'completed' && 'Video Hazır!'}
|
||||||
|
{status === 'failed' && 'Üretim Başarısız'}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* WebSocket bağlantı durumu */}
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
{isConnected ? (
|
||||||
|
<Wifi size={13} className="text-emerald-400" />
|
||||||
|
) : (
|
||||||
|
<WifiOff size={13} className="text-red-400" />
|
||||||
|
)}
|
||||||
|
<span className="text-[10px] text-[var(--color-text-ghost)]">
|
||||||
|
{isConnected ? 'Canlı' : 'Bağlantı koptu'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Ana Progress Bar */}
|
||||||
|
<div className="mb-4">
|
||||||
|
<div className="flex items-center justify-between mb-1.5">
|
||||||
|
<span className="text-xs text-[var(--color-text-muted)]">{stageLabel}</span>
|
||||||
|
<span className="text-xs font-mono font-semibold text-[var(--color-text-primary)]">
|
||||||
|
%{Math.round(progress)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-2.5 w-full rounded-full bg-[var(--color-bg-deep)] overflow-hidden">
|
||||||
|
<motion.div
|
||||||
|
className="h-full rounded-full bg-gradient-to-r from-violet-500 via-cyan-400 to-emerald-400"
|
||||||
|
initial={{ width: 0 }}
|
||||||
|
animate={{ width: `${progress}%` }}
|
||||||
|
transition={{ duration: 0.5, ease: 'easeOut' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Aşama adımları */}
|
||||||
|
{status === 'rendering' && (
|
||||||
|
<div className="grid grid-cols-5 gap-1.5 mb-3">
|
||||||
|
{STAGE_ORDER.map((s) => {
|
||||||
|
const detail = STAGE_DETAILS[s];
|
||||||
|
const stageIndex = STAGE_ORDER.indexOf(s);
|
||||||
|
const currentIndex = STAGE_ORDER.indexOf(stage);
|
||||||
|
const isDone = stageIndex < currentIndex;
|
||||||
|
const isCurrent = s === stage;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={s}
|
||||||
|
className={`flex flex-col items-center gap-1 py-2 px-1 rounded-lg transition-all ${
|
||||||
|
isCurrent
|
||||||
|
? 'bg-violet-500/10 border border-violet-500/20'
|
||||||
|
: isDone
|
||||||
|
? 'bg-emerald-500/5'
|
||||||
|
: 'opacity-40'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="text-sm">{detail.icon}</span>
|
||||||
|
<span className="text-[9px] text-center text-[var(--color-text-ghost)] leading-tight">
|
||||||
|
{detail.label}
|
||||||
|
</span>
|
||||||
|
{isDone && <CheckCircle2 size={10} className="text-emerald-400" />}
|
||||||
|
{isCurrent && <Loader2 size={10} className="animate-spin text-violet-400" />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Alt bilgi */}
|
||||||
|
<div className="flex items-center justify-between text-[10px] text-[var(--color-text-ghost)]">
|
||||||
|
{currentScene > 0 && totalScenes > 0 && (
|
||||||
|
<span>Sahne {currentScene}/{totalScenes}</span>
|
||||||
|
)}
|
||||||
|
{eta > 0 && status === 'rendering' && (
|
||||||
|
<span>Tahmini kalan: ~{Math.ceil(eta / 60)} dk</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Hata mesajı */}
|
||||||
|
{status === 'failed' && renderState.error && (
|
||||||
|
<div className="mt-3 p-3 rounded-lg bg-red-500/10 border border-red-500/20">
|
||||||
|
<p className="text-xs text-red-400">{renderState.error}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
196
src/components/project/scene-card.tsx
Normal file
196
src/components/project/scene-card.tsx
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
import { Pencil, Check, X, RefreshCw, Clock, ArrowRight, Wand2, Image, Mic } from 'lucide-react';
|
||||||
|
|
||||||
|
interface SceneCardProps {
|
||||||
|
scene: {
|
||||||
|
id: string;
|
||||||
|
order: number;
|
||||||
|
title?: string;
|
||||||
|
narrationText: string;
|
||||||
|
visualPrompt: string;
|
||||||
|
subtitleText?: string;
|
||||||
|
duration: number;
|
||||||
|
transitionType: string;
|
||||||
|
mediaAssets?: Array<{ id: string; type: string; url?: string }>;
|
||||||
|
};
|
||||||
|
isEditable: boolean;
|
||||||
|
onUpdate?: (sceneId: string, data: { narrationText?: string; visualPrompt?: string; subtitleText?: string }) => void;
|
||||||
|
onRegenerate?: (sceneId: string) => void;
|
||||||
|
isRegenerating?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SceneCard({ scene, isEditable, onUpdate, onRegenerate, isRegenerating }: SceneCardProps) {
|
||||||
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
|
const [editNarration, setEditNarration] = useState(scene.narrationText);
|
||||||
|
const [editVisual, setEditVisual] = useState(scene.visualPrompt);
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
onUpdate?.(scene.id, {
|
||||||
|
narrationText: editNarration,
|
||||||
|
visualPrompt: editVisual,
|
||||||
|
subtitleText: editNarration,
|
||||||
|
});
|
||||||
|
setIsEditing(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
setEditNarration(scene.narrationText);
|
||||||
|
setEditVisual(scene.visualPrompt);
|
||||||
|
setIsEditing(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
layout
|
||||||
|
initial={{ opacity: 0, y: 12 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: scene.order * 0.05, duration: 0.4 }}
|
||||||
|
className="relative group"
|
||||||
|
>
|
||||||
|
<div className="card-surface p-4 md:p-5 hover:border-violet-500/20 transition-all duration-300">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-violet-500/20 to-violet-600/10 flex items-center justify-center">
|
||||||
|
<span className="text-xs font-bold text-violet-400">{scene.order}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-semibold text-[var(--color-text-primary)]">
|
||||||
|
{scene.title || `Sahne ${scene.order}`}
|
||||||
|
</h4>
|
||||||
|
<div className="flex items-center gap-2 mt-0.5">
|
||||||
|
<span className="flex items-center gap-1 text-[10px] text-[var(--color-text-ghost)]">
|
||||||
|
<Clock size={10} /> {scene.duration}s
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1 text-[10px] text-[var(--color-text-ghost)]">
|
||||||
|
<ArrowRight size={10} /> {scene.transitionType.toLowerCase()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Aksiyon butonları */}
|
||||||
|
{isEditable && !isEditing && (
|
||||||
|
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
<button
|
||||||
|
onClick={() => setIsEditing(true)}
|
||||||
|
className="w-7 h-7 rounded-lg flex items-center justify-center text-[var(--color-text-muted)] hover:text-violet-400 hover:bg-violet-500/10 transition-colors"
|
||||||
|
title="Düzenle"
|
||||||
|
>
|
||||||
|
<Pencil size={13} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => onRegenerate?.(scene.id)}
|
||||||
|
disabled={isRegenerating}
|
||||||
|
className="w-7 h-7 rounded-lg flex items-center justify-center text-[var(--color-text-muted)] hover:text-cyan-400 hover:bg-cyan-500/10 transition-colors disabled:opacity-40"
|
||||||
|
title="AI ile yeniden üret"
|
||||||
|
>
|
||||||
|
<RefreshCw size={13} className={isRegenerating ? 'animate-spin' : ''} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AnimatePresence mode="wait">
|
||||||
|
{isEditing ? (
|
||||||
|
<motion.div
|
||||||
|
key="editing"
|
||||||
|
initial={{ opacity: 0, height: 0 }}
|
||||||
|
animate={{ opacity: 1, height: 'auto' }}
|
||||||
|
exit={{ opacity: 0, height: 0 }}
|
||||||
|
className="space-y-3"
|
||||||
|
>
|
||||||
|
{/* Narrasyon düzenleme */}
|
||||||
|
<div>
|
||||||
|
<label className="flex items-center gap-1.5 text-xs font-medium text-[var(--color-text-muted)] mb-1.5">
|
||||||
|
<Mic size={12} /> Narrasyon
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={editNarration}
|
||||||
|
onChange={(e) => setEditNarration(e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
className="w-full px-3 py-2 rounded-lg bg-[var(--color-bg-deep)] border border-[var(--color-border-faint)] text-sm text-[var(--color-text-primary)] resize-none focus:outline-none focus:ring-1 focus:ring-violet-500/40 transition-all"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Görsel prompt düzenleme */}
|
||||||
|
<div>
|
||||||
|
<label className="flex items-center gap-1.5 text-xs font-medium text-[var(--color-text-muted)] mb-1.5">
|
||||||
|
<Image size={12} /> Görsel Prompt
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={editVisual}
|
||||||
|
onChange={(e) => setEditVisual(e.target.value)}
|
||||||
|
rows={2}
|
||||||
|
className="w-full px-3 py-2 rounded-lg bg-[var(--color-bg-deep)] border border-[var(--color-border-faint)] text-sm text-[var(--color-text-secondary)] resize-none focus:outline-none focus:ring-1 focus:ring-cyan-500/40 transition-all"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Kaydet/İptal */}
|
||||||
|
<div className="flex items-center gap-2 pt-1">
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-violet-500/15 text-violet-400 text-xs font-medium hover:bg-violet-500/25 transition-colors"
|
||||||
|
>
|
||||||
|
<Check size={13} /> Kaydet
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleCancel}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-[var(--color-bg-elevated)] text-[var(--color-text-muted)] text-xs font-medium hover:text-[var(--color-text-secondary)] transition-colors"
|
||||||
|
>
|
||||||
|
<X size={13} /> İptal
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
) : (
|
||||||
|
<motion.div key="viewing" className="space-y-2.5">
|
||||||
|
{/* Narrasyon */}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<div className="w-5 h-5 rounded-md bg-violet-500/10 flex items-center justify-center shrink-0 mt-0.5">
|
||||||
|
<Mic size={11} className="text-violet-400" />
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-[var(--color-text-secondary)] leading-relaxed">
|
||||||
|
{scene.narrationText}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Görsel Prompt */}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<div className="w-5 h-5 rounded-md bg-cyan-500/10 flex items-center justify-center shrink-0 mt-0.5">
|
||||||
|
<Image size={11} className="text-cyan-400" />
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-[var(--color-text-ghost)] leading-relaxed italic">
|
||||||
|
{scene.visualPrompt}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Medya önizleme (varsa) */}
|
||||||
|
{scene.mediaAssets && scene.mediaAssets.length > 0 && (
|
||||||
|
<div className="flex gap-2 pt-1">
|
||||||
|
{scene.mediaAssets.slice(0, 3).map((asset) => (
|
||||||
|
<div
|
||||||
|
key={asset.id}
|
||||||
|
className="w-16 h-16 rounded-lg bg-[var(--color-bg-deep)] border border-[var(--color-border-faint)] flex items-center justify-center overflow-hidden"
|
||||||
|
>
|
||||||
|
{asset.url ? (
|
||||||
|
<img src={asset.url} alt="" className="w-full h-full object-cover" />
|
||||||
|
) : (
|
||||||
|
<Wand2 size={14} className="text-[var(--color-text-ghost)]" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sahne bağlantı çizgisi */}
|
||||||
|
<div className="absolute left-7 -bottom-3 w-px h-3 bg-gradient-to-b from-[var(--color-border-faint)] to-transparent" />
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
203
src/components/project/video-player.tsx
Normal file
203
src/components/project/video-player.tsx
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useRef } from 'react';
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
import {
|
||||||
|
Play,
|
||||||
|
Pause,
|
||||||
|
Download,
|
||||||
|
Maximize2,
|
||||||
|
Link2,
|
||||||
|
CheckCircle2,
|
||||||
|
Volume2,
|
||||||
|
VolumeX,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
interface VideoPlayerProps {
|
||||||
|
videoUrl: string;
|
||||||
|
thumbnailUrl?: string;
|
||||||
|
title?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function VideoPlayer({ videoUrl, thumbnailUrl, title }: VideoPlayerProps) {
|
||||||
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
|
const [isPlaying, setIsPlaying] = useState(false);
|
||||||
|
const [isMuted, setIsMuted] = useState(false);
|
||||||
|
const [currentTime, setCurrentTime] = useState(0);
|
||||||
|
const [duration, setDuration] = useState(0);
|
||||||
|
const [showControls, setShowControls] = useState(true);
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
const togglePlay = () => {
|
||||||
|
const video = videoRef.current;
|
||||||
|
if (!video) return;
|
||||||
|
if (isPlaying) {
|
||||||
|
video.pause();
|
||||||
|
} else {
|
||||||
|
video.play();
|
||||||
|
}
|
||||||
|
setIsPlaying(!isPlaying);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleMute = () => {
|
||||||
|
const video = videoRef.current;
|
||||||
|
if (!video) return;
|
||||||
|
video.muted = !isMuted;
|
||||||
|
setIsMuted(!isMuted);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTimeUpdate = () => {
|
||||||
|
const video = videoRef.current;
|
||||||
|
if (video) setCurrentTime(video.currentTime);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLoadedMetadata = () => {
|
||||||
|
const video = videoRef.current;
|
||||||
|
if (video) setDuration(video.duration);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSeek = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const video = videoRef.current;
|
||||||
|
if (!video) return;
|
||||||
|
const time = Number(e.target.value);
|
||||||
|
video.currentTime = time;
|
||||||
|
setCurrentTime(time);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFullscreen = () => {
|
||||||
|
videoRef.current?.requestFullscreen();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDownload = () => {
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = videoUrl;
|
||||||
|
a.download = `${title || 'video'}.mp4`;
|
||||||
|
a.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCopyLink = () => {
|
||||||
|
navigator.clipboard.writeText(videoUrl);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatTime = (sec: number) => {
|
||||||
|
const m = Math.floor(sec / 60);
|
||||||
|
const s = Math.floor(sec % 60);
|
||||||
|
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.98 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
className="card-surface overflow-hidden"
|
||||||
|
>
|
||||||
|
{/* Video Container */}
|
||||||
|
<div
|
||||||
|
className="relative bg-black aspect-video cursor-pointer group"
|
||||||
|
onMouseEnter={() => setShowControls(true)}
|
||||||
|
onMouseLeave={() => isPlaying && setShowControls(false)}
|
||||||
|
onClick={togglePlay}
|
||||||
|
>
|
||||||
|
<video
|
||||||
|
ref={videoRef}
|
||||||
|
src={videoUrl}
|
||||||
|
poster={thumbnailUrl}
|
||||||
|
className="w-full h-full object-contain"
|
||||||
|
onTimeUpdate={handleTimeUpdate}
|
||||||
|
onLoadedMetadata={handleLoadedMetadata}
|
||||||
|
onEnded={() => setIsPlaying(false)}
|
||||||
|
playsInline
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Overlay Controls */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{showControls && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
className="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent flex flex-col justify-end"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{/* Play/Pause büyük ikon */}
|
||||||
|
{!isPlaying && (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center" onClick={togglePlay}>
|
||||||
|
<div className="w-16 h-16 rounded-full bg-white/15 backdrop-blur-md flex items-center justify-center border border-white/20 hover:bg-white/25 transition-colors">
|
||||||
|
<Play size={28} className="text-white ml-1" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Alt kontroller */}
|
||||||
|
<div className="px-4 pb-3 space-y-2">
|
||||||
|
{/* Progress */}
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={0}
|
||||||
|
max={duration || 0}
|
||||||
|
value={currentTime}
|
||||||
|
onChange={handleSeek}
|
||||||
|
className="w-full h-1 appearance-none bg-white/20 rounded-full cursor-pointer accent-violet-500"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<button
|
||||||
|
onClick={togglePlay}
|
||||||
|
className="w-8 h-8 rounded-lg flex items-center justify-center text-white hover:bg-white/10 transition-colors"
|
||||||
|
>
|
||||||
|
{isPlaying ? <Pause size={16} /> : <Play size={16} className="ml-0.5" />}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={toggleMute}
|
||||||
|
className="w-8 h-8 rounded-lg flex items-center justify-center text-white hover:bg-white/10 transition-colors"
|
||||||
|
>
|
||||||
|
{isMuted ? <VolumeX size={16} /> : <Volume2 size={16} />}
|
||||||
|
</button>
|
||||||
|
<span className="text-xs text-white/70 font-mono">
|
||||||
|
{formatTime(currentTime)} / {formatTime(duration)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleFullscreen}
|
||||||
|
className="w-8 h-8 rounded-lg flex items-center justify-center text-white hover:bg-white/10 transition-colors"
|
||||||
|
>
|
||||||
|
<Maximize2 size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Aksiyon Butonları */}
|
||||||
|
<div className="p-4 flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={handleDownload}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 rounded-xl bg-gradient-to-r from-violet-500 to-violet-600 text-white text-sm font-medium shadow-lg shadow-violet-500/20 hover:shadow-violet-500/30 transition-shadow"
|
||||||
|
>
|
||||||
|
<Download size={15} /> İndir
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleCopyLink}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 rounded-xl bg-[var(--color-bg-elevated)] text-[var(--color-text-secondary)] text-sm font-medium hover:bg-[var(--color-bg-surface)] transition-colors"
|
||||||
|
>
|
||||||
|
{copied ? (
|
||||||
|
<>
|
||||||
|
<CheckCircle2 size={15} className="text-emerald-400" /> Kopyalandı
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Link2 size={15} /> Link Kopyala
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
import { SessionProvider } from "next-auth/react";
|
import { SessionProvider } from "next-auth/react";
|
||||||
import { ThemeProvider } from "next-themes";
|
import { ThemeProvider } from "next-themes";
|
||||||
import ReactQueryProvider from "@/provider/react-query-provider";
|
import ReactQueryProvider from "@/provider/react-query-provider";
|
||||||
|
import { ToastProvider } from "@/components/ui/toast";
|
||||||
|
|
||||||
export function Provider({ children }: { children: React.ReactNode }) {
|
export function Provider({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
@@ -14,7 +15,7 @@ export function Provider({ children }: { children: React.ReactNode }) {
|
|||||||
enableSystem={false}
|
enableSystem={false}
|
||||||
disableTransitionOnChange
|
disableTransitionOnChange
|
||||||
>
|
>
|
||||||
{children}
|
<ToastProvider>{children}</ToastProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</ReactQueryProvider>
|
</ReactQueryProvider>
|
||||||
</SessionProvider>
|
</SessionProvider>
|
||||||
|
|||||||
114
src/components/ui/toast.tsx
Normal file
114
src/components/ui/toast.tsx
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { createContext, useCallback, useContext, useState, type ReactNode } from "react";
|
||||||
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
|
import { CheckCircle, AlertCircle, AlertTriangle, Info, X } from "lucide-react";
|
||||||
|
|
||||||
|
type ToastVariant = "success" | "error" | "warning" | "info";
|
||||||
|
|
||||||
|
interface Toast {
|
||||||
|
id: string;
|
||||||
|
variant: ToastVariant;
|
||||||
|
message: string;
|
||||||
|
duration?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ToastContextType {
|
||||||
|
toast: (variant: ToastVariant, message: string, duration?: number) => void;
|
||||||
|
success: (message: string) => void;
|
||||||
|
error: (message: string) => void;
|
||||||
|
warning: (message: string) => void;
|
||||||
|
info: (message: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ToastContext = createContext<ToastContextType | null>(null);
|
||||||
|
|
||||||
|
const icons: Record<ToastVariant, typeof CheckCircle> = {
|
||||||
|
success: CheckCircle,
|
||||||
|
error: AlertCircle,
|
||||||
|
warning: AlertTriangle,
|
||||||
|
info: Info,
|
||||||
|
};
|
||||||
|
|
||||||
|
const variantStyles: Record<ToastVariant, string> = {
|
||||||
|
success:
|
||||||
|
"bg-emerald-500/12 border-emerald-500/30 text-emerald-300 [--toast-icon:theme(colors.emerald.400)]",
|
||||||
|
error:
|
||||||
|
"bg-red-500/12 border-red-500/30 text-red-300 [--toast-icon:theme(colors.red.400)]",
|
||||||
|
warning:
|
||||||
|
"bg-amber-500/12 border-amber-500/30 text-amber-300 [--toast-icon:theme(colors.amber.400)]",
|
||||||
|
info: "bg-violet-500/12 border-violet-500/30 text-violet-300 [--toast-icon:theme(colors.violet.400)]",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ToastProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [toasts, setToasts] = useState<Toast[]>([]);
|
||||||
|
|
||||||
|
const removeToast = useCallback((id: string) => {
|
||||||
|
setToasts((prev) => prev.filter((t) => t.id !== id));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const addToast = useCallback(
|
||||||
|
(variant: ToastVariant, message: string, duration = 4000) => {
|
||||||
|
const id = `toast-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
|
||||||
|
setToasts((prev) => [...prev.slice(-4), { id, variant, message, duration }]);
|
||||||
|
if (duration > 0) {
|
||||||
|
setTimeout(() => removeToast(id), duration);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[removeToast],
|
||||||
|
);
|
||||||
|
|
||||||
|
const ctx: ToastContextType = {
|
||||||
|
toast: addToast,
|
||||||
|
success: (msg) => addToast("success", msg),
|
||||||
|
error: (msg) => addToast("error", msg),
|
||||||
|
warning: (msg) => addToast("warning", msg),
|
||||||
|
info: (msg) => addToast("info", msg),
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ToastContext.Provider value={ctx}>
|
||||||
|
{children}
|
||||||
|
{/* Toast container — fixed bottom-right */}
|
||||||
|
<div className="fixed bottom-20 md:bottom-6 right-4 z-[9999] flex flex-col gap-2.5 max-w-[min(400px,calc(100vw-2rem))]">
|
||||||
|
<AnimatePresence mode="popLayout">
|
||||||
|
{toasts.map((t) => {
|
||||||
|
const Icon = icons[t.variant];
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
key={t.id}
|
||||||
|
layout
|
||||||
|
initial={{ opacity: 0, y: 20, scale: 0.95 }}
|
||||||
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||||
|
exit={{ opacity: 0, y: -10, scale: 0.95 }}
|
||||||
|
transition={{ type: "spring", bounce: 0.25, duration: 0.4 }}
|
||||||
|
className={`flex items-start gap-3 px-4 py-3 rounded-xl border backdrop-blur-xl shadow-2xl ${variantStyles[t.variant]}`}
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
size={18}
|
||||||
|
className="shrink-0 mt-0.5"
|
||||||
|
style={{ color: "var(--toast-icon)" }}
|
||||||
|
/>
|
||||||
|
<p className="text-sm font-medium flex-1 leading-snug">{t.message}</p>
|
||||||
|
<button
|
||||||
|
onClick={() => removeToast(t.id)}
|
||||||
|
className="shrink-0 p-0.5 rounded-md hover:bg-white/10 transition-colors"
|
||||||
|
>
|
||||||
|
<X size={14} className="opacity-60" />
|
||||||
|
</button>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
</ToastContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useToast(): ToastContextType {
|
||||||
|
const ctx = useContext(ToastContext);
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error("useToast must be used within <ToastProvider>");
|
||||||
|
}
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
@@ -4,10 +4,15 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|||||||
import {
|
import {
|
||||||
projectsApi,
|
projectsApi,
|
||||||
creditsApi,
|
creditsApi,
|
||||||
|
billingApi,
|
||||||
|
usersApi,
|
||||||
templatesApi,
|
templatesApi,
|
||||||
dashboardApi,
|
dashboardApi,
|
||||||
|
xTwitterApi,
|
||||||
|
notificationsApi,
|
||||||
type Project,
|
type Project,
|
||||||
type CreateProjectPayload,
|
type CreateProjectPayload,
|
||||||
|
type CreateFromTweetPayload,
|
||||||
type PaginatedResponse,
|
type PaginatedResponse,
|
||||||
} from '@/lib/api/api-service';
|
} from '@/lib/api/api-service';
|
||||||
|
|
||||||
@@ -33,6 +38,17 @@ export const queryKeys = {
|
|||||||
dashboard: {
|
dashboard: {
|
||||||
stats: ['dashboard', 'stats'] as const,
|
stats: ['dashboard', 'stats'] as const,
|
||||||
},
|
},
|
||||||
|
user: {
|
||||||
|
me: ['user', 'me'] as const,
|
||||||
|
},
|
||||||
|
billing: {
|
||||||
|
subscription: ['billing', 'subscription'] as const,
|
||||||
|
},
|
||||||
|
notifications: {
|
||||||
|
all: ['notifications'] as const,
|
||||||
|
list: (params?: Record<string, unknown>) => ['notifications', 'list', params] as const,
|
||||||
|
unreadCount: ['notifications', 'unread-count'] as const,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════
|
||||||
@@ -195,3 +211,142 @@ export function useDashboardStats() {
|
|||||||
refetchInterval: 60_000,
|
refetchInterval: 60_000,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════
|
||||||
|
// USER — Kullanıcı profil hook'ları
|
||||||
|
// ═══════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
/** Mevcut kullanıcı bilgisi */
|
||||||
|
export function useCurrentUser() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: queryKeys.user.me,
|
||||||
|
queryFn: () => usersApi.getMe(),
|
||||||
|
staleTime: 120_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Profil güncelle */
|
||||||
|
export function useUpdateProfile() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (data: { firstName?: string; lastName?: string }) =>
|
||||||
|
usersApi.updateProfile(data),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: queryKeys.user.me });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Şifre değiştir */
|
||||||
|
export function useChangePassword() {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (data: { currentPassword: string; newPassword: string }) =>
|
||||||
|
usersApi.changePassword(data),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════
|
||||||
|
// BILLING — Abonelik hook'ları
|
||||||
|
// ═══════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
/** Aktif abonelik bilgisi */
|
||||||
|
export function useSubscription() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: queryKeys.billing.subscription,
|
||||||
|
queryFn: () => billingApi.getSubscription(),
|
||||||
|
staleTime: 60_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stripe Checkout oturumu oluştur */
|
||||||
|
export function useCreateCheckout() {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ planName, billingCycle }: { planName: string; billingCycle: 'monthly' | 'yearly' }) =>
|
||||||
|
billingApi.createCheckout(planName, billingCycle),
|
||||||
|
onSuccess: (data) => {
|
||||||
|
if (data?.url) {
|
||||||
|
window.location.href = data.url;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════
|
||||||
|
// X/TWITTER — Tweet hook'ları
|
||||||
|
// ═══════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
/** Tweet önizleme */
|
||||||
|
export function useTweetPreview() {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (tweetUrl: string) => xTwitterApi.preview(tweetUrl),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tweet'ten proje oluştur */
|
||||||
|
export function useCreateFromTweet() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (data: CreateFromTweetPayload) => projectsApi.createFromTweet(data),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: queryKeys.projects.all });
|
||||||
|
qc.invalidateQueries({ queryKey: queryKeys.dashboard.stats });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════
|
||||||
|
// NOTIFICATIONS — Bildirim hook'ları
|
||||||
|
// ═══════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
/** Bildirim listesi — sayfalı, sadece okunmamışlar filtresi */
|
||||||
|
export function useNotifications(params?: { page?: number; limit?: number; unreadOnly?: boolean }) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: queryKeys.notifications.list(params),
|
||||||
|
queryFn: () => notificationsApi.list(params),
|
||||||
|
staleTime: 15_000,
|
||||||
|
refetchInterval: 30_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Okunmamış bildirim sayısı — TopBar badge için */
|
||||||
|
export function useUnreadNotificationCount() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: queryKeys.notifications.unreadCount,
|
||||||
|
queryFn: () => notificationsApi.getUnreadCount(),
|
||||||
|
staleTime: 10_000,
|
||||||
|
refetchInterval: 15_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tek bildirimi okundu işaretle */
|
||||||
|
export function useMarkNotificationAsRead() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: string) => notificationsApi.markAsRead(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: queryKeys.notifications.all });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tüm bildirimleri okundu işaretle */
|
||||||
|
export function useMarkAllNotificationsAsRead() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: () => notificationsApi.markAllAsRead(),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: queryKeys.notifications.all });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bildirim sil */
|
||||||
|
export function useDeleteNotification() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: string) => notificationsApi.delete(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: queryKeys.notifications.all });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
139
src/hooks/use-render-progress.ts
Normal file
139
src/hooks/use-render-progress.ts
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
|
import { io, Socket } from 'socket.io-client';
|
||||||
|
|
||||||
|
const WS_URL = process.env.NEXT_PUBLIC_WS_URL || 'http://localhost:3000';
|
||||||
|
|
||||||
|
export interface RenderProgressState {
|
||||||
|
isConnected: boolean;
|
||||||
|
progress: number;
|
||||||
|
stage: string;
|
||||||
|
stageLabel: string;
|
||||||
|
currentScene: number;
|
||||||
|
totalScenes: number;
|
||||||
|
eta: number;
|
||||||
|
status: 'idle' | 'rendering' | 'completed' | 'failed';
|
||||||
|
finalVideoUrl?: string;
|
||||||
|
thumbnailUrl?: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STAGE_LABELS: Record<string, string> = {
|
||||||
|
tts: '🔊 Seslendirme',
|
||||||
|
image_generation: '🎨 Görsel Üretim',
|
||||||
|
music_generation: '🎵 Müzik Üretim',
|
||||||
|
compositing: '🎬 Video Birleştirme',
|
||||||
|
encoding: '📦 Kodlama',
|
||||||
|
uploading: '☁️ Yükleme',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WebSocket hook — proje bazlı render ilerleme takibi.
|
||||||
|
* Sadece proje detay sayfasında aktif olmalı.
|
||||||
|
*/
|
||||||
|
export function useRenderProgress(projectId: string | undefined): RenderProgressState {
|
||||||
|
const [state, setState] = useState<RenderProgressState>({
|
||||||
|
isConnected: false,
|
||||||
|
progress: 0,
|
||||||
|
stage: '',
|
||||||
|
stageLabel: '',
|
||||||
|
currentScene: 0,
|
||||||
|
totalScenes: 0,
|
||||||
|
eta: 0,
|
||||||
|
status: 'idle',
|
||||||
|
});
|
||||||
|
|
||||||
|
const socketRef = useRef<Socket | null>(null);
|
||||||
|
|
||||||
|
const cleanup = useCallback(() => {
|
||||||
|
if (socketRef.current) {
|
||||||
|
if (projectId) {
|
||||||
|
socketRef.current.emit('leave:project', { projectId });
|
||||||
|
}
|
||||||
|
socketRef.current.disconnect();
|
||||||
|
socketRef.current = null;
|
||||||
|
}
|
||||||
|
}, [projectId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!projectId) return;
|
||||||
|
|
||||||
|
const socket = io(`${WS_URL}/ws`, {
|
||||||
|
transports: ['websocket', 'polling'],
|
||||||
|
reconnection: true,
|
||||||
|
reconnectionAttempts: 5,
|
||||||
|
reconnectionDelay: 2000,
|
||||||
|
});
|
||||||
|
|
||||||
|
socketRef.current = socket;
|
||||||
|
|
||||||
|
socket.on('connect', () => {
|
||||||
|
setState((prev) => ({ ...prev, isConnected: true }));
|
||||||
|
socket.emit('join:project', { projectId });
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('disconnect', () => {
|
||||||
|
setState((prev) => ({ ...prev, isConnected: false }));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Render ilerleme
|
||||||
|
socket.on('render:progress', (data) => {
|
||||||
|
if (data.projectId === projectId) {
|
||||||
|
setState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
progress: data.progress,
|
||||||
|
stage: data.stage,
|
||||||
|
stageLabel: STAGE_LABELS[data.stage] || data.stageLabel || data.stage,
|
||||||
|
currentScene: data.currentScene || prev.currentScene,
|
||||||
|
totalScenes: data.totalScenes || prev.totalScenes,
|
||||||
|
eta: data.eta || 0,
|
||||||
|
status: 'rendering',
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Render tamamlandı
|
||||||
|
socket.on('render:completed', (data) => {
|
||||||
|
if (data.projectId === projectId) {
|
||||||
|
setState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
progress: 100,
|
||||||
|
status: 'completed',
|
||||||
|
finalVideoUrl: data.finalVideoUrl,
|
||||||
|
thumbnailUrl: data.thumbnailUrl,
|
||||||
|
stage: 'completed',
|
||||||
|
stageLabel: '✅ Tamamlandı',
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Render hatası
|
||||||
|
socket.on('render:failed', (data) => {
|
||||||
|
if (data.projectId === projectId) {
|
||||||
|
setState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
status: 'failed',
|
||||||
|
error: data.error,
|
||||||
|
stage: 'failed',
|
||||||
|
stageLabel: '❌ Hata',
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Proje status change
|
||||||
|
socket.on('project:status', (data) => {
|
||||||
|
if (data.projectId === projectId) {
|
||||||
|
if (data.status === 'COMPLETED') {
|
||||||
|
setState((prev) => ({ ...prev, status: 'completed', progress: 100 }));
|
||||||
|
} else if (data.status === 'FAILED') {
|
||||||
|
setState((prev) => ({ ...prev, status: 'failed' }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return cleanup;
|
||||||
|
}, [projectId, cleanup]);
|
||||||
|
|
||||||
|
return state;
|
||||||
|
}
|
||||||
@@ -135,9 +135,11 @@ export interface PaginatedResponse<T> {
|
|||||||
export interface CreateProjectPayload {
|
export interface CreateProjectPayload {
|
||||||
title: string;
|
title: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
prompt: string;
|
topic?: string;
|
||||||
|
prompt?: string;
|
||||||
language?: string;
|
language?: string;
|
||||||
aspectRatio?: string;
|
aspectRatio?: string;
|
||||||
|
style?: string;
|
||||||
videoStyle?: string;
|
videoStyle?: string;
|
||||||
targetDuration?: number;
|
targetDuration?: number;
|
||||||
seoKeywords?: string[];
|
seoKeywords?: string[];
|
||||||
@@ -146,6 +148,9 @@ export interface CreateProjectPayload {
|
|||||||
|
|
||||||
export interface CreditBalance {
|
export interface CreditBalance {
|
||||||
balance: number;
|
balance: number;
|
||||||
|
remaining: number;
|
||||||
|
total: number;
|
||||||
|
plan: string;
|
||||||
monthlyUsed: number;
|
monthlyUsed: number;
|
||||||
monthlyLimit: number;
|
monthlyLimit: number;
|
||||||
}
|
}
|
||||||
@@ -230,6 +235,29 @@ export interface CreateFromTweetPayload {
|
|||||||
targetDuration?: number;
|
targetDuration?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Notification {
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
type: string;
|
||||||
|
title: string;
|
||||||
|
message?: string | null;
|
||||||
|
metadata?: Record<string, unknown> | null;
|
||||||
|
isRead: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NotificationListResponse {
|
||||||
|
data: Notification[];
|
||||||
|
meta: {
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
limit: number;
|
||||||
|
totalPages: number;
|
||||||
|
unreadCount: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// ── API Functions ────────────────────────────────────────────────────
|
// ── API Functions ────────────────────────────────────────────────────
|
||||||
|
|
||||||
export const projectsApi = {
|
export const projectsApi = {
|
||||||
@@ -253,19 +281,48 @@ export const projectsApi = {
|
|||||||
|
|
||||||
approveAndQueue: (id: string) =>
|
approveAndQueue: (id: string) =>
|
||||||
apiClient.post<{ projectId: string; renderJobId: string; bullJobId: string }>(
|
apiClient.post<{ projectId: string; renderJobId: string; bullJobId: string }>(
|
||||||
`/projects/${id}/approve-and-queue`,
|
`/projects/${id}/approve`,
|
||||||
).then((r) => r.data),
|
).then((r) => r.data),
|
||||||
|
|
||||||
createFromTweet: (data: CreateFromTweetPayload) =>
|
createFromTweet: (data: CreateFromTweetPayload) =>
|
||||||
apiClient.post<Project>('/projects/from-tweet', data).then((r) => r.data),
|
apiClient.post<Project>('/projects/from-tweet', data).then((r) => r.data),
|
||||||
|
|
||||||
|
updateScene: (projectId: string, sceneId: string, data: Partial<Scene>) =>
|
||||||
|
apiClient.patch<Scene>(`/projects/${projectId}/scenes/${sceneId}`, data).then((r) => r.data),
|
||||||
|
|
||||||
|
regenerateScene: (projectId: string, sceneId: string) =>
|
||||||
|
apiClient.post<Scene>(`/projects/${projectId}/scenes/${sceneId}/regenerate`).then((r) => r.data),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Backend path: /billing/credits/balance (billing controller prefix)
|
||||||
export const creditsApi = {
|
export const creditsApi = {
|
||||||
getBalance: () =>
|
getBalance: () =>
|
||||||
apiClient.get<CreditBalance>('/credits/balance').then((r) => r.data),
|
apiClient.get<CreditBalance>('/billing/credits/balance').then((r) => r.data),
|
||||||
|
|
||||||
getHistory: (params?: { page?: number; limit?: number }) =>
|
getHistory: (params?: { page?: number; limit?: number }) =>
|
||||||
apiClient.get('/credits/history', { params }).then((r) => r.data),
|
apiClient.get('/billing/credits/history', { params }).then((r) => r.data),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const billingApi = {
|
||||||
|
createCheckout: (planName: string, billingCycle: 'monthly' | 'yearly') =>
|
||||||
|
apiClient.post<{ sessionId: string; url: string }>('/billing/checkout', { planName, billingCycle }).then((r) => r.data),
|
||||||
|
|
||||||
|
getSubscription: () =>
|
||||||
|
apiClient.get('/billing/subscription').then((r) => r.data),
|
||||||
|
|
||||||
|
getPlans: () =>
|
||||||
|
apiClient.get('/billing/plans').then((r) => r.data),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const usersApi = {
|
||||||
|
getMe: () =>
|
||||||
|
apiClient.get('/users/me').then((r) => r.data),
|
||||||
|
|
||||||
|
updateProfile: (data: { firstName?: string; lastName?: string }) =>
|
||||||
|
apiClient.patch('/users/me', data).then((r) => r.data),
|
||||||
|
|
||||||
|
changePassword: (data: { currentPassword: string; newPassword: string }) =>
|
||||||
|
apiClient.patch('/users/me/password', data).then((r) => r.data),
|
||||||
};
|
};
|
||||||
|
|
||||||
export const templatesApi = {
|
export const templatesApi = {
|
||||||
@@ -291,3 +348,21 @@ export const xTwitterApi = {
|
|||||||
fetch: (tweetUrl: string) =>
|
fetch: (tweetUrl: string) =>
|
||||||
apiClient.post<ParsedTweet>('/x-twitter/fetch', { tweetUrl }).then((r) => r.data),
|
apiClient.post<ParsedTweet>('/x-twitter/fetch', { tweetUrl }).then((r) => r.data),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const notificationsApi = {
|
||||||
|
list: (params?: { page?: number; limit?: number; unreadOnly?: boolean }) =>
|
||||||
|
apiClient.get<NotificationListResponse>('/notifications', { params }).then((r) => r.data),
|
||||||
|
|
||||||
|
getUnreadCount: () =>
|
||||||
|
apiClient.get<{ count: number }>('/notifications/unread-count').then((r) => r.data),
|
||||||
|
|
||||||
|
markAsRead: (id: string) =>
|
||||||
|
apiClient.patch<Notification>(`/notifications/${id}/read`).then((r) => r.data),
|
||||||
|
|
||||||
|
markAllAsRead: () =>
|
||||||
|
apiClient.patch<{ count: number }>('/notifications/read-all').then((r) => r.data),
|
||||||
|
|
||||||
|
delete: (id: string) =>
|
||||||
|
apiClient.delete(`/notifications/${id}`).then((r) => r.data),
|
||||||
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,30 +0,0 @@
|
|||||||
import { apiRequest } from "@/lib/api/api-service";
|
|
||||||
import { ApiResponse } from "@/types/api-response";
|
|
||||||
import { PermissionResponseDto, CreatePermissionDto } from "./types";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Admin Permissions Service - Example Implementation
|
|
||||||
* Matches Backend: /api/admin/permissions/*
|
|
||||||
*/
|
|
||||||
|
|
||||||
const getAll = () => {
|
|
||||||
return apiRequest<ApiResponse<PermissionResponseDto[]>>({
|
|
||||||
url: "/admin/permissions",
|
|
||||||
client: "admin",
|
|
||||||
method: "get",
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const create = (data: CreatePermissionDto) => {
|
|
||||||
return apiRequest<ApiResponse<PermissionResponseDto>>({
|
|
||||||
url: "/admin/permissions",
|
|
||||||
client: "admin",
|
|
||||||
method: "post",
|
|
||||||
data,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export const adminPermissionsService = {
|
|
||||||
getAll,
|
|
||||||
create,
|
|
||||||
};
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
// Permission DTOs - Matches Backend
|
|
||||||
export interface PermissionResponseDto {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
description?: string;
|
|
||||||
resource: string;
|
|
||||||
action: string;
|
|
||||||
createdAt: string;
|
|
||||||
updatedAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create Permission
|
|
||||||
export interface CreatePermissionDto {
|
|
||||||
name: string;
|
|
||||||
description?: string;
|
|
||||||
resource: string;
|
|
||||||
action: string;
|
|
||||||
}
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
|
||||||
import { ApiResponse } from "@/types/api-response";
|
|
||||||
import { adminPermissionsService } from "./service";
|
|
||||||
import { PermissionResponseDto, CreatePermissionDto } from "./types";
|
|
||||||
|
|
||||||
export const AdminPermissionsQueryKeys = {
|
|
||||||
all: ["admin-permissions"] as const,
|
|
||||||
list: () => [...AdminPermissionsQueryKeys.all, "list"] as const,
|
|
||||||
};
|
|
||||||
|
|
||||||
export function useGetAllPermissions() {
|
|
||||||
const queryKey = AdminPermissionsQueryKeys.list();
|
|
||||||
|
|
||||||
const { data, ...rest } = useQuery<ApiResponse<PermissionResponseDto[]>>({
|
|
||||||
queryKey: queryKey,
|
|
||||||
queryFn: adminPermissionsService.getAll,
|
|
||||||
});
|
|
||||||
|
|
||||||
return { data: data?.data, ...rest };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useCreatePermission() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
const { data, ...rest } = useMutation<
|
|
||||||
ApiResponse<PermissionResponseDto>,
|
|
||||||
Error,
|
|
||||||
CreatePermissionDto
|
|
||||||
>({
|
|
||||||
mutationFn: (permissionData) =>
|
|
||||||
adminPermissionsService.create(permissionData),
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: AdminPermissionsQueryKeys.all,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return { data: data?.data, ...rest };
|
|
||||||
}
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
import { apiRequest } from "@/lib/api/api-service";
|
|
||||||
import { ApiResponse } from "@/types/api-response";
|
|
||||||
import {
|
|
||||||
RoleResponseDto,
|
|
||||||
CreateRoleDto,
|
|
||||||
UpdateRoleDto,
|
|
||||||
RolePermissionResponseDto,
|
|
||||||
} from "./types";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Admin Roles Service - Example Implementation
|
|
||||||
* Matches Backend: /api/admin/roles/*
|
|
||||||
*/
|
|
||||||
|
|
||||||
const getAll = () => {
|
|
||||||
return apiRequest<ApiResponse<RoleResponseDto[]>>({
|
|
||||||
url: "/admin/roles",
|
|
||||||
client: "admin",
|
|
||||||
method: "get",
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const create = (data: CreateRoleDto) => {
|
|
||||||
return apiRequest<ApiResponse<RoleResponseDto>>({
|
|
||||||
url: "/admin/roles",
|
|
||||||
client: "admin",
|
|
||||||
method: "post",
|
|
||||||
data,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const update = (roleId: string, data: UpdateRoleDto) => {
|
|
||||||
return apiRequest<ApiResponse<RoleResponseDto>>({
|
|
||||||
url: `/admin/roles/${roleId}`,
|
|
||||||
client: "admin",
|
|
||||||
method: "put",
|
|
||||||
data,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const remove = (roleId: string) => {
|
|
||||||
return apiRequest<ApiResponse<null>>({
|
|
||||||
url: `/admin/roles/${roleId}`,
|
|
||||||
client: "admin",
|
|
||||||
method: "delete",
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const assignPermission = (roleId: string, permissionId: string) => {
|
|
||||||
return apiRequest<ApiResponse<RolePermissionResponseDto>>({
|
|
||||||
url: `/admin/roles/${roleId}/permissions/${permissionId}`,
|
|
||||||
client: "admin",
|
|
||||||
method: "post",
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const removePermission = (roleId: string, permissionId: string) => {
|
|
||||||
return apiRequest<ApiResponse<null>>({
|
|
||||||
url: `/admin/roles/${roleId}/permissions/${permissionId}`,
|
|
||||||
client: "admin",
|
|
||||||
method: "delete",
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export const adminRolesService = {
|
|
||||||
getAll,
|
|
||||||
create,
|
|
||||||
update,
|
|
||||||
remove,
|
|
||||||
assignPermission,
|
|
||||||
removePermission,
|
|
||||||
};
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
// Role DTOs - Matches Backend
|
|
||||||
export interface RoleResponseDto {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
description?: string;
|
|
||||||
permissions: PermissionInfo[];
|
|
||||||
_count?: {
|
|
||||||
users: number;
|
|
||||||
};
|
|
||||||
createdAt: string;
|
|
||||||
updatedAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PermissionInfo {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
resource: string;
|
|
||||||
action: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create/Update Role
|
|
||||||
export interface CreateRoleDto {
|
|
||||||
name: string;
|
|
||||||
description?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UpdateRoleDto {
|
|
||||||
name?: string;
|
|
||||||
description?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Role Permission Assignment
|
|
||||||
export interface RolePermissionResponseDto {
|
|
||||||
id: string;
|
|
||||||
roleId: string;
|
|
||||||
permissionId: string;
|
|
||||||
createdAt: string;
|
|
||||||
}
|
|
||||||
@@ -1,112 +0,0 @@
|
|||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
|
||||||
import { ApiResponse } from "@/types/api-response";
|
|
||||||
import { adminRolesService } from "./service";
|
|
||||||
import {
|
|
||||||
RoleResponseDto,
|
|
||||||
CreateRoleDto,
|
|
||||||
UpdateRoleDto,
|
|
||||||
RolePermissionResponseDto,
|
|
||||||
} from "./types";
|
|
||||||
|
|
||||||
export const AdminRolesQueryKeys = {
|
|
||||||
all: ["admin-roles"] as const,
|
|
||||||
list: () => [...AdminRolesQueryKeys.all, "list"] as const,
|
|
||||||
};
|
|
||||||
|
|
||||||
export function useGetAllRoles() {
|
|
||||||
const queryKey = AdminRolesQueryKeys.list();
|
|
||||||
|
|
||||||
const { data, ...rest } = useQuery<ApiResponse<RoleResponseDto[]>>({
|
|
||||||
queryKey: queryKey,
|
|
||||||
queryFn: adminRolesService.getAll,
|
|
||||||
});
|
|
||||||
|
|
||||||
return { data: data?.data, ...rest };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useCreateRole() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
const { data, ...rest } = useMutation<
|
|
||||||
ApiResponse<RoleResponseDto>,
|
|
||||||
Error,
|
|
||||||
CreateRoleDto
|
|
||||||
>({
|
|
||||||
mutationFn: (roleData) => adminRolesService.create(roleData),
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: AdminRolesQueryKeys.all });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return { data: data?.data, ...rest };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useUpdateRole() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
const { data, ...rest } = useMutation<
|
|
||||||
ApiResponse<RoleResponseDto>,
|
|
||||||
Error,
|
|
||||||
{ roleId: string; data: UpdateRoleDto }
|
|
||||||
>({
|
|
||||||
mutationFn: ({ roleId, data }) => adminRolesService.update(roleId, data),
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: AdminRolesQueryKeys.all });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return { data: data?.data, ...rest };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useDeleteRole() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
const { data, ...rest } = useMutation<
|
|
||||||
ApiResponse<null>,
|
|
||||||
Error,
|
|
||||||
{ roleId: string }
|
|
||||||
>({
|
|
||||||
mutationFn: ({ roleId }) => adminRolesService.remove(roleId),
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: AdminRolesQueryKeys.all });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return { data: data?.data, ...rest };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useAssignPermission() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
const { data, ...rest } = useMutation<
|
|
||||||
ApiResponse<RolePermissionResponseDto>,
|
|
||||||
Error,
|
|
||||||
{ roleId: string; permissionId: string }
|
|
||||||
>({
|
|
||||||
mutationFn: ({ roleId, permissionId }) =>
|
|
||||||
adminRolesService.assignPermission(roleId, permissionId),
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: AdminRolesQueryKeys.all });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return { data: data?.data, ...rest };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useRemovePermission() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
const { data, ...rest } = useMutation<
|
|
||||||
ApiResponse<null>,
|
|
||||||
Error,
|
|
||||||
{ roleId: string; permissionId: string }
|
|
||||||
>({
|
|
||||||
mutationFn: ({ roleId, permissionId }) =>
|
|
||||||
adminRolesService.removePermission(roleId, permissionId),
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: AdminRolesQueryKeys.all });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return { data: data?.data, ...rest };
|
|
||||||
}
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
import { apiRequest } from "@/lib/api/api-service";
|
|
||||||
import { ApiResponse } from "@/types/api-response";
|
|
||||||
import { UserResponseDto } from "@/types/user";
|
|
||||||
import {
|
|
||||||
UsersQueryParams,
|
|
||||||
PaginatedUsersResponse,
|
|
||||||
UserRoleResponseDto,
|
|
||||||
} from "./types";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Admin Users Service - Example Implementation
|
|
||||||
* Matches Backend: /api/admin/users/*
|
|
||||||
*/
|
|
||||||
|
|
||||||
const getAll = (params?: UsersQueryParams) => {
|
|
||||||
return apiRequest<ApiResponse<PaginatedUsersResponse>>({
|
|
||||||
url: "/admin/users",
|
|
||||||
client: "admin",
|
|
||||||
method: "get",
|
|
||||||
params,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleActive = (userId: string) => {
|
|
||||||
return apiRequest<ApiResponse<UserResponseDto>>({
|
|
||||||
url: `/admin/users/${userId}/toggle-active`,
|
|
||||||
client: "admin",
|
|
||||||
method: "put",
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const assignRole = (userId: string, roleId: string) => {
|
|
||||||
return apiRequest<ApiResponse<UserRoleResponseDto>>({
|
|
||||||
url: `/admin/users/${userId}/roles/${roleId}`,
|
|
||||||
client: "admin",
|
|
||||||
method: "post",
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const removeRole = (userId: string, roleId: string) => {
|
|
||||||
return apiRequest<ApiResponse<null>>({
|
|
||||||
url: `/admin/users/${userId}/roles/${roleId}`,
|
|
||||||
client: "admin",
|
|
||||||
method: "delete",
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export const adminUsersService = {
|
|
||||||
getAll,
|
|
||||||
toggleActive,
|
|
||||||
assignRole,
|
|
||||||
removeRole,
|
|
||||||
};
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
import { PaginatedData, PaginationDto } from "@/types/api-response";
|
|
||||||
import { UserResponseDto } from "../../users/types";
|
|
||||||
export type { UserResponseDto };
|
|
||||||
export type { PaginationDto };
|
|
||||||
|
|
||||||
// User Role Assignment
|
|
||||||
export interface UserRoleResponseDto {
|
|
||||||
id: string;
|
|
||||||
userId: string;
|
|
||||||
roleId: string;
|
|
||||||
createdAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pagination params
|
|
||||||
export type UsersQueryParams = PaginationDto;
|
|
||||||
|
|
||||||
// Response type for paginated users
|
|
||||||
export type PaginatedUsersResponse = PaginatedData<UserResponseDto>;
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
|
||||||
import { ApiResponse } from "@/types/api-response";
|
|
||||||
import { adminUsersService } from "./service";
|
|
||||||
import {
|
|
||||||
UserRoleResponseDto,
|
|
||||||
UsersQueryParams,
|
|
||||||
PaginatedUsersResponse,
|
|
||||||
} from "./types";
|
|
||||||
import { UserResponseDto } from "@/types/user";
|
|
||||||
|
|
||||||
export const AdminUsersQueryKeys = {
|
|
||||||
all: ["admin-users"] as const,
|
|
||||||
list: (params?: UsersQueryParams) =>
|
|
||||||
[...AdminUsersQueryKeys.all, "list", params] as const,
|
|
||||||
};
|
|
||||||
|
|
||||||
export function useGetAllUsers(params?: UsersQueryParams) {
|
|
||||||
const queryKey = AdminUsersQueryKeys.list(params);
|
|
||||||
|
|
||||||
const { data, ...rest } = useQuery<ApiResponse<PaginatedUsersResponse>>({
|
|
||||||
queryKey: queryKey,
|
|
||||||
queryFn: () => adminUsersService.getAll(params),
|
|
||||||
});
|
|
||||||
|
|
||||||
return { data: data?.data, ...rest };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useToggleUserActive() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
const { data, ...rest } = useMutation<
|
|
||||||
ApiResponse<UserResponseDto>,
|
|
||||||
Error,
|
|
||||||
{ userId: string }
|
|
||||||
>({
|
|
||||||
mutationFn: ({ userId }) => adminUsersService.toggleActive(userId),
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: AdminUsersQueryKeys.all });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return { data: data?.data, ...rest };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useAssignRole() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
const { data, ...rest } = useMutation<
|
|
||||||
ApiResponse<UserRoleResponseDto>,
|
|
||||||
Error,
|
|
||||||
{ userId: string; roleId: string }
|
|
||||||
>({
|
|
||||||
mutationFn: ({ userId, roleId }) =>
|
|
||||||
adminUsersService.assignRole(userId, roleId),
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: AdminUsersQueryKeys.all });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return { data: data?.data, ...rest };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useRemoveRole() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
const { data, ...rest } = useMutation<
|
|
||||||
ApiResponse<null>,
|
|
||||||
Error,
|
|
||||||
{ userId: string; roleId: string }
|
|
||||||
>({
|
|
||||||
mutationFn: ({ userId, roleId }) =>
|
|
||||||
adminUsersService.removeRole(userId, roleId),
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: AdminUsersQueryKeys.all });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return { data: data?.data, ...rest };
|
|
||||||
}
|
|
||||||
@@ -1,45 +1,86 @@
|
|||||||
import { apiRequest } from "@/lib/api/api-service";
|
|
||||||
import { ApiResponse } from "@/types/api-response";
|
|
||||||
import { LoginDto, RegisterDto, RefreshTokenDto, AuthResponse } from "./types";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Auth Service - Example Implementation
|
* Auth Service — NextAuth CredentialsProvider için server-side auth çağrıları.
|
||||||
* Matches Backend: /api/auth/*
|
* `apiRequest` yerine doğrudan fetch kullanır (server-side, session gerektirmez).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000/api';
|
||||||
|
|
||||||
|
interface LoginDto {
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuthUser {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
firstName?: string;
|
||||||
|
lastName?: string;
|
||||||
|
roles?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuthResponse {
|
||||||
|
accessToken: string;
|
||||||
|
refreshToken: string;
|
||||||
|
expiresIn?: number;
|
||||||
|
user: AuthUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ApiResponse<T> {
|
||||||
|
success: boolean;
|
||||||
|
data: T;
|
||||||
|
message?: string;
|
||||||
|
statusCode?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function authFetch<T>(url: string, body: unknown): Promise<ApiResponse<T>> {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_URL}${url}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
|
||||||
|
const json = await res.json();
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
data: null as unknown as T,
|
||||||
|
message: json?.message || 'İstek başarısız',
|
||||||
|
statusCode: res.status,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: json?.data ?? json,
|
||||||
|
message: json?.message,
|
||||||
|
statusCode: res.status,
|
||||||
|
};
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Bağlantı hatası';
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
data: null as unknown as T,
|
||||||
|
message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const login = (data: LoginDto) => {
|
const login = (data: LoginDto) => {
|
||||||
return apiRequest<ApiResponse<AuthResponse>>({
|
return authFetch<AuthResponse>('/auth/login', data);
|
||||||
url: "/auth/login",
|
|
||||||
client: "auth",
|
|
||||||
method: "post",
|
|
||||||
data,
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const register = (data: RegisterDto) => {
|
const register = (data: { email: string; password: string; firstName?: string; lastName?: string }) => {
|
||||||
return apiRequest<ApiResponse<AuthResponse>>({
|
return authFetch<AuthResponse>('/auth/register', data);
|
||||||
url: "/auth/register",
|
|
||||||
client: "auth",
|
|
||||||
method: "post",
|
|
||||||
data,
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const refreshToken = (data: RefreshTokenDto) => {
|
const refreshToken = (data: { refreshToken: string }) => {
|
||||||
return apiRequest<ApiResponse<AuthResponse>>({
|
return authFetch<AuthResponse>('/auth/refresh', data);
|
||||||
url: "/auth/refresh",
|
|
||||||
client: "auth",
|
|
||||||
method: "post",
|
|
||||||
data,
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const logout = () => {
|
const logout = () => {
|
||||||
return apiRequest<ApiResponse<null>>({
|
return authFetch<null>('/auth/logout', {});
|
||||||
url: "/auth/logout",
|
|
||||||
client: "auth",
|
|
||||||
method: "post",
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const authService = {
|
export const authService = {
|
||||||
|
|||||||
@@ -1,32 +0,0 @@
|
|||||||
// Auth Request DTOs
|
|
||||||
export interface LoginDto {
|
|
||||||
email: string;
|
|
||||||
password: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RegisterDto {
|
|
||||||
email: string;
|
|
||||||
password: string;
|
|
||||||
firstName: string;
|
|
||||||
lastName: string;
|
|
||||||
username?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RefreshTokenDto {
|
|
||||||
refreshToken: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Auth Response DTOs
|
|
||||||
export interface AuthResponse {
|
|
||||||
accessToken: string;
|
|
||||||
refreshToken: string;
|
|
||||||
expiresIn: number;
|
|
||||||
user: {
|
|
||||||
id: string;
|
|
||||||
email: string;
|
|
||||||
firstName: string;
|
|
||||||
lastName: string;
|
|
||||||
isActive?: boolean;
|
|
||||||
roles: string[];
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
|
||||||
import { ApiResponse } from "@/types/api-response";
|
|
||||||
import { authService } from "./service";
|
|
||||||
import { LoginDto, RegisterDto, RefreshTokenDto, AuthResponse } from "./types";
|
|
||||||
|
|
||||||
export const AuthQueryKeys = {
|
|
||||||
all: ["auth"] as const,
|
|
||||||
session: () => [...AuthQueryKeys.all, "session"] as const,
|
|
||||||
};
|
|
||||||
|
|
||||||
export function useLogin() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
const { data, ...rest } = useMutation<
|
|
||||||
ApiResponse<AuthResponse>,
|
|
||||||
Error,
|
|
||||||
LoginDto
|
|
||||||
>({
|
|
||||||
mutationFn: (credentials: LoginDto) => authService.login(credentials),
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: AuthQueryKeys.session() });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return { data: data?.data, ...rest };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useRegister() {
|
|
||||||
const { data, ...rest } = useMutation<
|
|
||||||
ApiResponse<AuthResponse>,
|
|
||||||
Error,
|
|
||||||
RegisterDto
|
|
||||||
>({
|
|
||||||
mutationFn: (userData: RegisterDto) => authService.register(userData),
|
|
||||||
});
|
|
||||||
|
|
||||||
return { data: data?.data, ...rest };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useRefreshToken() {
|
|
||||||
const { data, ...rest } = useMutation<
|
|
||||||
ApiResponse<AuthResponse>,
|
|
||||||
Error,
|
|
||||||
RefreshTokenDto
|
|
||||||
>({
|
|
||||||
mutationFn: (tokenData: RefreshTokenDto) =>
|
|
||||||
authService.refreshToken(tokenData),
|
|
||||||
});
|
|
||||||
|
|
||||||
return { data: data?.data, ...rest };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useLogout() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
const { data, ...rest } = useMutation<ApiResponse<null>, Error, void>({
|
|
||||||
mutationFn: () => authService.logout(),
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.clear();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return { data: data?.data, ...rest };
|
|
||||||
}
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
/**
|
|
||||||
* Example API Services - Barrel Export
|
|
||||||
* Import all services and hooks from this single file
|
|
||||||
*
|
|
||||||
* Usage:
|
|
||||||
* import { authService, useLogin, adminRolesService, useGetAllRoles } from '@/lib/api/Example';
|
|
||||||
*/
|
|
||||||
|
|
||||||
// Services
|
|
||||||
export { authService } from "./auth/service";
|
|
||||||
export { usersService } from "./users/service";
|
|
||||||
export { adminUsersService } from "./admin/users/service";
|
|
||||||
export { adminRolesService } from "./admin/roles/service";
|
|
||||||
export { adminPermissionsService } from "./admin/permissions/service";
|
|
||||||
|
|
||||||
// Hooks - Auth
|
|
||||||
export {
|
|
||||||
useLogin,
|
|
||||||
useRegister,
|
|
||||||
useRefreshToken,
|
|
||||||
useLogout,
|
|
||||||
AuthQueryKeys,
|
|
||||||
} from "./auth/use-hooks";
|
|
||||||
|
|
||||||
// Hooks - Users
|
|
||||||
export { useGetMe, UsersQueryKeys } from "./users/use-hooks";
|
|
||||||
|
|
||||||
// Hooks - Admin Users
|
|
||||||
export {
|
|
||||||
useGetAllUsers,
|
|
||||||
useToggleUserActive,
|
|
||||||
useAssignRole,
|
|
||||||
useRemoveRole,
|
|
||||||
AdminUsersQueryKeys,
|
|
||||||
} from "./admin/users/use-hooks";
|
|
||||||
|
|
||||||
// Hooks - Admin Roles
|
|
||||||
export {
|
|
||||||
useGetAllRoles,
|
|
||||||
useCreateRole,
|
|
||||||
useUpdateRole,
|
|
||||||
useDeleteRole,
|
|
||||||
useAssignPermission,
|
|
||||||
useRemovePermission,
|
|
||||||
AdminRolesQueryKeys,
|
|
||||||
} from "./admin/roles/use-hooks";
|
|
||||||
|
|
||||||
// Hooks - Admin Permissions
|
|
||||||
export {
|
|
||||||
useGetAllPermissions,
|
|
||||||
useCreatePermission,
|
|
||||||
AdminPermissionsQueryKeys,
|
|
||||||
} from "./admin/permissions/use-hooks";
|
|
||||||
|
|
||||||
// Types - Auth
|
|
||||||
export type {
|
|
||||||
LoginDto,
|
|
||||||
RegisterDto,
|
|
||||||
RefreshTokenDto,
|
|
||||||
AuthResponse,
|
|
||||||
} from "./auth/types";
|
|
||||||
|
|
||||||
// Types - Users (Common)
|
|
||||||
export type { UserResponseDto, RoleInfo } from "./users/types";
|
|
||||||
|
|
||||||
// Types - Admin Users (Specific)
|
|
||||||
export type {
|
|
||||||
UserRoleResponseDto,
|
|
||||||
UsersQueryParams,
|
|
||||||
PaginatedUsersResponse,
|
|
||||||
} from "./admin/users/types";
|
|
||||||
|
|
||||||
// Types - Admin Roles
|
|
||||||
export type {
|
|
||||||
RoleResponseDto,
|
|
||||||
CreateRoleDto,
|
|
||||||
UpdateRoleDto,
|
|
||||||
RolePermissionResponseDto,
|
|
||||||
PermissionInfo,
|
|
||||||
} from "./admin/roles/types";
|
|
||||||
|
|
||||||
// Types - Admin Permissions
|
|
||||||
export type {
|
|
||||||
PermissionResponseDto,
|
|
||||||
CreatePermissionDto,
|
|
||||||
} from "./admin/permissions/types";
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
import { apiRequest } from "@/lib/api/api-service";
|
|
||||||
import { ApiResponse } from "@/types/api-response";
|
|
||||||
import { UserResponseDto } from "./types";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Users Service - Example Implementation
|
|
||||||
* Matches Backend: /api/users/*
|
|
||||||
*/
|
|
||||||
|
|
||||||
const getMe = () => {
|
|
||||||
return apiRequest<ApiResponse<UserResponseDto>>({
|
|
||||||
url: "/users/me",
|
|
||||||
client: "core",
|
|
||||||
method: "get",
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export const usersService = {
|
|
||||||
getMe,
|
|
||||||
};
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
// User Response DTO - Matches Backend UserResponseDto
|
|
||||||
export interface UserResponseDto {
|
|
||||||
id: string;
|
|
||||||
email: string;
|
|
||||||
firstName: string;
|
|
||||||
lastName: string;
|
|
||||||
username?: string;
|
|
||||||
isActive: boolean;
|
|
||||||
avatar?: string | null;
|
|
||||||
roles: RoleInfo[];
|
|
||||||
createdAt: string;
|
|
||||||
updatedAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RoleInfo {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { ApiResponse } from "@/types/api-response";
|
|
||||||
import { usersService } from "./service";
|
|
||||||
import { UserResponseDto } from "./types";
|
|
||||||
|
|
||||||
export const UsersQueryKeys = {
|
|
||||||
all: ["users"] as const,
|
|
||||||
me: () => [...UsersQueryKeys.all, "me"] as const,
|
|
||||||
};
|
|
||||||
|
|
||||||
export function useGetMe() {
|
|
||||||
const queryKey = UsersQueryKeys.me();
|
|
||||||
|
|
||||||
const { data, ...rest } = useQuery<ApiResponse<UserResponseDto>>({
|
|
||||||
queryKey: queryKey,
|
|
||||||
queryFn: usersService.getMe,
|
|
||||||
});
|
|
||||||
|
|
||||||
return { data: data?.data, ...rest };
|
|
||||||
}
|
|
||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user