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

View File

@@ -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",
}}
>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
style={{
width: "100%",
maxWidth: "420px",
}}
>
<Flex
as="form"
onSubmit={handleSubmit(onSubmit)}
alignItems="center"
justifyContent="start"
style={{ userSelect: "none" }}
w={{ base: "100%", md: "50%", lg: "42%" }}
>
<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"
>
{t("auth.welcome-back")}
</Heading>
<Text
mb="36px"
ms="4px"
color={{ base: "gray.400", _dark: "white" }}
fontWeight="bold"
fontSize="14px"
>
{t("auth.subtitle")}
</Text>
<Field
mb="24px"
label={t("email")}
errorText={errors.email?.message}
invalid={!!errors.email}
>
<InputGroup w="full" startElement={<MdMail size="1rem" />}>
<Input
borderRadius="15px"
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"
<Sparkles size={24} color="white" />
</div>
<h1
style={{
fontSize: "1.75rem",
fontWeight: 800,
color: "#f0f0f5",
letterSpacing: "-0.02em",
marginBottom: "0.5rem",
}}
>
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)",
}}
>
<Box
bgImage={`url(${signInImage.src})`}
w="100%"
h="100%"
bgSize="cover"
bgPos="50%"
position="absolute"
borderBottomLeftRadius="20px"
/>
</Box>
</Flex>
</Box>
<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",
}}
>
{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';
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', {

View File

@@ -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,27 +280,80 @@ export default function NewProjectPage() {
>
{/* Video Stili */}
<div>
<label className="text-sm font-medium text-[var(--color-text-secondary)] mb-3 block">
<Palette size={14} className="inline mr-1.5 text-violet-400" />
Video Stili
</label>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
{videoStyles.map((s) => (
<button
key={s.id}
onClick={() => setStyle(s.id)}
className={cn(
"flex flex-col items-start gap-1 p-3 rounded-xl text-left transition-all",
style === s.id
? "bg-violet-500/12 border border-violet-500/30 glow-violet"
: "bg-[var(--color-bg-surface)] border border-[var(--color-border-faint)] hover:border-[var(--color-border-default)]"
)}
>
<span className="text-xl mb-0.5">{s.emoji}</span>
<span className="text-sm font-medium">{s.label}</span>
<span className="text-[10px] text-[var(--color-text-ghost)]">{s.desc}</span>
</button>
))}
<div 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="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-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>

View File

@@ -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");

View File

@@ -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>
<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>
<p className="text-xs text-[var(--color-text-ghost)] leading-relaxed italic">
{scene.visualPrompt}
</p>
</div>
{/* Medya önizleme (varsa) */}
{scene.mediaAssets && scene.mediaAssets.length > 0 && (
<div className="flex gap-2 pt-1">
{scene.mediaAssets.slice(0, 3).map((asset) => (
<div
key={asset.id}
className="w-16 h-16 rounded-lg bg-[var(--color-bg-deep)] border border-[var(--color-border-faint)] flex items-center justify-center overflow-hidden"
>
{asset.url ? (
<img src={asset.url} alt="" className="w-full h-full object-cover" />
) : (
<Wand2 size={14} className="text-[var(--color-text-ghost)]" />
)}
{/* 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>
)}
</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>
);
}

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ı
// ═══════════════════════════════════════════════════════

View File

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