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

This commit is contained in:
Harun CAN
2026-03-14 14:01:11 +03:00
parent eca4b8c652
commit eba6ea9b8d
11 changed files with 1243 additions and 287 deletions

View File

@@ -3,7 +3,7 @@
import { Box, Heading, VStack, Text, Card, HStack, Badge, Button, Input, SimpleGrid, Spinner, Icon } from "@chakra-ui/react"; import { Box, Heading, VStack, Text, Card, HStack, Badge, Button, Input, SimpleGrid, Spinner, Icon } from "@chakra-ui/react";
import { LuTrendingUp, LuSearch, LuSparkles, LuRefreshCw, LuArrowRight, LuGlobe, LuHash, LuZoomIn } from "react-icons/lu"; import { LuTrendingUp, LuSearch, LuSparkles, LuRefreshCw, LuArrowRight, LuGlobe, LuHash, LuZoomIn } from "react-icons/lu";
import { toaster } from "@/components/ui/feedback/toaster"; import { toaster } from "@/components/ui/feedback/toaster";
import { useState } from "react"; import { useState, useEffect, useCallback } from "react";
import { useSession } from "next-auth/react"; import { useSession } from "next-auth/react";
import { useRouter } from "@/i18n/navigation"; import { useRouter } from "@/i18n/navigation";
@@ -19,6 +19,16 @@ interface Trend {
url?: string; url?: string;
} }
const TRENDS_CACHE_KEY = 'ch_trendAnalysisCache';
interface TrendsCache {
niche: string;
trends: Trend[];
scanCount: number;
translatedContent: Record<string, { title: string; description?: string }>;
timestamp: number;
}
export default function TrendsPage() { export default function TrendsPage() {
const { data: session } = useSession(); const { data: session } = useSession();
const router = useRouter(); const router = useRouter();
@@ -31,6 +41,45 @@ export default function TrendsPage() {
const [translatingId, setTranslatingId] = useState<string | null>(null); const [translatingId, setTranslatingId] = useState<string | null>(null);
const [translatedContent, setTranslatedContent] = useState<Record<string, { title: string; description?: string }>>({}); const [translatedContent, setTranslatedContent] = useState<Record<string, { title: string; description?: string }>>({});
// Load cached trend data from sessionStorage on mount
useEffect(() => {
try {
const cached = sessionStorage.getItem(TRENDS_CACHE_KEY);
if (cached) {
const data: TrendsCache = JSON.parse(cached);
if (Date.now() - data.timestamp < 30 * 60 * 1000) {
setNiche(data.niche || "");
setTrends(data.trends || []);
setScanCount(data.scanCount || 0);
setTranslatedContent(data.translatedContent || {});
}
}
} catch (e) {
console.warn('Failed to load cached trends:', e);
}
}, []);
// Persist trend data to sessionStorage whenever it changes
const persistToSession = useCallback(() => {
if (trends.length === 0 && !niche) return;
try {
const cache: TrendsCache = {
niche,
trends,
scanCount,
translatedContent,
timestamp: Date.now(),
};
sessionStorage.setItem(TRENDS_CACHE_KEY, JSON.stringify(cache));
} catch (e) {
console.warn('Failed to cache trends:', e);
}
}, [niche, trends, scanCount, translatedContent]);
useEffect(() => {
persistToSession();
}, [persistToSession]);
const handleScanTrends = async () => { const handleScanTrends = async () => {
if (!niche.trim()) { if (!niche.trim()) {
toaster.create({ title: "Please enter a niche or topic", type: "warning" }); toaster.create({ title: "Please enter a niche or topic", type: "warning" });

View File

@@ -11,6 +11,9 @@ const bricolage = Bricolage_Grotesque({
subsets: ['latin'], subsets: ['latin'],
}); });
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth-options";
export default async function RootLayout({ export default async function RootLayout({
children, children,
params, params,
@@ -23,6 +26,8 @@ export default async function RootLayout({
notFound(); notFound();
} }
const session = await getServerSession(authOptions);
return ( return (
<html lang={locale} dir={dir(locale)} suppressHydrationWarning data-scroll-behavior='smooth'> <html lang={locale} dir={dir(locale)} suppressHydrationWarning data-scroll-behavior='smooth'>
<head> <head>
@@ -33,7 +38,7 @@ export default async function RootLayout({
</head> </head>
<body className={bricolage.variable}> <body className={bricolage.variable}>
<NextIntlClientProvider> <NextIntlClientProvider>
<Provider>{children}</Provider> <Provider session={session}>{children}</Provider>
</NextIntlClientProvider> </NextIntlClientProvider>
</body> </body>
</html> </html>

View File

@@ -1,97 +1,6 @@
import baseUrl from "@/config/base-url";
import { authService } from "@/lib/api/example/auth/service";
import NextAuth from "next-auth"; import NextAuth from "next-auth";
import Credentials from "next-auth/providers/credentials"; import { authOptions } from "@/lib/auth-options";
const handler = NextAuth(authOptions);
function randomToken() {
return Math.random().toString(36).substring(2) + Date.now().toString(36);
}
const isMockMode = process.env.NEXT_PUBLIC_ENABLE_MOCK_MODE === "true";
const handler = NextAuth({
providers: [
Credentials({
name: "Credentials",
credentials: {
email: { label: "Email", type: "text" },
password: { label: "Password", type: "password" },
},
async authorize(credentials) {
console.log("credentials", credentials);
if (!credentials?.email || !credentials?.password) {
throw new Error("Email ve şifre gereklidir.");
}
// Eğer mock mod aktifse backend'e gitme
if (isMockMode) {
return {
id: credentials.email,
name: credentials.email.split("@")[0],
email: credentials.email,
accessToken: randomToken(),
refreshToken: randomToken(),
};
}
// Normal mod: backend'e istek at
const res = await authService.login({
email: credentials.email,
password: credentials.password,
});
console.log("res", res);
const response = res;
// Backend returns ApiResponse<TokenResponseDto>
// Structure: { data: { accessToken, refreshToken, expiresIn, user }, message, statusCode }
if (!res.success || !response?.data?.accessToken) {
throw new Error(response?.message || "Giriş başarısız");
}
const { accessToken, refreshToken, user } = response.data;
return {
id: user.id,
name: user.firstName
? `${user.firstName} ${user.lastName || ""}`.trim()
: user.email.split("@")[0],
email: user.email,
accessToken,
refreshToken,
roles: user.roles || [],
};
},
}),
],
callbacks: {
async jwt({ token, user }: any) {
if (user) {
token.accessToken = user.accessToken;
token.refreshToken = user.refreshToken;
token.id = user.id;
token.roles = user.roles;
}
return token;
},
async session({ session, token }: any) {
session.user.id = token.id;
session.user.roles = token.roles;
session.accessToken = token.accessToken;
session.refreshToken = token.refreshToken;
return session;
},
},
pages: {
signIn: "/signin",
error: "/signin",
},
session: { strategy: "jwt" },
secret: process.env.NEXTAUTH_SECRET,
});
export { handler as GET, handler as POST }; export { handler as GET, handler as POST };

View File

@@ -3,14 +3,12 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3001'; const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3000';
async function proxyRequest(request: NextRequest, pathSegments: string[]) { async function proxyRequest(request: NextRequest, pathSegments: string[]) {
const targetPath = pathSegments.join('/'); const targetPath = pathSegments.join('/');
const targetUrl = `${BACKEND_URL}/api/${targetPath}`; const targetUrl = `${BACKEND_URL}/api/${targetPath}`;
console.log(`[Proxy] Forwarding ${request.method} to ${targetUrl}`);
// Get request body for non-GET requests // Get request body for non-GET requests
let body: string | undefined; let body: string | undefined;
if (request.method !== 'GET' && request.method !== 'HEAD') { if (request.method !== 'GET' && request.method !== 'HEAD') {
@@ -73,25 +71,30 @@ async function proxyRequest(request: NextRequest, pathSegments: string[]) {
} }
} }
// Next.js 16 uses context.params directly (not a Promise) // Next.js 15+ uses context.params as a Promise
type RouteContext = { params: { path: string[] } }; type RouteContext = { params: Promise<{ path: string[] }> };
export async function GET(request: NextRequest, context: RouteContext) { export async function GET(request: NextRequest, context: RouteContext) {
return proxyRequest(request, context.params.path); const { path } = await context.params;
return proxyRequest(request, path);
} }
export async function POST(request: NextRequest, context: RouteContext) { export async function POST(request: NextRequest, context: RouteContext) {
return proxyRequest(request, context.params.path); const { path } = await context.params;
return proxyRequest(request, path);
} }
export async function PUT(request: NextRequest, context: RouteContext) { export async function PUT(request: NextRequest, context: RouteContext) {
return proxyRequest(request, context.params.path); const { path } = await context.params;
return proxyRequest(request, path);
} }
export async function PATCH(request: NextRequest, context: RouteContext) { export async function PATCH(request: NextRequest, context: RouteContext) {
return proxyRequest(request, context.params.path); const { path } = await context.params;
return proxyRequest(request, path);
} }
export async function DELETE(request: NextRequest, context: RouteContext) { export async function DELETE(request: NextRequest, context: RouteContext) {
return proxyRequest(request, context.params.path); const { path } = await context.params;
return proxyRequest(request, path);
} }

View File

@@ -1,6 +1,6 @@
"use client"; "use client";
import { Box, Text, VStack, HStack, Badge, Separator } from "@chakra-ui/react"; import { Box, Text, VStack, HStack, Badge, Separator, Button, Textarea } from "@chakra-ui/react";
import { import {
DialogBody, DialogBody,
DialogCloseTrigger, DialogCloseTrigger,
@@ -9,24 +9,32 @@ import {
DialogRoot, DialogRoot,
DialogTitle, DialogTitle,
} from "@/components/ui/overlays/dialog"; } from "@/components/ui/overlays/dialog";
import { LuCalendar, LuGlobe, LuFileText } from "react-icons/lu"; import { LuCalendar, LuGlobe, LuFileText, LuPencil, LuSave, LuHash, LuCopy, LuCheck } from "react-icons/lu";
import { useState } from "react";
import { useSession } from "next-auth/react";
import { toaster } from "@/components/ui/feedback/toaster";
interface ContentItem { interface ContentItem {
id: number; id: string;
title: string; title: string;
platform: string; platform?: string;
type?: string;
status: string; status: string;
date: string; date?: string;
createdAt?: string;
body?: string;
hashtags?: string[];
} }
interface ContentPreviewDialogProps { interface ContentPreviewDialogProps {
item: ContentItem | null; item: ContentItem | null;
open: boolean; open: boolean;
onOpenChange: (details: { open: boolean }) => void; onOpenChange: (details: { open: boolean }) => void;
onContentUpdated?: () => void;
} }
const getStatusColor = (status: string) => { const getStatusColor = (status: string) => {
switch (status) { switch (status?.toLowerCase()) {
case "published": return "green"; case "published": return "green";
case "draft": return "gray"; case "draft": return "gray";
case "scheduled": return "blue"; case "scheduled": return "blue";
@@ -35,45 +43,143 @@ const getStatusColor = (status: string) => {
} }
}; };
export function ContentPreviewDialog({ item, open, onOpenChange }: ContentPreviewDialogProps) { export function ContentPreviewDialog({ item, open, onOpenChange, onContentUpdated }: ContentPreviewDialogProps) {
const { data: session } = useSession();
const [isEditing, setIsEditing] = useState(false);
const [editedBody, setEditedBody] = useState("");
const [isSaving, setIsSaving] = useState(false);
const [copied, setCopied] = useState(false);
if (!item) return null; if (!item) return null;
const displayBody = item.body || "No content body available.";
const displayPlatform = item.type || item.platform || "Unknown";
const displayDate = item.createdAt || item.date || "";
const handleStartEdit = () => {
setEditedBody(displayBody);
setIsEditing(true);
};
const handleSave = async () => {
if (!item.id || !session?.accessToken) return;
setIsSaving(true);
try {
const res = await fetch(`/api/backend/content/${item.id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${session.accessToken}`,
},
body: JSON.stringify({ body: editedBody }),
});
if (!res.ok) throw new Error('Failed to update');
toaster.create({ title: "Saved!", description: "Content updated.", type: "success" });
setIsEditing(false);
onContentUpdated?.();
} catch (err) {
toaster.create({ title: "Save failed", type: "error" });
} finally {
setIsSaving(false);
}
};
const handleCopy = () => {
const text = isEditing ? editedBody : displayBody;
const hashtags = item.hashtags?.join(" ") || "";
navigator.clipboard.writeText(hashtags ? `${text}\n\n${hashtags}` : text);
setCopied(true);
toaster.create({ title: "Copied!", type: "success", duration: 2000 });
setTimeout(() => setCopied(false), 2000);
};
return ( return (
<DialogRoot open={open} onOpenChange={onOpenChange} size="lg"> <DialogRoot open={open} onOpenChange={onOpenChange} size="lg">
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle>{item.title}</DialogTitle> <DialogTitle>{item.title || "Content Preview"}</DialogTitle>
</DialogHeader> </DialogHeader>
<DialogBody pb={8}> <DialogBody pb={8}>
<VStack gap={4} align="stretch"> <VStack gap={4} align="stretch">
<HStack gap={4}> <HStack gap={4} flexWrap="wrap">
<Badge colorPalette={getStatusColor(item.status)} size="lg" variant="solid"> <Badge colorPalette={getStatusColor(item.status)} size="lg" variant="solid">
{item.status.toUpperCase()} {item.status?.toUpperCase()}
</Badge> </Badge>
<HStack color="fg.muted"> <HStack color="fg.muted">
<LuGlobe /> <LuGlobe />
<Text>{item.platform}</Text> <Text>{displayPlatform}</Text>
</HStack>
<HStack color="fg.muted">
<LuCalendar />
<Text>{item.date}</Text>
</HStack> </HStack>
{displayDate && (
<HStack color="fg.muted">
<LuCalendar />
<Text>{new Date(displayDate).toLocaleDateString()}</Text>
</HStack>
)}
</HStack> </HStack>
<Separator /> <Separator />
<Box p={4} bg="bg.subtle" borderRadius="md"> <Box>
<HStack mb={2} color="fg.muted"> <HStack justify="space-between" mb={2}>
<LuFileText /> <HStack color="fg.muted">
<Text fontWeight="medium">Content Preview</Text> <LuFileText />
<Text fontWeight="medium">Content</Text>
</HStack>
<HStack gap={2}>
<Button size="xs" variant="ghost" onClick={handleCopy}>
{copied ? <LuCheck /> : <LuCopy />}
{copied ? "Copied" : "Copy"}
</Button>
{isEditing ? (
<Button
size="xs"
colorPalette="green"
onClick={handleSave}
loading={isSaving}
>
<LuSave /> Save
</Button>
) : (
<Button size="xs" variant="ghost" onClick={handleStartEdit}>
<LuPencil /> Edit
</Button>
)}
</HStack>
</HStack> </HStack>
<Text>
This is a preview of the content for "{item.title}". In a real scenario, this would show the full post text, images, or video script generated by the system. {isEditing ? (
</Text> <Textarea
<Text mt={4} fontStyle="italic" color="fg.muted"> value={editedBody}
Generated content will appear here... onChange={(e) => setEditedBody(e.target.value)}
</Text> minHeight="200px"
fontFamily="mono"
fontSize="sm"
resize="vertical"
/>
) : (
<Box
p={4}
bg="bg.subtle"
borderRadius="md"
whiteSpace="pre-wrap"
fontFamily="mono"
fontSize="sm"
>
{displayBody}
</Box>
)}
</Box> </Box>
{/* Hashtags */}
{item.hashtags && item.hashtags.length > 0 && (
<Box>
<HStack mb={1} color="fg.muted">
<LuHash />
<Text fontWeight="medium" fontSize="sm">Hashtags</Text>
</HStack>
<Text color="blue.500" fontSize="sm">{item.hashtags.join(" ")}</Text>
</Box>
)}
</VStack> </VStack>
</DialogBody> </DialogBody>
<DialogCloseTrigger /> <DialogCloseTrigger />

View File

@@ -1,7 +1,7 @@
"use client"; "use client";
import { Box, Table, Badge, HStack, IconButton } from "@chakra-ui/react"; import { Box, Table, Badge, HStack, IconButton, Button } from "@chakra-ui/react";
import { useState, useEffect } from "react"; import { useState, useEffect, useCallback } from "react";
import { useSession } from "next-auth/react"; import { useSession } from "next-auth/react";
import { LuEye, LuPencil, LuTrash2, LuRefreshCw } from "react-icons/lu"; import { LuEye, LuPencil, LuTrash2, LuRefreshCw } from "react-icons/lu";
import { toaster } from "@/components/ui/feedback/toaster"; import { toaster } from "@/components/ui/feedback/toaster";
@@ -9,9 +9,8 @@ import { ContentPreviewDialog } from "./ContentPreviewDialog";
const getStatusColor = (status: string) => { const getStatusColor = (status: string) => {
switch (status) { switch (status?.toLowerCase()) {
case "published": return "green"; case "published": return "green";
case "draft": return "gray"; case "draft": return "gray";
case "scheduled": return "blue"; case "scheduled": return "blue";
@@ -27,30 +26,49 @@ export function ContentTable() {
const [contentList, setContentList] = useState<any[]>([]); const [contentList, setContentList] = useState<any[]>([]);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
useEffect(() => { const fetchContent = useCallback(async () => {
const fetchContent = async () => { if (!session?.accessToken) return;
if (!session?.accessToken) return; setIsLoading(true);
setIsLoading(true); try {
try { const res = await fetch('/api/backend/content', {
const res = await fetch('/api/backend/content', { headers: {
headers: { 'Authorization': `Bearer ${session.accessToken}`
'Authorization': `Bearer ${session.accessToken}`
}
});
if (res.ok) {
const data = await res.json();
setContentList(Array.isArray(data) ? data : []);
} }
} catch (error) { });
console.error("Failed to fetch content:", error); if (res.ok) {
} finally { const data = await res.json();
setIsLoading(false); setContentList(Array.isArray(data) ? data : []);
} }
}; } catch (error) {
console.error("Failed to fetch content:", error);
fetchContent(); } finally {
setIsLoading(false);
}
}, [session]); }, [session]);
useEffect(() => {
fetchContent();
}, [fetchContent]);
const handleDelete = async (item: any) => {
if (!session?.accessToken) return;
try {
const res = await fetch(`/api/backend/content/${item.id}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${session.accessToken}`
}
});
if (res.ok) {
toaster.create({ title: "Deleted", description: `"${item.title || 'Content'}" removed.`, type: "success" });
fetchContent();
} else {
toaster.create({ title: "Delete failed", type: "error" });
}
} catch {
toaster.create({ title: "Delete failed", type: "error" });
}
};
const handleAction = (action: string, item: any) => { const handleAction = (action: string, item: any) => {
if (action === 'View') { if (action === 'View') {
@@ -58,16 +76,30 @@ export function ContentTable() {
setIsPreviewOpen(true); setIsPreviewOpen(true);
return; return;
} }
if (action === 'Edit') {
toaster.create({ // Open the preview in edit mode
title: `${action} Action`, setSelectedItem(item);
description: `You clicked ${action} for ${item.title}`, setIsPreviewOpen(true);
type: "info", return;
}); }
if (action === 'Delete') {
handleDelete(item);
return;
}
}; };
return ( return (
<> <>
<HStack justify="flex-end" mb={3}>
<Button
size="sm"
variant="outline"
onClick={fetchContent}
loading={isLoading}
>
<LuRefreshCw /> Refresh
</Button>
</HStack>
<Box borderWidth="1px" borderRadius="lg" overflow="hidden" bg="bg.panel"> <Box borderWidth="1px" borderRadius="lg" overflow="hidden" bg="bg.panel">
<Table.Root striped> <Table.Root striped>
<Table.Header> <Table.Header>
@@ -97,7 +129,9 @@ export function ContentTable() {
</Table.Row> </Table.Row>
) : contentList.map((item) => ( ) : contentList.map((item) => (
<Table.Row key={item.id}> <Table.Row key={item.id}>
<Table.Cell fontWeight="medium">{item.title}</Table.Cell> <Table.Cell fontWeight="medium">
{item.title || item.body?.substring(0, 60) + '...' || 'Untitled'}
</Table.Cell>
<Table.Cell>{item.type || item.platform}</Table.Cell> <Table.Cell>{item.type || item.platform}</Table.Cell>
<Table.Cell> <Table.Cell>
<Badge colorPalette={getStatusColor(item.status)} variant="solid"> <Badge colorPalette={getStatusColor(item.status)} variant="solid">
@@ -145,6 +179,7 @@ export function ContentTable() {
item={selectedItem} item={selectedItem}
open={isPreviewOpen} open={isPreviewOpen}
onOpenChange={(e) => setIsPreviewOpen(e.open)} onOpenChange={(e) => setIsPreviewOpen(e.open)}
onContentUpdated={fetchContent}
/> />
</> </>
); );

View File

@@ -1,7 +1,8 @@
"use client"; "use client";
import { Box, Heading, Steps, VStack, Input, Button, Text, HStack, Textarea, Card, SimpleGrid, Badge, Progress, Spinner } from "@chakra-ui/react"; import { Box, Heading, VStack, Input, Button, Text, HStack, Textarea, Card, SimpleGrid, Badge, Progress, Spinner, NativeSelect } from "@chakra-ui/react";
import { LuSparkles, LuArrowRight, LuCheck, LuHash, LuRefreshCw } from "react-icons/lu"; import { LuSparkles, LuArrowRight, LuCheck, LuHash, LuPen, LuMegaphone } from "react-icons/lu";
import { StepsRoot, StepsList, StepsItem } from "@/components/ui/disclosure/steps";
import { toaster } from "@/components/ui/feedback/toaster"; import { toaster } from "@/components/ui/feedback/toaster";
@@ -31,6 +32,18 @@ interface Niche {
description?: string; description?: string;
} }
interface WritingStyle {
id: string;
name: string;
description: string;
}
interface CtaType {
id: string;
name: string;
description: string;
}
export function GenerateWizard() { export function GenerateWizard() {
const { data: session } = useSession(); const { data: session } = useSession();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
@@ -46,11 +59,16 @@ export function GenerateWizard() {
const [generationProgress, setGenerationProgress] = useState(0); const [generationProgress, setGenerationProgress] = useState(0);
const router = useRouter(); const router = useRouter();
const [niches, setNiches] = useState<Niche[]>([]); const [niches, setNiches] = useState<Niche[]>([]);
const [isLoadingNiches, setIsLoadingNiches] = useState(false); const [isLoadingNiches, setIsLoadingNiches] = useState(false);
const [writingStyles, setWritingStyles] = useState<WritingStyle[]>([]);
const [ctaTypes, setCtaTypes] = useState<CtaType[]>([]);
const [selectedWritingStyle, setSelectedWritingStyle] = useState("");
const [selectedCtaType, setSelectedCtaType] = useState("");
const [generatedBundle, setGeneratedBundle] = useState<any>(null); const [generatedBundle, setGeneratedBundle] = useState<any>(null);
const [masterContentId, setMasterContentId] = useState<string | null>(null);
// Read trend data from URL params (from trends page) // Read trend data from URL params (from trends page)
useEffect(() => { useEffect(() => {
@@ -85,11 +103,20 @@ export function GenerateWizard() {
// Using a relative path which the proxy should handle // Using a relative path which the proxy should handle
const res = await fetch('/api/backend/content-generation/niches', { headers }); const res = await fetch('/api/backend/content-generation/niches', { headers });
if (res.ok) { if (res.ok) {
const data = await res.json(); const responseData = await res.json();
if (Array.isArray(data)) {
setNiches(data); if (responseData.success === false) {
console.error("Backend error for niches:", responseData.message);
setNiches([]);
return;
}
const nichesData = responseData.data || responseData;
if (Array.isArray(nichesData)) {
setNiches(nichesData);
} else { } else {
console.error("API returned non-array for niches:", data); console.error("API returned non-array for niches:", JSON.stringify(responseData, null, 2));
setNiches([]); setNiches([]);
} }
} else { } else {
@@ -108,6 +135,29 @@ export function GenerateWizard() {
} }
}, [session]); }, [session]);
// Fetch writing styles & CTA types on mount
useEffect(() => {
async function fetchStylesAndCtas() {
try {
const [stylesRes, ctasRes] = await Promise.all([
fetch('/api/backend/content-generation/writing-styles'),
fetch('/api/backend/content-generation/cta-types'),
]);
if (stylesRes.ok) {
const styles = await stylesRes.json();
setWritingStyles(Array.isArray(styles) ? styles : []);
}
if (ctasRes.ok) {
const ctas = await ctasRes.json();
setCtaTypes(Array.isArray(ctas) ? ctas : []);
}
} catch (e) {
console.error('Failed to fetch styles/ctas:', e);
}
}
fetchStylesAndCtas();
}, []);
const togglePlatform = (id: string) => { const togglePlatform = (id: string) => {
setSelectedPlatforms(prev => setSelectedPlatforms(prev =>
prev.includes(id) ? prev.filter(p => p !== id) : [...prev, id] prev.includes(id) ? prev.filter(p => p !== id) : [...prev, id]
@@ -144,6 +194,8 @@ export function GenerateWizard() {
setGenerationProgress(10); setGenerationProgress(10);
setGenerationStage("Researching topic and niche..."); setGenerationStage("Researching topic and niche...");
// Removed "warn if not logged in" toast
try { try {
const payload = { const payload = {
topic, topic,
@@ -154,7 +206,9 @@ export function GenerateWizard() {
includeResearch: true, includeResearch: true,
includeHashtags: true, includeHashtags: true,
brandVoice: "friendly-expert", brandVoice: "friendly-expert",
count: 1 count: 1,
writingStyle: selectedWritingStyle || undefined,
ctaType: selectedCtaType || undefined,
}; };
// Simulated progress stages since the backend is a single call // Simulated progress stages since the backend is a single call
@@ -183,66 +237,99 @@ export function GenerateWizard() {
clearInterval(progressInterval); clearInterval(progressInterval);
if (!response.ok) throw new Error("Generation failed"); if (!response.ok) {
let errorDetails = `Status: ${response.status} ${response.statusText}`;
try {
const errorBody = await response.text();
console.error('API Error Body:', errorBody);
// Try to parse JSON error message from backend
const errorJson = JSON.parse(errorBody);
if (errorJson.message) {
errorDetails = errorJson.message;
} else if (errorJson.error) {
errorDetails = typeof errorJson.error === 'string' ? errorJson.error : JSON.stringify(errorJson.error);
}
} catch (e) {
console.error('Failed to parse error body:', e);
}
throw new Error(errorDetails);
}
setGenerationProgress(95); const responseData = await response.json();
setGenerationStage("Saving to library...");
const data = await response.json(); if (responseData.success === false) {
let errMessage = responseData.message || 'Generation failed';
if (Array.isArray(responseData.errors) && responseData.errors.length > 0) {
errMessage += ': ' + responseData.errors.join(', ');
}
throw new Error(errMessage);
}
const bundle = responseData.data || responseData;
const savedMasterContentId = bundle.masterContentId || responseData.masterContentId || null;
setGenerationProgress(100); setGenerationProgress(100);
setGenerationStage("Success!"); setGenerationStage("Success!");
// Give a small delay to show 100% // Give a small delay to show 100%
setTimeout(() => { setTimeout(() => {
setGeneratedBundle(data); setGeneratedBundle(bundle);
setMasterContentId(savedMasterContentId);
setIsGenerating(false); setIsGenerating(false);
const isLoggedIn = !!session?.user;
toaster.create({ toaster.create({
title: "Content Generated", title: isLoggedIn ? "✅ Content Generated & Saved" : "Content Generated",
description: "Your content is ready and saved to library!", description: isLoggedIn
? "Your content is ready and saved to library!"
: "Content generated but not saved — log in to save to library.",
type: "success" type: "success"
}); });
// Auto-redirect after success
if (data.masterContentId) {
router.push(`/[locale]/content?id=${data.masterContentId}`);
}
}, 500); }, 500);
} catch (error) { } catch (error: any) {
console.error(error); console.error('Generation Error:', error);
setIsGenerating(false); setIsGenerating(false);
let errorMessage = "Failed to generate content. Please try again.";
if (error instanceof Error) {
errorMessage = error.message;
}
toaster.create({ toaster.create({
title: "Error", title: "Generation Failed",
description: "Failed to generate content. Please try again.", description: errorMessage,
type: "error" type: "error",
duration: 5000,
}); });
} }
}; };
if (generatedBundle) { if (generatedBundle) {
return <GeneratedContentResult bundle={generatedBundle} onReset={() => { return <GeneratedContentResult bundle={generatedBundle} masterContentId={masterContentId} onReset={() => {
setGeneratedBundle(null); setGeneratedBundle(null);
setMasterContentId(null);
setActiveStep(0); setActiveStep(0);
setTopic("");
setSelectedPlatforms([]);
}} />; }} />;
} }
return ( return (
<Box> <Box>
<Box mb={8}> <Box mb={8}>
<Steps.Root index={activeStep} count={STEPS.length}> <StepsRoot step={activeStep} count={STEPS.length}>
<Steps.List> <StepsList>
{STEPS.map((step, index) => ( {STEPS.map((step, index) => (
<Steps.Item key={index} title={step.title} icon={<Box>{index + 1}</Box>}> <StepsItem
{step.description} key={index}
</Steps.Item> index={index}
title={step.title}
description={step.description}
icon={<Box>{index + 1}</Box>}
/>
))} ))}
</Steps.List> </StepsList>
</Steps.Root> </StepsRoot>
</Box> </Box>
<Box p={6} borderWidth="1px" borderRadius="lg" bg="bg.panel"> <Box p={6} borderWidth="1px" borderRadius="lg" bg="bg.panel">
@@ -334,6 +421,50 @@ export function GenerateWizard() {
))} ))}
</SimpleGrid> </SimpleGrid>
</Box> </Box>
{/* Writing Style Selector */}
<Box>
<HStack mb={2}>
<LuPen />
<Text fontWeight="medium">Yazım Stili (Opsiyonel)</Text>
</HStack>
<NativeSelect.Root size="lg">
<NativeSelect.Field
value={selectedWritingStyle}
onChange={(e: React.ChangeEvent<HTMLSelectElement>) => setSelectedWritingStyle(e.target.value)}
placeholder="Bir yazım stili seçin..."
>
<option value="">Varsayılan (Genel)</option>
{writingStyles.map((style) => (
<option key={style.id} value={style.id}>
{style.name} {style.description}
</option>
))}
</NativeSelect.Field>
</NativeSelect.Root>
</Box>
{/* CTA Type Selector */}
<Box>
<HStack mb={2}>
<LuMegaphone />
<Text fontWeight="medium">CTA Tipi (Opsiyonel)</Text>
</HStack>
<NativeSelect.Root size="lg">
<NativeSelect.Field
value={selectedCtaType}
onChange={(e: React.ChangeEvent<HTMLSelectElement>) => setSelectedCtaType(e.target.value)}
placeholder="Bir CTA tipi seçin..."
>
<option value="">Varsayılan (Genel CTA)</option>
{ctaTypes.map((cta) => (
<option key={cta.id} value={cta.id}>
{cta.name} {cta.description}
</option>
))}
</NativeSelect.Field>
</NativeSelect.Root>
</Box>
</VStack> </VStack>
)} )}
@@ -374,6 +505,8 @@ export function GenerateWizard() {
<VStack align="start" gap={2}> <VStack align="start" gap={2}>
<Text><strong>Topic:</strong> {topic}</Text> <Text><strong>Topic:</strong> {topic}</Text>
<Text><strong>Niche:</strong> {niches?.find((n) => n.id === selectedNiche)?.name || selectedNiche || "General"}</Text> <Text><strong>Niche:</strong> {niches?.find((n) => n.id === selectedNiche)?.name || selectedNiche || "General"}</Text>
<Text><strong>Yazım Stili:</strong> {writingStyles.find(s => s.id === selectedWritingStyle)?.name || "Varsayılan"}</Text>
<Text><strong>CTA Tipi:</strong> {ctaTypes.find(c => c.id === selectedCtaType)?.name || "Genel CTA"}</Text>
<Text><strong>Platforms:</strong> {selectedPlatforms.map(p => PLATFORMS.find(pl => pl.id === p)?.name).join(", ")}</Text> <Text><strong>Platforms:</strong> {selectedPlatforms.map(p => PLATFORMS.find(pl => pl.id === p)?.name).join(", ")}</Text>
</VStack> </VStack>
</Card.Body> </Card.Body>

View File

@@ -1,9 +1,20 @@
"use client"; "use client";
import { Box, Heading, VStack, Text, Card, HStack, Badge, Button, Tabs, SimpleGrid, Progress, Icon } from "@chakra-ui/react"; import { Box, Heading, VStack, Text, Card, HStack, Badge, Button, Tabs, SimpleGrid, Progress, Textarea } from "@chakra-ui/react";
import { LuCopy, LuCheck, LuFileText, LuBrainCircuit, LuLayers, LuSparkles, LuSearch, LuZap, LuDownload, LuHash } from "react-icons/lu"; import { LuCopy, LuCheck, LuFileText, LuBrainCircuit, LuLayers, LuSparkles, LuSearch, LuZap, LuDownload, LuHash, LuSave, LuPencil, LuImage, LuRefreshCw, LuPlus } from "react-icons/lu";
import {
DialogBody,
DialogCloseTrigger,
DialogContent,
DialogFooter,
DialogHeader,
DialogRoot,
DialogTitle,
} from "@/components/ui/overlays/dialog";
import { Input } from "@chakra-ui/react";
import { toaster } from "@/components/ui/feedback/toaster"; import { toaster } from "@/components/ui/feedback/toaster";
import { useState } from "react"; import { useState } from "react";
import { useSession } from "next-auth/react";
// Matches backend GeneratedContentBundle // Matches backend GeneratedContentBundle
interface GeneratedContentBundle { interface GeneratedContentBundle {
@@ -22,8 +33,8 @@ interface GeneratedContentBundle {
hashtags: string[]; hashtags: string[];
characterCount: number; characterCount: number;
postingRecommendation: string; postingRecommendation: string;
// mediaRecommendations is string[] in backend
mediaRecommendations: string[]; mediaRecommendations: string[];
imageUrl?: string;
}[]; }[];
variations?: { variations?: {
original: string; original: string;
@@ -32,6 +43,8 @@ interface GeneratedContentBundle {
seo?: { seo?: {
score: number; score: number;
keywords: string[]; keywords: string[];
questions?: string[];
longTail?: { keyword: string; estimatedVolume?: number; competitionLevel?: string }[];
suggestions: string[]; suggestions: string[];
meta: { title: string; description: string }; meta: { title: string; description: string };
}; };
@@ -46,11 +59,36 @@ interface GeneratedContentBundle {
interface Props { interface Props {
bundle: GeneratedContentBundle; bundle: GeneratedContentBundle;
masterContentId?: string | null;
onReset: () => void; onReset: () => void;
} }
export function GeneratedContentResult({ bundle, onReset }: Props) { export function GeneratedContentResult({ bundle, masterContentId, onReset }: Props) {
const { data: session } = useSession();
const [copiedId, setCopiedId] = useState<string | null>(null); const [copiedId, setCopiedId] = useState<string | null>(null);
const [editingPlatform, setEditingPlatform] = useState<string | null>(null);
const [editedContents, setEditedContents] = useState<Record<string, string>>({});
const [editedHashtags, setEditedHashtags] = useState<Record<string, string>>({});
const [isSaving, setIsSaving] = useState(false);
const [isRegenerating, setIsRegenerating] = useState(false);
const [neuroScore, setNeuroScore] = useState(bundle.neuro?.score || 0);
const [neuroImprovements, setNeuroImprovements] = useState(bundle.neuro?.improvements || []);
// Image Search State
const [isImageSearchOpen, setIsImageSearchOpen] = useState(false);
const [imageSearchQuery, setImageSearchQuery] = useState("");
const [imageSearchResults, setImageSearchResults] = useState<string[]>([]);
const [isSearchingImages, setIsSearchingImages] = useState(false);
const [selectedImagePlatform, setSelectedImagePlatform] = useState<string | null>(null);
const [customImages, setCustomImages] = useState<Record<string, string>>({});
const getContent = (platform: string, original: string) => {
return editedContents[platform] ?? original;
};
const getHashtags = (platform: string, original: string[]) => {
return editedHashtags[platform] ?? original.join(" ");
};
const handleCopy = (text: string, id: string) => { const handleCopy = (text: string, id: string) => {
navigator.clipboard.writeText(text); navigator.clipboard.writeText(text);
@@ -63,15 +101,18 @@ export function GeneratedContentResult({ bundle, onReset }: Props) {
setTimeout(() => setCopiedId(null), 2000); setTimeout(() => setCopiedId(null), 2000);
}; };
const handleCopyWithHashtags = (content: string, hashtags: string[], id: string) => { const handleCopyWithHashtags = (platform: string, originalContent: string, originalHashtags: string[], id: string) => {
const fullText = `${content}\n\n${hashtags.join(' ')}`; const content = getContent(platform, originalContent);
const hashtags = getHashtags(platform, originalHashtags);
const fullText = `${content}\n\n${hashtags}`;
navigator.clipboard.writeText(fullText); navigator.clipboard.writeText(fullText);
setCopiedId(id + '-full'); setCopiedId(id + '-full');
toaster.create({ title: "Copied with hashtags", type: "success", duration: 2000 }); toaster.create({ title: "Copied with hashtags", type: "success", duration: 2000 });
setTimeout(() => setCopiedId(null), 2000); setTimeout(() => setCopiedId(null), 2000);
}; };
const handleDownloadText = (content: string, platform: string) => { const handleDownloadText = (platform: string, originalContent: string) => {
const content = getContent(platform, originalContent);
const blob = new Blob([content], { type: 'text/plain' }); const blob = new Blob([content], { type: 'text/plain' });
const url = window.URL.createObjectURL(blob); const url = window.URL.createObjectURL(blob);
const a = document.createElement('a'); const a = document.createElement('a');
@@ -81,6 +122,248 @@ export function GeneratedContentResult({ bundle, onReset }: Props) {
window.URL.revokeObjectURL(url); window.URL.revokeObjectURL(url);
}; };
const handleSave = async (platformName: string) => {
if (!masterContentId || masterContentId === 'not-saved') {
toaster.create({
title: "Cannot save",
description: "Content was not saved to database. Please log in and regenerate.",
type: "warning",
});
return;
}
setIsSaving(true);
try {
// First fetch content records by masterContentId
const headers: HeadersInit = { 'Content-Type': 'application/json' };
if (session?.accessToken) {
headers['Authorization'] = `Bearer ${session.accessToken}`;
}
// Get master content details to find platform-specific content IDs
const masterRes = await fetch(`/api/backend/content/master/${masterContentId}`, {
headers,
cache: 'no-store',
next: { revalidate: 0 }
});
if (!masterRes.ok) throw new Error('Failed to fetch master content');
const masterData = await masterRes.json();
if (masterData.success === false) {
throw new Error(masterData.message || 'Failed to fetch master content from backend');
}
// Find the content record for this platform
const contents = masterData.contents || masterData.data?.contents || [];
const platformContent = contents.find((c: any) => {
const type = c.type?.toLowerCase() || '';
const pName = platformName.toLowerCase();
const title = c.title?.toLowerCase() || '';
// Match perfectly by title suffix (e.g., "Topic - medium")
if (title.endsWith(`- ${pName}`)) return true;
if (title.includes(pName)) return true;
// Match medium to blog since backend saves medium as BLOG type
if (pName === 'medium' && type === 'blog') return true;
return type === pName;
});
if (!platformContent) {
console.error(`Save failed. platformName: ${platformName}, contents found: ${contents.length}`);
throw new Error(`No saved content found for ${platformName}`);
}
// Update the content
const editedBody = editedContents[platformName];
const editedHashtagsStr = editedHashtags[platformName];
const updatePayload: any = {};
if (editedBody !== undefined) updatePayload.body = editedBody;
const updateRes = await fetch(`/api/backend/content/${platformContent.id}`, {
method: 'PUT',
headers,
body: JSON.stringify(updatePayload),
});
if (!updateRes.ok) throw new Error('Failed to update content');
toaster.create({
title: "Saved!",
description: `${platformName} content updated successfully.`,
type: "success",
});
setEditingPlatform(null);
} catch (error: any) {
console.error('Save error:', error);
toaster.create({
title: "Save Failed",
description: error.message || "Failed to save changes.",
type: "error",
});
} finally {
setIsSaving(false);
}
};
const handleSaveAll = async () => {
const platformsWithEdits = Object.keys(editedContents);
if (platformsWithEdits.length === 0) {
toaster.create({ title: "No changes", description: "No edits to save.", type: "info" });
return;
}
for (const platform of platformsWithEdits) {
await handleSave(platform);
}
};
const handleNeuroRegenerate = async () => {
if (!bundle.platforms?.length) return;
setIsRegenerating(true);
try {
const primaryPlatform = bundle.platforms[0];
const currentContent = getContent(primaryPlatform.platform, primaryPlatform.content);
const headers: HeadersInit = { 'Content-Type': 'application/json' };
if (session?.accessToken) {
headers['Authorization'] = `Bearer ${session.accessToken}`;
}
const response = await fetch('/api/backend/content-generation/regenerate-neuro', {
method: 'POST',
headers,
body: JSON.stringify({
content: currentContent,
platform: primaryPlatform.platform,
currentScore: neuroScore,
improvements: neuroImprovements,
}),
});
if (response.ok) {
const result = await response.json();
const data = result.data || result;
// Update the content with the regenerated version
setEditedContents(prev => ({
...prev,
[primaryPlatform.platform]: data.content,
}));
const oldScore = neuroScore;
setNeuroScore(data.score);
setNeuroImprovements(data.improvements || []);
toaster.create({
title: "Neuro Optimized!",
description: `Score: ${oldScore}${data.score}/100`,
type: "success",
});
} else {
throw new Error('Failed to regenerate');
}
} catch (error) {
console.error('Neuro regeneration failed:', error);
toaster.create({
title: "Regeneration Failed",
description: "Could not optimize content. Please try again.",
type: "error",
});
} finally {
setIsRegenerating(false);
}
};
const handleSearchImages = async () => {
if (!imageSearchQuery.trim()) return;
setIsSearchingImages(true);
try {
const res = await fetch(`/api/backend/visual-generation/search?q=${encodeURIComponent(imageSearchQuery)}`);
if (!res.ok) throw new Error("Search failed");
const images = await res.json();
setImageSearchResults(images);
} catch (error) {
console.error("Image search failed:", error);
toaster.create({
title: "Search failed",
description: "Could not find images. Please try again.",
type: "error",
});
} finally {
setIsSearchingImages(false);
}
};
const handleSelectImage = async (url: string) => {
if (!selectedImagePlatform) return;
// Update local state for immediate feedback
setCustomImages(prev => ({
...prev,
[selectedImagePlatform]: url
}));
setIsImageSearchOpen(false);
toaster.create({
title: "Image Selected",
description: "Saving changes...",
type: "info",
});
// Automatically save the image update
await handleSaveImage(selectedImagePlatform, url);
};
const handleSaveImage = async (platformName: string, imageUrl: string) => {
if (!masterContentId || masterContentId === 'not-saved') return;
try {
const headers: HeadersInit = { 'Content-Type': 'application/json' };
if (session?.accessToken) headers['Authorization'] = `Bearer ${session.accessToken}`;
const masterRes = await fetch(`/api/backend/content/master/${masterContentId}`, {
headers,
cache: 'no-store',
next: { revalidate: 0 }
});
if (!masterRes.ok) throw new Error('Failed to fetch master content');
const masterData = await masterRes.json();
const contents = masterData.contents || masterData.data?.contents || [];
const platformContent = contents.find((c: any) => {
const type = c.type?.toLowerCase() || '';
const pName = platformName.toLowerCase();
const title = c.title?.toLowerCase() || '';
if (title.endsWith(`- ${pName}`)) return true;
if (pName === 'medium' && type === 'blog') return true;
return type === pName;
});
if (platformContent) {
await fetch(`/api/backend/content/${platformContent.id}`, {
method: 'PUT',
headers,
body: JSON.stringify({ imageUrl }),
});
toaster.create({
title: "Image Saved",
description: "Content image updated successfully.",
type: "success",
});
}
} catch (error) {
console.error("Failed to save image:", error);
toaster.create({
title: "Save Failed",
description: "Could not save image update.",
type: "error",
});
}
};
if (!bundle || !bundle.platforms || bundle.platforms.length === 0) { if (!bundle || !bundle.platforms || bundle.platforms.length === 0) {
return ( return (
<VStack gap={4} py={10}> <VStack gap={4} py={10}>
@@ -94,7 +377,19 @@ export function GeneratedContentResult({ bundle, onReset }: Props) {
<VStack align="stretch" gap={6}> <VStack align="stretch" gap={6}>
<HStack justify="space-between"> <HStack justify="space-between">
<Heading size="lg">Generated Results</Heading> <Heading size="lg">Generated Results</Heading>
<Button variant="outline" onClick={onReset}>Create New</Button> <HStack gap={2}>
{Object.keys(editedContents).length > 0 && (
<Button
colorPalette="green"
size="sm"
onClick={handleSaveAll}
loading={isSaving}
>
<LuSave /> Save All Changes
</Button>
)}
<Button variant="outline" onClick={onReset}>Create New</Button>
</HStack>
</HStack> </HStack>
<Tabs.Root defaultValue="content"> <Tabs.Root defaultValue="content">
@@ -135,71 +430,170 @@ export function GeneratedContentResult({ bundle, onReset }: Props) {
))} ))}
</Tabs.List> </Tabs.List>
{bundle.platforms.map((item) => ( {bundle.platforms.map((item) => {
<Tabs.Content key={item.platform} value={item.platform}> const isEditing = editingPlatform === item.platform;
<Card.Root> const currentContent = getContent(item.platform, item.content);
<Card.Body> const currentHashtags = getHashtags(item.platform, item.hashtags);
<VStack align="stretch" gap={4}>
<HStack justify="space-between">
<Badge colorPalette="blue" variant="subtle">
{item.characterCount} chars
</Badge>
<Button
size="sm"
variant="ghost"
onClick={() => handleCopy(item.content, item.platform)}
>
{copiedId === item.platform ? <LuCheck /> : <LuCopy />}
{copiedId === item.platform ? "Copied" : "Copy"}
</Button>
</HStack>
<Box return (
p={4} <Tabs.Content key={item.platform} value={item.platform}>
bg="bg.subtle" <Card.Root>
borderRadius="md" <Card.Body>
whiteSpace="pre-wrap" <VStack align="stretch" gap={4}>
fontFamily="mono" <HStack justify="space-between">
fontSize="sm" <HStack gap={2}>
> <Badge colorPalette="blue" variant="subtle">
{item.content} {currentContent.length} chars
</Box> </Badge>
{editedContents[item.platform] !== undefined && (
<Box mt={2}> <Badge colorPalette="orange" variant="subtle">
<Text fontWeight="medium" mb={1} fontSize="sm">Hashtags:</Text> Edited
<Text color="blue.500" fontSize="sm">{item.hashtags.join(" ")}</Text> </Badge>
</Box> )}
{item.postingRecommendation && (
<Box mt={2} p={2} bg="blue.50/10" borderRadius="md">
<HStack>
<LuSparkles color="var(--chakra-colors-blue-500)" />
<Text fontSize="sm" color="fg.muted">{item.postingRecommendation}</Text>
</HStack> </HStack>
</Box> <HStack gap={2}>
)} <Button
size="sm"
variant={isEditing ? "solid" : "ghost"}
colorPalette={isEditing ? "blue" : undefined}
onClick={() => setEditingPlatform(isEditing ? null : item.platform)}
>
<LuPencil /> {isEditing ? "Done Editing" : "Edit"}
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => handleCopy(currentContent, item.platform)}
>
{copiedId === item.platform ? <LuCheck /> : <LuCopy />}
{copiedId === item.platform ? "Copied" : "Copy"}
</Button>
</HStack>
</HStack>
<HStack mt={3} gap={2}> {/* Generated Image */}
<Button <Box position="relative" borderRadius="md" overflow="hidden" border="1px solid" borderColor="border.subtle">
size="sm" {(customImages[item.platform] || item.imageUrl) ? (
variant="outline" <img
onClick={() => handleCopyWithHashtags(item.content, item.hashtags, item.platform)} src={customImages[item.platform] || item.imageUrl}
> alt={`Generated image for ${item.platform}`}
<LuHash /> Copy + Hashtags style={{ width: '100%', maxHeight: '400px', objectFit: 'cover' }}
</Button> onError={(e) => {
<Button e.currentTarget.src = 'https://placehold.co/600x400/png?text=Image+Not+Found';
size="sm" }}
variant="outline" />
onClick={() => handleDownloadText(item.content, item.platform)} ) : (
> <Box height="200px" bg="bg.subtle" display="flex" alignItems="center" justifyContent="center">
<LuDownload /> Download <Text color="fg.muted">No image generated</Text>
</Button> </Box>
</HStack> )}
</VStack> <Button
</Card.Body> size="sm"
</Card.Root> position="absolute"
</Tabs.Content> top={2}
))} right={2}
onClick={() => {
setSelectedImagePlatform(item.platform);
setImageSearchQuery(bundle.topic); // Default query
setImageSearchResults([]);
setIsImageSearchOpen(true);
}}
>
<LuImage /> Change Image
</Button>
</Box>
{/* Content - Editable or Read-only */}
{isEditing ? (
<Textarea
value={currentContent}
onChange={(e) => setEditedContents(prev => ({
...prev,
[item.platform]: e.target.value
}))}
minHeight="200px"
fontFamily="mono"
fontSize="sm"
resize="vertical"
/>
) : (
<Box
p={4}
bg="bg.subtle"
borderRadius="md"
whiteSpace="pre-wrap"
fontFamily="mono"
fontSize="sm"
cursor="pointer"
_hover={{ borderColor: "blue.500", borderWidth: "1px" }}
onClick={() => setEditingPlatform(item.platform)}
title="Click to edit"
>
{currentContent}
</Box>
)}
{/* Hashtags - Editable or Read-only */}
<Box mt={2}>
<Text fontWeight="medium" mb={1} fontSize="sm">Hashtags:</Text>
{isEditing ? (
<Textarea
value={currentHashtags}
onChange={(e) => setEditedHashtags(prev => ({
...prev,
[item.platform]: e.target.value
}))}
minHeight="60px"
fontSize="sm"
color="blue.500"
resize="vertical"
/>
) : (
<Text color="blue.500" fontSize="sm">{currentHashtags}</Text>
)}
</Box>
{item.postingRecommendation && (
<Box mt={2} p={2} bg="blue.50/10" borderRadius="md">
<HStack>
<LuSparkles color="var(--chakra-colors-blue-500)" />
<Text fontSize="sm" color="fg.muted">{item.postingRecommendation}</Text>
</HStack>
</Box>
)}
<HStack mt={3} gap={2} flexWrap="wrap">
<Button
size="sm"
variant="outline"
onClick={() => handleCopyWithHashtags(item.platform, item.content, item.hashtags, item.platform)}
>
<LuHash /> Copy + Hashtags
</Button>
<Button
size="sm"
variant="outline"
onClick={() => handleDownloadText(item.platform, item.content)}
>
<LuDownload /> Download
</Button>
{editedContents[item.platform] !== undefined && (
<Button
size="sm"
colorPalette="green"
onClick={() => handleSave(item.platform)}
loading={isSaving}
>
<LuSave /> Save {item.platform}
</Button>
)}
</HStack>
</VStack>
</Card.Body>
</Card.Root>
</Tabs.Content>
);
})}
</Tabs.Root> </Tabs.Root>
</Tabs.Content> </Tabs.Content>
@@ -312,22 +706,116 @@ export function GeneratedContentResult({ bundle, onReset }: Props) {
</Card.Body> </Card.Body>
</Card.Root> </Card.Root>
<SimpleGrid columns={{ base: 1, md: 2 }} gap={4}> {/* Keywords with Copy All */}
<Card.Root> <Card.Root>
<Card.Header> <Card.Header>
<HStack justify="space-between">
<Heading size="sm">Keywords</Heading> <Heading size="sm">Keywords</Heading>
</Card.Header> {bundle.seo?.keywords && bundle.seo.keywords.length > 0 && (
<Card.Body> <Button
<HStack flexWrap="wrap" gap={2}> size="xs"
{bundle.seo?.keywords?.map((kw, idx) => ( variant="ghost"
<Badge key={idx} variant="subtle" colorPalette="blue"> onClick={() => {
{kw} navigator.clipboard.writeText(bundle.seo!.keywords.join(', '));
</Badge> toaster.success({ title: 'Keywords copied!' });
))} }}
</HStack> >
</Card.Body> <LuCopy /> Copy All
</Card.Root> </Button>
)}
</HStack>
</Card.Header>
<Card.Body>
<HStack flexWrap="wrap" gap={2}>
{bundle.seo?.keywords?.map((kw, idx) => (
<Badge
key={idx}
variant="subtle"
colorPalette="blue"
cursor="pointer"
onClick={() => {
navigator.clipboard.writeText(kw);
toaster.success({ title: `"${kw}" copied!` });
}}
>
<LuHash /> {kw}
</Badge>
))}
</HStack>
</Card.Body>
</Card.Root>
<SimpleGrid columns={{ base: 1, md: 2 }} gap={4}>
{/* Question Keywords */}
{bundle.seo?.questions && bundle.seo.questions.length > 0 && (
<Card.Root>
<Card.Header>
<Heading size="sm">Question Keywords</Heading>
</Card.Header>
<Card.Body>
<VStack align="start" gap={2}>
{bundle.seo.questions.map((q, idx) => (
<HStack key={idx} justify="space-between" w="100%">
<Text fontSize="sm"> {q}</Text>
<Button
size="xs"
variant="ghost"
onClick={() => {
navigator.clipboard.writeText(q);
toaster.success({ title: 'Question copied!' });
}}
>
<LuCopy />
</Button>
</HStack>
))}
</VStack>
</Card.Body>
</Card.Root>
)}
{/* Long-tail Keywords */}
{bundle.seo?.longTail && bundle.seo.longTail.length > 0 && (
<Card.Root>
<Card.Header>
<HStack justify="space-between">
<Heading size="sm">Long-tail Keywords</Heading>
<Button
size="xs"
variant="ghost"
onClick={() => {
const text = bundle.seo!.longTail!.map(lt => lt.keyword).join(', ');
navigator.clipboard.writeText(text);
toaster.success({ title: 'Long-tail keywords copied!' });
}}
>
<LuCopy /> Copy All
</Button>
</HStack>
</Card.Header>
<Card.Body>
<VStack align="start" gap={2}>
{bundle.seo.longTail.map((lt, idx) => (
<HStack key={idx} justify="space-between" w="100%">
<Badge variant="outline" colorPalette="purple" cursor="pointer" onClick={() => {
navigator.clipboard.writeText(lt.keyword);
toaster.success({ title: `"${lt.keyword}" copied!` });
}}>
{lt.keyword}
</Badge>
{lt.competitionLevel && (
<Badge size="sm" colorPalette={lt.competitionLevel === 'low' ? 'green' : lt.competitionLevel === 'medium' ? 'yellow' : 'red'}>
{lt.competitionLevel}
</Badge>
)}
</HStack>
))}
</VStack>
</Card.Body>
</Card.Root>
)}
{/* Suggestions */}
<Card.Root> <Card.Root>
<Card.Header> <Card.Header>
<Heading size="sm">Suggestions</Heading> <Heading size="sm">Suggestions</Heading>
@@ -344,6 +832,40 @@ export function GeneratedContentResult({ bundle, onReset }: Props) {
</Card.Body> </Card.Body>
</Card.Root> </Card.Root>
</SimpleGrid> </SimpleGrid>
{/* Meta Info */}
{bundle.seo?.meta && (
<Card.Root>
<Card.Header>
<HStack justify="space-between">
<Heading size="sm">Meta Tags</Heading>
<Button
size="xs"
variant="ghost"
onClick={() => {
const meta = `Title: ${bundle.seo!.meta.title}\nDescription: ${bundle.seo!.meta.description}`;
navigator.clipboard.writeText(meta);
toaster.success({ title: 'Meta tags copied!' });
}}
>
<LuCopy /> Copy
</Button>
</HStack>
</Card.Header>
<Card.Body>
<VStack align="start" gap={2}>
<Box>
<Text fontSize="xs" color="fg.muted">Title</Text>
<Text fontSize="sm" fontWeight="medium">{bundle.seo.meta.title}</Text>
</Box>
<Box>
<Text fontSize="xs" color="fg.muted">Description</Text>
<Text fontSize="sm">{bundle.seo.meta.description}</Text>
</Box>
</VStack>
</Card.Body>
</Card.Root>
)}
</VStack> </VStack>
</Tabs.Content> </Tabs.Content>
@@ -355,15 +877,15 @@ export function GeneratedContentResult({ bundle, onReset }: Props) {
<HStack justify="space-between"> <HStack justify="space-between">
<Heading size="md">Neuro Score</Heading> <Heading size="md">Neuro Score</Heading>
<Badge <Badge
colorPalette={bundle.neuro?.score && bundle.neuro.score >= 70 ? 'green' : bundle.neuro?.score && bundle.neuro.score >= 50 ? 'yellow' : 'red'} colorPalette={neuroScore >= 70 ? 'green' : neuroScore >= 50 ? 'yellow' : 'red'}
size="lg" size="lg"
> >
{bundle.neuro?.score || 0}/100 {neuroScore || 0}/100
</Badge> </Badge>
</HStack> </HStack>
</Card.Header> </Card.Header>
<Card.Body> <Card.Body>
<Progress.Root value={bundle.neuro?.score || 0} max={100} size="lg"> <Progress.Root value={neuroScore || 0} max={100} size="lg">
<Progress.Track> <Progress.Track>
<Progress.Range /> <Progress.Range />
</Progress.Track> </Progress.Track>
@@ -371,6 +893,36 @@ export function GeneratedContentResult({ bundle, onReset }: Props) {
</Card.Body> </Card.Body>
</Card.Root> </Card.Root>
{/* Neuro Regeneration Button */}
<Card.Root bg="orange.50/10" borderColor="orange.500" borderWidth="1px">
<Card.Body>
<VStack gap={3}>
<HStack justify="space-between" width="full">
<VStack align="start" gap={1}>
<Text fontWeight="bold">🧠 Nöro Skoru Artır</Text>
<Text fontSize="sm" color="fg.muted">
İçeriğinizi nöro-pazarlama ilkeleriyle yeniden yazarak daha yüksek skor elde edin.
</Text>
</VStack>
<Button
onClick={handleNeuroRegenerate}
loading={isRegenerating}
loadingText="Optimizing..."
colorPalette="orange"
size="lg"
>
<LuRefreshCw /> Regenerate
</Button>
</HStack>
{neuroScore > 0 && (
<Text fontSize="xs" color="fg.muted">
Mevcut Skor: {neuroScore}/100
</Text>
)}
</VStack>
</Card.Body>
</Card.Root>
<SimpleGrid columns={{ base: 1, md: 2 }} gap={4}> <SimpleGrid columns={{ base: 1, md: 2 }} gap={4}>
<Card.Root> <Card.Root>
<Card.Header> <Card.Header>
@@ -396,7 +948,7 @@ export function GeneratedContentResult({ bundle, onReset }: Props) {
</Card.Header> </Card.Header>
<Card.Body> <Card.Body>
<VStack align="start" gap={2}> <VStack align="start" gap={2}>
{bundle.neuro?.improvements?.map((imp, idx) => ( {neuroImprovements?.map((imp, idx) => (
<HStack key={idx}> <HStack key={idx}>
<LuZap color="orange" /> <LuZap color="orange" />
<Text fontSize="sm">{imp}</Text> <Text fontSize="sm">{imp}</Text>
@@ -424,6 +976,71 @@ export function GeneratedContentResult({ bundle, onReset }: Props) {
</VStack> </VStack>
</Tabs.Content> </Tabs.Content>
</Tabs.Root> </Tabs.Root>
{/* IMAGE SEARCH DIALOG */}
<DialogRoot open={isImageSearchOpen} onOpenChange={(e) => setIsImageSearchOpen(e.open)} size="lg">
<DialogContent>
<DialogHeader>
<DialogTitle>Search Images</DialogTitle>
<DialogCloseTrigger />
</DialogHeader>
<DialogBody>
<VStack gap={4}>
<HStack w="full">
<Input
placeholder="Search for images..."
value={imageSearchQuery}
onChange={(e) => setImageSearchQuery(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSearchImages()}
/>
<Button onClick={handleSearchImages} loading={isSearchingImages} colorPalette="blue">
<LuSearch />
</Button>
</HStack>
<Box maxH="400px" overflowY="auto" w="full">
{isSearchingImages ? (
<Progress.Root value={null} size="sm">
<Progress.Track>
<Progress.Range />
</Progress.Track>
</Progress.Root>
) : (
<SimpleGrid columns={3} gap={2}>
{imageSearchResults.map((url, idx) => (
<Box
key={idx}
cursor="pointer"
borderRadius="md"
overflow="hidden"
aspectRatio="1"
onClick={() => handleSelectImage(url)}
_hover={{ ring: 2, ringColor: "blue.500" }}
>
<img
src={url}
alt={`Result ${idx}`}
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
onError={(e) => {
e.currentTarget.src = 'https://placehold.co/600x400/png?text=Preview+Unavailable';
}}
/>
</Box>
))}
</SimpleGrid>
)}
{!isSearchingImages && imageSearchResults.length === 0 && (
<Text textAlign="center" color="fg.muted" py={8}>
Enter a search term to find images.
</Text>
)}
</Box>
</VStack>
</DialogBody>
<DialogFooter>
<Button variant="ghost" onClick={() => setIsImageSearchOpen(false)}>Cancel</Button>
</DialogFooter>
</DialogContent>
</DialogRoot>
</VStack> </VStack>
); );
} }

View File

@@ -8,14 +8,15 @@ import { Toaster } from "./feedback/toaster";
import TopLoader from "./top-loader"; import TopLoader from "./top-loader";
import ReactQueryProvider from "@/provider/react-query-provider"; import ReactQueryProvider from "@/provider/react-query-provider";
export function Provider(props: ColorModeProviderProps) { export function Provider({ children, session, ...props }: any) {
return ( return (
<SessionProvider> <SessionProvider session={session}>
<ReactQueryProvider> <ReactQueryProvider>
<ChakraProvider value={system}> <ChakraProvider value={system}>
<TopLoader /> <TopLoader />
<ColorModeProvider {...props} /> <ColorModeProvider {...props} />
<Toaster /> <Toaster />
{children}
</ChakraProvider> </ChakraProvider>
</ReactQueryProvider> </ReactQueryProvider>
</SessionProvider> </SessionProvider>

98
src/lib/auth-options.ts Normal file
View File

@@ -0,0 +1,98 @@
import baseUrl from "@/config/base-url";
import { authService } from "@/lib/api/example/auth/service";
import Credentials from "next-auth/providers/credentials";
import { AuthOptions } from "next-auth";
function randomToken() {
return Math.random().toString(36).substring(2) + Date.now().toString(36);
}
const isMockMode = process.env.NEXT_PUBLIC_ENABLE_MOCK_MODE === "true";
export const authOptions: AuthOptions = {
providers: [
Credentials({
name: "Credentials",
credentials: {
email: { label: "Email", type: "text" },
password: { label: "Password", type: "password" },
},
async authorize(credentials) {
console.log("credentials", credentials);
if (!credentials?.email || !credentials?.password) {
throw new Error("Email ve şifre gereklidir.");
}
// Eğer mock mod aktifse backend'e gitme
if (isMockMode) {
return {
id: credentials.email,
name: credentials.email.split("@")[0],
email: credentials.email,
accessToken: randomToken(),
refreshToken: randomToken(),
};
}
// Normal mod: backend'e istek at
const res = await authService.login({
email: credentials.email,
password: credentials.password,
});
console.log("res", res);
const response = res;
// Backend returns ApiResponse<TokenResponseDto>
// Structure: { data: { accessToken, refreshToken, expiresIn, user }, message, statusCode }
if (!res.success || !response?.data?.accessToken) {
throw new Error(response?.message || "Giriş başarısız");
}
const { accessToken, refreshToken, user } = response.data;
return {
id: user.id,
name: user.firstName
? `${user.firstName} ${user.lastName || ""}`.trim()
: user.email.split("@")[0],
email: user.email,
accessToken,
refreshToken,
roles: user.roles || [],
};
},
}),
],
callbacks: {
async jwt({ token, user, account, profile }: any) {
console.log('[DEBUG-AUTH] JWT Callback Triggered', { hasUser: !!user, tokenKeys: Object.keys(token) });
if (user) {
console.log('[DEBUG-AUTH] JWT User details:', { id: user.id, email: user.email, hasAccessToken: !!user.accessToken });
token.accessToken = user.accessToken;
token.refreshToken = user.refreshToken;
token.id = user.id;
token.roles = user.roles;
}
return token;
},
async session({ session, token }: any) {
console.log('[DEBUG-AUTH] Session Callback Triggered', { hasToken: !!token, sessionKeys: Object.keys(session) });
if (token) {
console.log('[DEBUG-AUTH] Session Token details:', { id: token.id, hasAccessToken: !!token.accessToken });
session.user.id = token.id;
session.user.roles = token.roles;
session.accessToken = token.accessToken;
session.refreshToken = token.refreshToken;
}
return session;
},
},
pages: {
signIn: "/signin",
error: "/signin",
},
session: { strategy: "jwt" },
secret: process.env.NEXTAUTH_SECRET,
};

File diff suppressed because one or more lines are too long