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:
2
next-env.d.ts
vendored
2
next-env.d.ts
vendored
@@ -1,6 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/dev/types/routes.d.ts";
|
||||
import "./.next/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
|
||||
@@ -1,231 +1,268 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Box,
|
||||
Flex,
|
||||
Heading,
|
||||
Input,
|
||||
Link as ChakraLink,
|
||||
Text,
|
||||
ClientOnly,
|
||||
} from "@chakra-ui/react";
|
||||
import { Button } from "@/components/ui/buttons/button";
|
||||
import { Switch } from "@/components/ui/forms/switch";
|
||||
import { Field } from "@/components/ui/forms/field";
|
||||
import { useTranslations } from "next-intl";
|
||||
import signInImage from "/public/assets/img/sign-in-image.png";
|
||||
import { InputGroup } from "@/components/ui/forms/input-group";
|
||||
import { BiLock } from "react-icons/bi";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { yupResolver } from "@hookform/resolvers/yup";
|
||||
import * as yup from "yup";
|
||||
import { Link, useRouter } from "@/i18n/navigation";
|
||||
import { MdMail } from "react-icons/md";
|
||||
import { PasswordInput } from "@/components/ui/forms/password-input";
|
||||
import { Skeleton } from "@/components/ui/feedback/skeleton";
|
||||
import { signIn } from "next-auth/react";
|
||||
import { toaster } from "@/components/ui/feedback/toaster";
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { signIn } from "next-auth/react";
|
||||
import { motion } from "framer-motion";
|
||||
import { Sparkles, Mail, Lock, Loader2, ArrowRight } from "lucide-react";
|
||||
|
||||
const schema = yup.object({
|
||||
email: yup.string().email().required(),
|
||||
password: yup.string().required(),
|
||||
});
|
||||
|
||||
type SignInForm = yup.InferType<typeof schema>;
|
||||
|
||||
const defaultValues = {
|
||||
email: "test@test.com.ue",
|
||||
password: "test1234",
|
||||
};
|
||||
|
||||
function SignInPage() {
|
||||
const t = useTranslations();
|
||||
export default function SignInPage() {
|
||||
const router = useRouter();
|
||||
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const {
|
||||
handleSubmit,
|
||||
register,
|
||||
formState: { errors },
|
||||
} = useForm<SignInForm>({
|
||||
resolver: yupResolver(schema),
|
||||
mode: "onChange",
|
||||
defaultValues,
|
||||
});
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
|
||||
if (!email || !password) {
|
||||
setError("Email ve şifre gereklidir.");
|
||||
return;
|
||||
}
|
||||
|
||||
const onSubmit = async (formData: SignInForm) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const res = await signIn("credentials", {
|
||||
redirect: false,
|
||||
email: formData.email,
|
||||
password: formData.password,
|
||||
email,
|
||||
password,
|
||||
});
|
||||
|
||||
if (res?.error) {
|
||||
throw new Error(res.error);
|
||||
setError("Giriş başarısız. Email veya şifrenizi kontrol edin.");
|
||||
return;
|
||||
}
|
||||
|
||||
router.replace("/home");
|
||||
} catch (error) {
|
||||
toaster.error({
|
||||
title: (error as Error).message || "Giriş yaparken hata oluştu!",
|
||||
type: "error",
|
||||
});
|
||||
router.replace("/dashboard");
|
||||
} catch {
|
||||
setError("Bağlantı hatası. Lütfen tekrar deneyin.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box position="relative">
|
||||
<Flex
|
||||
h={{ sm: "initial", md: "75vh", lg: "85vh" }}
|
||||
w="100%"
|
||||
maxW="1044px"
|
||||
mx="auto"
|
||||
justifyContent="space-between"
|
||||
mb="30px"
|
||||
pt={{ sm: "100px", md: "0px" }}
|
||||
<div
|
||||
style={{
|
||||
minHeight: "100vh",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: "linear-gradient(135deg, #0a0a14 0%, #0d0d1f 50%, #0a0a14 100%)",
|
||||
padding: "1rem",
|
||||
}}
|
||||
>
|
||||
<Flex
|
||||
as="form"
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
alignItems="center"
|
||||
justifyContent="start"
|
||||
style={{ userSelect: "none" }}
|
||||
w={{ base: "100%", md: "50%", lg: "42%" }}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
style={{
|
||||
width: "100%",
|
||||
maxWidth: "420px",
|
||||
}}
|
||||
>
|
||||
<Flex
|
||||
direction="column"
|
||||
w="100%"
|
||||
background="transparent"
|
||||
p="10"
|
||||
mt={{ md: "150px", lg: "80px" }}
|
||||
{/* Logo */}
|
||||
<div style={{ textAlign: "center", marginBottom: "2rem" }}>
|
||||
<div
|
||||
style={{
|
||||
width: 56,
|
||||
height: 56,
|
||||
borderRadius: 16,
|
||||
background: "linear-gradient(135deg, #8b5cf6, #06b6d4)",
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
marginBottom: "1rem",
|
||||
boxShadow: "0 8px 32px rgba(139, 92, 246, 0.3)",
|
||||
}}
|
||||
>
|
||||
<Heading
|
||||
color={{ base: "primary.400", _dark: "primary.200" }}
|
||||
fontSize="32px"
|
||||
mb="10px"
|
||||
fontWeight="bold"
|
||||
<Sparkles size={24} color="white" />
|
||||
</div>
|
||||
<h1
|
||||
style={{
|
||||
fontSize: "1.75rem",
|
||||
fontWeight: 800,
|
||||
color: "#f0f0f5",
|
||||
letterSpacing: "-0.02em",
|
||||
marginBottom: "0.5rem",
|
||||
}}
|
||||
>
|
||||
{t("auth.welcome-back")}
|
||||
</Heading>
|
||||
<Text
|
||||
mb="36px"
|
||||
ms="4px"
|
||||
color={{ base: "gray.400", _dark: "white" }}
|
||||
fontWeight="bold"
|
||||
fontSize="14px"
|
||||
ContentGen AI
|
||||
</h1>
|
||||
<p style={{ fontSize: "0.875rem", color: "#6b7280" }}>
|
||||
Hesabınıza giriş yapın
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Form Card */}
|
||||
<div
|
||||
style={{
|
||||
background: "rgba(255,255,255,0.03)",
|
||||
border: "1px solid rgba(255,255,255,0.06)",
|
||||
borderRadius: 20,
|
||||
padding: "2rem",
|
||||
backdropFilter: "blur(20px)",
|
||||
}}
|
||||
>
|
||||
{t("auth.subtitle")}
|
||||
</Text>
|
||||
<Field
|
||||
mb="24px"
|
||||
label={t("email")}
|
||||
errorText={errors.email?.message}
|
||||
invalid={!!errors.email}
|
||||
<form onSubmit={handleSubmit}>
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div
|
||||
style={{
|
||||
padding: "0.75rem 1rem",
|
||||
borderRadius: 12,
|
||||
background: "rgba(239, 68, 68, 0.1)",
|
||||
border: "1px solid rgba(239, 68, 68, 0.2)",
|
||||
color: "#f87171",
|
||||
fontSize: "0.8125rem",
|
||||
marginBottom: "1.25rem",
|
||||
}}
|
||||
>
|
||||
<InputGroup w="full" startElement={<MdMail size="1rem" />}>
|
||||
<Input
|
||||
borderRadius="15px"
|
||||
fontSize="sm"
|
||||
type="text"
|
||||
placeholder={t("email")}
|
||||
size="lg"
|
||||
{...register("email")}
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Email */}
|
||||
<div style={{ marginBottom: "1.25rem" }}>
|
||||
<label
|
||||
style={{
|
||||
display: "block",
|
||||
fontSize: "0.8125rem",
|
||||
fontWeight: 600,
|
||||
color: "#d1d5db",
|
||||
marginBottom: "0.5rem",
|
||||
}}
|
||||
>
|
||||
E-posta
|
||||
</label>
|
||||
<div style={{ position: "relative" }}>
|
||||
<Mail
|
||||
size={16}
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: 14,
|
||||
top: "50%",
|
||||
transform: "translateY(-50%)",
|
||||
color: "#6b7280",
|
||||
}}
|
||||
/>
|
||||
</InputGroup>
|
||||
</Field>
|
||||
<Field
|
||||
mb="24px"
|
||||
label={t("password")}
|
||||
errorText={errors.password?.message}
|
||||
invalid={!!errors.password}
|
||||
>
|
||||
<InputGroup w="full" startElement={<BiLock size="1rem" />}>
|
||||
<PasswordInput
|
||||
borderRadius="15px"
|
||||
fontSize="sm"
|
||||
placeholder={t("password")}
|
||||
size="lg"
|
||||
{...register("password")}
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="admin@contentgen.ai"
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "0.75rem 1rem 0.75rem 2.5rem",
|
||||
borderRadius: 12,
|
||||
border: "1px solid rgba(255,255,255,0.08)",
|
||||
background: "rgba(255,255,255,0.04)",
|
||||
color: "#f0f0f5",
|
||||
fontSize: "0.875rem",
|
||||
outline: "none",
|
||||
transition: "border-color 0.2s",
|
||||
}}
|
||||
onFocus={(e) => (e.target.style.borderColor = "rgba(139, 92, 246, 0.4)")}
|
||||
onBlur={(e) => (e.target.style.borderColor = "rgba(255,255,255,0.08)")}
|
||||
/>
|
||||
</InputGroup>
|
||||
</Field>
|
||||
<Field mb="24px">
|
||||
<Switch colorPalette="teal" label={t("auth.remember-me")}>
|
||||
{t("auth.remember-me")}
|
||||
</Switch>
|
||||
</Field>
|
||||
<Field mb="24px">
|
||||
<ClientOnly fallback={<Skeleton height="45px" width="100%" />}>
|
||||
<Button
|
||||
loading={loading}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Password */}
|
||||
<div style={{ marginBottom: "1.5rem" }}>
|
||||
<label
|
||||
style={{
|
||||
display: "block",
|
||||
fontSize: "0.8125rem",
|
||||
fontWeight: 600,
|
||||
color: "#d1d5db",
|
||||
marginBottom: "0.5rem",
|
||||
}}
|
||||
>
|
||||
Şifre
|
||||
</label>
|
||||
<div style={{ position: "relative" }}>
|
||||
<Lock
|
||||
size={16}
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: 14,
|
||||
top: "50%",
|
||||
transform: "translateY(-50%)",
|
||||
color: "#6b7280",
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "0.75rem 1rem 0.75rem 2.5rem",
|
||||
borderRadius: 12,
|
||||
border: "1px solid rgba(255,255,255,0.08)",
|
||||
background: "rgba(255,255,255,0.04)",
|
||||
color: "#f0f0f5",
|
||||
fontSize: "0.875rem",
|
||||
outline: "none",
|
||||
transition: "border-color 0.2s",
|
||||
}}
|
||||
onFocus={(e) => (e.target.style.borderColor = "rgba(139, 92, 246, 0.4)")}
|
||||
onBlur={(e) => (e.target.style.borderColor = "rgba(255,255,255,0.08)")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Submit Button */}
|
||||
<button
|
||||
type="submit"
|
||||
bg="primary.400"
|
||||
w="100%"
|
||||
h="45px"
|
||||
color="white"
|
||||
_hover={{
|
||||
bg: "primary.500",
|
||||
}}
|
||||
_active={{
|
||||
bg: "primary.400",
|
||||
disabled={loading}
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "0.875rem",
|
||||
borderRadius: 14,
|
||||
border: "none",
|
||||
background: "linear-gradient(135deg, #8b5cf6, #7c3aed)",
|
||||
color: "white",
|
||||
fontSize: "0.9375rem",
|
||||
fontWeight: 700,
|
||||
cursor: loading ? "not-allowed" : "pointer",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: "0.5rem",
|
||||
opacity: loading ? 0.7 : 1,
|
||||
transition: "opacity 0.2s, transform 0.1s",
|
||||
boxShadow: "0 4px 16px rgba(139, 92, 246, 0.3)",
|
||||
}}
|
||||
>
|
||||
{t("auth.sign-in")}
|
||||
</Button>
|
||||
</ClientOnly>
|
||||
</Field>
|
||||
<Flex
|
||||
flexDirection="column"
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
maxW="100%"
|
||||
{loading ? (
|
||||
<Loader2 size={18} className="animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
Giriş Yap
|
||||
<ArrowRight size={16} />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Footer text */}
|
||||
<p
|
||||
style={{
|
||||
textAlign: "center",
|
||||
fontSize: "0.75rem",
|
||||
color: "#4b5563",
|
||||
marginTop: "1.5rem",
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
color={{ base: "gray.400", _dark: "white" }}
|
||||
fontWeight="medium"
|
||||
>
|
||||
{t("auth.dont-have-account")}
|
||||
<ChakraLink
|
||||
as={Link}
|
||||
href="/signup"
|
||||
color={{ base: "primary.400", _dark: "primary.200" }}
|
||||
ms="5px"
|
||||
fontWeight="bold"
|
||||
focusRing="none"
|
||||
>
|
||||
{t("auth.sign-up")}
|
||||
</ChakraLink>
|
||||
</Text>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Flex>
|
||||
<Box
|
||||
display={{ base: "none", md: "block" }}
|
||||
overflowX="hidden"
|
||||
h="100%"
|
||||
w="40vw"
|
||||
position="absolute"
|
||||
right="0px"
|
||||
>
|
||||
<Box
|
||||
bgImage={`url(${signInImage.src})`}
|
||||
w="100%"
|
||||
h="100%"
|
||||
bgSize="cover"
|
||||
bgPos="50%"
|
||||
position="absolute"
|
||||
borderBottomLeftRadius="20px"
|
||||
/>
|
||||
</Box>
|
||||
</Flex>
|
||||
</Box>
|
||||
ContentGen AI Studio © 2026
|
||||
</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SignInPage;
|
||||
|
||||
@@ -18,7 +18,16 @@ import {
|
||||
} from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { useState } from 'react';
|
||||
import { useProject, useGenerateScript, useApproveAndQueue, useUpdateProject, useDeleteProject } from '@/hooks/use-api';
|
||||
import {
|
||||
useProject,
|
||||
useGenerateScript,
|
||||
useApproveAndQueue,
|
||||
useUpdateProject,
|
||||
useDeleteProject,
|
||||
useGenerateSceneImage,
|
||||
useUpscaleSceneImage,
|
||||
useRegenerateScene
|
||||
} 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';
|
||||
@@ -42,16 +51,61 @@ const STATUS_MAP: Record<string, { label: string; color: string; icon: React.Ele
|
||||
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 videoStyles = [
|
||||
// Film & Sinema
|
||||
{ id: "CINEMATIC", label: "Sinematik", emoji: "🎬", desc: "Film kalitesinde görseller", category: "Film & Sinema" },
|
||||
{ id: "DOCUMENTARY", label: "Belgesel", emoji: "📹", desc: "Bilimsel ve ciddi ton", category: "Film & Sinema" },
|
||||
{ id: "STORYTELLING", label: "Hikâye Anlatımı", emoji: "📖", desc: "Anlatı odaklı, sürükleyici", category: "Film & Sinema" },
|
||||
{ id: "NEWS", label: "Haber", emoji: "📰", desc: "Güncel ve bilgilendirici", category: "Film & Sinema" },
|
||||
{ id: "ARTISTIC", label: "Sanatsal", emoji: "🎨", desc: "Yaratıcı ve sıra dışı", category: "Film & Sinema" },
|
||||
{ id: "NOIR", label: "Film Noir", emoji: "🖤", desc: "Karanlık, dramatik", category: "Film & Sinema" },
|
||||
{ id: "VLOG", label: "Vlog", emoji: "📱", desc: "Günlük, samimi", category: "Film & Sinema" },
|
||||
// Animasyon
|
||||
{ id: "ANIME", label: "Anime", emoji: "⛩️", desc: "Japon animasyon stili", category: "Animasyon" },
|
||||
{ id: "ANIMATION_3D", label: "3D Animasyon", emoji: "🧊", desc: "Pixar kalitesi", category: "Animasyon" },
|
||||
{ id: "ANIMATION_2D", label: "2D Animasyon", emoji: "✏️", desc: "Klasik el çizimi", category: "Animasyon" },
|
||||
{ id: "STOP_MOTION", label: "Stop Motion", emoji: "🧸", desc: "Kare kare animasyon", category: "Animasyon" },
|
||||
{ id: "MOTION_COMIC", label: "Hareketli Çizgi Roman", emoji: "💥", desc: "Panel bazlı anlatım", category: "Animasyon" },
|
||||
{ id: "CARTOON", label: "Karikatür", emoji: "🎭", desc: "Çizgi film stili", category: "Animasyon" },
|
||||
{ id: "CLAYMATION", label: "Claymation", emoji: "🏺", desc: "Kil animasyon", category: "Animasyon" },
|
||||
{ id: "PIXEL_ART", label: "Pixel Art", emoji: "👾", desc: "8-bit retro oyun", category: "Animasyon" },
|
||||
{ id: "ISOMETRIC", label: "İzometrik", emoji: "🔷", desc: "İzometrik animasyon", category: "Animasyon" },
|
||||
// Eğitim & Bilgi
|
||||
{ id: "EDUCATIONAL", label: "Eğitim", emoji: "🎓", desc: "Öğretici ve açıklayıcı", category: "Eğitim & Bilgi" },
|
||||
{ id: "INFOGRAPHIC", label: "İnfografik", emoji: "📊", desc: "Veri görselleştirme", category: "Eğitim & Bilgi" },
|
||||
{ id: "WHITEBOARD", label: "Whiteboard", emoji: "📝", desc: "Tahta animasyonu", category: "Eğitim & Bilgi" },
|
||||
{ id: "EXPLAINER", label: "Explainer", emoji: "💡", desc: "Ürün/konsept anlatımı", category: "Eğitim & Bilgi" },
|
||||
{ id: "DATA_VIZ", label: "Veri Görselleştirme", emoji: "📈", desc: "Grafikler ve tablolar", category: "Eğitim & Bilgi" },
|
||||
// Retro & Nostaljik
|
||||
{ id: "RETRO_80S", label: "Retro 80s", emoji: "🕹️", desc: "Synthwave estetik", category: "Retro & Nostaljik" },
|
||||
{ id: "VINTAGE_FILM", label: "Vintage Film", emoji: "📽️", desc: "Super 8 filmi", category: "Retro & Nostaljik" },
|
||||
{ id: "VHS", label: "VHS", emoji: "📼", desc: "Kaset estetik", category: "Retro & Nostaljik" },
|
||||
{ id: "POLAROID", label: "Polaroid", emoji: "📸", desc: "Analog fotoğraf", category: "Retro & Nostaljik" },
|
||||
{ id: "RETRO_90S", label: "Retro 90s Y2K", emoji: "💿", desc: "Y2K & internet", category: "Retro & Nostaljik" },
|
||||
// Sanat Akımları
|
||||
{ id: "WATERCOLOR", label: "Suluboya", emoji: "🎨", desc: "Suluboya resim", category: "Sanat Akımları" },
|
||||
{ id: "OIL_PAINTING", label: "Yağlı Boya", emoji: "🖌️", desc: "Klasik tuval", category: "Sanat Akımları" },
|
||||
{ id: "IMPRESSIONIST", label: "Empresyonist", emoji: "🌅", desc: "Monet tarzı", category: "Sanat Akımları" },
|
||||
{ id: "POP_ART", label: "Pop Art", emoji: "🎯", desc: "Warhol stili", category: "Sanat Akımları" },
|
||||
{ id: "UKIYO_E", label: "Ukiyo-e", emoji: "🏯", desc: "Japon gravür", category: "Sanat Akımları" },
|
||||
{ id: "ART_DECO", label: "Art Deco", emoji: "✨", desc: "1920s zarafet", category: "Sanat Akımları" },
|
||||
{ id: "SURREAL", label: "Sürrealist", emoji: "🌀", desc: "Dalí tarzı", category: "Sanat Akımları" },
|
||||
{ id: "COMIC_BOOK", label: "Çizgi Roman", emoji: "💬", desc: "Marvel/DC stili", category: "Sanat Akımları" },
|
||||
{ id: "SKETCH", label: "Karakalem", emoji: "✍️", desc: "Kalem çizim", category: "Sanat Akımları" },
|
||||
// Modern & Minimal
|
||||
{ id: "MINIMALIST", label: "Minimalist", emoji: "⚪", desc: "Apple estetiği", category: "Modern & Minimal" },
|
||||
{ id: "GLASSMORPHISM", label: "Glassmorphism", emoji: "🔮", desc: "Cam efekti", category: "Modern & Minimal" },
|
||||
{ id: "NEON", label: "Neon Glow", emoji: "💜", desc: "Neon ışıkları", category: "Modern & Minimal" },
|
||||
{ id: "CYBERPUNK", label: "Cyberpunk", emoji: "🤖", desc: "Gelecek distopya", category: "Modern & Minimal" },
|
||||
{ id: "STEAMPUNK", label: "Steampunk", emoji: "⚙️", desc: "Viktoryan mekanik", category: "Modern & Minimal" },
|
||||
{ id: "ABSTRACT", label: "Soyut", emoji: "🔵", desc: "Abstract sanat", category: "Modern & Minimal" },
|
||||
// Fotoğrafik
|
||||
{ id: "PRODUCT", label: "Ürün Fotoğrafı", emoji: "📦", desc: "Studio çekim", category: "Fotoğrafik" },
|
||||
{ id: "FASHION", label: "Moda", emoji: "👗", desc: "Editöryal çekim", category: "Fotoğrafik" },
|
||||
{ id: "AERIAL", label: "Havadan", emoji: "🚁", desc: "Drone görüntüsü", category: "Fotoğrafik" },
|
||||
{ id: "MACRO", label: "Makro", emoji: "🔬", desc: "Yakın çekim", category: "Fotoğrafik" },
|
||||
{ id: "PORTRAIT", label: "Portre", emoji: "🧑", desc: "Portre fotoğraf", category: "Fotoğrafik" },
|
||||
];
|
||||
|
||||
const stagger = {
|
||||
hidden: { opacity: 0 },
|
||||
@@ -75,6 +129,13 @@ export default function ProjectDetailPage() {
|
||||
const approveMutation = useApproveAndQueue();
|
||||
const deleteMutation = useDeleteProject();
|
||||
|
||||
const generateImageMutation = useGenerateSceneImage();
|
||||
const upscaleImageMutation = useUpscaleSceneImage();
|
||||
const regenerateSceneMutation = useRegenerateScene();
|
||||
|
||||
const [generatingImageId, setGeneratingImageId] = useState<string | null>(null);
|
||||
const [upscalingImageId, setUpscalingImageId] = useState<string | null>(null);
|
||||
|
||||
// WebSocket progress
|
||||
const renderState = useRenderProgress(
|
||||
project?.status && ['PENDING', 'GENERATING_MEDIA', 'RENDERING'].includes(project.status) ? id : undefined,
|
||||
@@ -93,17 +154,37 @@ export default function ProjectDetailPage() {
|
||||
// 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);
|
||||
regenerateSceneMutation.mutate(
|
||||
{ projectId: id, sceneId },
|
||||
{
|
||||
onSettled: () => setRegeneratingSceneId(null),
|
||||
onSuccess: () => refetch(),
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
// Sahne görseli oluşturma
|
||||
const handleGenerateImage = (sceneId: string, customPrompt?: string) => {
|
||||
setGeneratingImageId(sceneId);
|
||||
generateImageMutation.mutate(
|
||||
{ projectId: id, sceneId, customPrompt },
|
||||
{
|
||||
onSettled: () => setGeneratingImageId(null),
|
||||
onSuccess: () => refetch(),
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
// Sahne görseli upscale
|
||||
const handleUpscaleImage = (sceneId: string) => {
|
||||
setUpscalingImageId(sceneId);
|
||||
upscaleImageMutation.mutate(
|
||||
{ projectId: id, sceneId },
|
||||
{
|
||||
onSettled: () => setUpscalingImageId(null),
|
||||
onSuccess: () => refetch(),
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
// Senaryo üret
|
||||
@@ -161,12 +242,24 @@ export default function ProjectDetailPage() {
|
||||
|
||||
const statusInfo = STATUS_MAP[project.status] || STATUS_MAP.DRAFT;
|
||||
const StatusIcon = statusInfo.icon;
|
||||
const isEditable = project.status === 'DRAFT' || project.status === 'FAILED';
|
||||
const isRendering = ['PENDING', 'GENERATING_MEDIA', 'RENDERING', 'GENERATING_SCRIPT'].includes(project.status);
|
||||
const isEditable = !isRendering;
|
||||
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;
|
||||
|
||||
const currentStyle = videoStyles.find(s => s.id === project.videoStyle);
|
||||
|
||||
const handleStyleChange = async (newStyleId: string) => {
|
||||
if (newStyleId === project.videoStyle) return;
|
||||
try {
|
||||
await projectsApi.update(id, { videoStyle: newStyleId } as any);
|
||||
refetch();
|
||||
} catch (err) {
|
||||
console.error('Üslup (Stil) değiştirme hatası:', err);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
variants={stagger}
|
||||
@@ -246,7 +339,21 @@ export default function ProjectDetailPage() {
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock size={12} /> {project.targetDuration}s
|
||||
</span>
|
||||
<span>{STYLE_LABELS[project.videoStyle] || project.videoStyle}</span>
|
||||
{isEditable ? (
|
||||
<select
|
||||
value={project.videoStyle}
|
||||
onChange={(e) => handleStyleChange(e.target.value)}
|
||||
className="bg-[var(--color-bg-base)] border border-[var(--color-border-faint)] rounded-md px-2 py-0.5 text-xs text-[var(--color-text-secondary)] focus:outline-none focus:border-violet-500/50 cursor-pointer"
|
||||
>
|
||||
{videoStyles.map((s) => (
|
||||
<option key={s.id} value={s.id}>
|
||||
{s.emoji} {s.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<span>{currentStyle ? `${currentStyle.emoji} ${currentStyle.label}` : project.videoStyle}</span>
|
||||
)}
|
||||
<span className="uppercase text-[10px] tracking-wider">{project.language}</span>
|
||||
<span className="text-[10px]">
|
||||
{new Date(project.createdAt).toLocaleDateString('tr-TR', {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useState, useMemo } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import {
|
||||
@@ -16,6 +16,8 @@ import {
|
||||
Loader2,
|
||||
Check,
|
||||
Wand2,
|
||||
Search,
|
||||
ChevronDown,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -37,12 +39,59 @@ const languages = [
|
||||
];
|
||||
|
||||
const videoStyles = [
|
||||
{ id: "CINEMATIC", label: "Sinematik", emoji: "🎬", desc: "Film kalitesinde görseller" },
|
||||
{ id: "DOCUMENTARY", label: "Belgesel", emoji: "📹", desc: "Bilimsel ve ciddi ton" },
|
||||
{ id: "EDUCATIONAL", label: "Eğitim", emoji: "📚", desc: "Öğretici ve açıklayıcı" },
|
||||
{ id: "STORYTELLING", label: "Hikaye", emoji: "📖", desc: "Anlatı odaklı, sürükleyici" },
|
||||
{ id: "NEWS", label: "Haber", emoji: "📰", desc: "Güncel ve bilgilendirici" },
|
||||
{ id: "ARTISTIC", label: "Sanatsal", emoji: "🎨", desc: "Yaratıcı ve sıra dışı" },
|
||||
// Film & Sinema
|
||||
{ id: "CINEMATIC", label: "Sinematik", emoji: "🎬", desc: "Film kalitesinde görseller", category: "Film & Sinema" },
|
||||
{ id: "DOCUMENTARY", label: "Belgesel", emoji: "📹", desc: "Bilimsel ve ciddi ton", category: "Film & Sinema" },
|
||||
{ id: "STORYTELLING", label: "Hikâye Anlatımı", emoji: "📖", desc: "Anlatı odaklı, sürükleyici", category: "Film & Sinema" },
|
||||
{ id: "NEWS", label: "Haber", emoji: "📰", desc: "Güncel ve bilgilendirici", category: "Film & Sinema" },
|
||||
{ id: "ARTISTIC", label: "Sanatsal", emoji: "🎨", desc: "Yaratıcı ve sıra dışı", category: "Film & Sinema" },
|
||||
{ id: "NOIR", label: "Film Noir", emoji: "🖤", desc: "Karanlık, dramatik", category: "Film & Sinema" },
|
||||
{ id: "VLOG", label: "Vlog", emoji: "📱", desc: "Günlük, samimi", category: "Film & Sinema" },
|
||||
// Animasyon
|
||||
{ id: "ANIME", label: "Anime", emoji: "⛩️", desc: "Japon animasyon stili", category: "Animasyon" },
|
||||
{ id: "ANIMATION_3D", label: "3D Animasyon", emoji: "🧊", desc: "Pixar kalitesi", category: "Animasyon" },
|
||||
{ id: "ANIMATION_2D", label: "2D Animasyon", emoji: "✏️", desc: "Klasik el çizimi", category: "Animasyon" },
|
||||
{ id: "STOP_MOTION", label: "Stop Motion", emoji: "🧸", desc: "Kare kare animasyon", category: "Animasyon" },
|
||||
{ id: "MOTION_COMIC", label: "Hareketli Çizgi Roman", emoji: "💥", desc: "Panel bazlı anlatım", category: "Animasyon" },
|
||||
{ id: "CARTOON", label: "Karikatür", emoji: "🎭", desc: "Çizgi film stili", category: "Animasyon" },
|
||||
{ id: "CLAYMATION", label: "Claymation", emoji: "🏺", desc: "Kil animasyon", category: "Animasyon" },
|
||||
{ id: "PIXEL_ART", label: "Pixel Art", emoji: "👾", desc: "8-bit retro oyun", category: "Animasyon" },
|
||||
{ id: "ISOMETRIC", label: "İzometrik", emoji: "🔷", desc: "İzometrik animasyon", category: "Animasyon" },
|
||||
// Eğitim & Bilgi
|
||||
{ id: "EDUCATIONAL", label: "Eğitim", emoji: "🎓", desc: "Öğretici ve açıklayıcı", category: "Eğitim & Bilgi" },
|
||||
{ id: "INFOGRAPHIC", label: "İnfografik", emoji: "📊", desc: "Veri görselleştirme", category: "Eğitim & Bilgi" },
|
||||
{ id: "WHITEBOARD", label: "Whiteboard", emoji: "📝", desc: "Tahta animasyonu", category: "Eğitim & Bilgi" },
|
||||
{ id: "EXPLAINER", label: "Explainer", emoji: "💡", desc: "Ürün/konsept anlatımı", category: "Eğitim & Bilgi" },
|
||||
{ id: "DATA_VIZ", label: "Veri Görselleştirme", emoji: "📈", desc: "Grafikler ve tablolar", category: "Eğitim & Bilgi" },
|
||||
// Retro & Nostaljik
|
||||
{ id: "RETRO_80S", label: "Retro 80s", emoji: "🕹️", desc: "Synthwave estetik", category: "Retro & Nostaljik" },
|
||||
{ id: "VINTAGE_FILM", label: "Vintage Film", emoji: "📽️", desc: "Super 8 filmi", category: "Retro & Nostaljik" },
|
||||
{ id: "VHS", label: "VHS", emoji: "📼", desc: "Kaset estetik", category: "Retro & Nostaljik" },
|
||||
{ id: "POLAROID", label: "Polaroid", emoji: "📸", desc: "Analog fotoğraf", category: "Retro & Nostaljik" },
|
||||
{ id: "RETRO_90S", label: "Retro 90s Y2K", emoji: "💿", desc: "Y2K & internet", category: "Retro & Nostaljik" },
|
||||
// Sanat Akımları
|
||||
{ id: "WATERCOLOR", label: "Suluboya", emoji: "🎨", desc: "Suluboya resim", category: "Sanat Akımları" },
|
||||
{ id: "OIL_PAINTING", label: "Yağlı Boya", emoji: "🖌️", desc: "Klasik tuval", category: "Sanat Akımları" },
|
||||
{ id: "IMPRESSIONIST", label: "Empresyonist", emoji: "🌅", desc: "Monet tarzı", category: "Sanat Akımları" },
|
||||
{ id: "POP_ART", label: "Pop Art", emoji: "🎯", desc: "Warhol stili", category: "Sanat Akımları" },
|
||||
{ id: "UKIYO_E", label: "Ukiyo-e", emoji: "🏯", desc: "Japon gravür", category: "Sanat Akımları" },
|
||||
{ id: "ART_DECO", label: "Art Deco", emoji: "✨", desc: "1920s zarafet", category: "Sanat Akımları" },
|
||||
{ id: "SURREAL", label: "Sürrealist", emoji: "🌀", desc: "Dalí tarzı", category: "Sanat Akımları" },
|
||||
{ id: "COMIC_BOOK", label: "Çizgi Roman", emoji: "💬", desc: "Marvel/DC stili", category: "Sanat Akımları" },
|
||||
{ id: "SKETCH", label: "Karakalem", emoji: "✍️", desc: "Kalem çizim", category: "Sanat Akımları" },
|
||||
// Modern & Minimal
|
||||
{ id: "MINIMALIST", label: "Minimalist", emoji: "⚪", desc: "Apple estetiği", category: "Modern & Minimal" },
|
||||
{ id: "GLASSMORPHISM", label: "Glassmorphism", emoji: "🔮", desc: "Cam efekti", category: "Modern & Minimal" },
|
||||
{ id: "NEON", label: "Neon Glow", emoji: "💜", desc: "Neon ışıkları", category: "Modern & Minimal" },
|
||||
{ id: "CYBERPUNK", label: "Cyberpunk", emoji: "🤖", desc: "Gelecek distopya", category: "Modern & Minimal" },
|
||||
{ id: "STEAMPUNK", label: "Steampunk", emoji: "⚙️", desc: "Viktoryan mekanik", category: "Modern & Minimal" },
|
||||
{ id: "ABSTRACT", label: "Soyut", emoji: "🔵", desc: "Abstract sanat", category: "Modern & Minimal" },
|
||||
// Fotoğrafik
|
||||
{ id: "PRODUCT", label: "Ürün Fotoğrafı", emoji: "📦", desc: "Studio çekim", category: "Fotoğrafik" },
|
||||
{ id: "FASHION", label: "Moda", emoji: "👗", desc: "Editöryal çekim", category: "Fotoğrafik" },
|
||||
{ id: "AERIAL", label: "Havadan", emoji: "🚁", desc: "Drone görüntüsü", category: "Fotoğrafik" },
|
||||
{ id: "MACRO", label: "Makro", emoji: "🔬", desc: "Yakın çekim", category: "Fotoğrafik" },
|
||||
{ id: "PORTRAIT", label: "Portre", emoji: "🧑", desc: "Portre fotoğraf", category: "Fotoğrafik" },
|
||||
];
|
||||
|
||||
const aspectRatios = [
|
||||
@@ -65,6 +114,26 @@ export default function NewProjectPage() {
|
||||
const [duration, setDuration] = useState(60);
|
||||
const [aspectRatio, setAspectRatio] = useState("PORTRAIT_9_16");
|
||||
|
||||
// Search & Category state
|
||||
const [styleSearch, setStyleSearch] = useState("");
|
||||
const [expandedCategory, setExpandedCategory] = useState<string | null>("Film & Sinema");
|
||||
|
||||
const filteredStyles = useMemo(() => {
|
||||
return videoStyles.filter(s =>
|
||||
s.label.toLowerCase().includes(styleSearch.toLowerCase()) ||
|
||||
s.desc.toLowerCase().includes(styleSearch.toLowerCase())
|
||||
);
|
||||
}, [styleSearch]);
|
||||
|
||||
const groupedStyles = useMemo(() => {
|
||||
return filteredStyles.reduce((acc, curr) => {
|
||||
const cat = curr.category || "Diğer";
|
||||
if (!acc[cat]) acc[cat] = [];
|
||||
acc[cat].push(curr);
|
||||
return acc;
|
||||
}, {} as Record<string, typeof videoStyles>);
|
||||
}, [filteredStyles]);
|
||||
|
||||
const canProceed = currentStep === 0 ? topic.trim().length >= 5 : true;
|
||||
const isGenerating = createProject.isPending;
|
||||
|
||||
@@ -211,28 +280,81 @@ export default function NewProjectPage() {
|
||||
>
|
||||
{/* Video Stili */}
|
||||
<div>
|
||||
<label className="text-sm font-medium text-[var(--color-text-secondary)] mb-3 block">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2 mb-3">
|
||||
<label className="text-sm font-medium text-[var(--color-text-secondary)] block">
|
||||
<Palette size={14} className="inline mr-1.5 text-violet-400" />
|
||||
Video Stili
|
||||
</label>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
|
||||
{videoStyles.map((s) => (
|
||||
<div className="relative w-full sm:w-56">
|
||||
<div className="absolute inset-y-0 left-0 pl-2.5 flex items-center pointer-events-none">
|
||||
<Search size={14} className="text-[var(--color-text-ghost)]" />
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Stil ara..."
|
||||
value={styleSearch}
|
||||
onChange={(e) => setStyleSearch(e.target.value)}
|
||||
className="w-full pl-8 pr-3 py-1.5 bg-[var(--color-bg-surface)] border border-[var(--color-border-faint)] rounded-md text-xs text-[var(--color-text-primary)] placeholder:text-[var(--color-text-ghost)] focus:outline-none focus:border-violet-500/50"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 max-h-[360px] overflow-y-auto pr-1 custom-scrollbar">
|
||||
{Object.entries(groupedStyles).map(([category, items]) => {
|
||||
const isExpanded = styleSearch ? true : expandedCategory === category;
|
||||
return (
|
||||
<div key={category} className="bg-[var(--color-bg-surface)] border border-[var(--color-border-faint)] rounded-xl overflow-hidden">
|
||||
<button
|
||||
onClick={() => !styleSearch && setExpandedCategory(isExpanded ? null : category)}
|
||||
className="w-full flex items-center justify-between p-3 bg-[var(--color-bg-elevated)] hover:bg-[var(--color-bg-surface-hover)] transition-colors"
|
||||
>
|
||||
<span className="text-sm font-medium text-[var(--color-text-secondary)]">{category} <span className="text-[11px] text-[var(--color-text-ghost)] ml-1">({items.length})</span></span>
|
||||
{!styleSearch && (
|
||||
<ChevronDown
|
||||
size={16}
|
||||
className={cn("text-[var(--color-text-ghost)] transition-transform duration-200", isExpanded && "rotate-180")}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<AnimatePresence initial={false}>
|
||||
{isExpanded && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: "auto", opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
<div className="p-3 pt-0 mt-3 grid grid-cols-2 sm:grid-cols-3 gap-2">
|
||||
{items.map((s) => (
|
||||
<button
|
||||
key={s.id}
|
||||
onClick={() => setStyle(s.id)}
|
||||
className={cn(
|
||||
"flex flex-col items-start gap-1 p-3 rounded-xl text-left transition-all",
|
||||
"flex flex-col items-start gap-1 p-2.5 rounded-xl text-left transition-all",
|
||||
style === s.id
|
||||
? "bg-violet-500/12 border border-violet-500/30 glow-violet"
|
||||
: "bg-[var(--color-bg-surface)] border border-[var(--color-border-faint)] hover:border-[var(--color-border-default)]"
|
||||
: "bg-[var(--color-bg-base)] border border-[var(--color-border-faint)] hover:border-[var(--color-border-default)]"
|
||||
)}
|
||||
>
|
||||
<span className="text-xl mb-0.5">{s.emoji}</span>
|
||||
<span className="text-sm font-medium">{s.label}</span>
|
||||
<span className="text-[10px] text-[var(--color-text-ghost)]">{s.desc}</span>
|
||||
<span className="text-xs font-semibold leading-tight">{s.label}</span>
|
||||
<span className="text-[10px] text-[var(--color-text-ghost)] leading-tight">{s.desc}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{Object.keys(groupedStyles).length === 0 && (
|
||||
<div className="text-center py-8 text-[var(--color-text-ghost)] text-sm">
|
||||
"{styleSearch}" için sonuç bulunamadı.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Süre */}
|
||||
|
||||
@@ -90,11 +90,6 @@ export default function XToVideoPage() {
|
||||
toast.success("Tweet → Video projesi oluşturuldu!");
|
||||
const projectId = result?.id;
|
||||
if (projectId) {
|
||||
// Otomatik senaryo üretimini tetikle
|
||||
const { projectsApi } = await import("@/lib/api/api-service");
|
||||
projectsApi.generateScript(projectId).catch((err) => {
|
||||
console.error("Tweet→Video senaryo üretimi başlatılamadı:", err);
|
||||
});
|
||||
router.push(`/dashboard/projects/${projectId}`);
|
||||
} else {
|
||||
router.push("/dashboard/projects");
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Pencil, Check, X, RefreshCw, Clock, ArrowRight, Wand2, Image, Mic } from 'lucide-react';
|
||||
import { Pencil, Check, X, RefreshCw, Clock, ArrowRight, Wand2, Image as ImageIcon, Mic, Maximize2 } from 'lucide-react';
|
||||
|
||||
interface SceneCardProps {
|
||||
scene: {
|
||||
@@ -19,13 +19,28 @@ interface SceneCardProps {
|
||||
isEditable: boolean;
|
||||
onUpdate?: (sceneId: string, data: { narrationText?: string; visualPrompt?: string; subtitleText?: string }) => void;
|
||||
onRegenerate?: (sceneId: string) => void;
|
||||
onGenerateImage?: (sceneId: string, customPrompt?: string) => void;
|
||||
onUpscaleImage?: (sceneId: string) => void;
|
||||
isRegenerating?: boolean;
|
||||
isGeneratingImage?: boolean;
|
||||
isUpscalingImage?: boolean;
|
||||
}
|
||||
|
||||
export function SceneCard({ scene, isEditable, onUpdate, onRegenerate, isRegenerating }: SceneCardProps) {
|
||||
export function SceneCard({
|
||||
scene,
|
||||
isEditable,
|
||||
onUpdate,
|
||||
onRegenerate,
|
||||
onGenerateImage,
|
||||
onUpscaleImage,
|
||||
isRegenerating,
|
||||
isGeneratingImage,
|
||||
isUpscalingImage,
|
||||
}: SceneCardProps) {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [editNarration, setEditNarration] = useState(scene.narrationText);
|
||||
const [editVisual, setEditVisual] = useState(scene.visualPrompt);
|
||||
const [lightboxOpen, setLightboxOpen] = useState(false);
|
||||
|
||||
const handleSave = () => {
|
||||
onUpdate?.(scene.id, {
|
||||
@@ -33,6 +48,7 @@ export function SceneCard({ scene, isEditable, onUpdate, onRegenerate, isRegener
|
||||
visualPrompt: editVisual,
|
||||
subtitleText: editNarration,
|
||||
});
|
||||
// If user edited visual prompt, and maybe wants to generate, they can click generate visual later.
|
||||
setIsEditing(false);
|
||||
};
|
||||
|
||||
@@ -42,6 +58,8 @@ export function SceneCard({ scene, isEditable, onUpdate, onRegenerate, isRegener
|
||||
setIsEditing(false);
|
||||
};
|
||||
|
||||
const thumbnailAsset = scene.mediaAssets?.find(a => a.type === 'THUMBNAIL');
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
layout
|
||||
@@ -119,7 +137,7 @@ export function SceneCard({ scene, isEditable, onUpdate, onRegenerate, isRegener
|
||||
{/* 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
|
||||
<ImageIcon size={12} /> Görsel Prompt
|
||||
</label>
|
||||
<textarea
|
||||
value={editVisual}
|
||||
@@ -160,30 +178,71 @@ export function SceneCard({ scene, isEditable, onUpdate, onRegenerate, isRegener
|
||||
{/* 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" />
|
||||
<ImageIcon size={11} className="text-cyan-400" />
|
||||
</div>
|
||||
<p className="text-xs text-[var(--color-text-ghost)] leading-relaxed italic">
|
||||
<div className="flex-1 group/prompt relative">
|
||||
<p className="text-xs text-[var(--color-text-ghost)] leading-relaxed italic pr-6">
|
||||
{scene.visualPrompt}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(scene.visualPrompt);
|
||||
}}
|
||||
className="absolute top-0 right-0 p-1 opacity-0 group-hover/prompt:opacity-100 transition-opacity bg-[var(--color-bg-elevated)] rounded-md text-[var(--color-text-muted)] hover:text-cyan-400 hover:bg-cyan-500/10"
|
||||
title="Prompt'u Kopyala"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect width="14" height="14" x="8" y="8" rx="2" ry="2" />
|
||||
<path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 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" />
|
||||
{/* Görsel / Upscale Alanı */}
|
||||
<div className="flex flex-col gap-2 pt-2">
|
||||
{thumbnailAsset?.url ? (
|
||||
<div className="relative group/thumb rounded-lg overflow-hidden border border-[var(--color-border-faint)] aspect-video max-w-sm">
|
||||
<img
|
||||
src={thumbnailAsset.url}
|
||||
alt="Scene Thumbnail"
|
||||
className="w-full h-full object-cover cursor-pointer hover:scale-105 transition-transform duration-500"
|
||||
onClick={() => setLightboxOpen(true)}
|
||||
/>
|
||||
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover/thumb:opacity-100 transition-opacity flex items-center justify-center pointer-events-none">
|
||||
<Maximize2 size={24} className="text-white" />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Wand2 size={14} className="text-[var(--color-text-ghost)]" />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<div className="rounded-lg border border-dashed border-[var(--color-border-faint)] bg-[var(--color-bg-deep)] aspect-video max-w-sm flex flex-col items-center justify-center p-4">
|
||||
<ImageIcon size={24} className="text-[var(--color-text-ghost)] mb-2" />
|
||||
<p className="text-xs text-[var(--color-text-muted)] text-center">Görsel Henüz Üretilmedi</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isEditable && (
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<button
|
||||
onClick={() => onGenerateImage?.(scene.id, scene.visualPrompt)}
|
||||
disabled={isGeneratingImage || isUpscalingImage}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-emerald-500/15 text-emerald-400 text-xs font-medium hover:bg-emerald-500/25 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{isGeneratingImage ? <RefreshCw size={13} className="animate-spin" /> : <ImageIcon size={13} />}
|
||||
{thumbnailAsset ? "Görseli Yeniden Üret" : "Görsel Üret"}
|
||||
</button>
|
||||
{thumbnailAsset?.url && (
|
||||
<button
|
||||
onClick={() => onUpscaleImage?.(scene.id)}
|
||||
disabled={isUpscalingImage || isGeneratingImage}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-orange-500/15 text-orange-400 text-xs font-medium hover:bg-orange-500/25 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{isUpscalingImage ? <RefreshCw size={13} className="animate-spin" /> : <Wand2 size={13} />}
|
||||
Upscale (4K)
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
@@ -191,6 +250,36 @@ export function SceneCard({ scene, isEditable, onUpdate, onRegenerate, isRegener
|
||||
|
||||
{/* 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" />
|
||||
|
||||
{/* Lightbox Modal */}
|
||||
<AnimatePresence>
|
||||
{lightboxOpen && thumbnailAsset?.url && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
onClick={() => setLightboxOpen(false)}
|
||||
className="fixed inset-0 z-[100] flex items-center justify-center bg-black/80 backdrop-blur-sm p-4 md:p-10 cursor-zoom-out"
|
||||
>
|
||||
<motion.img
|
||||
initial={{ scale: 0.9, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
exit={{ scale: 0.9, opacity: 0 }}
|
||||
transition={{ type: "spring", damping: 25, stiffness: 300 }}
|
||||
src={thumbnailAsset.url}
|
||||
alt="Fullscreen Scene"
|
||||
className="max-w-full max-h-full object-contain rounded-xl shadow-2xl"
|
||||
onClick={(e) => e.stopPropagation()} // Click image prevents closing
|
||||
/>
|
||||
<button
|
||||
onClick={() => setLightboxOpen(false)}
|
||||
className="absolute top-6 right-6 p-2 rounded-full bg-black/50 text-white/70 hover:text-white hover:bg-black/70 transition-colors"
|
||||
>
|
||||
<X size={24} />
|
||||
</button>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -146,6 +146,54 @@ export function useApproveAndQueue() {
|
||||
});
|
||||
}
|
||||
|
||||
/** Sahne güncelleme (narrasyon, prompt) */
|
||||
export function useUpdateScene() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ projectId, sceneId, data }: { projectId: string; sceneId: string; data: any }) =>
|
||||
projectsApi.updateScene(projectId, sceneId, data),
|
||||
onSuccess: (updatedScene, variables) => {
|
||||
qc.invalidateQueries({ queryKey: queryKeys.projects.detail(variables.projectId) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Sahneyi AI ile yeniden üretme (Senaryo) */
|
||||
export function useRegenerateScene() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ projectId, sceneId }: { projectId: string; sceneId: string }) =>
|
||||
projectsApi.regenerateScene(projectId, sceneId),
|
||||
onSuccess: (updatedScene, variables) => {
|
||||
qc.invalidateQueries({ queryKey: queryKeys.projects.detail(variables.projectId) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Sahne görseli oluştur (Gemini) */
|
||||
export function useGenerateSceneImage() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ projectId, sceneId, customPrompt }: { projectId: string; sceneId: string; customPrompt?: string }) =>
|
||||
projectsApi.generateSceneImage(projectId, sceneId, customPrompt),
|
||||
onSuccess: (updatedScene, variables) => {
|
||||
qc.invalidateQueries({ queryKey: queryKeys.projects.detail(variables.projectId) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Sahne görselini upscale (4K) yap */
|
||||
export function useUpscaleSceneImage() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ projectId, sceneId }: { projectId: string; sceneId: string }) =>
|
||||
projectsApi.upscaleSceneImage(projectId, sceneId),
|
||||
onSuccess: (updatedScene, variables) => {
|
||||
qc.invalidateQueries({ queryKey: queryKeys.projects.detail(variables.projectId) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// CREDITS — Kredi hook'ları
|
||||
// ═══════════════════════════════════════════════════════
|
||||
|
||||
@@ -299,6 +299,12 @@ export const projectsApi = {
|
||||
|
||||
regenerateScene: (projectId: string, sceneId: string) =>
|
||||
apiClient.post<Scene>(`/projects/${projectId}/scenes/${sceneId}/regenerate`).then((r) => r.data),
|
||||
|
||||
generateSceneImage: (projectId: string, sceneId: string, customPrompt?: string) =>
|
||||
apiClient.post<Scene>(`/projects/${projectId}/scenes/${sceneId}/generate-image`, { customPrompt }).then((r) => r.data),
|
||||
|
||||
upscaleSceneImage: (projectId: string, sceneId: string) =>
|
||||
apiClient.post<Scene>(`/projects/${projectId}/scenes/${sceneId}/upscale-image`).then((r) => r.data),
|
||||
};
|
||||
|
||||
// Backend path: /billing/credits/balance (billing controller prefix)
|
||||
|
||||
31
test-fe-api.js
Normal file
31
test-fe-api.js
Normal file
@@ -0,0 +1,31 @@
|
||||
const axios = require('axios');
|
||||
|
||||
async function test() {
|
||||
const login = await axios.post('http://localhost:3000/api/auth/login', {
|
||||
email: 'admin@contentgen.ai',
|
||||
password: 'Admin123!',
|
||||
});
|
||||
const token = login.data.data.accessToken;
|
||||
|
||||
// Simulate interceptor
|
||||
let responseData = await axios.get('http://localhost:3000/api/projects', {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
}).then(r => {
|
||||
let data = r.data;
|
||||
if (data && typeof data === 'object' && 'success' in data && 'data' in data) {
|
||||
data = data.data;
|
||||
}
|
||||
return data;
|
||||
});
|
||||
|
||||
console.log("Interceptor Output:", JSON.stringify(responseData, null, 2).substring(0, 500));
|
||||
|
||||
const raw = responseData;
|
||||
const rawProjects = raw?.data ?? raw ?? [];
|
||||
const projects = Array.isArray(rawProjects) ? rawProjects : [];
|
||||
|
||||
console.log("Is array?", Array.isArray(rawProjects));
|
||||
console.log("Projects length:", projects.length);
|
||||
}
|
||||
|
||||
test();
|
||||
47
test-frontend-useProjects.js
Normal file
47
test-frontend-useProjects.js
Normal file
@@ -0,0 +1,47 @@
|
||||
import axios from 'axios';
|
||||
|
||||
// Bu bir simulasyon, frontend API klientinin nasil calistigini gosterecek.
|
||||
const client = axios.create({ baseURL: 'http://localhost:3000/api' });
|
||||
client.interceptors.response.use(
|
||||
(response) => {
|
||||
if (
|
||||
response.data &&
|
||||
typeof response.data === 'object' &&
|
||||
'success' in response.data &&
|
||||
'data' in response.data
|
||||
) {
|
||||
response.data = response.data.data;
|
||||
}
|
||||
return response;
|
||||
}
|
||||
);
|
||||
|
||||
async function test() {
|
||||
const loginRes = await axios.post('http://localhost:3000/api/auth/login', {
|
||||
email: 'admin@contentgen.ai',
|
||||
password: 'Admin123!'
|
||||
});
|
||||
const token = loginRes.data.accessToken;
|
||||
|
||||
if (!token) {
|
||||
console.log("No token");
|
||||
return;
|
||||
}
|
||||
|
||||
client.interceptors.request.use((config) => {
|
||||
config.headers['Authorization'] = `Bearer ${token}`;
|
||||
return config;
|
||||
});
|
||||
|
||||
const res = await client.get('/projects?limit=100');
|
||||
const rData = res.data;
|
||||
console.log("rData structure:", typeof rData, Object.keys(rData));
|
||||
|
||||
const raw = rData;
|
||||
const rawProjects = raw?.data ?? raw ?? [];
|
||||
const projects = Array.isArray(rawProjects) ? rawProjects : [];
|
||||
|
||||
console.log("Final extracted projects count:", projects.length);
|
||||
}
|
||||
|
||||
test().catch(console.error);
|
||||
Reference in New Issue
Block a user