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/image-types/global" />
|
||||
import "./.next/types/routes.d.ts";
|
||||
import "./.next/dev/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
|
||||
@@ -11,7 +11,7 @@ const nextConfig: NextConfig = {
|
||||
return [
|
||||
{
|
||||
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==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^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",
|
||||
"integrity": "sha512-puvrZOfnfMA+DckDcz0UxO20l7TVhwsdQ9ksCv4nIUB430yuWzon0yo9fM10lEr3hd7BhjZARpMCVw5u280clw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@ark-ui/react": "^5.29.1",
|
||||
"@emotion/is-prop-valid": "^1.4.0",
|
||||
@@ -692,7 +690,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz",
|
||||
"integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.18.3",
|
||||
"@emotion/babel-plugin": "^11.13.5",
|
||||
@@ -1986,7 +1983,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.10.0.tgz",
|
||||
"integrity": "sha512-oxDR/NTEJ1k+UFVQElaNIk65E/Z83HK1z1WI3lQyhTtnNg4R5oVXaPzK3jcpKG8UHKDVuDQHzn+wsxSz8RP3aw==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@swc/helpers": "^0.5.0"
|
||||
}
|
||||
@@ -2905,7 +2901,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.18.tgz",
|
||||
"integrity": "sha512-TXTnIcNJQEKwThMMqBXsZ4VGAza6bvN4pa41Rkqoio6QBKMvo+5lexeTMScGCIxtzgQJzElcvIltani+adC5PQ==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.8.0"
|
||||
}
|
||||
@@ -3067,7 +3062,6 @@
|
||||
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"csstype": "^3.0.2"
|
||||
}
|
||||
@@ -3127,7 +3121,6 @@
|
||||
"integrity": "sha512-BtE0k6cjwjLZoZixN0t5AKP0kSzlGu7FctRXYuPAm//aaiZhmfq1JwdYpYr1brzEspYyFeF+8XF5j2VK6oalrA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.54.0",
|
||||
"@typescript-eslint/types": "8.54.0",
|
||||
@@ -4555,7 +4548,6 @@
|
||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -4921,7 +4913,6 @@
|
||||
"integrity": "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/types": "^7.26.0"
|
||||
}
|
||||
@@ -5028,7 +5019,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.9.0",
|
||||
"caniuse-lite": "^1.0.30001759",
|
||||
@@ -5781,7 +5771,6 @@
|
||||
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.8.0",
|
||||
"@eslint-community/regexpp": "^4.12.1",
|
||||
@@ -5983,7 +5972,6 @@
|
||||
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@rtsao/scc": "^1.1.0",
|
||||
"array-includes": "^3.1.9",
|
||||
@@ -7935,7 +7923,6 @@
|
||||
"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.",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@next/env": "16.0.0",
|
||||
"@swc/helpers": "0.5.15",
|
||||
@@ -8599,7 +8586,6 @@
|
||||
"resolved": "https://registry.npmjs.org/preact/-/preact-10.28.2.tgz",
|
||||
"integrity": "sha512-lbteaWGzGHdlIuiJ0l2Jq454m6kcpI1zNje6d8MlGAFlYvP2GO4ibnat7P74Esfz4sPTdM6UxtTwh/d3pwM9JA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/preact"
|
||||
@@ -8747,7 +8733,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
|
||||
"integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@@ -8757,7 +8742,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz",
|
||||
"integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"scheduler": "^0.27.0"
|
||||
},
|
||||
@@ -8770,7 +8754,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.71.1.tgz",
|
||||
"integrity": "sha512-9SUJKCGKo8HUSsCO+y0CtqkqI5nNuaDqTxyqPsZPqIwudpj4rCrAz/jZV+jn57bx5gtZKOh3neQu94DXMc+w5w==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
@@ -9683,7 +9666,6 @@
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -9864,7 +9846,6 @@
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -10319,7 +10300,6 @@
|
||||
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"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';
|
||||
|
||||
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 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 {
|
||||
Box,
|
||||
Flex,
|
||||
HStack,
|
||||
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";
|
||||
import { Flex, Text, HStack, Box } from '@chakra-ui/react';
|
||||
import { ColorModeButton } from '@/components/ui/color-mode';
|
||||
import { Avatar } from '@/components/ui/data-display/avatar';
|
||||
import { usePathname } from 'next/navigation';
|
||||
|
||||
export default function Header() {
|
||||
const t = useTranslations();
|
||||
const [isSticky, setIsSticky] = useState(false);
|
||||
const [loginModalOpen, setLoginModalOpen] = useState(false);
|
||||
const router = useRouter();
|
||||
const { data: session, status } = useSession();
|
||||
export function Header() {
|
||||
const pathname = usePathname();
|
||||
|
||||
const isAuthenticated = !!session;
|
||||
const isLoading = status === "loading";
|
||||
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
setIsSticky(window.scrollY >= 10);
|
||||
};
|
||||
|
||||
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>
|
||||
);
|
||||
const getPageTitle = (path: string) => {
|
||||
if (path === '/home') return 'Dashboard';
|
||||
if (path.startsWith('/content')) return 'Content Management';
|
||||
if (path.startsWith('/schedule')) return 'Schedule & Calendar';
|
||||
if (path.startsWith('/analytics')) return 'Analytics & Insights';
|
||||
if (path.startsWith('/settings')) return 'Settings';
|
||||
return 'Dashboard';
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box
|
||||
as="nav"
|
||||
bg={isSticky ? "rgba(255, 255, 255, 0.6)" : "white"}
|
||||
_dark={{
|
||||
bg: isSticky ? "rgba(1, 1, 1, 0.6)" : "black",
|
||||
}}
|
||||
shadow={isSticky ? "sm" : "none"}
|
||||
backdropFilter="blur(12px) saturate(180%)"
|
||||
border="1px solid"
|
||||
borderColor={isSticky ? "whiteAlpha.300" : "transparent"}
|
||||
borderBottomRadius={isSticky ? "xl" : "none"}
|
||||
transition="all 0.4s ease-in-out"
|
||||
px={{ base: 4, md: 8 }}
|
||||
py="3"
|
||||
position="sticky"
|
||||
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>
|
||||
<Flex
|
||||
as="header"
|
||||
h="64px"
|
||||
bg="bg.panel"
|
||||
borderBottomWidth="1px"
|
||||
borderColor="border.subtle"
|
||||
align="center"
|
||||
justify="space-between"
|
||||
px={8}
|
||||
position="sticky"
|
||||
top={0}
|
||||
zIndex={99}
|
||||
>
|
||||
<Text fontSize="lg" fontWeight="semibold" color="fg.default">
|
||||
{getPageTitle(pathname || '')}
|
||||
</Text>
|
||||
|
||||
{/* DESKTOP NAVIGATION */}
|
||||
<HStack spaceX={4} display={{ base: "none", lg: "flex" }}>
|
||||
{NAV_ITEMS.map((item, index) => (
|
||||
<HeaderLink key={index} item={item} />
|
||||
))}
|
||||
</HStack>
|
||||
|
||||
<HStack>
|
||||
<ColorModeButton colorPalette="gray" />
|
||||
<Box display={{ base: "none", lg: "inline-flex" }} gap={2}>
|
||||
<LocaleSwitcher />
|
||||
<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} />
|
||||
</>
|
||||
<HStack gap={4}>
|
||||
<ColorModeButton />
|
||||
<HStack gap={3}>
|
||||
<Box textAlign="right" display={{ base: 'none', md: 'block' }}>
|
||||
<Text textStyle="sm" fontWeight="medium">John Doe</Text>
|
||||
<Text textStyle="xs" color="fg.muted">Admin</Text>
|
||||
</Box>
|
||||
<Avatar name="John Doe" src="https://bit.ly/dan-abramov" size="sm" />
|
||||
</HStack>
|
||||
</HStack>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
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[] = [
|
||||
{ label: "home", href: "/home", public: true },
|
||||
{ label: "predictions", href: "/predictions", public: true },
|
||||
{ label: "dashboard", href: "/home", 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';
|
||||
|
||||
export const locales = ['en', 'tr'];
|
||||
export const locales = ['en', 'tr', 'es', 'fr', 'de', 'zh', 'pt', 'ar', 'ru', 'ja'];
|
||||
export const defaultLocale = 'tr';
|
||||
|
||||
export const routing = defineRouting({
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user