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 { LuTrendingUp, LuSearch, LuSparkles, LuRefreshCw, LuArrowRight, LuGlobe, LuHash, LuZoomIn } from "react-icons/lu";
|
||||
import { toaster } from "@/components/ui/feedback/toaster";
|
||||
import { useState } from "react";
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { useRouter } from "@/i18n/navigation";
|
||||
|
||||
@@ -19,6 +19,16 @@ interface Trend {
|
||||
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() {
|
||||
const { data: session } = useSession();
|
||||
const router = useRouter();
|
||||
@@ -31,6 +41,45 @@ export default function TrendsPage() {
|
||||
const [translatingId, setTranslatingId] = useState<string | null>(null);
|
||||
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 () => {
|
||||
if (!niche.trim()) {
|
||||
toaster.create({ title: "Please enter a niche or topic", type: "warning" });
|
||||
|
||||
@@ -11,6 +11,9 @@ const bricolage = Bricolage_Grotesque({
|
||||
subsets: ['latin'],
|
||||
});
|
||||
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "@/lib/auth-options";
|
||||
|
||||
export default async function RootLayout({
|
||||
children,
|
||||
params,
|
||||
@@ -23,6 +26,8 @@ export default async function RootLayout({
|
||||
notFound();
|
||||
}
|
||||
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
return (
|
||||
<html lang={locale} dir={dir(locale)} suppressHydrationWarning data-scroll-behavior='smooth'>
|
||||
<head>
|
||||
@@ -33,7 +38,7 @@ export default async function RootLayout({
|
||||
</head>
|
||||
<body className={bricolage.variable}>
|
||||
<NextIntlClientProvider>
|
||||
<Provider>{children}</Provider>
|
||||
<Provider session={session}>{children}</Provider>
|
||||
</NextIntlClientProvider>
|
||||
</body>
|
||||
</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 Credentials from "next-auth/providers/credentials";
|
||||
import { authOptions } from "@/lib/auth-options";
|
||||
|
||||
|
||||
|
||||
|
||||
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,
|
||||
});
|
||||
const handler = NextAuth(authOptions);
|
||||
|
||||
export { handler as GET, handler as POST };
|
||||
|
||||
@@ -3,14 +3,12 @@
|
||||
|
||||
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[]) {
|
||||
const targetPath = pathSegments.join('/');
|
||||
const targetUrl = `${BACKEND_URL}/api/${targetPath}`;
|
||||
|
||||
console.log(`[Proxy] Forwarding ${request.method} to ${targetUrl}`);
|
||||
|
||||
// Get request body for non-GET requests
|
||||
let body: string | undefined;
|
||||
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)
|
||||
type RouteContext = { params: { path: string[] } };
|
||||
// Next.js 15+ uses context.params as a Promise
|
||||
type RouteContext = { params: Promise<{ path: string[] }> };
|
||||
|
||||
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) {
|
||||
return proxyRequest(request, context.params.path);
|
||||
const { path } = await context.params;
|
||||
return proxyRequest(request, path);
|
||||
}
|
||||
|
||||
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) {
|
||||
return proxyRequest(request, context.params.path);
|
||||
const { path } = await context.params;
|
||||
return proxyRequest(request, path);
|
||||
}
|
||||
|
||||
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";
|
||||
|
||||
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 {
|
||||
DialogBody,
|
||||
DialogCloseTrigger,
|
||||
@@ -9,24 +9,32 @@ import {
|
||||
DialogRoot,
|
||||
DialogTitle,
|
||||
} 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 {
|
||||
id: number;
|
||||
id: string;
|
||||
title: string;
|
||||
platform: string;
|
||||
platform?: string;
|
||||
type?: string;
|
||||
status: string;
|
||||
date: string;
|
||||
date?: string;
|
||||
createdAt?: string;
|
||||
body?: string;
|
||||
hashtags?: string[];
|
||||
}
|
||||
|
||||
interface ContentPreviewDialogProps {
|
||||
item: ContentItem | null;
|
||||
open: boolean;
|
||||
onOpenChange: (details: { open: boolean }) => void;
|
||||
onContentUpdated?: () => void;
|
||||
}
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
switch (status?.toLowerCase()) {
|
||||
case "published": return "green";
|
||||
case "draft": return "gray";
|
||||
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;
|
||||
|
||||
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 (
|
||||
<DialogRoot open={open} onOpenChange={onOpenChange} size="lg">
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{item.title}</DialogTitle>
|
||||
<DialogTitle>{item.title || "Content Preview"}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<DialogBody pb={8}>
|
||||
<VStack gap={4} align="stretch">
|
||||
<HStack gap={4}>
|
||||
<HStack gap={4} flexWrap="wrap">
|
||||
<Badge colorPalette={getStatusColor(item.status)} size="lg" variant="solid">
|
||||
{item.status.toUpperCase()}
|
||||
{item.status?.toUpperCase()}
|
||||
</Badge>
|
||||
<HStack color="fg.muted">
|
||||
<LuGlobe />
|
||||
<Text>{item.platform}</Text>
|
||||
</HStack>
|
||||
<HStack color="fg.muted">
|
||||
<LuCalendar />
|
||||
<Text>{item.date}</Text>
|
||||
<Text>{displayPlatform}</Text>
|
||||
</HStack>
|
||||
{displayDate && (
|
||||
<HStack color="fg.muted">
|
||||
<LuCalendar />
|
||||
<Text>{new Date(displayDate).toLocaleDateString()}</Text>
|
||||
</HStack>
|
||||
)}
|
||||
</HStack>
|
||||
|
||||
<Separator />
|
||||
|
||||
<Box p={4} bg="bg.subtle" borderRadius="md">
|
||||
<HStack mb={2} color="fg.muted">
|
||||
<LuFileText />
|
||||
<Text fontWeight="medium">Content Preview</Text>
|
||||
<Box>
|
||||
<HStack justify="space-between" mb={2}>
|
||||
<HStack color="fg.muted">
|
||||
<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>
|
||||
<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.
|
||||
</Text>
|
||||
<Text mt={4} fontStyle="italic" color="fg.muted">
|
||||
Generated content will appear here...
|
||||
</Text>
|
||||
|
||||
{isEditing ? (
|
||||
<Textarea
|
||||
value={editedBody}
|
||||
onChange={(e) => setEditedBody(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"
|
||||
>
|
||||
{displayBody}
|
||||
</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>
|
||||
</DialogBody>
|
||||
<DialogCloseTrigger />
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { Box, Table, Badge, HStack, IconButton } from "@chakra-ui/react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { Box, Table, Badge, HStack, IconButton, Button } from "@chakra-ui/react";
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { LuEye, LuPencil, LuTrash2, LuRefreshCw } from "react-icons/lu";
|
||||
import { toaster } from "@/components/ui/feedback/toaster";
|
||||
@@ -9,9 +9,8 @@ import { ContentPreviewDialog } from "./ContentPreviewDialog";
|
||||
|
||||
|
||||
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
switch (status?.toLowerCase()) {
|
||||
case "published": return "green";
|
||||
case "draft": return "gray";
|
||||
case "scheduled": return "blue";
|
||||
@@ -27,30 +26,49 @@ export function ContentTable() {
|
||||
const [contentList, setContentList] = useState<any[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchContent = async () => {
|
||||
if (!session?.accessToken) return;
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const res = await fetch('/api/backend/content', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${session.accessToken}`
|
||||
}
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setContentList(Array.isArray(data) ? data : []);
|
||||
const fetchContent = useCallback(async () => {
|
||||
if (!session?.accessToken) return;
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const res = await fetch('/api/backend/content', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${session.accessToken}`
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch content:", error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setContentList(Array.isArray(data) ? data : []);
|
||||
}
|
||||
};
|
||||
|
||||
fetchContent();
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch content:", error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [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) => {
|
||||
if (action === 'View') {
|
||||
@@ -58,16 +76,30 @@ export function ContentTable() {
|
||||
setIsPreviewOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
toaster.create({
|
||||
title: `${action} Action`,
|
||||
description: `You clicked ${action} for ${item.title}`,
|
||||
type: "info",
|
||||
});
|
||||
if (action === 'Edit') {
|
||||
// Open the preview in edit mode
|
||||
setSelectedItem(item);
|
||||
setIsPreviewOpen(true);
|
||||
return;
|
||||
}
|
||||
if (action === 'Delete') {
|
||||
handleDelete(item);
|
||||
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">
|
||||
<Table.Root striped>
|
||||
<Table.Header>
|
||||
@@ -97,7 +129,9 @@ export function ContentTable() {
|
||||
</Table.Row>
|
||||
) : contentList.map((item) => (
|
||||
<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>
|
||||
<Badge colorPalette={getStatusColor(item.status)} variant="solid">
|
||||
@@ -145,6 +179,7 @@ export function ContentTable() {
|
||||
item={selectedItem}
|
||||
open={isPreviewOpen}
|
||||
onOpenChange={(e) => setIsPreviewOpen(e.open)}
|
||||
onContentUpdated={fetchContent}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { Box, Heading, Steps, VStack, Input, Button, Text, HStack, Textarea, Card, SimpleGrid, Badge, Progress, Spinner } from "@chakra-ui/react";
|
||||
import { LuSparkles, LuArrowRight, LuCheck, LuHash, LuRefreshCw } from "react-icons/lu";
|
||||
import { Box, Heading, VStack, Input, Button, Text, HStack, Textarea, Card, SimpleGrid, Badge, Progress, Spinner, NativeSelect } from "@chakra-ui/react";
|
||||
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";
|
||||
@@ -31,6 +32,18 @@ interface Niche {
|
||||
description?: string;
|
||||
}
|
||||
|
||||
interface WritingStyle {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
interface CtaType {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export function GenerateWizard() {
|
||||
const { data: session } = useSession();
|
||||
const searchParams = useSearchParams();
|
||||
@@ -46,11 +59,16 @@ export function GenerateWizard() {
|
||||
const [generationProgress, setGenerationProgress] = useState(0);
|
||||
const router = useRouter();
|
||||
|
||||
|
||||
const [niches, setNiches] = useState<Niche[]>([]);
|
||||
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 [masterContentId, setMasterContentId] = useState<string | null>(null);
|
||||
|
||||
// Read trend data from URL params (from trends page)
|
||||
useEffect(() => {
|
||||
@@ -85,11 +103,20 @@ export function GenerateWizard() {
|
||||
// Using a relative path which the proxy should handle
|
||||
const res = await fetch('/api/backend/content-generation/niches', { headers });
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
if (Array.isArray(data)) {
|
||||
setNiches(data);
|
||||
const responseData = await res.json();
|
||||
|
||||
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 {
|
||||
console.error("API returned non-array for niches:", data);
|
||||
console.error("API returned non-array for niches:", JSON.stringify(responseData, null, 2));
|
||||
setNiches([]);
|
||||
}
|
||||
} else {
|
||||
@@ -108,6 +135,29 @@ export function GenerateWizard() {
|
||||
}
|
||||
}, [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) => {
|
||||
setSelectedPlatforms(prev =>
|
||||
prev.includes(id) ? prev.filter(p => p !== id) : [...prev, id]
|
||||
@@ -144,6 +194,8 @@ export function GenerateWizard() {
|
||||
setGenerationProgress(10);
|
||||
setGenerationStage("Researching topic and niche...");
|
||||
|
||||
// Removed "warn if not logged in" toast
|
||||
|
||||
try {
|
||||
const payload = {
|
||||
topic,
|
||||
@@ -154,7 +206,9 @@ export function GenerateWizard() {
|
||||
includeResearch: true,
|
||||
includeHashtags: true,
|
||||
brandVoice: "friendly-expert",
|
||||
count: 1
|
||||
count: 1,
|
||||
writingStyle: selectedWritingStyle || undefined,
|
||||
ctaType: selectedCtaType || undefined,
|
||||
};
|
||||
|
||||
// Simulated progress stages since the backend is a single call
|
||||
@@ -183,66 +237,99 @@ export function GenerateWizard() {
|
||||
|
||||
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);
|
||||
setGenerationStage("Saving to library...");
|
||||
const responseData = await response.json();
|
||||
|
||||
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);
|
||||
setGenerationStage("Success!");
|
||||
|
||||
// Give a small delay to show 100%
|
||||
setTimeout(() => {
|
||||
setGeneratedBundle(data);
|
||||
setGeneratedBundle(bundle);
|
||||
setMasterContentId(savedMasterContentId);
|
||||
setIsGenerating(false);
|
||||
|
||||
const isLoggedIn = !!session?.user;
|
||||
toaster.create({
|
||||
title: "Content Generated",
|
||||
description: "Your content is ready and saved to library!",
|
||||
title: isLoggedIn ? "✅ Content Generated & Saved" : "Content Generated",
|
||||
description: isLoggedIn
|
||||
? "Your content is ready and saved to library!"
|
||||
: "Content generated but not saved — log in to save to library.",
|
||||
type: "success"
|
||||
});
|
||||
|
||||
// Auto-redirect after success
|
||||
if (data.masterContentId) {
|
||||
router.push(`/[locale]/content?id=${data.masterContentId}`);
|
||||
}
|
||||
}, 500);
|
||||
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} catch (error: any) {
|
||||
console.error('Generation Error:', error);
|
||||
setIsGenerating(false);
|
||||
|
||||
let errorMessage = "Failed to generate content. Please try again.";
|
||||
if (error instanceof Error) {
|
||||
errorMessage = error.message;
|
||||
}
|
||||
|
||||
toaster.create({
|
||||
title: "Error",
|
||||
description: "Failed to generate content. Please try again.",
|
||||
type: "error"
|
||||
title: "Generation Failed",
|
||||
description: errorMessage,
|
||||
type: "error",
|
||||
duration: 5000,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
if (generatedBundle) {
|
||||
return <GeneratedContentResult bundle={generatedBundle} onReset={() => {
|
||||
return <GeneratedContentResult bundle={generatedBundle} masterContentId={masterContentId} onReset={() => {
|
||||
setGeneratedBundle(null);
|
||||
setMasterContentId(null);
|
||||
setActiveStep(0);
|
||||
setTopic("");
|
||||
setSelectedPlatforms([]);
|
||||
}} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box mb={8}>
|
||||
<Steps.Root index={activeStep} count={STEPS.length}>
|
||||
<Steps.List>
|
||||
<StepsRoot step={activeStep} count={STEPS.length}>
|
||||
<StepsList>
|
||||
{STEPS.map((step, index) => (
|
||||
<Steps.Item key={index} title={step.title} icon={<Box>{index + 1}</Box>}>
|
||||
{step.description}
|
||||
</Steps.Item>
|
||||
<StepsItem
|
||||
key={index}
|
||||
index={index}
|
||||
title={step.title}
|
||||
description={step.description}
|
||||
icon={<Box>{index + 1}</Box>}
|
||||
/>
|
||||
))}
|
||||
</Steps.List>
|
||||
</Steps.Root>
|
||||
</StepsList>
|
||||
</StepsRoot>
|
||||
</Box>
|
||||
|
||||
<Box p={6} borderWidth="1px" borderRadius="lg" bg="bg.panel">
|
||||
@@ -334,6 +421,50 @@ export function GenerateWizard() {
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</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>
|
||||
)}
|
||||
|
||||
@@ -374,6 +505,8 @@ export function GenerateWizard() {
|
||||
<VStack align="start" gap={2}>
|
||||
<Text><strong>Topic:</strong> {topic}</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>
|
||||
</VStack>
|
||||
</Card.Body>
|
||||
|
||||
@@ -1,9 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import { Box, Heading, VStack, Text, Card, HStack, Badge, Button, Tabs, SimpleGrid, Progress, Icon } from "@chakra-ui/react";
|
||||
import { LuCopy, LuCheck, LuFileText, LuBrainCircuit, LuLayers, LuSparkles, LuSearch, LuZap, LuDownload, LuHash } from "react-icons/lu";
|
||||
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, 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 { useState } from "react";
|
||||
import { useSession } from "next-auth/react";
|
||||
|
||||
// Matches backend GeneratedContentBundle
|
||||
interface GeneratedContentBundle {
|
||||
@@ -22,8 +33,8 @@ interface GeneratedContentBundle {
|
||||
hashtags: string[];
|
||||
characterCount: number;
|
||||
postingRecommendation: string;
|
||||
// mediaRecommendations is string[] in backend
|
||||
mediaRecommendations: string[];
|
||||
imageUrl?: string;
|
||||
}[];
|
||||
variations?: {
|
||||
original: string;
|
||||
@@ -32,6 +43,8 @@ interface GeneratedContentBundle {
|
||||
seo?: {
|
||||
score: number;
|
||||
keywords: string[];
|
||||
questions?: string[];
|
||||
longTail?: { keyword: string; estimatedVolume?: number; competitionLevel?: string }[];
|
||||
suggestions: string[];
|
||||
meta: { title: string; description: string };
|
||||
};
|
||||
@@ -46,11 +59,36 @@ interface GeneratedContentBundle {
|
||||
|
||||
interface Props {
|
||||
bundle: GeneratedContentBundle;
|
||||
masterContentId?: string | null;
|
||||
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 [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) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
@@ -63,15 +101,18 @@ export function GeneratedContentResult({ bundle, onReset }: Props) {
|
||||
setTimeout(() => setCopiedId(null), 2000);
|
||||
};
|
||||
|
||||
const handleCopyWithHashtags = (content: string, hashtags: string[], id: string) => {
|
||||
const fullText = `${content}\n\n${hashtags.join(' ')}`;
|
||||
const handleCopyWithHashtags = (platform: string, originalContent: string, originalHashtags: string[], id: string) => {
|
||||
const content = getContent(platform, originalContent);
|
||||
const hashtags = getHashtags(platform, originalHashtags);
|
||||
const fullText = `${content}\n\n${hashtags}`;
|
||||
navigator.clipboard.writeText(fullText);
|
||||
setCopiedId(id + '-full');
|
||||
toaster.create({ title: "Copied with hashtags", type: "success", duration: 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 url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
@@ -81,6 +122,248 @@ export function GeneratedContentResult({ bundle, onReset }: Props) {
|
||||
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) {
|
||||
return (
|
||||
<VStack gap={4} py={10}>
|
||||
@@ -94,7 +377,19 @@ export function GeneratedContentResult({ bundle, onReset }: Props) {
|
||||
<VStack align="stretch" gap={6}>
|
||||
<HStack justify="space-between">
|
||||
<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>
|
||||
|
||||
<Tabs.Root defaultValue="content">
|
||||
@@ -135,71 +430,170 @@ export function GeneratedContentResult({ bundle, onReset }: Props) {
|
||||
))}
|
||||
</Tabs.List>
|
||||
|
||||
{bundle.platforms.map((item) => (
|
||||
<Tabs.Content key={item.platform} value={item.platform}>
|
||||
<Card.Root>
|
||||
<Card.Body>
|
||||
<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>
|
||||
{bundle.platforms.map((item) => {
|
||||
const isEditing = editingPlatform === item.platform;
|
||||
const currentContent = getContent(item.platform, item.content);
|
||||
const currentHashtags = getHashtags(item.platform, item.hashtags);
|
||||
|
||||
<Box
|
||||
p={4}
|
||||
bg="bg.subtle"
|
||||
borderRadius="md"
|
||||
whiteSpace="pre-wrap"
|
||||
fontFamily="mono"
|
||||
fontSize="sm"
|
||||
>
|
||||
{item.content}
|
||||
</Box>
|
||||
|
||||
<Box mt={2}>
|
||||
<Text fontWeight="medium" mb={1} fontSize="sm">Hashtags:</Text>
|
||||
<Text color="blue.500" fontSize="sm">{item.hashtags.join(" ")}</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>
|
||||
return (
|
||||
<Tabs.Content key={item.platform} value={item.platform}>
|
||||
<Card.Root>
|
||||
<Card.Body>
|
||||
<VStack align="stretch" gap={4}>
|
||||
<HStack justify="space-between">
|
||||
<HStack gap={2}>
|
||||
<Badge colorPalette="blue" variant="subtle">
|
||||
{currentContent.length} chars
|
||||
</Badge>
|
||||
{editedContents[item.platform] !== undefined && (
|
||||
<Badge colorPalette="orange" variant="subtle">
|
||||
Edited
|
||||
</Badge>
|
||||
)}
|
||||
</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}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handleCopyWithHashtags(item.content, item.hashtags, item.platform)}
|
||||
>
|
||||
<LuHash /> Copy + Hashtags
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handleDownloadText(item.content, item.platform)}
|
||||
>
|
||||
<LuDownload /> Download
|
||||
</Button>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</Card.Body>
|
||||
</Card.Root>
|
||||
</Tabs.Content>
|
||||
))}
|
||||
{/* Generated Image */}
|
||||
<Box position="relative" borderRadius="md" overflow="hidden" border="1px solid" borderColor="border.subtle">
|
||||
{(customImages[item.platform] || item.imageUrl) ? (
|
||||
<img
|
||||
src={customImages[item.platform] || item.imageUrl}
|
||||
alt={`Generated image for ${item.platform}`}
|
||||
style={{ width: '100%', maxHeight: '400px', objectFit: 'cover' }}
|
||||
onError={(e) => {
|
||||
e.currentTarget.src = 'https://placehold.co/600x400/png?text=Image+Not+Found';
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Box height="200px" bg="bg.subtle" display="flex" alignItems="center" justifyContent="center">
|
||||
<Text color="fg.muted">No image generated</Text>
|
||||
</Box>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
position="absolute"
|
||||
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.Content>
|
||||
|
||||
@@ -312,22 +706,116 @@ export function GeneratedContentResult({ bundle, onReset }: Props) {
|
||||
</Card.Body>
|
||||
</Card.Root>
|
||||
|
||||
<SimpleGrid columns={{ base: 1, md: 2 }} gap={4}>
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
{/* Keywords with Copy All */}
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<HStack justify="space-between">
|
||||
<Heading size="sm">Keywords</Heading>
|
||||
</Card.Header>
|
||||
<Card.Body>
|
||||
<HStack flexWrap="wrap" gap={2}>
|
||||
{bundle.seo?.keywords?.map((kw, idx) => (
|
||||
<Badge key={idx} variant="subtle" colorPalette="blue">
|
||||
{kw}
|
||||
</Badge>
|
||||
))}
|
||||
</HStack>
|
||||
</Card.Body>
|
||||
</Card.Root>
|
||||
{bundle.seo?.keywords && bundle.seo.keywords.length > 0 && (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(bundle.seo!.keywords.join(', '));
|
||||
toaster.success({ title: 'Keywords copied!' });
|
||||
}}
|
||||
>
|
||||
<LuCopy /> Copy All
|
||||
</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.Header>
|
||||
<Heading size="sm">Suggestions</Heading>
|
||||
@@ -344,6 +832,40 @@ export function GeneratedContentResult({ bundle, onReset }: Props) {
|
||||
</Card.Body>
|
||||
</Card.Root>
|
||||
</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>
|
||||
</Tabs.Content>
|
||||
|
||||
@@ -355,15 +877,15 @@ export function GeneratedContentResult({ bundle, onReset }: Props) {
|
||||
<HStack justify="space-between">
|
||||
<Heading size="md">Neuro Score</Heading>
|
||||
<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"
|
||||
>
|
||||
{bundle.neuro?.score || 0}/100
|
||||
{neuroScore || 0}/100
|
||||
</Badge>
|
||||
</HStack>
|
||||
</Card.Header>
|
||||
<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.Range />
|
||||
</Progress.Track>
|
||||
@@ -371,6 +893,36 @@ export function GeneratedContentResult({ bundle, onReset }: Props) {
|
||||
</Card.Body>
|
||||
</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}>
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
@@ -396,7 +948,7 @@ export function GeneratedContentResult({ bundle, onReset }: Props) {
|
||||
</Card.Header>
|
||||
<Card.Body>
|
||||
<VStack align="start" gap={2}>
|
||||
{bundle.neuro?.improvements?.map((imp, idx) => (
|
||||
{neuroImprovements?.map((imp, idx) => (
|
||||
<HStack key={idx}>
|
||||
<LuZap color="orange" />
|
||||
<Text fontSize="sm">{imp}</Text>
|
||||
@@ -424,6 +976,71 @@ export function GeneratedContentResult({ bundle, onReset }: Props) {
|
||||
</VStack>
|
||||
</Tabs.Content>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,14 +8,15 @@ import { Toaster } from "./feedback/toaster";
|
||||
import TopLoader from "./top-loader";
|
||||
import ReactQueryProvider from "@/provider/react-query-provider";
|
||||
|
||||
export function Provider(props: ColorModeProviderProps) {
|
||||
export function Provider({ children, session, ...props }: any) {
|
||||
return (
|
||||
<SessionProvider>
|
||||
<SessionProvider session={session}>
|
||||
<ReactQueryProvider>
|
||||
<ChakraProvider value={system}>
|
||||
<TopLoader />
|
||||
<ColorModeProvider {...props} />
|
||||
<Toaster />
|
||||
{children}
|
||||
</ChakraProvider>
|
||||
</ReactQueryProvider>
|
||||
</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