generated from fahricansecer/boilerplate-fe
main
Some checks failed
UI Deploy (Next-Auth Support) 🎨 / build-and-deploy (push) Has been cancelled
Some checks failed
UI Deploy (Next-Auth Support) 🎨 / build-and-deploy (push) Has been cancelled
This commit is contained in:
@@ -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" });
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 />
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
98
src/lib/auth-options.ts
Normal 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
Reference in New Issue
Block a user