main
Some checks failed
UI Deploy (Next-Auth Support) 🎨 / build-and-deploy (push) Has been cancelled

This commit is contained in:
Harun CAN
2026-04-05 20:37:03 +03:00
parent d8f9865dcf
commit dd8878d403
10 changed files with 767 additions and 285 deletions

2
next-env.d.ts vendored
View File

@@ -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.

View File

@@ -1,231 +1,268 @@
"use client"; "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 { 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({ export default function SignInPage() {
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();
const router = useRouter(); const router = useRouter();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const { const handleSubmit = async (e: React.FormEvent) => {
handleSubmit, e.preventDefault();
register, setError("");
formState: { errors },
} = useForm<SignInForm>({ if (!email || !password) {
resolver: yupResolver(schema), setError("Email ve şifre gereklidir.");
mode: "onChange", return;
defaultValues, }
});
const onSubmit = async (formData: SignInForm) => {
try { try {
setLoading(true); setLoading(true);
const res = await signIn("credentials", { const res = await signIn("credentials", {
redirect: false, redirect: false,
email: formData.email, email,
password: formData.password, password,
}); });
if (res?.error) { if (res?.error) {
throw new Error(res.error); setError("Giriş başarısız. Email veya şifrenizi kontrol edin.");
return;
} }
router.replace("/home"); router.replace("/dashboard");
} catch (error) { } catch {
toaster.error({ setError("Bağlantı hatası. Lütfen tekrar deneyin.");
title: (error as Error).message || "Giriş yaparken hata oluştu!",
type: "error",
});
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; };
return ( return (
<Box position="relative"> <div
<Flex style={{
h={{ sm: "initial", md: "75vh", lg: "85vh" }} minHeight: "100vh",
w="100%" display: "flex",
maxW="1044px" alignItems: "center",
mx="auto" justifyContent: "center",
justifyContent="space-between" background: "linear-gradient(135deg, #0a0a14 0%, #0d0d1f 50%, #0a0a14 100%)",
mb="30px" padding: "1rem",
pt={{ sm: "100px", md: "0px" }} }}
>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
style={{
width: "100%",
maxWidth: "420px",
}}
> >
<Flex {/* Logo */}
as="form" <div style={{ textAlign: "center", marginBottom: "2rem" }}>
onSubmit={handleSubmit(onSubmit)} <div
alignItems="center" style={{
justifyContent="start" width: 56,
style={{ userSelect: "none" }} height: 56,
w={{ base: "100%", md: "50%", lg: "42%" }} borderRadius: 16,
> background: "linear-gradient(135deg, #8b5cf6, #06b6d4)",
<Flex display: "inline-flex",
direction="column" alignItems: "center",
w="100%" justifyContent: "center",
background="transparent" marginBottom: "1rem",
p="10" boxShadow: "0 8px 32px rgba(139, 92, 246, 0.3)",
mt={{ md: "150px", lg: "80px" }} }}
> >
<Heading <Sparkles size={24} color="white" />
color={{ base: "primary.400", _dark: "primary.200" }} </div>
fontSize="32px" <h1
mb="10px" style={{
fontWeight="bold" fontSize: "1.75rem",
> fontWeight: 800,
{t("auth.welcome-back")} color: "#f0f0f5",
</Heading> letterSpacing: "-0.02em",
<Text marginBottom: "0.5rem",
mb="36px" }}
ms="4px" >
color={{ base: "gray.400", _dark: "white" }} ContentGen AI
fontWeight="bold" </h1>
fontSize="14px" <p style={{ fontSize: "0.875rem", color: "#6b7280" }}>
> Hesabınıza giriş yapın
{t("auth.subtitle")} </p>
</Text> </div>
<Field
mb="24px" {/* Form Card */}
label={t("email")} <div
errorText={errors.email?.message} style={{
invalid={!!errors.email} background: "rgba(255,255,255,0.03)",
> border: "1px solid rgba(255,255,255,0.06)",
<InputGroup w="full" startElement={<MdMail size="1rem" />}> borderRadius: 20,
<Input padding: "2rem",
borderRadius="15px" backdropFilter: "blur(20px)",
fontSize="sm" }}
type="text"
placeholder={t("email")}
size="lg"
{...register("email")}
/>
</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")}
/>
</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}
type="submit"
bg="primary.400"
w="100%"
h="45px"
color="white"
_hover={{
bg: "primary.500",
}}
_active={{
bg: "primary.400",
}}
>
{t("auth.sign-in")}
</Button>
</ClientOnly>
</Field>
<Flex
flexDirection="column"
justifyContent="center"
alignItems="center"
maxW="100%"
>
<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 <form onSubmit={handleSubmit}>
bgImage={`url(${signInImage.src})`} {/* Error */}
w="100%" {error && (
h="100%" <div
bgSize="cover" style={{
bgPos="50%" padding: "0.75rem 1rem",
position="absolute" borderRadius: 12,
borderBottomLeftRadius="20px" background: "rgba(239, 68, 68, 0.1)",
/> border: "1px solid rgba(239, 68, 68, 0.2)",
</Box> color: "#f87171",
</Flex> fontSize: "0.8125rem",
</Box> marginBottom: "1.25rem",
}}
>
{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",
}}
/>
<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)")}
/>
</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"
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)",
}}
>
{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",
}}
>
ContentGen AI Studio © 2026
</p>
</motion.div>
</div>
); );
} }
export default SignInPage;

