generated from fahricansecer/boilerplate-fe
main
Some checks failed
UI Deploy (Next-Auth Support) 🎨 / build-and-deploy (push) Failing after 2m44s
Some checks failed
UI Deploy (Next-Auth Support) 🎨 / build-and-deploy (push) Failing after 2m44s
This commit is contained in:
13
.env
Normal file
13
.env
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
|
||||||
|
# NextAuth Configuration
|
||||||
|
NEXTAUTH_URL=http://localhost:3001
|
||||||
|
NEXTAUTH_SECRET=dev_secret_key_change_in_production
|
||||||
|
|
||||||
|
# Backend API URL
|
||||||
|
NEXT_PUBLIC_API_URL=http://localhost:3000/api
|
||||||
|
|
||||||
|
# Auth Mode
|
||||||
|
NEXT_PUBLIC_AUTH_REQUIRED=false
|
||||||
|
|
||||||
|
# Third Party Keys (Placeholders)
|
||||||
|
NEXT_PUBLIC_GOOGLE_API_KEY='test-key'
|
||||||
32
messages/ar.json
Normal file
32
messages/ar.json
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"home": "Home",
|
||||||
|
"about": "About",
|
||||||
|
"solutions": "Solutions",
|
||||||
|
"intelligent-transportation-systems": "Intelligent Transportation Systems",
|
||||||
|
"artificial-intelligence": "Artificial Intelligence",
|
||||||
|
"error": {
|
||||||
|
"not-found": "Oops! The page you’re looking for doesn’t exist.",
|
||||||
|
"404": "404",
|
||||||
|
"back-to-home": "Go back home"
|
||||||
|
},
|
||||||
|
"email": "E-Mail",
|
||||||
|
"password": "Password",
|
||||||
|
"auth": {
|
||||||
|
"remember-me": "Remember Me",
|
||||||
|
"dont-have-account": "Don't have an account?",
|
||||||
|
"sign-out": "Sign Out",
|
||||||
|
"sign-up": "Sign Up",
|
||||||
|
"sign-in": "Sign In",
|
||||||
|
"welcome-back": "Welcome Back",
|
||||||
|
"subtitle": "Enter your email and password to sign in",
|
||||||
|
"already-have-an-account": "Already have an account?",
|
||||||
|
"create-an-account-now": "Create an account now"
|
||||||
|
},
|
||||||
|
"all-right-reserved": "All rights reserved.",
|
||||||
|
"privacy-policy": "Privacy Policy",
|
||||||
|
"terms-of-service": "Terms of Service",
|
||||||
|
"name": "Name",
|
||||||
|
"low": "Low",
|
||||||
|
"medium": "Medium",
|
||||||
|
"high": "High"
|
||||||
|
}
|
||||||
32
messages/de.json
Normal file
32
messages/de.json
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"home": "Home",
|
||||||
|
"about": "About",
|
||||||
|
"solutions": "Solutions",
|
||||||
|
"intelligent-transportation-systems": "Intelligent Transportation Systems",
|
||||||
|
"artificial-intelligence": "Artificial Intelligence",
|
||||||
|
"error": {
|
||||||
|
"not-found": "Oops! The page you’re looking for doesn’t exist.",
|
||||||
|
"404": "404",
|
||||||
|
"back-to-home": "Go back home"
|
||||||
|
},
|
||||||
|
"email": "E-Mail",
|
||||||
|
"password": "Password",
|
||||||
|
"auth": {
|
||||||
|
"remember-me": "Remember Me",
|
||||||
|
"dont-have-account": "Don't have an account?",
|
||||||
|
"sign-out": "Sign Out",
|
||||||
|
"sign-up": "Sign Up",
|
||||||
|
"sign-in": "Sign In",
|
||||||
|
"welcome-back": "Welcome Back",
|
||||||
|
"subtitle": "Enter your email and password to sign in",
|
||||||
|
"already-have-an-account": "Already have an account?",
|
||||||
|
"create-an-account-now": "Create an account now"
|
||||||
|
},
|
||||||
|
"all-right-reserved": "All rights reserved.",
|
||||||
|
"privacy-policy": "Privacy Policy",
|
||||||
|
"terms-of-service": "Terms of Service",
|
||||||
|
"name": "Name",
|
||||||
|
"low": "Low",
|
||||||
|
"medium": "Medium",
|
||||||
|
"high": "High"
|
||||||
|
}
|
||||||
32
messages/es.json
Normal file
32
messages/es.json
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"home": "Home",
|
||||||
|
"about": "About",
|
||||||
|
"solutions": "Solutions",
|
||||||
|
"intelligent-transportation-systems": "Intelligent Transportation Systems",
|
||||||
|
"artificial-intelligence": "Artificial Intelligence",
|
||||||
|
"error": {
|
||||||
|
"not-found": "Oops! The page you’re looking for doesn’t exist.",
|
||||||
|
"404": "404",
|
||||||
|
"back-to-home": "Go back home"
|
||||||
|
},
|
||||||
|
"email": "E-Mail",
|
||||||
|
"password": "Password",
|
||||||
|
"auth": {
|
||||||
|
"remember-me": "Remember Me",
|
||||||
|
"dont-have-account": "Don't have an account?",
|
||||||
|
"sign-out": "Sign Out",
|
||||||
|
"sign-up": "Sign Up",
|
||||||
|
"sign-in": "Sign In",
|
||||||
|
"welcome-back": "Welcome Back",
|
||||||
|
"subtitle": "Enter your email and password to sign in",
|
||||||
|
"already-have-an-account": "Already have an account?",
|
||||||
|
"create-an-account-now": "Create an account now"
|
||||||
|
},
|
||||||
|
"all-right-reserved": "All rights reserved.",
|
||||||
|
"privacy-policy": "Privacy Policy",
|
||||||
|
"terms-of-service": "Terms of Service",
|
||||||
|
"name": "Name",
|
||||||
|
"low": "Low",
|
||||||
|
"medium": "Medium",
|
||||||
|
"high": "High"
|
||||||
|
}
|
||||||
32
messages/fr.json
Normal file
32
messages/fr.json
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"home": "Home",
|
||||||
|
"about": "About",
|
||||||
|
"solutions": "Solutions",
|
||||||
|
"intelligent-transportation-systems": "Intelligent Transportation Systems",
|
||||||
|
"artificial-intelligence": "Artificial Intelligence",
|
||||||
|
"error": {
|
||||||
|
"not-found": "Oops! The page you’re looking for doesn’t exist.",
|
||||||
|
"404": "404",
|
||||||
|
"back-to-home": "Go back home"
|
||||||
|
},
|
||||||
|
"email": "E-Mail",
|
||||||
|
"password": "Password",
|
||||||
|
"auth": {
|
||||||
|
"remember-me": "Remember Me",
|
||||||
|
"dont-have-account": "Don't have an account?",
|
||||||
|
"sign-out": "Sign Out",
|
||||||
|
"sign-up": "Sign Up",
|
||||||
|
"sign-in": "Sign In",
|
||||||
|
"welcome-back": "Welcome Back",
|
||||||
|
"subtitle": "Enter your email and password to sign in",
|
||||||
|
"already-have-an-account": "Already have an account?",
|
||||||
|
"create-an-account-now": "Create an account now"
|
||||||
|
},
|
||||||
|
"all-right-reserved": "All rights reserved.",
|
||||||
|
"privacy-policy": "Privacy Policy",
|
||||||
|
"terms-of-service": "Terms of Service",
|
||||||
|
"name": "Name",
|
||||||
|
"low": "Low",
|
||||||
|
"medium": "Medium",
|
||||||
|
"high": "High"
|
||||||
|
}
|
||||||
32
messages/ja.json
Normal file
32
messages/ja.json
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"home": "Home",
|
||||||
|
"about": "About",
|
||||||
|
"solutions": "Solutions",
|
||||||
|
"intelligent-transportation-systems": "Intelligent Transportation Systems",
|
||||||
|
"artificial-intelligence": "Artificial Intelligence",
|
||||||
|
"error": {
|
||||||
|
"not-found": "Oops! The page you’re looking for doesn’t exist.",
|
||||||
|
"404": "404",
|
||||||
|
"back-to-home": "Go back home"
|
||||||
|
},
|
||||||
|
"email": "E-Mail",
|
||||||
|
"password": "Password",
|
||||||
|
"auth": {
|
||||||
|
"remember-me": "Remember Me",
|
||||||
|
"dont-have-account": "Don't have an account?",
|
||||||
|
"sign-out": "Sign Out",
|
||||||
|
"sign-up": "Sign Up",
|
||||||
|
"sign-in": "Sign In",
|
||||||
|
"welcome-back": "Welcome Back",
|
||||||
|
"subtitle": "Enter your email and password to sign in",
|
||||||
|
"already-have-an-account": "Already have an account?",
|
||||||
|
"create-an-account-now": "Create an account now"
|
||||||
|
},
|
||||||
|
"all-right-reserved": "All rights reserved.",
|
||||||
|
"privacy-policy": "Privacy Policy",
|
||||||
|
"terms-of-service": "Terms of Service",
|
||||||
|
"name": "Name",
|
||||||
|
"low": "Low",
|
||||||
|
"medium": "Medium",
|
||||||
|
"high": "High"
|
||||||
|
}
|
||||||
32
messages/pt.json
Normal file
32
messages/pt.json
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"home": "Home",
|
||||||
|
"about": "About",
|
||||||
|
"solutions": "Solutions",
|
||||||
|
"intelligent-transportation-systems": "Intelligent Transportation Systems",
|
||||||
|
"artificial-intelligence": "Artificial Intelligence",
|
||||||
|
"error": {
|
||||||
|
"not-found": "Oops! The page you’re looking for doesn’t exist.",
|
||||||
|
"404": "404",
|
||||||
|
"back-to-home": "Go back home"
|
||||||
|
},
|
||||||
|
"email": "E-Mail",
|
||||||
|
"password": "Password",
|
||||||
|
"auth": {
|
||||||
|
"remember-me": "Remember Me",
|
||||||
|
"dont-have-account": "Don't have an account?",
|
||||||
|
"sign-out": "Sign Out",
|
||||||
|
"sign-up": "Sign Up",
|
||||||
|
"sign-in": "Sign In",
|
||||||
|
"welcome-back": "Welcome Back",
|
||||||
|
"subtitle": "Enter your email and password to sign in",
|
||||||
|
"already-have-an-account": "Already have an account?",
|
||||||
|
"create-an-account-now": "Create an account now"
|
||||||
|
},
|
||||||
|
"all-right-reserved": "All rights reserved.",
|
||||||
|
"privacy-policy": "Privacy Policy",
|
||||||
|
"terms-of-service": "Terms of Service",
|
||||||
|
"name": "Name",
|
||||||
|
"low": "Low",
|
||||||
|
"medium": "Medium",
|
||||||
|
"high": "High"
|
||||||
|
}
|
||||||
32
messages/ru.json
Normal file
32
messages/ru.json
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"home": "Home",
|
||||||
|
"about": "About",
|
||||||
|
"solutions": "Solutions",
|
||||||
|
"intelligent-transportation-systems": "Intelligent Transportation Systems",
|
||||||
|
"artificial-intelligence": "Artificial Intelligence",
|
||||||
|
"error": {
|
||||||
|
"not-found": "Oops! The page you’re looking for doesn’t exist.",
|
||||||
|
"404": "404",
|
||||||
|
"back-to-home": "Go back home"
|
||||||
|
},
|
||||||
|
"email": "E-Mail",
|
||||||
|
"password": "Password",
|
||||||
|
"auth": {
|
||||||
|
"remember-me": "Remember Me",
|
||||||
|
"dont-have-account": "Don't have an account?",
|
||||||
|
"sign-out": "Sign Out",
|
||||||
|
"sign-up": "Sign Up",
|
||||||
|
"sign-in": "Sign In",
|
||||||
|
"welcome-back": "Welcome Back",
|
||||||
|
"subtitle": "Enter your email and password to sign in",
|
||||||
|
"already-have-an-account": "Already have an account?",
|
||||||
|
"create-an-account-now": "Create an account now"
|
||||||
|
},
|
||||||
|
"all-right-reserved": "All rights reserved.",
|
||||||
|
"privacy-policy": "Privacy Policy",
|
||||||
|
"terms-of-service": "Terms of Service",
|
||||||
|
"name": "Name",
|
||||||
|
"low": "Low",
|
||||||
|
"medium": "Medium",
|
||||||
|
"high": "High"
|
||||||
|
}
|
||||||
32
messages/zh.json
Normal file
32
messages/zh.json
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"home": "Home",
|
||||||
|
"about": "About",
|
||||||
|
"solutions": "Solutions",
|
||||||
|
"intelligent-transportation-systems": "Intelligent Transportation Systems",
|
||||||
|
"artificial-intelligence": "Artificial Intelligence",
|
||||||
|
"error": {
|
||||||
|
"not-found": "Oops! The page you’re looking for doesn’t exist.",
|
||||||
|
"404": "404",
|
||||||
|
"back-to-home": "Go back home"
|
||||||
|
},
|
||||||
|
"email": "E-Mail",
|
||||||
|
"password": "Password",
|
||||||
|
"auth": {
|
||||||
|
"remember-me": "Remember Me",
|
||||||
|
"dont-have-account": "Don't have an account?",
|
||||||
|
"sign-out": "Sign Out",
|
||||||
|
"sign-up": "Sign Up",
|
||||||
|
"sign-in": "Sign In",
|
||||||
|
"welcome-back": "Welcome Back",
|
||||||
|
"subtitle": "Enter your email and password to sign in",
|
||||||
|
"already-have-an-account": "Already have an account?",
|
||||||
|
"create-an-account-now": "Create an account now"
|
||||||
|
},
|
||||||
|
"all-right-reserved": "All rights reserved.",
|
||||||
|
"privacy-policy": "Privacy Policy",
|
||||||
|
"terms-of-service": "Terms of Service",
|
||||||
|
"name": "Name",
|
||||||
|
"low": "Low",
|
||||||
|
"medium": "Medium",
|
||||||
|
"high": "High"
|
||||||
|
}
|
||||||
2
next-env.d.ts
vendored
2
next-env.d.ts
vendored
@@ -1,6 +1,6 @@
|
|||||||
/// <reference types="next" />
|
/// <reference types="next" />
|
||||||
/// <reference types="next/image-types/global" />
|
/// <reference types="next/image-types/global" />
|
||||||
import "./.next/types/routes.d.ts";
|
import "./.next/dev/types/routes.d.ts";
|
||||||
|
|
||||||
// NOTE: This file should not be edited
|
// NOTE: This file should not be edited
|
||||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ const nextConfig: NextConfig = {
|
|||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
source: "/api/backend/:path*",
|
source: "/api/backend/:path*",
|
||||||
destination: "http://localhost:3000/api/:path*",
|
destination: "http://localhost:3001/api/:path*",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
|
|||||||
20
package-lock.json
generated
20
package-lock.json
generated
@@ -146,7 +146,6 @@
|
|||||||
"integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==",
|
"integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.28.6",
|
"@babel/code-frame": "^7.28.6",
|
||||||
"@babel/generator": "^7.28.6",
|
"@babel/generator": "^7.28.6",
|
||||||
@@ -556,7 +555,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@chakra-ui/react/-/react-3.31.0.tgz",
|
"resolved": "https://registry.npmjs.org/@chakra-ui/react/-/react-3.31.0.tgz",
|
||||||
"integrity": "sha512-puvrZOfnfMA+DckDcz0UxO20l7TVhwsdQ9ksCv4nIUB430yuWzon0yo9fM10lEr3hd7BhjZARpMCVw5u280clw==",
|
"integrity": "sha512-puvrZOfnfMA+DckDcz0UxO20l7TVhwsdQ9ksCv4nIUB430yuWzon0yo9fM10lEr3hd7BhjZARpMCVw5u280clw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ark-ui/react": "^5.29.1",
|
"@ark-ui/react": "^5.29.1",
|
||||||
"@emotion/is-prop-valid": "^1.4.0",
|
"@emotion/is-prop-valid": "^1.4.0",
|
||||||
@@ -692,7 +690,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz",
|
"resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz",
|
||||||
"integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==",
|
"integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/runtime": "^7.18.3",
|
"@babel/runtime": "^7.18.3",
|
||||||
"@emotion/babel-plugin": "^11.13.5",
|
"@emotion/babel-plugin": "^11.13.5",
|
||||||
@@ -1986,7 +1983,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.10.0.tgz",
|
"resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.10.0.tgz",
|
||||||
"integrity": "sha512-oxDR/NTEJ1k+UFVQElaNIk65E/Z83HK1z1WI3lQyhTtnNg4R5oVXaPzK3jcpKG8UHKDVuDQHzn+wsxSz8RP3aw==",
|
"integrity": "sha512-oxDR/NTEJ1k+UFVQElaNIk65E/Z83HK1z1WI3lQyhTtnNg4R5oVXaPzK3jcpKG8UHKDVuDQHzn+wsxSz8RP3aw==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@swc/helpers": "^0.5.0"
|
"@swc/helpers": "^0.5.0"
|
||||||
}
|
}
|
||||||
@@ -2905,7 +2901,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.18.tgz",
|
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.18.tgz",
|
||||||
"integrity": "sha512-TXTnIcNJQEKwThMMqBXsZ4VGAza6bvN4pa41Rkqoio6QBKMvo+5lexeTMScGCIxtzgQJzElcvIltani+adC5PQ==",
|
"integrity": "sha512-TXTnIcNJQEKwThMMqBXsZ4VGAza6bvN4pa41Rkqoio6QBKMvo+5lexeTMScGCIxtzgQJzElcvIltani+adC5PQ==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"tslib": "^2.8.0"
|
"tslib": "^2.8.0"
|
||||||
}
|
}
|
||||||
@@ -3067,7 +3062,6 @@
|
|||||||
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
|
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"csstype": "^3.0.2"
|
"csstype": "^3.0.2"
|
||||||
}
|
}
|
||||||
@@ -3127,7 +3121,6 @@
|
|||||||
"integrity": "sha512-BtE0k6cjwjLZoZixN0t5AKP0kSzlGu7FctRXYuPAm//aaiZhmfq1JwdYpYr1brzEspYyFeF+8XF5j2VK6oalrA==",
|
"integrity": "sha512-BtE0k6cjwjLZoZixN0t5AKP0kSzlGu7FctRXYuPAm//aaiZhmfq1JwdYpYr1brzEspYyFeF+8XF5j2VK6oalrA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/scope-manager": "8.54.0",
|
"@typescript-eslint/scope-manager": "8.54.0",
|
||||||
"@typescript-eslint/types": "8.54.0",
|
"@typescript-eslint/types": "8.54.0",
|
||||||
@@ -4555,7 +4548,6 @@
|
|||||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"acorn": "bin/acorn"
|
"acorn": "bin/acorn"
|
||||||
},
|
},
|
||||||
@@ -4921,7 +4913,6 @@
|
|||||||
"integrity": "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw==",
|
"integrity": "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/types": "^7.26.0"
|
"@babel/types": "^7.26.0"
|
||||||
}
|
}
|
||||||
@@ -5028,7 +5019,6 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"baseline-browser-mapping": "^2.9.0",
|
"baseline-browser-mapping": "^2.9.0",
|
||||||
"caniuse-lite": "^1.0.30001759",
|
"caniuse-lite": "^1.0.30001759",
|
||||||
@@ -5781,7 +5771,6 @@
|
|||||||
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
|
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/eslint-utils": "^4.8.0",
|
"@eslint-community/eslint-utils": "^4.8.0",
|
||||||
"@eslint-community/regexpp": "^4.12.1",
|
"@eslint-community/regexpp": "^4.12.1",
|
||||||
@@ -5983,7 +5972,6 @@
|
|||||||
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
|
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@rtsao/scc": "^1.1.0",
|
"@rtsao/scc": "^1.1.0",
|
||||||
"array-includes": "^3.1.9",
|
"array-includes": "^3.1.9",
|
||||||
@@ -7935,7 +7923,6 @@
|
|||||||
"integrity": "sha512-nYohiNdxGu4OmBzggxy9rczmjIGI+TpR5vbKTsE1HqYwNm1B+YSiugSrFguX6omMOKnDHAmBPY4+8TNJk0Idyg==",
|
"integrity": "sha512-nYohiNdxGu4OmBzggxy9rczmjIGI+TpR5vbKTsE1HqYwNm1B+YSiugSrFguX6omMOKnDHAmBPY4+8TNJk0Idyg==",
|
||||||
"deprecated": "This version has a security vulnerability. Please upgrade to a patched version. See https://nextjs.org/blog/CVE-2025-66478 for more details.",
|
"deprecated": "This version has a security vulnerability. Please upgrade to a patched version. See https://nextjs.org/blog/CVE-2025-66478 for more details.",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@next/env": "16.0.0",
|
"@next/env": "16.0.0",
|
||||||
"@swc/helpers": "0.5.15",
|
"@swc/helpers": "0.5.15",
|
||||||
@@ -8599,7 +8586,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/preact/-/preact-10.28.2.tgz",
|
"resolved": "https://registry.npmjs.org/preact/-/preact-10.28.2.tgz",
|
||||||
"integrity": "sha512-lbteaWGzGHdlIuiJ0l2Jq454m6kcpI1zNje6d8MlGAFlYvP2GO4ibnat7P74Esfz4sPTdM6UxtTwh/d3pwM9JA==",
|
"integrity": "sha512-lbteaWGzGHdlIuiJ0l2Jq454m6kcpI1zNje6d8MlGAFlYvP2GO4ibnat7P74Esfz4sPTdM6UxtTwh/d3pwM9JA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "opencollective",
|
"type": "opencollective",
|
||||||
"url": "https://opencollective.com/preact"
|
"url": "https://opencollective.com/preact"
|
||||||
@@ -8747,7 +8733,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
|
||||||
"integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==",
|
"integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
@@ -8757,7 +8742,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz",
|
||||||
"integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==",
|
"integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"scheduler": "^0.27.0"
|
"scheduler": "^0.27.0"
|
||||||
},
|
},
|
||||||
@@ -8770,7 +8754,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.71.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.71.1.tgz",
|
||||||
"integrity": "sha512-9SUJKCGKo8HUSsCO+y0CtqkqI5nNuaDqTxyqPsZPqIwudpj4rCrAz/jZV+jn57bx5gtZKOh3neQu94DXMc+w5w==",
|
"integrity": "sha512-9SUJKCGKo8HUSsCO+y0CtqkqI5nNuaDqTxyqPsZPqIwudpj4rCrAz/jZV+jn57bx5gtZKOh3neQu94DXMc+w5w==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18.0.0"
|
"node": ">=18.0.0"
|
||||||
},
|
},
|
||||||
@@ -9683,7 +9666,6 @@
|
|||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
@@ -9864,7 +9846,6 @@
|
|||||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
"tsserver": "bin/tsserver"
|
"tsserver": "bin/tsserver"
|
||||||
@@ -10319,7 +10300,6 @@
|
|||||||
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/colinhacks"
|
"url": "https://github.com/sponsors/colinhacks"
|
||||||
}
|
}
|
||||||
|
|||||||
22
src/app/[locale]/(dashboard)/analytics/page.tsx
Normal file
22
src/app/[locale]/(dashboard)/analytics/page.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
import { Box, Heading, Text, Center } from "@chakra-ui/react";
|
||||||
|
import { LuTrendingUp } from "react-icons/lu";
|
||||||
|
|
||||||
|
export async function generateMetadata() {
|
||||||
|
const t = await getTranslations();
|
||||||
|
return {
|
||||||
|
title: `Analytics | Content Hunter`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AnalyticsPage() {
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Heading size="2xl" mb={4}>Analytics & Insights</Heading>
|
||||||
|
<Center h="50vh" flexDirection="column" color="fg.muted">
|
||||||
|
<LuTrendingUp size={48} />
|
||||||
|
<Text mt={4} fontSize="lg">Analytics dashboard coming soon.</Text>
|
||||||
|
</Center>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
19
src/app/[locale]/(dashboard)/content/page.tsx
Normal file
19
src/app/[locale]/(dashboard)/content/page.tsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
import { Box, Heading } from "@chakra-ui/react";
|
||||||
|
import { ContentTable } from "@/components/content/ContentTable";
|
||||||
|
|
||||||
|
export async function generateMetadata() {
|
||||||
|
const t = await getTranslations();
|
||||||
|
return {
|
||||||
|
title: `Content | Content Hunter`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ContentPage() {
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Heading size="2xl" mb={6}>Content Management</Heading>
|
||||||
|
<ContentTable />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
22
src/app/[locale]/(dashboard)/generate/page.tsx
Normal file
22
src/app/[locale]/(dashboard)/generate/page.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { Box, Heading } from "@chakra-ui/react";
|
||||||
|
import { GenerateWizard } from "@/components/generate/GenerateWizard";
|
||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
import { Suspense } from "react";
|
||||||
|
|
||||||
|
export async function generateMetadata() {
|
||||||
|
const t = await getTranslations();
|
||||||
|
return {
|
||||||
|
title: `Generate Content | Content Hunter`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function GeneratePage() {
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Heading size="2xl" mb={6}>Content Generator</Heading>
|
||||||
|
<Suspense fallback={<Box>Loading...</Box>}>
|
||||||
|
<GenerateWizard />
|
||||||
|
</Suspense>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
67
src/app/[locale]/(dashboard)/home/page.tsx
Normal file
67
src/app/[locale]/(dashboard)/home/page.tsx
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
import { Box, Heading, Text, SimpleGrid, Card } from "@chakra-ui/react";
|
||||||
|
import { LuFileText, LuCalendar, LuTrendingUp } from "react-icons/lu";
|
||||||
|
import { Link } from '@/i18n/navigation';
|
||||||
|
|
||||||
|
export async function generateMetadata() {
|
||||||
|
const t = await getTranslations();
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: `${t("home")} | Content Hunter`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Box mb={8}>
|
||||||
|
<Heading size="2xl" mb={2} fontWeight="bold">Welcome back, Hunter!</Heading>
|
||||||
|
<Text color="fg.muted" fontSize="lg">Here's what's happening with your content today.</Text>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<SimpleGrid columns={{ base: 1, md: 3 }} gap={6}>
|
||||||
|
<Link href="/content">
|
||||||
|
<Card.Root _hover={{ transform: 'translateY(-4px)', shadow: 'md', transition: 'all 0.2s' }}>
|
||||||
|
<Card.Body>
|
||||||
|
<Box mb={4} color="primary.solid">
|
||||||
|
<LuFileText size={24} />
|
||||||
|
</Box>
|
||||||
|
<Card.Title mb={2}>Content Pieces</Card.Title>
|
||||||
|
<Card.Description>
|
||||||
|
You have 12 active content pieces in your pipeline.
|
||||||
|
</Card.Description>
|
||||||
|
</Card.Body>
|
||||||
|
</Card.Root>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<Link href="/schedule">
|
||||||
|
<Card.Root _hover={{ transform: 'translateY(-4px)', shadow: 'md', transition: 'all 0.2s' }}>
|
||||||
|
<Card.Body>
|
||||||
|
<Box mb={4} color="primary.solid">
|
||||||
|
<LuCalendar size={24} />
|
||||||
|
</Box>
|
||||||
|
<Card.Title mb={2}>Scheduled Posts</Card.Title>
|
||||||
|
<Card.Description>
|
||||||
|
5 posts are scheduled for meaningful engagement today.
|
||||||
|
</Card.Description>
|
||||||
|
</Card.Body>
|
||||||
|
</Card.Root>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<Link href="/analytics">
|
||||||
|
<Card.Root _hover={{ transform: 'translateY(-4px)', shadow: 'md', transition: 'all 0.2s' }}>
|
||||||
|
<Card.Body>
|
||||||
|
<Box mb={4} color="primary.solid">
|
||||||
|
<LuTrendingUp size={24} />
|
||||||
|
</Box>
|
||||||
|
<Card.Title mb={2}>Analytics</Card.Title>
|
||||||
|
<Card.Description>
|
||||||
|
Your engagement rate increased by 15% this week.
|
||||||
|
</Card.Description>
|
||||||
|
</Card.Body>
|
||||||
|
</Card.Root>
|
||||||
|
</Link>
|
||||||
|
</SimpleGrid>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
7
src/app/[locale]/(dashboard)/layout.tsx
Normal file
7
src/app/[locale]/(dashboard)/layout.tsx
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { DashboardLayout } from '@/components/layout/dashboard/DashboardLayout';
|
||||||
|
|
||||||
|
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||||
|
return <DashboardLayout>{children}</DashboardLayout>;
|
||||||
|
}
|
||||||
22
src/app/[locale]/(dashboard)/schedule/page.tsx
Normal file
22
src/app/[locale]/(dashboard)/schedule/page.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
import { Box, Heading, Text, Center } from "@chakra-ui/react";
|
||||||
|
import { LuCalendar } from "react-icons/lu";
|
||||||
|
|
||||||
|
export async function generateMetadata() {
|
||||||
|
const t = await getTranslations();
|
||||||
|
return {
|
||||||
|
title: `Schedule | Content Hunter`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SchedulePage() {
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Heading size="2xl" mb={4}>Schedule & Calendar</Heading>
|
||||||
|
<Center h="50vh" flexDirection="column" color="fg.muted">
|
||||||
|
<LuCalendar size={48} />
|
||||||
|
<Text mt={4} fontSize="lg">Calendar and scheduling tools coming soon.</Text>
|
||||||
|
</Center>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
22
src/app/[locale]/(dashboard)/settings/page.tsx
Normal file
22
src/app/[locale]/(dashboard)/settings/page.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
import { Box, Heading, Text, Center } from "@chakra-ui/react";
|
||||||
|
import { LuSettings } from "react-icons/lu";
|
||||||
|
|
||||||
|
export async function generateMetadata() {
|
||||||
|
const t = await getTranslations();
|
||||||
|
return {
|
||||||
|
title: `Settings | Content Hunter`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SettingsPage() {
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Heading size="2xl" mb={4}>Settings</Heading>
|
||||||
|
<Center h="50vh" flexDirection="column" color="fg.muted">
|
||||||
|
<LuSettings size={48} />
|
||||||
|
<Text mt={4} fontSize="lg">Global application settings coming soon.</Text>
|
||||||
|
</Center>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
321
src/app/[locale]/(dashboard)/source-accounts/page.tsx
Normal file
321
src/app/[locale]/(dashboard)/source-accounts/page.tsx
Normal file
@@ -0,0 +1,321 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Box, Heading, VStack, Text, Card, HStack, Badge, Button, Input, SimpleGrid, Spinner, Icon, Table, IconButton, Dialog, Field } from "@chakra-ui/react";
|
||||||
|
import { LuPlus, LuTrash, LuLink, LuRefreshCw, LuSparkles, LuEye, LuTrendingUp, LuUser } from "react-icons/lu";
|
||||||
|
import { toaster } from "@/components/ui/feedback/toaster";
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { useSession } from "next-auth/react";
|
||||||
|
import { useRouter } from "@/i18n/navigation";
|
||||||
|
|
||||||
|
interface SourceAccount {
|
||||||
|
id: string;
|
||||||
|
platform: string;
|
||||||
|
username: string;
|
||||||
|
url: string;
|
||||||
|
followersCount?: number;
|
||||||
|
postsAnalyzed?: number;
|
||||||
|
averageEngagement?: number;
|
||||||
|
lastAnalyzed?: string;
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AnalyzedPost {
|
||||||
|
id: string;
|
||||||
|
content: string;
|
||||||
|
engagement: number;
|
||||||
|
likes: number;
|
||||||
|
comments: number;
|
||||||
|
postedAt: string;
|
||||||
|
viralScore?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SourceAccountsPage() {
|
||||||
|
const { data: session } = useSession();
|
||||||
|
const router = useRouter();
|
||||||
|
const [accounts, setAccounts] = useState<SourceAccount[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [isAdding, setIsAdding] = useState(false);
|
||||||
|
const [showAddModal, setShowAddModal] = useState(false);
|
||||||
|
const [newAccountUrl, setNewAccountUrl] = useState("");
|
||||||
|
const [selectedAccount, setSelectedAccount] = useState<SourceAccount | null>(null);
|
||||||
|
const [posts, setPosts] = useState<AnalyzedPost[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchAccounts();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const getHeaders = (): HeadersInit => {
|
||||||
|
const headers: HeadersInit = { 'Content-Type': 'application/json' };
|
||||||
|
if (session?.accessToken) {
|
||||||
|
headers['Authorization'] = `Bearer ${session.accessToken}`;
|
||||||
|
}
|
||||||
|
return headers;
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchAccounts = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/backend/source-accounts', { headers: getHeaders() });
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setAccounts(Array.isArray(data) ? data : []);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Fetch error:', error);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddAccount = async () => {
|
||||||
|
if (!newAccountUrl.trim()) {
|
||||||
|
toaster.create({ title: "Please enter a URL", type: "warning" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsAdding(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/backend/source-accounts', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: getHeaders(),
|
||||||
|
body: JSON.stringify({ url: newAccountUrl }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
toaster.create({ title: "Source account added", type: "success" });
|
||||||
|
setNewAccountUrl("");
|
||||||
|
setShowAddModal(false);
|
||||||
|
fetchAccounts();
|
||||||
|
} else {
|
||||||
|
throw new Error('Failed to add account');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toaster.create({ title: "Failed to add account", type: "error" });
|
||||||
|
} finally {
|
||||||
|
setIsAdding(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteAccount = async (id: string) => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/backend/source-accounts/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: getHeaders(),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
toaster.create({ title: "Account removed", type: "success" });
|
||||||
|
setAccounts(accounts.filter(a => a.id !== id));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toaster.create({ title: "Failed to remove account", type: "error" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAnalyze = async (account: SourceAccount) => {
|
||||||
|
toaster.create({ title: "Analyzing account...", type: "info" });
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/backend/source-accounts/${account.id}/analyze`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: getHeaders(),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
toaster.create({ title: "Analysis complete", type: "success" });
|
||||||
|
fetchAccounts();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toaster.create({ title: "Analysis failed", type: "error" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleViewPosts = async (account: SourceAccount) => {
|
||||||
|
setSelectedAccount(account);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/backend/source-accounts/${account.id}/posts`, {
|
||||||
|
headers: getHeaders()
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setPosts(Array.isArray(data) ? data : []);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching posts:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInspireContent = (post?: AnalyzedPost) => {
|
||||||
|
const topic = post?.content?.slice(0, 100) || selectedAccount?.username;
|
||||||
|
router.push(`/generate?topic=${encodeURIComponent(topic || '')}&mode=inspire`);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<VStack align="stretch" gap={6} p={6}>
|
||||||
|
{/* Header */}
|
||||||
|
<HStack justify="space-between">
|
||||||
|
<Box>
|
||||||
|
<Heading size="xl">Kaynak Hesaplar</Heading>
|
||||||
|
<Text color="fg.muted">Başarılı hesapları takip et ve içerik ilhamı al</Text>
|
||||||
|
</Box>
|
||||||
|
<Button colorPalette="blue" onClick={() => setShowAddModal(true)}>
|
||||||
|
<LuPlus /> Hesap Ekle
|
||||||
|
</Button>
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
{/* Accounts Grid */}
|
||||||
|
{isLoading ? (
|
||||||
|
<VStack py={10}><Spinner size="lg" /></VStack>
|
||||||
|
) : accounts.length > 0 ? (
|
||||||
|
<SimpleGrid columns={{ base: 1, md: 2, lg: 3 }} gap={4}>
|
||||||
|
{accounts.map((account) => (
|
||||||
|
<Card.Root key={account.id}>
|
||||||
|
<Card.Header pb={2}>
|
||||||
|
<HStack justify="space-between">
|
||||||
|
<HStack>
|
||||||
|
<Icon as={LuUser} boxSize={5} />
|
||||||
|
<Heading size="md">@{account.username}</Heading>
|
||||||
|
</HStack>
|
||||||
|
<Badge colorPalette={account.status === 'active' ? 'green' : 'gray'}>
|
||||||
|
{account.platform}
|
||||||
|
</Badge>
|
||||||
|
</HStack>
|
||||||
|
</Card.Header>
|
||||||
|
<Card.Body>
|
||||||
|
<VStack align="start" gap={3}>
|
||||||
|
<HStack w="100%" justify="space-between">
|
||||||
|
<Text fontSize="sm" color="fg.muted">
|
||||||
|
<Icon as={LuTrendingUp} /> {account.followersCount?.toLocaleString() || 0} takipçi
|
||||||
|
</Text>
|
||||||
|
<Text fontSize="sm" color="fg.muted">
|
||||||
|
{account.postsAnalyzed || 0} post analiz
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
{account.averageEngagement && (
|
||||||
|
<Badge colorPalette="blue" variant="subtle">
|
||||||
|
Avg. Engagement: {account.averageEngagement.toFixed(1)}%
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<HStack w="100%" justify="space-between" pt={2}>
|
||||||
|
<Button size="sm" variant="outline" onClick={() => handleViewPosts(account)}>
|
||||||
|
<LuEye /> Postlar
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="outline" onClick={() => handleAnalyze(account)}>
|
||||||
|
<LuRefreshCw /> Analiz Et
|
||||||
|
</Button>
|
||||||
|
<IconButton
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
colorPalette="red"
|
||||||
|
onClick={() => handleDeleteAccount(account.id)}
|
||||||
|
aria-label="Delete"
|
||||||
|
>
|
||||||
|
<LuTrash />
|
||||||
|
</IconButton>
|
||||||
|
</HStack>
|
||||||
|
</VStack>
|
||||||
|
</Card.Body>
|
||||||
|
</Card.Root>
|
||||||
|
))}
|
||||||
|
</SimpleGrid>
|
||||||
|
) : (
|
||||||
|
<Card.Root>
|
||||||
|
<Card.Body>
|
||||||
|
<VStack py={10} gap={4}>
|
||||||
|
<Icon as={LuLink} boxSize={12} color="fg.muted" />
|
||||||
|
<Text color="fg.muted" textAlign="center">
|
||||||
|
Henüz kaynak hesap eklenmedi.<br />
|
||||||
|
Başarılı hesapları ekleyerek içerik ilhamı alın.
|
||||||
|
</Text>
|
||||||
|
<Button colorPalette="blue" onClick={() => setShowAddModal(true)}>
|
||||||
|
<LuPlus /> İlk Hesabı Ekle
|
||||||
|
</Button>
|
||||||
|
</VStack>
|
||||||
|
</Card.Body>
|
||||||
|
</Card.Root>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Posts Modal */}
|
||||||
|
{selectedAccount && (
|
||||||
|
<Dialog.Root open={!!selectedAccount} onOpenChange={() => setSelectedAccount(null)}>
|
||||||
|
<Dialog.Backdrop />
|
||||||
|
<Dialog.Positioner>
|
||||||
|
<Dialog.Content maxW="800px">
|
||||||
|
<Dialog.Header>
|
||||||
|
<Dialog.Title>@{selectedAccount.username} Postları</Dialog.Title>
|
||||||
|
</Dialog.Header>
|
||||||
|
<Dialog.Body>
|
||||||
|
<VStack gap={3} align="stretch">
|
||||||
|
{posts.map((post) => (
|
||||||
|
<Card.Root key={post.id} size="sm">
|
||||||
|
<Card.Body>
|
||||||
|
<VStack align="start" gap={2}>
|
||||||
|
<Text fontSize="sm">{post.content}</Text>
|
||||||
|
<HStack justify="space-between" w="100%">
|
||||||
|
<HStack gap={2}>
|
||||||
|
<Badge>❤️ {post.likes}</Badge>
|
||||||
|
<Badge>💬 {post.comments}</Badge>
|
||||||
|
{post.viralScore && (
|
||||||
|
<Badge colorPalette="purple">
|
||||||
|
🔥 Viral: {post.viralScore}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</HStack>
|
||||||
|
<Button size="xs" colorPalette="green" onClick={() => handleInspireContent(post)}>
|
||||||
|
<LuSparkles /> İçerik Oluştur
|
||||||
|
</Button>
|
||||||
|
</HStack>
|
||||||
|
</VStack>
|
||||||
|
</Card.Body>
|
||||||
|
</Card.Root>
|
||||||
|
))}
|
||||||
|
{posts.length === 0 && (
|
||||||
|
<Text color="fg.muted" textAlign="center" py={4}>
|
||||||
|
Henüz analiz edilmiş post yok
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</VStack>
|
||||||
|
</Dialog.Body>
|
||||||
|
<Dialog.Footer>
|
||||||
|
<Button variant="outline" onClick={() => setSelectedAccount(null)}>
|
||||||
|
Kapat
|
||||||
|
</Button>
|
||||||
|
</Dialog.Footer>
|
||||||
|
</Dialog.Content>
|
||||||
|
</Dialog.Positioner>
|
||||||
|
</Dialog.Root>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Add Account Modal */}
|
||||||
|
<Dialog.Root open={showAddModal} onOpenChange={() => setShowAddModal(false)}>
|
||||||
|
<Dialog.Backdrop />
|
||||||
|
<Dialog.Positioner>
|
||||||
|
<Dialog.Content>
|
||||||
|
<Dialog.Header>
|
||||||
|
<Dialog.Title>Kaynak Hesap Ekle</Dialog.Title>
|
||||||
|
</Dialog.Header>
|
||||||
|
<Dialog.Body>
|
||||||
|
<VStack gap={4}>
|
||||||
|
<Field.Root>
|
||||||
|
<Field.Label>Hesap URL'si</Field.Label>
|
||||||
|
<Input
|
||||||
|
placeholder="https://twitter.com/username veya https://instagram.com/username"
|
||||||
|
value={newAccountUrl}
|
||||||
|
onChange={(e) => setNewAccountUrl(e.target.value)}
|
||||||
|
/>
|
||||||
|
<Field.HelperText>Twitter, Instagram, LinkedIn veya TikTok hesap linki</Field.HelperText>
|
||||||
|
</Field.Root>
|
||||||
|
</VStack>
|
||||||
|
</Dialog.Body>
|
||||||
|
<Dialog.Footer>
|
||||||
|
<Button variant="outline" onClick={() => setShowAddModal(false)}>İptal</Button>
|
||||||
|
<Button colorPalette="blue" onClick={handleAddAccount} disabled={isAdding}>
|
||||||
|
{isAdding ? <Spinner size="sm" /> : <LuPlus />} Ekle
|
||||||
|
</Button>
|
||||||
|
</Dialog.Footer>
|
||||||
|
</Dialog.Content>
|
||||||
|
</Dialog.Positioner>
|
||||||
|
</Dialog.Root>
|
||||||
|
</VStack>
|
||||||
|
);
|
||||||
|
}
|
||||||
371
src/app/[locale]/(dashboard)/trends/page.tsx
Normal file
371
src/app/[locale]/(dashboard)/trends/page.tsx
Normal file
@@ -0,0 +1,371 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
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 { useSession } from "next-auth/react";
|
||||||
|
import { useRouter } from "@/i18n/navigation";
|
||||||
|
|
||||||
|
interface Trend {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
score: number;
|
||||||
|
volume?: number;
|
||||||
|
source: string;
|
||||||
|
keywords: string[];
|
||||||
|
relatedTopics: string[];
|
||||||
|
url?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TrendsPage() {
|
||||||
|
const { data: session } = useSession();
|
||||||
|
const router = useRouter();
|
||||||
|
const [niche, setNiche] = useState("");
|
||||||
|
const [trends, setTrends] = useState<Trend[]>([]);
|
||||||
|
const [isScanning, setIsScanning] = useState(false);
|
||||||
|
const [isDeepScanning, setIsDeepScanning] = useState(false);
|
||||||
|
const [selectedTrend, setSelectedTrend] = useState<Trend | null>(null);
|
||||||
|
const [scanCount, setScanCount] = useState(0);
|
||||||
|
const [translatingId, setTranslatingId] = useState<string | null>(null);
|
||||||
|
const [translatedContent, setTranslatedContent] = useState<Record<string, { title: string; description?: string }>>({});
|
||||||
|
|
||||||
|
const handleScanTrends = async () => {
|
||||||
|
if (!niche.trim()) {
|
||||||
|
toaster.create({ title: "Please enter a niche or topic", type: "warning" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsScanning(true);
|
||||||
|
try {
|
||||||
|
const headers: HeadersInit = { 'Content-Type': 'application/json' };
|
||||||
|
if (session?.accessToken) {
|
||||||
|
headers['Authorization'] = `Bearer ${session.accessToken}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch('/api/backend/trends/scan', {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify({
|
||||||
|
keywords: [niche],
|
||||||
|
country: 'TR',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
const response = await res.json();
|
||||||
|
// Backend wraps response in { success, status, message, data }
|
||||||
|
const trendsData = response.data || response.trends || response;
|
||||||
|
const trendsArray = Array.isArray(trendsData) ? trendsData : [];
|
||||||
|
setTrends(trendsArray);
|
||||||
|
setScanCount(1);
|
||||||
|
toaster.create({
|
||||||
|
title: `${trendsArray.length} trend bulundu`,
|
||||||
|
type: trendsArray.length > 0 ? "success" : "info"
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
throw new Error('Failed to scan trends');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Scan error:', error);
|
||||||
|
toaster.create({ title: "Failed to scan trends", type: "error" });
|
||||||
|
} finally {
|
||||||
|
setIsScanning(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// DEEPER RESEARCH - Prepends new results and uses all languages
|
||||||
|
const handleDeepResearch = async () => {
|
||||||
|
if (!niche.trim()) {
|
||||||
|
toaster.create({ title: "Please enter a niche or topic first", type: "warning" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsDeepScanning(true);
|
||||||
|
try {
|
||||||
|
const headers: HeadersInit = { 'Content-Type': 'application/json' };
|
||||||
|
if (session?.accessToken) {
|
||||||
|
headers['Authorization'] = `Bearer ${session.accessToken}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use different keywords for deeper research
|
||||||
|
const deepKeywords = [
|
||||||
|
niche,
|
||||||
|
`${niche} trends`,
|
||||||
|
`${niche} news`,
|
||||||
|
`latest ${niche}`,
|
||||||
|
];
|
||||||
|
|
||||||
|
const res = await fetch('/api/backend/trends/scan', {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify({
|
||||||
|
keywords: deepKeywords,
|
||||||
|
country: 'TR',
|
||||||
|
allLanguages: true, // Fetch global trends
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
const response = await res.json();
|
||||||
|
const trendsData = response.data || response.trends || response;
|
||||||
|
const newTrends = Array.isArray(trendsData) ? trendsData : [];
|
||||||
|
|
||||||
|
// Prepend new trends, filter duplicates by title
|
||||||
|
const existingTitles = new Set(trends.map(t => t.title.toLowerCase()));
|
||||||
|
const uniqueNewTrends = newTrends.filter(
|
||||||
|
(t: Trend) => !existingTitles.has(t.title.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
setTrends(prev => [...uniqueNewTrends, ...prev]);
|
||||||
|
setScanCount(prev => prev + 1);
|
||||||
|
toaster.create({
|
||||||
|
title: `${uniqueNewTrends.length} yeni trend eklendi (Üste eklendi)`,
|
||||||
|
description: `Toplam: ${trends.length + uniqueNewTrends.length} trend`,
|
||||||
|
type: uniqueNewTrends.length > 0 ? "success" : "info"
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
throw new Error('Failed to deep scan');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Deep scan error:', error);
|
||||||
|
toaster.create({ title: "Derin araştırma başarısız", type: "error" });
|
||||||
|
} finally {
|
||||||
|
setIsDeepScanning(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTranslate = async (trend: Trend) => {
|
||||||
|
if (translatedContent[trend.id]) return;
|
||||||
|
|
||||||
|
setTranslatingId(trend.id);
|
||||||
|
try {
|
||||||
|
const headers: HeadersInit = { 'Content-Type': 'application/json' };
|
||||||
|
if (session?.accessToken) {
|
||||||
|
headers['Authorization'] = `Bearer ${session.accessToken}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Translate title and description together for efficiency
|
||||||
|
const textToTranslate = `${trend.title}\n---\n${trend.description || ""}`;
|
||||||
|
|
||||||
|
const res = await fetch('/api/backend/trends/translate', {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify({
|
||||||
|
text: textToTranslate,
|
||||||
|
targetLanguage: 'Turkish',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
const responseData = await res.json();
|
||||||
|
// Handle potential 'data' wrapping from NestJS interceptors
|
||||||
|
const data = responseData.data || responseData;
|
||||||
|
const translatedText = data.translatedText;
|
||||||
|
|
||||||
|
if (translatedText && typeof translatedText === 'string') {
|
||||||
|
const [newTitle, ...descParts] = translatedText.split('\n---\n');
|
||||||
|
|
||||||
|
setTranslatedContent(prev => ({
|
||||||
|
...prev,
|
||||||
|
[trend.id]: {
|
||||||
|
title: newTitle.trim(),
|
||||||
|
description: descParts.join('\n---\n').trim(),
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
throw new Error('Invalid translation response');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Translation error:', error);
|
||||||
|
toaster.create({ title: "Çeviri başarısız", type: "error" });
|
||||||
|
} finally {
|
||||||
|
setTranslatingId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateContent = (trend: Trend) => {
|
||||||
|
// Navigate to generate page with pre-filled trend data
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set('topic', trend.title);
|
||||||
|
if (trend.description) {
|
||||||
|
params.set('description', trend.description);
|
||||||
|
}
|
||||||
|
if (trend.keywords && trend.keywords.length > 0) {
|
||||||
|
params.set('keywords', JSON.stringify(trend.keywords));
|
||||||
|
}
|
||||||
|
if (trend.source) {
|
||||||
|
params.set('source', trend.source);
|
||||||
|
}
|
||||||
|
router.push(`/generate?${params.toString()}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<VStack align="stretch" gap={6} p={6}>
|
||||||
|
{/* Header */}
|
||||||
|
<HStack justify="space-between">
|
||||||
|
<Box>
|
||||||
|
<Heading size="xl">Trend Araştırması</Heading>
|
||||||
|
<Text color="fg.muted">Güncel trendleri keşfet ve içerik oluştur</Text>
|
||||||
|
</Box>
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
{/* Search Box */}
|
||||||
|
<Card.Root>
|
||||||
|
<Card.Body>
|
||||||
|
<VStack gap={4}>
|
||||||
|
<HStack w="100%" gap={4}>
|
||||||
|
<Input
|
||||||
|
placeholder="Niş veya konu girin (örn: yapay zeka, kripto, fitness)"
|
||||||
|
value={niche}
|
||||||
|
onChange={(e) => setNiche(e.target.value)}
|
||||||
|
onKeyPress={(e) => e.key === 'Enter' && handleScanTrends()}
|
||||||
|
flex={1}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
colorPalette="blue"
|
||||||
|
onClick={handleScanTrends}
|
||||||
|
disabled={isScanning}
|
||||||
|
>
|
||||||
|
{isScanning ? <Spinner size="sm" /> : <LuSearch />}
|
||||||
|
{isScanning ? "Taranıyor..." : "Trend Tara"}
|
||||||
|
</Button>
|
||||||
|
</HStack>
|
||||||
|
<Text fontSize="sm" color="fg.muted">
|
||||||
|
Google Trends, Twitter, Reddit ve haber kaynaklarından güncel trendleri tarar
|
||||||
|
</Text>
|
||||||
|
</VStack>
|
||||||
|
</Card.Body>
|
||||||
|
</Card.Root>
|
||||||
|
|
||||||
|
{/* Deeper Research Button - Shows after initial scan */}
|
||||||
|
{trends.length > 0 && (
|
||||||
|
<HStack justify="space-between" align="center">
|
||||||
|
<Text color="fg.muted">
|
||||||
|
{trends.length} trend bulundu (Tarama #{scanCount})
|
||||||
|
</Text>
|
||||||
|
<Button
|
||||||
|
colorPalette="purple"
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleDeepResearch}
|
||||||
|
disabled={isDeepScanning || isScanning}
|
||||||
|
>
|
||||||
|
{isDeepScanning ? <Spinner size="sm" /> : <LuZoomIn />}
|
||||||
|
{isDeepScanning ? "Derin Araştırma Yapılıyor..." : "Daha Derin Araştır"}
|
||||||
|
</Button>
|
||||||
|
</HStack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Results */}
|
||||||
|
{trends.length > 0 && (
|
||||||
|
<SimpleGrid columns={{ base: 1, md: 2, lg: 3 }} gap={4}>
|
||||||
|
{trends.map((trend) => (
|
||||||
|
<Card.Root
|
||||||
|
key={trend.id}
|
||||||
|
cursor="pointer"
|
||||||
|
borderWidth={selectedTrend?.id === trend.id ? "2px" : "1px"}
|
||||||
|
borderColor={selectedTrend?.id === trend.id ? "blue.500" : "border.subtle"}
|
||||||
|
onClick={() => setSelectedTrend(trend)}
|
||||||
|
_hover={{ shadow: "md" }}
|
||||||
|
>
|
||||||
|
<Card.Header pb={2}>
|
||||||
|
<HStack justify="space-between">
|
||||||
|
<Badge colorPalette="blue" variant="subtle">
|
||||||
|
<LuTrendingUp /> {Math.round(trend.score)}
|
||||||
|
</Badge>
|
||||||
|
<Badge variant="outline">{trend.source}</Badge>
|
||||||
|
</HStack>
|
||||||
|
</Card.Header>
|
||||||
|
<Card.Body pt={0}>
|
||||||
|
<VStack align="start" gap={2}>
|
||||||
|
<Heading size="md">
|
||||||
|
{translatedContent[trend.id]?.title || trend.title}
|
||||||
|
</Heading>
|
||||||
|
{(translatedContent[trend.id]?.description || trend.description) && (
|
||||||
|
<Text fontSize="sm" color="fg.muted" lineClamp={2}>
|
||||||
|
{translatedContent[trend.id]?.description || trend.description}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{translatedContent[trend.id] && (
|
||||||
|
<Badge colorPalette="orange" size="xs" variant="outline">
|
||||||
|
AI Çeviri
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{trend.keywords.length > 0 && (
|
||||||
|
<HStack flexWrap="wrap" gap={1}>
|
||||||
|
{trend.keywords.slice(0, 3).map((kw, i) => (
|
||||||
|
<Badge key={i} size="sm" variant="subtle">
|
||||||
|
<LuHash /> {kw}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</HStack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<HStack w="100%" justify="space-between" pt={2} flexWrap="wrap">
|
||||||
|
<HStack gap={2}>
|
||||||
|
{trend.url && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
paddingInline={2}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
window.open(trend.url, '_blank');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<LuGlobe /> Kaynak
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{!translatedContent[trend.id] && trend.relatedTopics?.some(t => ['EN', 'DE'].includes(t)) && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
colorPalette="blue"
|
||||||
|
loading={translatingId === trend.id}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleTranslate(trend);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<LuRefreshCw /> Çevir
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</HStack>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
colorPalette="green"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleCreateContent(trend);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<LuSparkles /> İçerik Oluştur
|
||||||
|
</Button>
|
||||||
|
</HStack>
|
||||||
|
</VStack>
|
||||||
|
</Card.Body>
|
||||||
|
</Card.Root>
|
||||||
|
))}
|
||||||
|
</SimpleGrid>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Empty State */}
|
||||||
|
{!isScanning && trends.length === 0 && (
|
||||||
|
<Card.Root>
|
||||||
|
<Card.Body>
|
||||||
|
<VStack py={10} gap={4}>
|
||||||
|
<Icon as={LuTrendingUp} boxSize={12} color="fg.muted" />
|
||||||
|
<Text color="fg.muted" textAlign="center">
|
||||||
|
Henüz trend taraması yapılmadı.<br />
|
||||||
|
Bir niş veya konu girerek başlayın.
|
||||||
|
</Text>
|
||||||
|
</VStack>
|
||||||
|
</Card.Body>
|
||||||
|
</Card.Root>
|
||||||
|
)}
|
||||||
|
</VStack>
|
||||||
|
);
|
||||||
|
}
|
||||||
252
src/app/[locale]/(dashboard)/video/page.tsx
Normal file
252
src/app/[locale]/(dashboard)/video/page.tsx
Normal file
@@ -0,0 +1,252 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Box, Heading, VStack, Text, Card, HStack, Badge, Button, Input, SimpleGrid, Spinner, Icon, Tabs, Textarea } from "@chakra-ui/react";
|
||||||
|
import { LuVideo, LuImage, LuDownload, LuSparkles, LuRefreshCw, LuCopy } from "react-icons/lu";
|
||||||
|
import { toaster } from "@/components/ui/feedback/toaster";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useSession } from "next-auth/react";
|
||||||
|
|
||||||
|
interface GeneratedAsset {
|
||||||
|
id: string;
|
||||||
|
type: 'image' | 'video' | 'thumbnail';
|
||||||
|
url: string;
|
||||||
|
prompt: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function VideoPage() {
|
||||||
|
const { data: session } = useSession();
|
||||||
|
const [topic, setTopic] = useState("");
|
||||||
|
const [style, setStyle] = useState("professional");
|
||||||
|
const [isGenerating, setIsGenerating] = useState(false);
|
||||||
|
const [generatedAssets, setGeneratedAssets] = useState<GeneratedAsset[]>([]);
|
||||||
|
const [activeTab, setActiveTab] = useState("thumbnail");
|
||||||
|
|
||||||
|
const getHeaders = (): HeadersInit => {
|
||||||
|
const headers: HeadersInit = { 'Content-Type': 'application/json' };
|
||||||
|
if (session?.accessToken) {
|
||||||
|
headers['Authorization'] = `Bearer ${session.accessToken}`;
|
||||||
|
}
|
||||||
|
return headers;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGenerate = async (type: 'thumbnail' | 'video') => {
|
||||||
|
if (!topic.trim()) {
|
||||||
|
toaster.create({ title: "Please enter a topic", type: "warning" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsGenerating(true);
|
||||||
|
try {
|
||||||
|
// Use correct backend endpoints
|
||||||
|
const endpoint = type === 'thumbnail'
|
||||||
|
? '/api/backend/video-thumbnail/thumbnails/generate'
|
||||||
|
: '/api/backend/video-thumbnail/package';
|
||||||
|
|
||||||
|
const body = type === 'thumbnail'
|
||||||
|
? {
|
||||||
|
title: topic,
|
||||||
|
videoType: style,
|
||||||
|
pattern: 'face_text',
|
||||||
|
keyMessage: topic,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
topic,
|
||||||
|
platform: 'youtube',
|
||||||
|
format: 'short',
|
||||||
|
targetAudience: 'general',
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await fetch(endpoint, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: getHeaders(),
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
// Extract URL from the response - thumbnail returns prompt, not URL
|
||||||
|
const assetUrl = data.url || data.thumbnailUrl || data.videoUrl || '';
|
||||||
|
const promptData = data.prompt || data.thumbnail?.prompt || topic;
|
||||||
|
|
||||||
|
setGeneratedAssets(prev => [{
|
||||||
|
id: `asset-${Date.now()}`,
|
||||||
|
type,
|
||||||
|
url: assetUrl || `/api/placeholder/${type}`, // Fallback
|
||||||
|
prompt: typeof promptData === 'string' ? promptData : topic,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
}, ...prev]);
|
||||||
|
toaster.create({ title: `${type === 'thumbnail' ? 'Thumbnail' : 'Video'} package generated!`, type: "success" });
|
||||||
|
} else {
|
||||||
|
const error = await res.json();
|
||||||
|
throw new Error(error.message || 'Generation failed');
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Generation error:', error);
|
||||||
|
toaster.create({ title: error.message || "Generation failed", type: "error" });
|
||||||
|
} finally {
|
||||||
|
setIsGenerating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCopyPrompt = (prompt: string) => {
|
||||||
|
navigator.clipboard.writeText(prompt);
|
||||||
|
toaster.create({ title: "Prompt copied", type: "success" });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDownload = async (url: string, filename: string) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(url);
|
||||||
|
const blob = await response.blob();
|
||||||
|
const downloadUrl = window.URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = downloadUrl;
|
||||||
|
a.download = filename;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
a.remove();
|
||||||
|
window.URL.revokeObjectURL(downloadUrl);
|
||||||
|
} catch (error) {
|
||||||
|
toaster.create({ title: "Download failed", type: "error" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<VStack align="stretch" gap={6} p={6}>
|
||||||
|
{/* Header */}
|
||||||
|
<HStack justify="space-between">
|
||||||
|
<Box>
|
||||||
|
<Heading size="xl">Video & Thumbnail</Heading>
|
||||||
|
<Text color="fg.muted">AI ile görsel ve video oluştur</Text>
|
||||||
|
</Box>
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
{/* Generator Card */}
|
||||||
|
<Card.Root>
|
||||||
|
<Card.Body>
|
||||||
|
<Tabs.Root value={activeTab} onValueChange={(e) => setActiveTab(e.value)}>
|
||||||
|
<Tabs.List mb={4}>
|
||||||
|
<Tabs.Trigger value="thumbnail">
|
||||||
|
<LuImage /> Thumbnail
|
||||||
|
</Tabs.Trigger>
|
||||||
|
<Tabs.Trigger value="video">
|
||||||
|
<LuVideo /> Video
|
||||||
|
</Tabs.Trigger>
|
||||||
|
</Tabs.List>
|
||||||
|
|
||||||
|
<VStack gap={4} align="stretch">
|
||||||
|
<Textarea
|
||||||
|
placeholder="Konu veya başlık girin (örn: 5 Dakikada Python Öğrenin)"
|
||||||
|
value={topic}
|
||||||
|
onChange={(e) => setTopic(e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<HStack>
|
||||||
|
<Box flex={1}>
|
||||||
|
<Text fontSize="sm" mb={1}>Stil</Text>
|
||||||
|
<select
|
||||||
|
value={style}
|
||||||
|
onChange={(e) => setStyle(e.target.value)}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: '8px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
border: '1px solid #ccc',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="professional">Profesyonel</option>
|
||||||
|
<option value="vibrant">Canlı Renkler</option>
|
||||||
|
<option value="minimal">Minimal</option>
|
||||||
|
<option value="bold">Dikkat Çekici</option>
|
||||||
|
<option value="cinematic">Sinematik</option>
|
||||||
|
</select>
|
||||||
|
</Box>
|
||||||
|
<Button
|
||||||
|
colorPalette="blue"
|
||||||
|
onClick={() => handleGenerate(activeTab as 'thumbnail' | 'video')}
|
||||||
|
disabled={isGenerating}
|
||||||
|
mt={6}
|
||||||
|
>
|
||||||
|
{isGenerating ? <Spinner size="sm" /> : <LuSparkles />}
|
||||||
|
{isGenerating ? "Oluşturuluyor..." : "Oluştur"}
|
||||||
|
</Button>
|
||||||
|
</HStack>
|
||||||
|
</VStack>
|
||||||
|
</Tabs.Root>
|
||||||
|
</Card.Body>
|
||||||
|
</Card.Root>
|
||||||
|
|
||||||
|
{/* Generated Assets */}
|
||||||
|
{generatedAssets.length > 0 && (
|
||||||
|
<Box>
|
||||||
|
<Heading size="md" mb={4}>Oluşturulan Görseller</Heading>
|
||||||
|
<SimpleGrid columns={{ base: 1, md: 2, lg: 3 }} gap={4}>
|
||||||
|
{generatedAssets.map((asset) => (
|
||||||
|
<Card.Root key={asset.id}>
|
||||||
|
<Card.Body p={0}>
|
||||||
|
{asset.type === 'video' ? (
|
||||||
|
<video
|
||||||
|
src={asset.url}
|
||||||
|
controls
|
||||||
|
style={{ width: '100%', borderRadius: '8px 8px 0 0' }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<img
|
||||||
|
src={asset.url}
|
||||||
|
alt={asset.prompt}
|
||||||
|
style={{ width: '100%', borderRadius: '8px 8px 0 0' }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<VStack p={3} align="stretch" gap={2}>
|
||||||
|
<HStack justify="space-between">
|
||||||
|
<Badge colorPalette={asset.type === 'video' ? 'purple' : 'blue'}>
|
||||||
|
{asset.type === 'video' ? <LuVideo /> : <LuImage />}
|
||||||
|
{asset.type.charAt(0).toUpperCase() + asset.type.slice(1)}
|
||||||
|
</Badge>
|
||||||
|
<Text fontSize="xs" color="fg.muted">
|
||||||
|
{new Date(asset.createdAt).toLocaleString('tr-TR')}
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
<Text fontSize="sm" lineClamp={2}>{asset.prompt}</Text>
|
||||||
|
<HStack justify="space-between">
|
||||||
|
<Button
|
||||||
|
size="xs"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => handleCopyPrompt(asset.prompt)}
|
||||||
|
>
|
||||||
|
<LuCopy /> Prompt
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="xs"
|
||||||
|
colorPalette="green"
|
||||||
|
onClick={() => handleDownload(asset.url, `${asset.type}-${asset.id}.${asset.type === 'video' ? 'mp4' : 'png'}`)}
|
||||||
|
>
|
||||||
|
<LuDownload /> İndir
|
||||||
|
</Button>
|
||||||
|
</HStack>
|
||||||
|
</VStack>
|
||||||
|
</Card.Body>
|
||||||
|
</Card.Root>
|
||||||
|
))}
|
||||||
|
</SimpleGrid>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Empty State */}
|
||||||
|
{generatedAssets.length === 0 && (
|
||||||
|
<Card.Root>
|
||||||
|
<Card.Body>
|
||||||
|
<VStack py={10} gap={4}>
|
||||||
|
<Icon as={LuImage} boxSize={12} color="fg.muted" />
|
||||||
|
<Text color="fg.muted" textAlign="center">
|
||||||
|
Henüz görsel oluşturulmadı.<br />
|
||||||
|
Bir konu girerek başlayın.
|
||||||
|
</Text>
|
||||||
|
</VStack>
|
||||||
|
</Card.Body>
|
||||||
|
</Card.Root>
|
||||||
|
)}
|
||||||
|
</VStack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
import { getTranslations } from "next-intl/server";
|
|
||||||
import HomeCard from "@/components/site/home/home-card";
|
|
||||||
|
|
||||||
export async function generateMetadata() {
|
|
||||||
const t = await getTranslations();
|
|
||||||
|
|
||||||
return {
|
|
||||||
title: `${t("home")} | FCS`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Home() {
|
|
||||||
return <HomeCard />;
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { Container, Flex } from '@chakra-ui/react';
|
import { Container, Flex } from '@chakra-ui/react';
|
||||||
import Header from '@/components/layout/header/header';
|
import { Header } from '@/components/layout/header/header';
|
||||||
import Footer from '@/components/layout/footer/footer';
|
import Footer from '@/components/layout/footer/footer';
|
||||||
import BackToTop from '@/components/ui/back-to-top';
|
import BackToTop from '@/components/ui/back-to-top';
|
||||||
|
|
||||||
|
|||||||
97
src/app/api/backend/[...path]/route.ts
Normal file
97
src/app/api/backend/[...path]/route.ts
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
// API Proxy Route - Forwards requests to backend
|
||||||
|
// Path: src/app/api/backend/[...path]/route.ts
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3001';
|
||||||
|
|
||||||
|
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') {
|
||||||
|
try {
|
||||||
|
body = await request.text();
|
||||||
|
} catch {
|
||||||
|
// No body
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Forward headers (excluding host)
|
||||||
|
const headers: HeadersInit = {};
|
||||||
|
request.headers.forEach((value, key) => {
|
||||||
|
if (key.toLowerCase() !== 'host') {
|
||||||
|
headers[key] = value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(targetUrl, {
|
||||||
|
method: request.method,
|
||||||
|
headers,
|
||||||
|
body: body || undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get response data
|
||||||
|
const contentType = response.headers.get('content-type') || '';
|
||||||
|
let data: string | ArrayBuffer;
|
||||||
|
|
||||||
|
if (contentType.includes('application/json')) {
|
||||||
|
data = await response.text();
|
||||||
|
} else {
|
||||||
|
data = await response.arrayBuffer();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build response headers
|
||||||
|
const responseHeaders = new Headers();
|
||||||
|
response.headers.forEach((value, key) => {
|
||||||
|
// Skip certain headers
|
||||||
|
if (!['content-encoding', 'transfer-encoding'].includes(key.toLowerCase())) {
|
||||||
|
responseHeaders.set(key, value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return new NextResponse(data, {
|
||||||
|
status: response.status,
|
||||||
|
statusText: response.statusText,
|
||||||
|
headers: responseHeaders,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Proxy error:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: 'Backend unavailable',
|
||||||
|
message: error instanceof Error ? error.message : 'Unknown error',
|
||||||
|
},
|
||||||
|
{ status: 502 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Next.js 16 uses context.params directly (not a Promise)
|
||||||
|
type RouteContext = { params: { path: string[] } };
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest, context: RouteContext) {
|
||||||
|
return proxyRequest(request, context.params.path);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest, context: RouteContext) {
|
||||||
|
return proxyRequest(request, context.params.path);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(request: NextRequest, context: RouteContext) {
|
||||||
|
return proxyRequest(request, context.params.path);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PATCH(request: NextRequest, context: RouteContext) {
|
||||||
|
return proxyRequest(request, context.params.path);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(request: NextRequest, context: RouteContext) {
|
||||||
|
return proxyRequest(request, context.params.path);
|
||||||
|
}
|
||||||
83
src/components/content/ContentPreviewDialog.tsx
Normal file
83
src/components/content/ContentPreviewDialog.tsx
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Box, Text, VStack, HStack, Badge, Separator } from "@chakra-ui/react";
|
||||||
|
import {
|
||||||
|
DialogBody,
|
||||||
|
DialogCloseTrigger,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogRoot,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/overlays/dialog";
|
||||||
|
import { LuCalendar, LuGlobe, LuFileText } from "react-icons/lu";
|
||||||
|
|
||||||
|
interface ContentItem {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
platform: string;
|
||||||
|
status: string;
|
||||||
|
date: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ContentPreviewDialogProps {
|
||||||
|
item: ContentItem | null;
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (details: { open: boolean }) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStatusColor = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case "published": return "green";
|
||||||
|
case "draft": return "gray";
|
||||||
|
case "scheduled": return "blue";
|
||||||
|
case "review": return "orange";
|
||||||
|
default: return "gray";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ContentPreviewDialog({ item, open, onOpenChange }: ContentPreviewDialogProps) {
|
||||||
|
if (!item) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DialogRoot open={open} onOpenChange={onOpenChange} size="lg">
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{item.title}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogBody pb={8}>
|
||||||
|
<VStack gap={4} align="stretch">
|
||||||
|
<HStack gap={4}>
|
||||||
|
<Badge colorPalette={getStatusColor(item.status)} size="lg" variant="solid">
|
||||||
|
{item.status.toUpperCase()}
|
||||||
|
</Badge>
|
||||||
|
<HStack color="fg.muted">
|
||||||
|
<LuGlobe />
|
||||||
|
<Text>{item.platform}</Text>
|
||||||
|
</HStack>
|
||||||
|
<HStack color="fg.muted">
|
||||||
|
<LuCalendar />
|
||||||
|
<Text>{item.date}</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>
|
||||||
|
</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>
|
||||||
|
</Box>
|
||||||
|
</VStack>
|
||||||
|
</DialogBody>
|
||||||
|
<DialogCloseTrigger />
|
||||||
|
</DialogContent>
|
||||||
|
</DialogRoot>
|
||||||
|
);
|
||||||
|
}
|
||||||
111
src/components/content/ContentTable.tsx
Normal file
111
src/components/content/ContentTable.tsx
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Box, Table, Badge, HStack, IconButton } from "@chakra-ui/react";
|
||||||
|
import { LuEye, LuPencil, LuTrash2 } from "react-icons/lu";
|
||||||
|
import { toaster } from "@/components/ui/feedback/toaster";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { ContentPreviewDialog } from "./ContentPreviewDialog";
|
||||||
|
|
||||||
|
const MOCK_CONTENT = [
|
||||||
|
{ id: 1, title: "The Future of AI in Marketing", platform: "LinkedIn", status: "published", date: "2024-03-10" },
|
||||||
|
{ id: 2, title: "5 Tips for Better Sleep", platform: "Twitter", status: "draft", date: "2024-03-12" },
|
||||||
|
{ id: 3, title: "Product Launch Announcement", platform: "Instagram", status: "scheduled", date: "2024-03-15" },
|
||||||
|
{ id: 4, title: "Weekly Tech Roundup", platform: "LinkedIn", status: "review", date: "2024-03-18" },
|
||||||
|
{ id: 5, title: "Customer Success Story", platform: "Blog", status: "draft", date: "2024-03-20" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const getStatusColor = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case "published": return "green";
|
||||||
|
case "draft": return "gray";
|
||||||
|
case "scheduled": return "blue";
|
||||||
|
case "review": return "orange";
|
||||||
|
default: return "gray";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ContentTable() {
|
||||||
|
const [selectedItem, setSelectedItem] = useState<any>(null);
|
||||||
|
const [isPreviewOpen, setIsPreviewOpen] = useState(false);
|
||||||
|
|
||||||
|
const handleAction = (action: string, item: any) => {
|
||||||
|
if (action === 'View') {
|
||||||
|
setSelectedItem(item);
|
||||||
|
setIsPreviewOpen(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
toaster.create({
|
||||||
|
title: `${action} Action`,
|
||||||
|
description: `You clicked ${action} for ${item.title}`,
|
||||||
|
type: "info",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Box borderWidth="1px" borderRadius="lg" overflow="hidden" bg="bg.panel">
|
||||||
|
<Table.Root striped>
|
||||||
|
<Table.Header>
|
||||||
|
<Table.Row>
|
||||||
|
<Table.ColumnHeader>Title</Table.ColumnHeader>
|
||||||
|
<Table.ColumnHeader>Platform</Table.ColumnHeader>
|
||||||
|
<Table.ColumnHeader>Status</Table.ColumnHeader>
|
||||||
|
<Table.ColumnHeader>Date</Table.ColumnHeader>
|
||||||
|
<Table.ColumnHeader textAlign="right">Actions</Table.ColumnHeader>
|
||||||
|
</Table.Row>
|
||||||
|
</Table.Header>
|
||||||
|
<Table.Body>
|
||||||
|
{MOCK_CONTENT.map((item) => (
|
||||||
|
<Table.Row key={item.id}>
|
||||||
|
<Table.Cell fontWeight="medium">{item.title}</Table.Cell>
|
||||||
|
<Table.Cell>{item.platform}</Table.Cell>
|
||||||
|
<Table.Cell>
|
||||||
|
<Badge colorPalette={getStatusColor(item.status)} variant="solid">
|
||||||
|
{item.status}
|
||||||
|
</Badge>
|
||||||
|
</Table.Cell>
|
||||||
|
<Table.Cell>{item.date}</Table.Cell>
|
||||||
|
<Table.Cell textAlign="right">
|
||||||
|
<HStack justify="flex-end" gap={2}>
|
||||||
|
<IconButton
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
aria-label="View"
|
||||||
|
onClick={() => handleAction('View', item)}
|
||||||
|
>
|
||||||
|
<LuEye />
|
||||||
|
</IconButton>
|
||||||
|
<IconButton
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
aria-label="Edit"
|
||||||
|
onClick={() => handleAction('Edit', item)}
|
||||||
|
>
|
||||||
|
<LuPencil />
|
||||||
|
</IconButton>
|
||||||
|
<IconButton
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
colorPalette="red"
|
||||||
|
aria-label="Delete"
|
||||||
|
onClick={() => handleAction('Delete', item)}
|
||||||
|
>
|
||||||
|
<LuTrash2 />
|
||||||
|
</IconButton>
|
||||||
|
</HStack>
|
||||||
|
</Table.Cell>
|
||||||
|
</Table.Row>
|
||||||
|
))}
|
||||||
|
</Table.Body>
|
||||||
|
</Table.Root>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<ContentPreviewDialog
|
||||||
|
item={selectedItem}
|
||||||
|
open={isPreviewOpen}
|
||||||
|
onOpenChange={(e) => setIsPreviewOpen(e.open)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
340
src/components/generate/GenerateWizard.tsx
Normal file
340
src/components/generate/GenerateWizard.tsx
Normal file
@@ -0,0 +1,340 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Box, Heading, Steps, VStack, Input, Button, Text, HStack, Textarea, Card, SimpleGrid, Badge } from "@chakra-ui/react";
|
||||||
|
import { LuSparkles, LuArrowRight, LuCheck, LuHash } from "react-icons/lu";
|
||||||
|
import { toaster } from "@/components/ui/feedback/toaster";
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { GeneratedContentResult } from "./GeneratedContentResult";
|
||||||
|
import { useSession } from "next-auth/react";
|
||||||
|
import { useSearchParams } from "next/navigation";
|
||||||
|
|
||||||
|
// Platform Data (can also be fetched from backend if dynamic)
|
||||||
|
const PLATFORMS = [
|
||||||
|
{ id: 'twitter', name: 'Twitter/X', icon: '𝕏' },
|
||||||
|
{ id: 'linkedin', name: 'LinkedIn', icon: '💼' },
|
||||||
|
{ id: 'instagram', name: 'Instagram', icon: '📸' },
|
||||||
|
{ id: 'medium', name: 'Medium/Blog', icon: '📝' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const STEPS = [
|
||||||
|
{ title: "Topic & Niche", description: "Define what to create" },
|
||||||
|
{ title: "Platforms", description: "Where to post" },
|
||||||
|
{ title: "Review", description: "Generate content" },
|
||||||
|
];
|
||||||
|
|
||||||
|
interface Niche {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GenerateWizard() {
|
||||||
|
const { data: session } = useSession();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const [activeStep, setActiveStep] = useState(0);
|
||||||
|
const [topic, setTopic] = useState("");
|
||||||
|
const [trendDescription, setTrendDescription] = useState("");
|
||||||
|
const [trendKeywords, setTrendKeywords] = useState<string[]>([]);
|
||||||
|
const [trendSource, setTrendSource] = useState("");
|
||||||
|
const [selectedNiche, setSelectedNiche] = useState("");
|
||||||
|
const [selectedPlatforms, setSelectedPlatforms] = useState<string[]>([]);
|
||||||
|
const [isGenerating, setIsGenerating] = useState(false);
|
||||||
|
|
||||||
|
const [niches, setNiches] = useState<Niche[]>([]);
|
||||||
|
const [isLoadingNiches, setIsLoadingNiches] = useState(false);
|
||||||
|
|
||||||
|
const [generatedBundle, setGeneratedBundle] = useState<any>(null);
|
||||||
|
|
||||||
|
// Read trend data from URL params (from trends page)
|
||||||
|
useEffect(() => {
|
||||||
|
const topicParam = searchParams.get('topic');
|
||||||
|
const descParam = searchParams.get('description');
|
||||||
|
const keywordsParam = searchParams.get('keywords');
|
||||||
|
const sourceParam = searchParams.get('source');
|
||||||
|
|
||||||
|
if (topicParam) setTopic(decodeURIComponent(topicParam));
|
||||||
|
if (descParam) setTrendDescription(decodeURIComponent(descParam));
|
||||||
|
if (sourceParam) setTrendSource(sourceParam);
|
||||||
|
if (keywordsParam) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(keywordsParam);
|
||||||
|
if (Array.isArray(parsed)) setTrendKeywords(parsed);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to parse keywords:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [searchParams]);
|
||||||
|
|
||||||
|
// Fetch niches on mount
|
||||||
|
useEffect(() => {
|
||||||
|
async function fetchNiches() {
|
||||||
|
setIsLoadingNiches(true);
|
||||||
|
try {
|
||||||
|
const headers: HeadersInit = {};
|
||||||
|
if (session?.accessToken) {
|
||||||
|
headers['Authorization'] = `Bearer ${session.accessToken}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
} else {
|
||||||
|
console.error("API returned non-array for niches:", data);
|
||||||
|
setNiches([]);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.error("Failed to fetch niches", res.status);
|
||||||
|
setNiches([]);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching niches:", error);
|
||||||
|
} finally {
|
||||||
|
setIsLoadingNiches(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session) {
|
||||||
|
fetchNiches();
|
||||||
|
}
|
||||||
|
}, [session]);
|
||||||
|
|
||||||
|
const togglePlatform = (id: string) => {
|
||||||
|
setSelectedPlatforms(prev =>
|
||||||
|
prev.includes(id) ? prev.filter(p => p !== id) : [...prev, id]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNext = () => {
|
||||||
|
if (activeStep === 0 && !topic) {
|
||||||
|
toaster.create({
|
||||||
|
title: "Topic Required",
|
||||||
|
description: "Please enter a topic to proceed.",
|
||||||
|
type: "error"
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (activeStep === 1 && selectedPlatforms.length === 0) {
|
||||||
|
toaster.create({
|
||||||
|
title: "Platform Required",
|
||||||
|
description: "Select at least one platform.",
|
||||||
|
type: "error"
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setActiveStep((prev) => Math.min(prev + 1, STEPS.length - 1));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBack = () => {
|
||||||
|
setActiveStep((prev) => Math.max(prev - 1, 0));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGenerate = async () => {
|
||||||
|
setIsGenerating(true);
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
topic,
|
||||||
|
description: trendDescription || undefined,
|
||||||
|
keywords: trendKeywords.length > 0 ? trendKeywords : undefined,
|
||||||
|
niche: selectedNiche, // The service expects 'niche' as string ID
|
||||||
|
platforms: selectedPlatforms,
|
||||||
|
includeResearch: true,
|
||||||
|
includeHashtags: true,
|
||||||
|
brandVoice: "friendly-expert", // Default for now, can be added to UI
|
||||||
|
count: 1
|
||||||
|
};
|
||||||
|
|
||||||
|
const headers: HeadersInit = { 'Content-Type': 'application/json' };
|
||||||
|
if (session?.accessToken) {
|
||||||
|
headers['Authorization'] = `Bearer ${session.accessToken}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch('/api/backend/content-generation/generate', {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error("Generation failed");
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
setGeneratedBundle(data);
|
||||||
|
|
||||||
|
toaster.create({
|
||||||
|
title: "Content Generated",
|
||||||
|
description: "Your content is ready!",
|
||||||
|
type: "success"
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
toaster.create({
|
||||||
|
title: "Error",
|
||||||
|
description: "Failed to generate content. Please try again.",
|
||||||
|
type: "error"
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsGenerating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (generatedBundle) {
|
||||||
|
return <GeneratedContentResult bundle={generatedBundle} onReset={() => {
|
||||||
|
setGeneratedBundle(null);
|
||||||
|
setActiveStep(0);
|
||||||
|
setTopic("");
|
||||||
|
setSelectedPlatforms([]);
|
||||||
|
}} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Box mb={8}>
|
||||||
|
<Steps.Root index={activeStep} count={STEPS.length}>
|
||||||
|
<Steps.List>
|
||||||
|
{STEPS.map((step, index) => (
|
||||||
|
<Steps.Item key={index} title={step.title} icon={<Box>{index + 1}</Box>}>
|
||||||
|
{step.description}
|
||||||
|
</Steps.Item>
|
||||||
|
))}
|
||||||
|
</Steps.List>
|
||||||
|
</Steps.Root>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box p={6} borderWidth="1px" borderRadius="lg" bg="bg.panel">
|
||||||
|
{activeStep === 0 && (
|
||||||
|
<VStack align="stretch" gap={6}>
|
||||||
|
<Heading size="md">1. Start with a Topic</Heading>
|
||||||
|
|
||||||
|
{/* Show trend info if coming from trends page */}
|
||||||
|
{(trendSource || trendDescription || trendKeywords.length > 0) && (
|
||||||
|
<Card.Root bg="blue.50/10" borderColor="blue.500" borderWidth="1px">
|
||||||
|
<Card.Body>
|
||||||
|
<VStack align="start" gap={2}>
|
||||||
|
<HStack>
|
||||||
|
<Badge colorPalette="blue">📊 Trend'den Geldi</Badge>
|
||||||
|
{trendSource && <Badge variant="outline">{trendSource}</Badge>}
|
||||||
|
</HStack>
|
||||||
|
{trendDescription && (
|
||||||
|
<Text fontSize="sm" color="fg.muted">{trendDescription}</Text>
|
||||||
|
)}
|
||||||
|
{trendKeywords.length > 0 && (
|
||||||
|
<HStack flexWrap="wrap" gap={1}>
|
||||||
|
{trendKeywords.map((kw, i) => (
|
||||||
|
<Badge key={i} size="sm" variant="subtle">
|
||||||
|
<LuHash /> {kw}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</HStack>
|
||||||
|
)}
|
||||||
|
</VStack>
|
||||||
|
</Card.Body>
|
||||||
|
</Card.Root>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Box>
|
||||||
|
<Text mb={2} fontWeight="medium">What do you want to post about?</Text>
|
||||||
|
<Input
|
||||||
|
placeholder="e.g. 5 ways to use AI for marketing..."
|
||||||
|
value={topic}
|
||||||
|
onChange={(e) => setTopic(e.target.value)}
|
||||||
|
size="lg"
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box>
|
||||||
|
<Text mb={3} fontWeight="medium">Select a Niche (Optional)</Text>
|
||||||
|
{/* Fallback mock niches if fetch fails/empty for demo purposes */}
|
||||||
|
<SimpleGrid columns={{ base: 2, md: 3 }} gap={4}>
|
||||||
|
{(niches.length > 0 ? niches : [
|
||||||
|
{ id: 'personal-finance', name: 'Personal Finance' },
|
||||||
|
{ id: 'productivity', name: 'Productivity' },
|
||||||
|
{ id: 'ai-tech', name: 'AI & Tech' },
|
||||||
|
{ id: 'marketing', name: 'Marketing' }
|
||||||
|
]).map((niche) => (
|
||||||
|
<Card.Root
|
||||||
|
key={niche.id}
|
||||||
|
cursor="pointer"
|
||||||
|
onClick={() => setSelectedNiche(niche.id)}
|
||||||
|
borderColor={selectedNiche === niche.id ? "blue.500" : "border"}
|
||||||
|
borderWidth={selectedNiche === niche.id ? "2px" : "1px"}
|
||||||
|
bg={selectedNiche === niche.id ? "blue.50/10" : "bg.subtle"}
|
||||||
|
_hover={{ borderColor: "blue.500" }}
|
||||||
|
>
|
||||||
|
<Card.Body p={4}>
|
||||||
|
<HStack justify="space-between">
|
||||||
|
<Text fontWeight="medium" fontSize="sm">{niche.name}</Text>
|
||||||
|
{selectedNiche === niche.id && <LuCheck color="var(--chakra-colors-blue-500)" />}
|
||||||
|
</HStack>
|
||||||
|
</Card.Body>
|
||||||
|
</Card.Root>
|
||||||
|
))}
|
||||||
|
</SimpleGrid>
|
||||||
|
</Box>
|
||||||
|
</VStack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeStep === 1 && (
|
||||||
|
<VStack align="stretch" gap={6}>
|
||||||
|
<Heading size="md">2. Select Platforms</Heading>
|
||||||
|
<Text mb={2} color="fg.muted">Where should this content be posted?</Text>
|
||||||
|
|
||||||
|
<SimpleGrid columns={{ base: 2, md: 4 }} gap={4}>
|
||||||
|
{PLATFORMS.map((platform) => (
|
||||||
|
<Card.Root
|
||||||
|
key={platform.id}
|
||||||
|
cursor="pointer"
|
||||||
|
onClick={() => togglePlatform(platform.id)}
|
||||||
|
borderColor={selectedPlatforms.includes(platform.id) ? "blue.500" : "border"}
|
||||||
|
borderWidth={selectedPlatforms.includes(platform.id) ? "2px" : "1px"}
|
||||||
|
bg={selectedPlatforms.includes(platform.id) ? "blue.50/10" : "bg.subtle"}
|
||||||
|
_hover={{ borderColor: "blue.500" }}
|
||||||
|
>
|
||||||
|
<Card.Body p={4} textAlign="center">
|
||||||
|
<VStack>
|
||||||
|
<Text fontSize="2xl">{platform.icon}</Text>
|
||||||
|
<Text fontWeight="medium">{platform.name}</Text>
|
||||||
|
{selectedPlatforms.includes(platform.id) && <LuCheck color="var(--chakra-colors-blue-500)" />}
|
||||||
|
</VStack>
|
||||||
|
</Card.Body>
|
||||||
|
</Card.Root>
|
||||||
|
))}
|
||||||
|
</SimpleGrid>
|
||||||
|
</VStack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeStep === 2 && (
|
||||||
|
<VStack align="stretch" gap={4}>
|
||||||
|
<Heading size="md">3. Ready to Generate?</Heading>
|
||||||
|
<Card.Root>
|
||||||
|
<Card.Body>
|
||||||
|
<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>Platforms:</strong> {selectedPlatforms.map(p => PLATFORMS.find(pl => pl.id === p)?.name).join(", ")}</Text>
|
||||||
|
</VStack>
|
||||||
|
</Card.Body>
|
||||||
|
</Card.Root>
|
||||||
|
</VStack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<HStack justify="flex-end" mt={8} gap={4}>
|
||||||
|
<Button variant="outline" onClick={handleBack} disabled={activeStep === 0 || isGenerating}>
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
{activeStep === STEPS.length - 1 ? (
|
||||||
|
<Button onClick={handleGenerate} loading={isGenerating} loadingText="Generating...">
|
||||||
|
Generate <LuSparkles />
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button onClick={handleNext}>
|
||||||
|
Next <LuArrowRight />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</HStack>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
429
src/components/generate/GeneratedContentResult.tsx
Normal file
429
src/components/generate/GeneratedContentResult.tsx
Normal file
@@ -0,0 +1,429 @@
|
|||||||
|
"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 { toaster } from "@/components/ui/feedback/toaster";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
// Matches backend GeneratedContentBundle
|
||||||
|
interface GeneratedContentBundle {
|
||||||
|
id: string;
|
||||||
|
topic: string;
|
||||||
|
niche?: any;
|
||||||
|
research?: {
|
||||||
|
summary: string;
|
||||||
|
keyFindings: { finding: string; confidence: string }[];
|
||||||
|
statistics: { value: string; context: string }[];
|
||||||
|
quotes: { text: string; author: string }[];
|
||||||
|
};
|
||||||
|
platforms: {
|
||||||
|
platform: string;
|
||||||
|
content: string;
|
||||||
|
hashtags: string[];
|
||||||
|
characterCount: number;
|
||||||
|
postingRecommendation: string;
|
||||||
|
// mediaRecommendations is string[] in backend
|
||||||
|
mediaRecommendations: string[];
|
||||||
|
}[];
|
||||||
|
variations?: {
|
||||||
|
original: string;
|
||||||
|
variations: { type: string; content: string }[];
|
||||||
|
}[];
|
||||||
|
seo?: {
|
||||||
|
score: number;
|
||||||
|
keywords: string[];
|
||||||
|
suggestions: string[];
|
||||||
|
meta: { title: string; description: string };
|
||||||
|
};
|
||||||
|
neuro?: {
|
||||||
|
score: number;
|
||||||
|
triggersUsed: string[];
|
||||||
|
emotionProfile: string[];
|
||||||
|
improvements: string[];
|
||||||
|
};
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
bundle: GeneratedContentBundle;
|
||||||
|
onReset: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GeneratedContentResult({ bundle, onReset }: Props) {
|
||||||
|
const [copiedId, setCopiedId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleCopy = (text: string, id: string) => {
|
||||||
|
navigator.clipboard.writeText(text);
|
||||||
|
setCopiedId(id);
|
||||||
|
toaster.create({
|
||||||
|
title: "Copied to clipboard",
|
||||||
|
type: "success",
|
||||||
|
duration: 2000,
|
||||||
|
});
|
||||||
|
setTimeout(() => setCopiedId(null), 2000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCopyWithHashtags = (content: string, hashtags: string[], id: string) => {
|
||||||
|
const fullText = `${content}\n\n${hashtags.join(' ')}`;
|
||||||
|
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 blob = new Blob([content], { type: 'text/plain' });
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `${bundle.topic}-${platform}.txt`;
|
||||||
|
a.click();
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!bundle || !bundle.platforms || bundle.platforms.length === 0) {
|
||||||
|
return (
|
||||||
|
<VStack gap={4} py={10}>
|
||||||
|
<Text color="fg.muted">No content generated. Please try again.</Text>
|
||||||
|
<Button variant="outline" onClick={onReset}>Try Again</Button>
|
||||||
|
</VStack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<VStack align="stretch" gap={6}>
|
||||||
|
<HStack justify="space-between">
|
||||||
|
<Heading size="lg">Generated Results</Heading>
|
||||||
|
<Button variant="outline" onClick={onReset}>Create New</Button>
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
<Tabs.Root defaultValue="content">
|
||||||
|
<Tabs.List>
|
||||||
|
<Tabs.Trigger value="content">
|
||||||
|
<LuFileText /> Content
|
||||||
|
</Tabs.Trigger>
|
||||||
|
{bundle.research && (
|
||||||
|
<Tabs.Trigger value="research">
|
||||||
|
<LuBrainCircuit /> Deep Research
|
||||||
|
</Tabs.Trigger>
|
||||||
|
)}
|
||||||
|
{bundle.variations && bundle.variations.length > 0 && (
|
||||||
|
<Tabs.Trigger value="variations">
|
||||||
|
<LuLayers /> Variations
|
||||||
|
</Tabs.Trigger>
|
||||||
|
)}
|
||||||
|
{bundle.seo && (
|
||||||
|
<Tabs.Trigger value="seo">
|
||||||
|
<LuSearch /> SEO
|
||||||
|
</Tabs.Trigger>
|
||||||
|
)}
|
||||||
|
{bundle.neuro && (
|
||||||
|
<Tabs.Trigger value="neuro">
|
||||||
|
<LuZap /> Neuro
|
||||||
|
</Tabs.Trigger>
|
||||||
|
)}
|
||||||
|
</Tabs.List>
|
||||||
|
|
||||||
|
{/* CONTENT TAB */}
|
||||||
|
<Tabs.Content value="content">
|
||||||
|
<Tabs.Root defaultValue={bundle.platforms[0]?.platform}>
|
||||||
|
<Tabs.List>
|
||||||
|
{bundle.platforms.map(p => (
|
||||||
|
<Tabs.Trigger key={p.platform} value={p.platform}>
|
||||||
|
{p.platform.charAt(0).toUpperCase() + p.platform.slice(1)}
|
||||||
|
</Tabs.Trigger>
|
||||||
|
))}
|
||||||
|
</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>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
</HStack>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<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>
|
||||||
|
))}
|
||||||
|
</Tabs.Root>
|
||||||
|
</Tabs.Content>
|
||||||
|
|
||||||
|
{/* RESEARCH TAB */}
|
||||||
|
<Tabs.Content value="research">
|
||||||
|
<VStack align="stretch" gap={6}>
|
||||||
|
<Card.Root>
|
||||||
|
<Card.Header>
|
||||||
|
<Heading size="md">Executive Summary</Heading>
|
||||||
|
</Card.Header>
|
||||||
|
<Card.Body>
|
||||||
|
<Text>{bundle.research?.summary}</Text>
|
||||||
|
</Card.Body>
|
||||||
|
</Card.Root>
|
||||||
|
|
||||||
|
<SimpleGrid columns={{ base: 1, md: 2 }} gap={4}>
|
||||||
|
<Card.Root>
|
||||||
|
<Card.Header>
|
||||||
|
<Heading size="sm">Key Findings</Heading>
|
||||||
|
</Card.Header>
|
||||||
|
<Card.Body>
|
||||||
|
<VStack align="start" gap={3}>
|
||||||
|
{bundle.research?.keyFindings.map((finding, idx) => (
|
||||||
|
<HStack key={idx} align="start">
|
||||||
|
<LuCheck color="green" style={{ marginTop: '4px' }} />
|
||||||
|
<Text fontSize="sm">{finding.finding}</Text>
|
||||||
|
</HStack>
|
||||||
|
))}
|
||||||
|
</VStack>
|
||||||
|
</Card.Body>
|
||||||
|
</Card.Root>
|
||||||
|
|
||||||
|
<Card.Root>
|
||||||
|
<Card.Header>
|
||||||
|
<Heading size="sm">Statistics</Heading>
|
||||||
|
</Card.Header>
|
||||||
|
<Card.Body>
|
||||||
|
<VStack align="start" gap={3}>
|
||||||
|
{bundle.research?.statistics.map((stat, idx) => (
|
||||||
|
<Box key={idx}>
|
||||||
|
<Text fontWeight="bold" color="blue.500">{stat.value}</Text>
|
||||||
|
<Text fontSize="sm" color="fg.muted">{stat.context}</Text>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</VStack>
|
||||||
|
</Card.Body>
|
||||||
|
</Card.Root>
|
||||||
|
</SimpleGrid>
|
||||||
|
</VStack>
|
||||||
|
</Tabs.Content>
|
||||||
|
|
||||||
|
{/* VARIATIONS TAB */}
|
||||||
|
<Tabs.Content value="variations">
|
||||||
|
<VStack align="stretch" gap={4}>
|
||||||
|
{bundle.variations?.map((varSet, i) => (
|
||||||
|
<Box key={i}>
|
||||||
|
{varSet.variations.map((variation, j) => (
|
||||||
|
<Card.Root key={j} mb={4}>
|
||||||
|
<Card.Header>
|
||||||
|
<Badge>{variation.type}</Badge>
|
||||||
|
</Card.Header>
|
||||||
|
<Card.Body>
|
||||||
|
<Box
|
||||||
|
p={3}
|
||||||
|
bg="bg.subtle"
|
||||||
|
borderRadius="md"
|
||||||
|
whiteSpace="pre-wrap"
|
||||||
|
fontFamily="mono"
|
||||||
|
fontSize="sm"
|
||||||
|
>
|
||||||
|
{variation.content}
|
||||||
|
</Box>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
mt={2}
|
||||||
|
onClick={() => handleCopy(variation.content, `var-${i}-${j}`)}
|
||||||
|
>
|
||||||
|
<LuCopy /> Copy
|
||||||
|
</Button>
|
||||||
|
</Card.Body>
|
||||||
|
</Card.Root>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</VStack>
|
||||||
|
</Tabs.Content>
|
||||||
|
|
||||||
|
{/* SEO TAB */}
|
||||||
|
<Tabs.Content value="seo">
|
||||||
|
<VStack align="stretch" gap={4}>
|
||||||
|
<Card.Root>
|
||||||
|
<Card.Header>
|
||||||
|
<HStack justify="space-between">
|
||||||
|
<Heading size="md">SEO Score</Heading>
|
||||||
|
<Badge
|
||||||
|
colorPalette={bundle.seo?.score && bundle.seo.score >= 70 ? 'green' : bundle.seo?.score && bundle.seo.score >= 50 ? 'yellow' : 'red'}
|
||||||
|
size="lg"
|
||||||
|
>
|
||||||
|
{bundle.seo?.score || 0}/100
|
||||||
|
</Badge>
|
||||||
|
</HStack>
|
||||||
|
</Card.Header>
|
||||||
|
<Card.Body>
|
||||||
|
<Progress.Root value={bundle.seo?.score || 0} max={100} size="lg">
|
||||||
|
<Progress.Track>
|
||||||
|
<Progress.Range />
|
||||||
|
</Progress.Track>
|
||||||
|
</Progress.Root>
|
||||||
|
</Card.Body>
|
||||||
|
</Card.Root>
|
||||||
|
|
||||||
|
<SimpleGrid columns={{ base: 1, md: 2 }} gap={4}>
|
||||||
|
<Card.Root>
|
||||||
|
<Card.Header>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<Card.Root>
|
||||||
|
<Card.Header>
|
||||||
|
<Heading size="sm">Suggestions</Heading>
|
||||||
|
</Card.Header>
|
||||||
|
<Card.Body>
|
||||||
|
<VStack align="start" gap={2}>
|
||||||
|
{bundle.seo?.suggestions?.map((sug, idx) => (
|
||||||
|
<HStack key={idx}>
|
||||||
|
<LuCheck color="green" />
|
||||||
|
<Text fontSize="sm">{sug}</Text>
|
||||||
|
</HStack>
|
||||||
|
))}
|
||||||
|
</VStack>
|
||||||
|
</Card.Body>
|
||||||
|
</Card.Root>
|
||||||
|
</SimpleGrid>
|
||||||
|
</VStack>
|
||||||
|
</Tabs.Content>
|
||||||
|
|
||||||
|
{/* NEURO TAB */}
|
||||||
|
<Tabs.Content value="neuro">
|
||||||
|
<VStack align="stretch" gap={4}>
|
||||||
|
<Card.Root>
|
||||||
|
<Card.Header>
|
||||||
|
<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'}
|
||||||
|
size="lg"
|
||||||
|
>
|
||||||
|
{bundle.neuro?.score || 0}/100
|
||||||
|
</Badge>
|
||||||
|
</HStack>
|
||||||
|
</Card.Header>
|
||||||
|
<Card.Body>
|
||||||
|
<Progress.Root value={bundle.neuro?.score || 0} max={100} size="lg">
|
||||||
|
<Progress.Track>
|
||||||
|
<Progress.Range />
|
||||||
|
</Progress.Track>
|
||||||
|
</Progress.Root>
|
||||||
|
</Card.Body>
|
||||||
|
</Card.Root>
|
||||||
|
|
||||||
|
<SimpleGrid columns={{ base: 1, md: 2 }} gap={4}>
|
||||||
|
<Card.Root>
|
||||||
|
<Card.Header>
|
||||||
|
<Heading size="sm">Triggers Used</Heading>
|
||||||
|
</Card.Header>
|
||||||
|
<Card.Body>
|
||||||
|
<HStack flexWrap="wrap" gap={2}>
|
||||||
|
{bundle.neuro?.triggersUsed?.map((trigger, idx) => (
|
||||||
|
<Badge key={idx} variant="subtle" colorPalette="purple">
|
||||||
|
{trigger}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
{(!bundle.neuro?.triggersUsed || bundle.neuro.triggersUsed.length === 0) && (
|
||||||
|
<Text fontSize="sm" color="fg.muted">No triggers detected</Text>
|
||||||
|
)}
|
||||||
|
</HStack>
|
||||||
|
</Card.Body>
|
||||||
|
</Card.Root>
|
||||||
|
|
||||||
|
<Card.Root>
|
||||||
|
<Card.Header>
|
||||||
|
<Heading size="sm">Improvements</Heading>
|
||||||
|
</Card.Header>
|
||||||
|
<Card.Body>
|
||||||
|
<VStack align="start" gap={2}>
|
||||||
|
{bundle.neuro?.improvements?.map((imp, idx) => (
|
||||||
|
<HStack key={idx}>
|
||||||
|
<LuZap color="orange" />
|
||||||
|
<Text fontSize="sm">{imp}</Text>
|
||||||
|
</HStack>
|
||||||
|
))}
|
||||||
|
</VStack>
|
||||||
|
</Card.Body>
|
||||||
|
</Card.Root>
|
||||||
|
</SimpleGrid>
|
||||||
|
|
||||||
|
<Card.Root>
|
||||||
|
<Card.Header>
|
||||||
|
<Heading size="sm">Emotion Profile</Heading>
|
||||||
|
</Card.Header>
|
||||||
|
<Card.Body>
|
||||||
|
<HStack flexWrap="wrap" gap={2}>
|
||||||
|
{bundle.neuro?.emotionProfile?.map((emotion, idx) => (
|
||||||
|
<Badge key={idx} variant="outline" colorPalette="green">
|
||||||
|
{emotion}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</HStack>
|
||||||
|
</Card.Body>
|
||||||
|
</Card.Root>
|
||||||
|
</VStack>
|
||||||
|
</Tabs.Content>
|
||||||
|
</Tabs.Root>
|
||||||
|
</VStack>
|
||||||
|
);
|
||||||
|
}
|
||||||
24
src/components/layout/dashboard/DashboardLayout.tsx
Normal file
24
src/components/layout/dashboard/DashboardLayout.tsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Box, Flex } from '@chakra-ui/react';
|
||||||
|
import { Sidebar } from '../sidebar/Sidebar';
|
||||||
|
import { Header } from '../header/header';
|
||||||
|
import { ReactNode } from 'react';
|
||||||
|
|
||||||
|
interface DashboardLayoutProps {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DashboardLayout({ children }: DashboardLayoutProps) {
|
||||||
|
return (
|
||||||
|
<Flex minH="100vh" bg="bg.canvas">
|
||||||
|
<Sidebar />
|
||||||
|
<Box flex="1" ml="250px" transition="margin-left 0.2s">
|
||||||
|
<Header />
|
||||||
|
<Box as="main" p={8}>
|
||||||
|
{children}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,229 +1,50 @@
|
|||||||
"use client";
|
'use client';
|
||||||
|
|
||||||
import {
|
import { Flex, Text, HStack, Box } from '@chakra-ui/react';
|
||||||
Box,
|
import { ColorModeButton } from '@/components/ui/color-mode';
|
||||||
Flex,
|
import { Avatar } from '@/components/ui/data-display/avatar';
|
||||||
HStack,
|
import { usePathname } from 'next/navigation';
|
||||||
IconButton,
|
|
||||||
Link as ChakraLink,
|
|
||||||
Stack,
|
|
||||||
VStack,
|
|
||||||
Button,
|
|
||||||
MenuItem,
|
|
||||||
ClientOnly,
|
|
||||||
} from "@chakra-ui/react";
|
|
||||||
import { Link, useRouter } from "@/i18n/navigation";
|
|
||||||
import { ColorModeButton } from "@/components/ui/color-mode";
|
|
||||||
import {
|
|
||||||
PopoverBody,
|
|
||||||
PopoverContent,
|
|
||||||
PopoverRoot,
|
|
||||||
PopoverTrigger,
|
|
||||||
} from "@/components/ui/overlays/popover";
|
|
||||||
import { RxHamburgerMenu } from "react-icons/rx";
|
|
||||||
import { NAV_ITEMS } from "@/config/navigation";
|
|
||||||
import HeaderLink from "./header-link";
|
|
||||||
import MobileHeaderLink from "./mobile-header-link";
|
|
||||||
import LocaleSwitcher from "@/components/ui/locale-switcher";
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { useTranslations } from "next-intl";
|
|
||||||
import {
|
|
||||||
MenuContent,
|
|
||||||
MenuRoot,
|
|
||||||
MenuTrigger,
|
|
||||||
} from "@/components/ui/overlays/menu";
|
|
||||||
import { Avatar } from "@/components/ui/data-display/avatar";
|
|
||||||
import { Skeleton } from "@/components/ui/feedback/skeleton";
|
|
||||||
import { signOut, useSession } from "next-auth/react";
|
|
||||||
import { authConfig } from "@/config/auth";
|
|
||||||
import { LoginModal } from "@/components/auth/login-modal";
|
|
||||||
import { LuLogIn } from "react-icons/lu";
|
|
||||||
|
|
||||||
export default function Header() {
|
export function Header() {
|
||||||
const t = useTranslations();
|
const pathname = usePathname();
|
||||||
const [isSticky, setIsSticky] = useState(false);
|
|
||||||
const [loginModalOpen, setLoginModalOpen] = useState(false);
|
|
||||||
const router = useRouter();
|
|
||||||
const { data: session, status } = useSession();
|
|
||||||
|
|
||||||
const isAuthenticated = !!session;
|
const getPageTitle = (path: string) => {
|
||||||
const isLoading = status === "loading";
|
if (path === '/home') return 'Dashboard';
|
||||||
|
if (path.startsWith('/content')) return 'Content Management';
|
||||||
useEffect(() => {
|
if (path.startsWith('/schedule')) return 'Schedule & Calendar';
|
||||||
const handleScroll = () => {
|
if (path.startsWith('/analytics')) return 'Analytics & Insights';
|
||||||
setIsSticky(window.scrollY >= 10);
|
if (path.startsWith('/settings')) return 'Settings';
|
||||||
};
|
return 'Dashboard';
|
||||||
|
|
||||||
window.addEventListener("scroll", handleScroll);
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener("scroll", handleScroll);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleLogout = async () => {
|
|
||||||
await signOut({ redirect: false });
|
|
||||||
if (authConfig.isAuthRequired) {
|
|
||||||
router.replace("/signin");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Render user menu or login button based on auth state
|
|
||||||
const renderAuthSection = () => {
|
|
||||||
if (isLoading) {
|
|
||||||
return <Skeleton boxSize="10" rounded="full" />;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isAuthenticated) {
|
|
||||||
return (
|
|
||||||
<MenuRoot positioning={{ placement: "bottom-start" }}>
|
|
||||||
<MenuTrigger rounded="full" focusRing="none">
|
|
||||||
<Avatar name={session?.user?.name || "User"} variant="solid" />
|
|
||||||
</MenuTrigger>
|
|
||||||
<MenuContent>
|
|
||||||
<MenuItem onClick={handleLogout} value="sign-out">
|
|
||||||
{t("auth.sign-out")}
|
|
||||||
</MenuItem>
|
|
||||||
</MenuContent>
|
|
||||||
</MenuRoot>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Not authenticated - show login button
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
variant="solid"
|
|
||||||
colorPalette="primary"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setLoginModalOpen(true)}
|
|
||||||
>
|
|
||||||
<LuLogIn />
|
|
||||||
{t("auth.sign-in")}
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Render mobile auth section
|
|
||||||
const renderMobileAuthSection = () => {
|
|
||||||
if (isLoading) {
|
|
||||||
return <Skeleton height="10" width="full" />;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isAuthenticated) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Avatar name={session?.user?.name || "User"} variant="solid" />
|
|
||||||
<Button
|
|
||||||
variant="surface"
|
|
||||||
size="sm"
|
|
||||||
width="full"
|
|
||||||
onClick={handleLogout}
|
|
||||||
>
|
|
||||||
{t("auth.sign-out")}
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
variant="solid"
|
|
||||||
colorPalette="primary"
|
|
||||||
size="sm"
|
|
||||||
width="full"
|
|
||||||
onClick={() => setLoginModalOpen(true)}
|
|
||||||
>
|
|
||||||
<LuLogIn />
|
|
||||||
{t("auth.sign-in")}
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<Flex
|
||||||
<Box
|
as="header"
|
||||||
as="nav"
|
h="64px"
|
||||||
bg={isSticky ? "rgba(255, 255, 255, 0.6)" : "white"}
|
bg="bg.panel"
|
||||||
_dark={{
|
borderBottomWidth="1px"
|
||||||
bg: isSticky ? "rgba(1, 1, 1, 0.6)" : "black",
|
borderColor="border.subtle"
|
||||||
}}
|
align="center"
|
||||||
shadow={isSticky ? "sm" : "none"}
|
justify="space-between"
|
||||||
backdropFilter="blur(12px) saturate(180%)"
|
px={8}
|
||||||
border="1px solid"
|
position="sticky"
|
||||||
borderColor={isSticky ? "whiteAlpha.300" : "transparent"}
|
top={0}
|
||||||
borderBottomRadius={isSticky ? "xl" : "none"}
|
zIndex={99}
|
||||||
transition="all 0.4s ease-in-out"
|
>
|
||||||
px={{ base: 4, md: 8 }}
|
<Text fontSize="lg" fontWeight="semibold" color="fg.default">
|
||||||
py="3"
|
{getPageTitle(pathname || '')}
|
||||||
position="sticky"
|
</Text>
|
||||||
top={0}
|
|
||||||
zIndex={10}
|
|
||||||
w="full"
|
|
||||||
>
|
|
||||||
<Flex justify="space-between" align="center" maxW="8xl" mx="auto">
|
|
||||||
{/* Logo */}
|
|
||||||
<HStack>
|
|
||||||
<ChakraLink
|
|
||||||
as={Link}
|
|
||||||
href="/home"
|
|
||||||
fontSize="lg"
|
|
||||||
fontWeight="bold"
|
|
||||||
color={{ base: "primary.500", _dark: "primary.300" }}
|
|
||||||
focusRing="none"
|
|
||||||
textDecor="none"
|
|
||||||
transition="all 0.3s ease-in-out"
|
|
||||||
_hover={{
|
|
||||||
color: { base: "primary.900", _dark: "primary.50" },
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{"FCS "}
|
|
||||||
</ChakraLink>
|
|
||||||
</HStack>
|
|
||||||
|
|
||||||
{/* DESKTOP NAVIGATION */}
|
<HStack gap={4}>
|
||||||
<HStack spaceX={4} display={{ base: "none", lg: "flex" }}>
|
<ColorModeButton />
|
||||||
{NAV_ITEMS.map((item, index) => (
|
<HStack gap={3}>
|
||||||
<HeaderLink key={index} item={item} />
|
<Box textAlign="right" display={{ base: 'none', md: 'block' }}>
|
||||||
))}
|
<Text textStyle="sm" fontWeight="medium">John Doe</Text>
|
||||||
</HStack>
|
<Text textStyle="xs" color="fg.muted">Admin</Text>
|
||||||
|
</Box>
|
||||||
<HStack>
|
<Avatar name="John Doe" src="https://bit.ly/dan-abramov" size="sm" />
|
||||||
<ColorModeButton colorPalette="gray" />
|
</HStack>
|
||||||
<Box display={{ base: "none", lg: "inline-flex" }} gap={2}>
|
</HStack>
|
||||||
<LocaleSwitcher />
|
</Flex>
|
||||||
<ClientOnly fallback={<Skeleton boxSize="10" rounded="full" />}>
|
|
||||||
{renderAuthSection()}
|
|
||||||
</ClientOnly>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* MOBILE NAVIGATION */}
|
|
||||||
<Stack display={{ base: "inline-flex", lg: "none" }}>
|
|
||||||
<ClientOnly fallback={<Skeleton boxSize="9" />}>
|
|
||||||
<PopoverRoot>
|
|
||||||
<PopoverTrigger as="span">
|
|
||||||
<IconButton aria-label="Open menu" variant="ghost">
|
|
||||||
<RxHamburgerMenu />
|
|
||||||
</IconButton>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent width={{ base: "xs", sm: "sm", md: "md" }}>
|
|
||||||
<PopoverBody>
|
|
||||||
<VStack mt="2" align="start" spaceY="2" w="full">
|
|
||||||
{NAV_ITEMS.map((item) => (
|
|
||||||
<MobileHeaderLink key={item.label} item={item} />
|
|
||||||
))}
|
|
||||||
<LocaleSwitcher />
|
|
||||||
{renderMobileAuthSection()}
|
|
||||||
</VStack>
|
|
||||||
</PopoverBody>
|
|
||||||
</PopoverContent>
|
|
||||||
</PopoverRoot>
|
|
||||||
</ClientOnly>
|
|
||||||
</Stack>
|
|
||||||
</HStack>
|
|
||||||
</Flex>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Login Modal */}
|
|
||||||
<LoginModal open={loginModalOpen} onOpenChange={setLoginModalOpen} />
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
102
src/components/layout/sidebar/Sidebar.tsx
Normal file
102
src/components/layout/sidebar/Sidebar.tsx
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Box, Flex, VStack, Text, Icon, Link as ChakraLink, Separator } from '@chakra-ui/react';
|
||||||
|
import { Link, usePathname } from '@/i18n/navigation';
|
||||||
|
import { LuLayoutDashboard, LuFileText, LuCalendar, LuTrendingUp, LuSettings, LuLogOut, LuSparkles } from 'react-icons/lu';
|
||||||
|
|
||||||
|
import { NAV_ITEMS as CONFIG_NAV_ITEMS } from '@/config/navigation';
|
||||||
|
|
||||||
|
const ICON_MAP: Record<string, any> = {
|
||||||
|
'/home': LuLayoutDashboard,
|
||||||
|
'/generate': LuSparkles,
|
||||||
|
'/content': LuFileText,
|
||||||
|
'/schedule': LuCalendar,
|
||||||
|
'/analytics': LuTrendingUp,
|
||||||
|
'/settings': LuSettings,
|
||||||
|
};
|
||||||
|
|
||||||
|
const NAV_ITEMS = CONFIG_NAV_ITEMS.map(item => ({
|
||||||
|
name: item.label.charAt(0).toUpperCase() + item.label.slice(1),
|
||||||
|
href: item.href,
|
||||||
|
icon: ICON_MAP[item.href] || LuFileText
|
||||||
|
}));
|
||||||
|
|
||||||
|
export function Sidebar() {
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
w="250px"
|
||||||
|
h="100vh"
|
||||||
|
bg="bg.panel"
|
||||||
|
borderRightWidth="1px"
|
||||||
|
borderColor="border.subtle"
|
||||||
|
position="fixed"
|
||||||
|
left={0}
|
||||||
|
top={0}
|
||||||
|
zIndex={100}
|
||||||
|
py={6}
|
||||||
|
px={4}
|
||||||
|
>
|
||||||
|
<Flex align="center" gap={3} px={2} mb={8}>
|
||||||
|
<Box w={8} h={8} bg="primary.solid" borderRadius="lg" display="flex" alignItems="center" justifyContent="center">
|
||||||
|
<Text color="white" fontWeight="bold">CH</Text>
|
||||||
|
</Box>
|
||||||
|
<Text fontSize="xl" fontWeight="bold">Content Hunter</Text>
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
<VStack gap={2} align="stretch">
|
||||||
|
{NAV_ITEMS.map((item) => {
|
||||||
|
const isActive = pathname === item.href || pathname?.startsWith(item.href + '/');
|
||||||
|
return (
|
||||||
|
<ChakraLink
|
||||||
|
as={Link}
|
||||||
|
key={item.name}
|
||||||
|
href={item.href}
|
||||||
|
display="flex"
|
||||||
|
alignItems="center"
|
||||||
|
gap={3}
|
||||||
|
px={3}
|
||||||
|
py={2.5}
|
||||||
|
borderRadius="md"
|
||||||
|
textDecoration="none"
|
||||||
|
bg={isActive ? 'primary.subtle' : 'transparent'}
|
||||||
|
color={isActive ? 'primary.solid' : 'fg.muted'}
|
||||||
|
fontWeight={isActive ? 'semibold' : 'medium'}
|
||||||
|
_hover={{
|
||||||
|
bg: isActive ? 'primary.subtle' : 'bg.subtle',
|
||||||
|
textDecoration: 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon as={item.icon} boxSize={5} />
|
||||||
|
<Text>{item.name}</Text>
|
||||||
|
</ChakraLink>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</VStack>
|
||||||
|
|
||||||
|
<Box mt="auto">
|
||||||
|
<Separator mb={4} />
|
||||||
|
<ChakraLink
|
||||||
|
display="flex"
|
||||||
|
alignItems="center"
|
||||||
|
gap={3}
|
||||||
|
px={3}
|
||||||
|
py={2.5}
|
||||||
|
borderRadius="md"
|
||||||
|
textDecoration="none"
|
||||||
|
color="fg.muted"
|
||||||
|
cursor="pointer"
|
||||||
|
_hover={{
|
||||||
|
bg: 'bg.subtle',
|
||||||
|
color: 'fg.error',
|
||||||
|
textDecoration: 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon as={LuLogOut} boxSize={5} />
|
||||||
|
<Text>Logout</Text>
|
||||||
|
</ChakraLink>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -9,6 +9,13 @@ export type NavItem = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const NAV_ITEMS: NavItem[] = [
|
export const NAV_ITEMS: NavItem[] = [
|
||||||
{ label: "home", href: "/home", public: true },
|
{ label: "dashboard", href: "/home", public: true },
|
||||||
{ label: "predictions", href: "/predictions", public: true },
|
{ label: "trends", href: "/trends", public: true },
|
||||||
|
{ label: "generate", href: "/generate", public: true },
|
||||||
|
{ label: "content", href: "/content", public: true },
|
||||||
|
{ label: "sources", href: "/source-accounts", public: true },
|
||||||
|
{ label: "video", href: "/video", public: true },
|
||||||
|
{ label: "schedule", href: "/schedule", public: true },
|
||||||
|
{ label: "analytics", href: "/analytics", public: true },
|
||||||
|
{ label: "settings", href: "/settings", public: true },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { defineRouting } from 'next-intl/routing';
|
import { defineRouting } from 'next-intl/routing';
|
||||||
|
|
||||||
export const locales = ['en', 'tr'];
|
export const locales = ['en', 'tr', 'es', 'fr', 'de', 'zh', 'pt', 'ar', 'ru', 'ja'];
|
||||||
export const defaultLocale = 'tr';
|
export const defaultLocale = 'tr';
|
||||||
|
|
||||||
export const routing = defineRouting({
|
export const routing = defineRouting({
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user