View File

@@ -18,7 +18,16 @@ import {
} from 'lucide-react'; } from 'lucide-react';
import Link from 'next/link'; import Link from 'next/link';
import { useState } from 'react'; 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 { useRenderProgress } from '@/hooks/use-render-progress';
import { SceneCard } from '@/components/project/scene-card'; import { SceneCard } from '@/components/project/scene-card';
import { RenderProgress } from '@/components/project/render-progress'; 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' }, 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> = { const videoStyles = [
CINEMATIC: '🎬 Sinematik', // Film & Sinema
DOCUMENTARY: '📹 Belgesel', { id: "CINEMATIC", label: "Sinematik", emoji: "🎬", desc: "Film kalitesinde görseller", category: "Film & Sinema" },
EDUCATIONAL: '📚 Eğitici', { id: "DOCUMENTARY", label: "Belgesel", emoji: "📹", desc: "Bilimsel ve ciddi ton", category: "Film & Sinema" },
STORYTELLING: '📖 Hikaye', { id: "STORYTELLING", label: "Hikâye Anlatımı", emoji: "📖", desc: "Anlatı odaklı, sürükleyici", category: "Film & Sinema" },
NEWS: '📰 Haber', { id: "NEWS", label: "Haber", emoji: "📰", desc: "Güncel ve bilgilendirici", category: "Film & Sinema" },
PROMOTIONAL: '📢 Tanıtım', { id: "ARTISTIC", label: "Sanatsal", emoji: "🎨", desc: "Yaratıcı ve sıra dışı", category: "Film & Sinema" },
ARTISTIC: '🎨 Sanatsal', { id: "NOIR", label: "Film Noir", emoji: "🖤", desc: "Karanlık, dramatik", category: "Film & Sinema" },
MINIMALIST: '✨ Minimalist', { 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 = { const stagger = {
hidden: { opacity: 0 }, hidden: { opacity: 0 },
@@ -75,6 +129,13 @@ export default function ProjectDetailPage() {
const approveMutation = useApproveAndQueue(); const approveMutation = useApproveAndQueue();
const deleteMutation = useDeleteProject(); 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 // WebSocket progress
const renderState = useRenderProgress( const renderState = useRenderProgress(
project?.status && ['PENDING', 'GENERATING_MEDIA', 'RENDERING'].includes(project.status) ? id : undefined, project?.status && ['PENDING', 'GENERATING_MEDIA', 'RENDERING'].includes(project.status) ? id : undefined,
@@ -93,17 +154,37 @@ export default function ProjectDetailPage() {
// Sahne yeniden üretim // Sahne yeniden üretim
const handleSceneRegenerate = async (sceneId: string) => { const handleSceneRegenerate = async (sceneId: string) => {
setRegeneratingSceneId(sceneId); setRegeneratingSceneId(sceneId);
try { regenerateSceneMutation.mutate(
await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000/api'}/projects/${id}/scenes/${sceneId}/regenerate`, { { projectId: id, sceneId },
method: 'POST', {
headers: { 'Content-Type': 'application/json' }, onSettled: () => setRegeneratingSceneId(null),
}); onSuccess: () => refetch(),
refetch(); }
} catch (err) { );
console.error('Sahne yeniden üretim hatası:', err); };
} finally {
setRegeneratingSceneId(null); // 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 // Senaryo üret
@@ -161,12 +242,24 @@ export default function ProjectDetailPage() {
const statusInfo = STATUS_MAP[project.status] || STATUS_MAP.DRAFT; const statusInfo = STATUS_MAP[project.status] || STATUS_MAP.DRAFT;
const StatusIcon = statusInfo.icon; 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 hasScript = project.scenes && project.scenes.length > 0;
const isRendering = ['PENDING', 'GENERATING_MEDIA', 'RENDERING'].includes(project.status);
const isCompleted = project.status === 'COMPLETED'; const isCompleted = project.status === 'COMPLETED';
const tweetData = project.sourceTweetData as Record<string, any> | undefined; 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 ( return (
<motion.div <motion.div
variants={stagger} variants={stagger}
@@ -246,7 +339,21 @@ export default function ProjectDetailPage() {
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
<Clock size={12} /> {project.targetDuration}s <Clock size={12} /> {project.targetDuration}s
</span> </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="uppercase text-[10px] tracking-wider">{project.language}</span>
<span className="text-[10px]"> <span className="text-[10px]">
{new Date(project.createdAt).toLocaleDateString('tr-TR', { {new Date(project.createdAt).toLocaleDateString('tr-TR', {

View File

@@ -1,6 +1,6 @@
"use client"; "use client";
import { useState } from "react"; import { useState, useMemo } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { motion, AnimatePresence } from "framer-motion"; import { motion, AnimatePresence } from "framer-motion";
import { import {
@@ -16,6 +16,8 @@ import {
Loader2, Loader2,
Check, Check,
Wand2, Wand2,
Search,
ChevronDown,
} 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";
@@ -37,12 +39,59 @@ const languages = [
]; ];
const videoStyles = [ const videoStyles = [
{ id: "CINEMATIC", label: "Sinematik", emoji: "🎬", desc: "Film kalitesinde görseller" }, // Film & Sinema
{ id: "DOCUMENTARY", label: "Belgesel", emoji: "📹", desc: "Bilimsel ve ciddi ton" }, { id: "CINEMATIC", label: "Sinematik", emoji: "🎬", desc: "Film kalitesinde görseller", category: "Film & Sinema" },
{ id: "EDUCATIONAL", label: "Eğitim", emoji: "📚", desc: "Öğretici ve açıklayıcı" }, { id: "DOCUMENTARY", label: "Belgesel", emoji: "📹", desc: "Bilimsel ve ciddi ton", category: "Film & Sinema" },
{ id: "STORYTELLING", label: "Hikaye", emoji: "📖", desc: "Anlatı odaklı, sürükleyici" }, { 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" }, { 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ışı" }, { 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 = [ const aspectRatios = [
@@ -65,6 +114,26 @@ export default function NewProjectPage() {
const [duration, setDuration] = useState(60); const [duration, setDuration] = useState(60);
const [aspectRatio, setAspectRatio] = useState("PORTRAIT_9_16"); 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 canProceed = currentStep === 0 ? topic.trim().length >= 5 : true;
const isGenerating = createProject.isPending; const isGenerating = createProject.isPending;
@@ -211,27 +280,80 @@ export default function NewProjectPage() {
> >
{/* Video Stili */} {/* Video Stili */}
<div> <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">
<Palette size={14} className="inline mr-1.5 text-violet-400" /> <label className="text-sm font-medium text-[var(--color-text-secondary)] block">
Video Stili <Palette size={14} className="inline mr-1.5 text-violet-400" />
</label> Video Stili
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2"> </label>
{videoStyles.map((s) => ( <div className="relative w-full sm:w-56">
<button <div className="absolute inset-y-0 left-0 pl-2.5 flex items-center pointer-events-none">
key={s.id} <Search size={14} className="text-[var(--color-text-ghost)]" />
onClick={() => setStyle(s.id)} </div>
className={cn( <input
"flex flex-col items-start gap-1 p-3 rounded-xl text-left transition-all", type="text"
style === s.id placeholder="Stil ara..."
? "bg-violet-500/12 border border-violet-500/30 glow-violet" value={styleSearch}
: "bg-[var(--color-bg-surface)] border border-[var(--color-border-faint)] hover:border-[var(--color-border-default)]" 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"
> />
<span className="text-xl mb-0.5">{s.emoji}</span> </div>
<span className="text-sm font-medium">{s.label}</span> </div>
<span className="text-[10px] text-[var(--color-text-ghost)]">{s.desc}</span>
</button> <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-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-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-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>
</div> </div>

View File

@@ -90,11 +90,6 @@ export default function XToVideoPage() {
toast.success("Tweet → Video projesi oluşturuldu!"); toast.success("Tweet → Video projesi oluşturuldu!");
const projectId = result?.id; const projectId = result?.id;
if (projectId) { 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}`); router.push(`/dashboard/projects/${projectId}`);
} else { } else {
router.push("/dashboard/projects"); router.push("/dashboard/projects");

View File

@@ -2,7 +2,7 @@
import { useState } from 'react'; import { useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion'; 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 { interface SceneCardProps {
scene: { scene: {
@@ -19,13 +19,28 @@ interface SceneCardProps {
isEditable: boolean; isEditable: boolean;
onUpdate?: (sceneId: string, data: { narrationText?: string; visualPrompt?: string; subtitleText?: string }) => void; onUpdate?: (sceneId: string, data: { narrationText?: string; visualPrompt?: string; subtitleText?: string }) => void;
onRegenerate?: (sceneId: string) => void; onRegenerate?: (sceneId: string) => void;
onGenerateImage?: (sceneId: string, customPrompt?: string) => void;
onUpscaleImage?: (sceneId: string) => void;
isRegenerating?: boolean; 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 [isEditing, setIsEditing] = useState(false);
const [editNarration, setEditNarration] = useState(scene.narrationText); const [editNarration, setEditNarration] = useState(scene.narrationText);
const [editVisual, setEditVisual] = useState(scene.visualPrompt); const [editVisual, setEditVisual] = useState(scene.visualPrompt);
const [lightboxOpen, setLightboxOpen] = useState(false);
const handleSave = () => { const handleSave = () => {
onUpdate?.(scene.id, { onUpdate?.(scene.id, {
@@ -33,6 +48,7 @@ export function SceneCard({ scene, isEditable, onUpdate, onRegenerate, isRegener
visualPrompt: editVisual, visualPrompt: editVisual,
subtitleText: editNarration, subtitleText: editNarration,
}); });
// If user edited visual prompt, and maybe wants to generate, they can click generate visual later.
setIsEditing(false); setIsEditing(false);
}; };
@@ -42,6 +58,8 @@ export function SceneCard({ scene, isEditable, onUpdate, onRegenerate, isRegener
setIsEditing(false); setIsEditing(false);
}; };
const thumbnailAsset = scene.mediaAssets?.find(a => a.type === 'THUMBNAIL');
return ( return (
<motion.div <motion.div
layout layout
@@ -119,7 +137,7 @@ export function SceneCard({ scene, isEditable, onUpdate, onRegenerate, isRegener
{/* Görsel prompt düzenleme */} {/* Görsel prompt düzenleme */}
<div> <div>
<label className="flex items-center gap-1.5 text-xs font-medium text-[var(--color-text-muted)] mb-1.5"> <label className="flex items-center gap-1.5 text-xs font-medium text-[var(--color-text-muted)] mb-1.5">
<Image size={12} /> Görsel Prompt <ImageIcon size={12} /> Görsel Prompt
</label> </label>
<textarea <textarea
value={editVisual} value={editVisual}
@@ -160,30 +178,71 @@ export function SceneCard({ scene, isEditable, onUpdate, onRegenerate, isRegener
{/* Görsel Prompt */} {/* Görsel Prompt */}
<div className="flex gap-2"> <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"> <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>
<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>
<p className="text-xs text-[var(--color-text-ghost)] leading-relaxed italic">
{scene.visualPrompt}
</p>
</div> </div>
{/* Medya önizleme (varsa) */} {/* Görsel / Upscale Alanı */}
{scene.mediaAssets && scene.mediaAssets.length > 0 && ( <div className="flex flex-col gap-2 pt-2">
<div className="flex gap-2 pt-1"> {thumbnailAsset?.url ? (
{scene.mediaAssets.slice(0, 3).map((asset) => ( <div className="relative group/thumb rounded-lg overflow-hidden border border-[var(--color-border-faint)] aspect-video max-w-sm">
<div <img
key={asset.id} src={thumbnailAsset.url}
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" alt="Scene Thumbnail"
> className="w-full h-full object-cover cursor-pointer hover:scale-105 transition-transform duration-500"
{asset.url ? ( onClick={() => setLightboxOpen(true)}
<img src={asset.url} alt="" className="w-full h-full object-cover" /> />
) : ( <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">
<Wand2 size={14} className="text-[var(--color-text-ghost)]" /> <Maximize2 size={24} className="text-white" />
)}
</div> </div>
))} </div>
</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> </motion.div>
)} )}
</AnimatePresence> </AnimatePresence>
@@ -191,6 +250,36 @@ export function SceneCard({ scene, isEditable, onUpdate, onRegenerate, isRegener
{/* Sahne bağlantı çizgisi */} {/* 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" /> <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> </motion.div>
); );
} }

View File

@@ -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ı // CREDITS — Kredi hook'ları
// ═══════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════

View File

@@ -299,6 +299,12 @@ export const projectsApi = {
regenerateScene: (projectId: string, sceneId: string) => regenerateScene: (projectId: string, sceneId: string) =>
apiClient.post<Scene>(`/projects/${projectId}/scenes/${sceneId}/regenerate`).then((r) => r.data), 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) // Backend path: /billing/credits/balance (billing controller prefix)

31
test-fe-api.js Normal file
View 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();

View 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);