diff --git a/jsconfig.json b/jsconfig.json index b30b1c0f..680d82bb 100644 --- a/jsconfig.json +++ b/jsconfig.json @@ -1,8 +1,10 @@ { "compilerOptions": { + "jsx": "react-jsx", + "baseUrl": ".", "paths": { "@/*": ["src/*"], }, }, -} \ No newline at end of file +} diff --git a/package.json b/package.json index 44f14fa6..307cb018 100644 --- a/package.json +++ b/package.json @@ -11,14 +11,19 @@ "dependencies": { "@heroicons/react": "2.0.18", "@material-tailwind/react": "2.1.4", + "@tanstack/react-query": "^5.83.0", "apexcharts": "3.44.0", + "axios": "^1.10.0", "prop-types": "15.8.1", "react": "18.2.0", "react-apexcharts": "1.4.1", "react-dom": "18.2.0", - "react-router-dom": "6.17.0" + "react-loader-spinner": "^6.1.6", + "react-router-dom": "6.17.0", + "react-select": "^5.10.2" }, "devDependencies": { + "@tailwindcss/line-clamp": "^0.4.4", "@types/react": "18.2.31", "@types/react-dom": "18.2.14", "@vitejs/plugin-react": "4.1.0", @@ -27,6 +32,6 @@ "prettier": "3.0.3", "prettier-plugin-tailwindcss": "0.5.6", "tailwindcss": "3.3.4", - "vite": "4.5.0" + "vite": "^4.5.0" } -} \ No newline at end of file +} diff --git a/src/App.jsx b/src/App.jsx index 87826600..2429bf57 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,14 +1,65 @@ -import { Routes, Route, Navigate } from "react-router-dom"; +import React, {useEffect} from "react"; +import { Routes, Route, Navigate, useNavigate } from "react-router-dom"; import { Dashboard, Auth } from "@/layouts"; - +import VideoFarmById from "./pages/dashboard/VideoFarms/VideoById"; +import VideoLikeList from "./pages/dashboard/VideoFarms/VideoLikeList"; +import PostDetail from "./pages/dashboard/post/PostDetail"; +import CommentPostbyId from "./pages/dashboard/AdminCommentPost/CommentPostbyId"; +import CommentPostbyIdPost from "./pages/dashboard/AdminCommentPost/CommentPostbyIdPost"; +import CommentPostByIdUser from "./pages/dashboard/AdminCommentPost/CommentPostByIdUser"; +import FarmDetail from "./pages/dashboard/farm/FarmDetail"; +import { Farms } from "./pages/dashboard/farm/farms"; +import UserDetail from "./pages/dashboard/user/UserDetail"; +import VideoById from "./pages/dashboard/VideoFarms/VideoById"; function App() { + const navigate = useNavigate(); + useEffect(() => { + const token = localStorage.getItem("token"); + if (!token) { + navigate("/auth/sign-in"); + } + + const handleUnload = () => { + if (performance.getEntriesByType("navigation")[0].type !== "reload") { + localStorage.removeItem("token"); + localStorage.removeItem("refreshToken"); + localStorage.removeItem("apiBaseUrl"); + } + }; + + window.addEventListener("beforeunload", handleUnload); + + + return () => { + window.removeEventListener("beforeunload", handleUnload); + } + }, [navigate]); + return ( - - } /> + + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + {/* } /> */} + } /> + } /> + } /> } /> + ); } -export default App; + + + + + +export default App; \ No newline at end of file diff --git a/src/components/AnswerDetailDialog.jsx b/src/components/AnswerDetailDialog.jsx new file mode 100644 index 00000000..8c8de809 --- /dev/null +++ b/src/components/AnswerDetailDialog.jsx @@ -0,0 +1,55 @@ +// src/components/AnswerDetailDialog.jsx +import React from "react"; +import { + Dialog, + DialogHeader, + DialogBody, + DialogFooter, + Typography, + Button, +} from "@material-tailwind/react"; + +const AnswerDetailDialog = ({ open, onClose, data }) => { + if (!data) return null; + return ( + + Chi tiết câu trả lời + + Farm ID: {data.farmId} + Question ID: {data.questionId} + + Selected Options: {data.selectedOptions?.join(", ") || "—"} + + Other Text: {data.otherText || "—"} + User ID: {data.userId || "—"} +
+ Tệp đính kèm: +
+ {data.uploadedFiles?.length > 0 ? ( + data.uploadedFiles.map((file, idx) => ( + + File {idx + 1} + + )) + ) : ( + Không có + )} +
+
+
+ + + +
+ ); +}; + +export default AnswerDetailDialog; diff --git a/src/components/LikeButton.jsx b/src/components/LikeButton.jsx new file mode 100644 index 00000000..6c67aab1 --- /dev/null +++ b/src/components/LikeButton.jsx @@ -0,0 +1,69 @@ +// src/components/LikeButton.jsx + +import React, { useState } from 'react'; +import axios from 'axios'; +import { BaseUrl } from '@/ipconfig'; +import { useNavigate } from 'react-router-dom'; + +export default function LikeButton({ videoId }) { + const [liked, setLiked] = useState(false); + const [loading, setLoading] = useState(false); + const token = localStorage.getItem('token'); + const navigate = useNavigate(); + + const handleLikeToggle = async () => { + if (!videoId || !token) return; + setLoading(true); + + try { + if (liked) { + // ✅ GỌI API UNLIKE CHUẨN BE + await axios.post( + `${BaseUrl}/video-like/${videoId}/unlike`, + {}, + { headers: { Authorization: `Bearer ${token}` } } + ); + } else { + // ✅ GỌI API LIKE CHUẨN BE + await axios.post( + `${BaseUrl}/video-like/${videoId}/like`, + {}, + { headers: { Authorization: `Bearer ${token}` } } + ); + } + + // Toggle trạng thái + setLiked(!liked); + } catch (error) { + console.error('Lỗi khi Like/Unlike video:', error); + } finally { + setLoading(false); + } + }; + + const handleViewLikes = () => { + // Điều hướng đến trang hiển thị danh sách users đã Like + navigate(`/dashboard/video-like/${videoId}`); + }; + + return ( +
+ + + +
+ ); +} diff --git a/src/components/VideoLikeBox.jsx b/src/components/VideoLikeBox.jsx new file mode 100644 index 00000000..08c3d7da --- /dev/null +++ b/src/components/VideoLikeBox.jsx @@ -0,0 +1,57 @@ +// src/components/VideoLikeBox.jsx +import React, { useEffect, useState } from 'react'; +import axios from 'axios'; +import { BaseUrl } from '@/ipconfig'; + +export default function VideoLikeBox({ videoId, refreshKey }) { + const [users, setUsers] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + const token = localStorage.getItem('token'); + + const fetchLikes = async () => { + if (!videoId || videoId === ':videoId') return; + setLoading(true); + try { + const res = await axios.get(`${BaseUrl}/video-like/${videoId}/users`, { + headers: { Authorization: `Bearer ${token}` }, + }); + + const usersList = Array.isArray(res.data.data) + ? res.data.data + : res.data?.users || []; + + setUsers(usersList); + } catch (err) { + console.error('Lỗi khi lấy danh sách like:', err); + setError('Không thể lấy danh sách người like.'); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + fetchLikes(); + }, [videoId, refreshKey]); + + return ( +
+

Người đã like:

+ {loading ? ( + Đang tải... + ) : error ? ( + {error} + ) : users.length === 0 ? ( + Chưa có ai like + ) : ( +
    + {users.map((user, idx) => ( +
  • + {user.fullName || user.username || 'Ẩn danh'} +
  • + ))} +
+ )} +
+ ); +} diff --git a/src/context/index.jsx b/src/context/index.jsx index 653a362d..169d1528 100644 --- a/src/context/index.jsx +++ b/src/context/index.jsx @@ -24,6 +24,9 @@ export function reducer(state, action) { case "OPEN_CONFIGURATOR": { return { ...state, openConfigurator: action.value }; } + case "AUTH_STATUS": { + return { ...state, isAuthenticated: action.value} + } default: { throw new Error(`Unhandled action type: ${action.type}`); } @@ -33,11 +36,12 @@ export function reducer(state, action) { export function MaterialTailwindControllerProvider({ children }) { const initialState = { openSidenav: false, - sidenavColor: "dark", + sidenavColor: "blue-gray", sidenavType: "white", transparentNavbar: true, fixedNavbar: false, openConfigurator: false, + isAuthenticated: Boolean(localStorage.getItem("token")), }; const [controller, dispatch] = React.useReducer(reducer, initialState); @@ -83,3 +87,5 @@ export const setFixedNavbar = (dispatch, value) => dispatch({ type: "FIXED_NAVBAR", value }); export const setOpenConfigurator = (dispatch, value) => dispatch({ type: "OPEN_CONFIGURATOR", value }); +export const setAuthStatus = (dispatch, value) => + dispatch({type: "AUTH_STATUS", value}) diff --git a/src/ipconfig.jsx b/src/ipconfig.jsx new file mode 100644 index 00000000..995e4dd3 --- /dev/null +++ b/src/ipconfig.jsx @@ -0,0 +1 @@ +export const BaseUrl= `https://api-ndolv2.nongdanonline.cc` \ No newline at end of file diff --git a/src/layouts/auth.jsx b/src/layouts/auth.jsx index c7a21165..7810f8c8 100644 --- a/src/layouts/auth.jsx +++ b/src/layouts/auth.jsx @@ -16,7 +16,7 @@ export function Auth() { icon: ChartPieIcon, }, { - name: "profile", + name: "User", path: "/dashboard/home", icon: UserIcon, }, diff --git a/src/layouts/dashboard.jsx b/src/layouts/dashboard.jsx index 888a627a..8fe90237 100644 --- a/src/layouts/dashboard.jsx +++ b/src/layouts/dashboard.jsx @@ -1,6 +1,9 @@ import { Routes, Route } from "react-router-dom"; +import { useState } from "react"; import { Cog6ToothIcon } from "@heroicons/react/24/solid"; import { IconButton } from "@material-tailwind/react"; +import { Outlet } from "react-router-dom"; + import { Sidenav, DashboardNavbar, @@ -14,15 +17,22 @@ export function Dashboard() { const [controller, dispatch] = useMaterialTailwindController(); const { sidenavType } = controller; + const [collapsed, setCollapsed] = useState(false); + return ( -
+
setCollapsed(value)} /> -
+
+ {routes.map( ({ layout, pages }) => @@ -43,6 +54,7 @@ export function Dashboard() { )) )} +
diff --git a/src/main.jsx b/src/main.jsx index a28f5a60..dc0c35e0 100644 --- a/src/main.jsx +++ b/src/main.jsx @@ -16,13 +16,17 @@ import { BrowserRouter } from "react-router-dom"; import { ThemeProvider } from "@material-tailwind/react"; import { MaterialTailwindControllerProvider } from "@/context"; import "../public/css/tailwind.css"; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +const queryClient = new QueryClient(); ReactDOM.createRoot(document.getElementById("root")).render( + + diff --git a/src/pages/auth/sign-in.jsx b/src/pages/auth/sign-in.jsx index 3b3da41a..7289f322 100644 --- a/src/pages/auth/sign-in.jsx +++ b/src/pages/auth/sign-in.jsx @@ -1,22 +1,77 @@ +import React, { useRef, useState } from "react"; import { - Card, - Input, - Checkbox, - Button, - Typography, + Card, Input, Checkbox, Button, Typography, Radio } from "@material-tailwind/react"; -import { Link } from "react-router-dom"; - +import { Link, useNavigate } from "react-router-dom"; +import { useMaterialTailwindController, setAuthStatus } from "@/context"; export function SignIn() { + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [emailError, setEmailError] = useState(""); + const [passwordError, setPasswordError] = useState(""); + const [env, setEnv] = useState("dev"); + const navigate = useNavigate(); + const [, dispatch] = useMaterialTailwindController(); + const emailRef = useRef(); + const passwordRef = useRef(); + + + const handleLogin = async (e) => { + e.preventDefault(); + + setEmailError(""); + setPasswordError(""); + + if (!email.trim()) { + setEmailError("Vui lòng nhập Email"); + emailRef.current?.focus(); + return; + } + + if (!password.trim()) { + setPasswordError("Vui lòng nhập Password"); + passwordRef.current?.focus(); + return; + } + + try { + const BASE_URL = env === "dev"? "https://api-ndolv2.nongdanonline.cc" : "https://api-ndol-v2-prod.nongdanonline.cc/"; + localStorage.setItem("apiBaseUrl", BASE_URL); + + const res = await fetch("https://api-ndolv2.nongdanonline.cc/auth/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email, password }), + }); + + const data = await res.json(); + + if (res.ok) { + localStorage.setItem("token", data.accessToken); + localStorage.setItem("refreshToken", data.refreshToken); + setAuthStatus(dispatch, true); + navigate("/dashboard/home"); + } else { + alert(data.message || "Đăng nhập thất bại"); + } + } catch (error) { + console.error("Login error:", error); + alert("Không thể kết nối tới máy chủ, thử lại sau"); + } + }; + + return (
Sign In - Enter your email and password to Sign In. + + Enter your email and password to Sign In. +
-
+
Your email @@ -24,11 +79,15 @@ export function SignIn() { setEmail(e.target.value)} className=" !border-t-blue-gray-200 focus:!border-t-gray-900" - labelProps={{ - className: "before:content-none after:content-none", - }} + labelProps={{ className: "before:content-none after:content-none" }} + inputRef={emailRef} /> + {emailError && ( + {emailError} + )} Password @@ -36,89 +95,62 @@ export function SignIn() { type="password" size="lg" placeholder="********" + value={password} + onChange={(e) => setPassword(e.target.value)} className=" !border-t-blue-gray-200 focus:!border-t-gray-900" - labelProps={{ - className: "before:content-none after:content-none", - }} + labelProps={{ className: "before:content-none after:content-none" }} + inputRef={passwordRef} /> + {passwordError && ( + {passwordError} + )} +
+ +
+ + Chọn môi trường API: + +
+ setEnv("dev")} + /> + setEnv("prod")} + /> +
- - I agree the  - - Terms and Conditions - - - } - containerProps={{ className: "-ml-2.5" }} - /> - + + +
+ Subscribe me to newsletter } containerProps={{ className: "-ml-2.5" }} /> - - Forgot Password - + Forgot Password
-
- - -
- - Not registered? - Create account - -
- +
-
); } diff --git a/src/pages/dashboard/AdminCommentPost/CommentPost.jsx b/src/pages/dashboard/AdminCommentPost/CommentPost.jsx new file mode 100644 index 00000000..26a50462 --- /dev/null +++ b/src/pages/dashboard/AdminCommentPost/CommentPost.jsx @@ -0,0 +1,173 @@ +import { BaseUrl } from '@/ipconfig' +import axios from 'axios' +import React, { useEffect, useState } from 'react' +import { Audio } from 'react-loader-spinner' +import { Navigate, useNavigate } from 'react-router-dom' +import { Button } from '@material-tailwind/react' +export const CommentPost = () => { + const tokenUser = localStorage.getItem('token') + const [loading, setLoading] = useState(true) + const navigate=useNavigate() + const [page, setPage] = useState(1) + const [totalPages, setTotalPages] = useState(1); + const limit = 50; +const [comment,setComment]=useState([]) +const [post,setPost]=useState([]) +const gotoCommentId=(id)=>{ +navigate(`/dashboard/CommentPostbyId/${id}`) +} +console.log(post) + + + +const gotoCommentByIdPost=(postId)=>{ +navigate(`/dashboard/CommentPostbyIdPost/${postId}`) +} + +const callApiCommentPost=async()=>{ +try { + +const res= await axios.get(`${BaseUrl}/admin-comment-post/?page=${page}&limit=${limit}`,{ + headers:{Authorization:`Bearer ${tokenUser}`} +}) + +if(res.status===200){ +setComment(res.data.data) +setTotalPages(res.data.totalPages) + setLoading(false) +} +} catch (error) { + console.log("Lỗi nè",error) + setLoading(false) +} + +} +// console.log(comment) + +useEffect(()=>{ + setLoading(true) +callApiCommentPost() +},[page]) + +useEffect(() => { + getPost() +}, [comment]); + +const postMap = React.useMemo(() => { + const map = {}; + post.forEach(p => { + // Dùng cả id và _id, ép kiểu về chuỗi, loại bỏ khoảng trắng + map[String(p.id ?? p._id).trim()] = p; + }); + return map; +}, [post]); + +const getPost = async () => { + try { + const uniqueIds = [...new Set(comment.map(item => item.postId))]; // loại bỏ trùng + if (uniqueIds.length === 0) return; + + const res = await axios.get(`${BaseUrl}/admin-post-feed?ids=${uniqueIds.join(',')}`, { + headers: { Authorization: `Bearer ${tokenUser}` } + }); + + if (res.status === 200) { + console.log('API trả về post:', res.data.data); + + setPost(res.data.data); + setLoading(false); + } + } catch (error) { + setLoading(false); + console.error("Lỗi getPost:", error); + } +}; + + + return ( +
+{loading? +(
+
+ ):( +comment.map((item) => { + const postInfo = postMap[String(item.postId).trim()]; + const title = postInfo && postInfo.title && postInfo.title.trim() + ? postInfo.title + : "Bài viết đã bị xóa hoặc không xác định"; + return ( +
gotoCommentByIdPost(item.postId)} key={item.postId} className="mb-4 p-4 border rounded bg-white"> +
+ Tiêu đề bài viết: {title} + + + Ngày đăng: {item.createdAt ? new Date(item.createdAt).toLocaleDateString() : "Chưa cập nhật"} + + {item.comments.map((cmt, index) => ( +
+
+ + {cmt.userId.fullName} + {new Date(cmt.createdAt).toLocaleDateString()} +
+
{cmt.comment}
+ {cmt.replies && cmt.replies.length > 0 && ( +
+ {cmt.replies.map((rep, ridx) => ( +
+ + {rep.userId.fullName}: + {rep.comment} + {new Date(rep.createdAt).toLocaleDateString()} +
+ ))} +
+ )} +
+ ))} +
+
+ ); +}) + ) + + } +
+ + Trang {page} / {totalPages} + +
+
+ ) +} + +export default CommentPost \ No newline at end of file diff --git a/src/pages/dashboard/AdminCommentPost/CommentPostByIdUser.jsx b/src/pages/dashboard/AdminCommentPost/CommentPostByIdUser.jsx new file mode 100644 index 00000000..81b8e1e9 --- /dev/null +++ b/src/pages/dashboard/AdminCommentPost/CommentPostByIdUser.jsx @@ -0,0 +1,9 @@ +import React from 'react' + +export const CommentPostByIdUser = () => { + return ( +
CommentPostByIdUser
+ ) +} + +export default CommentPostByIdUser \ No newline at end of file diff --git a/src/pages/dashboard/AdminCommentPost/CommentPostbyId.jsx b/src/pages/dashboard/AdminCommentPost/CommentPostbyId.jsx new file mode 100644 index 00000000..40c8c74e --- /dev/null +++ b/src/pages/dashboard/AdminCommentPost/CommentPostbyId.jsx @@ -0,0 +1,37 @@ +import { BaseUrl } from '@/ipconfig'; +import axios from 'axios'; +import React, { useEffect, useState } from 'react' +import { useParams } from 'react-router-dom' +export const CommentPostbyId = () => { + const [commentDetail,setCommentDetail]=useState() + const [loading, setLoading] = useState(true) + const tokenUser = localStorage.getItem('token'); + const {id}=useParams() + const getCommentById=async()=>{ +try { + const res= await axios.get(`${BaseUrl}/admin-comment-post/${id}`, + {headers:{Authorization:`Bearer ${tokenUser}` }}) + if(res.status===200){ +setCommentDetail(res.data) + } + +} catch (error) { + console.log("Lỗi nè",error) + setLoading(false) +} + } +console.log("data nè",commentDetail) + + useEffect(()=>{ +getCommentById() + },[]) + setLoading(false) + return ( +
+ + +
+ ) +} + +export default CommentPostbyId \ No newline at end of file diff --git a/src/pages/dashboard/AdminCommentPost/CommentPostbyIdPost.jsx b/src/pages/dashboard/AdminCommentPost/CommentPostbyIdPost.jsx new file mode 100644 index 00000000..5716e07e --- /dev/null +++ b/src/pages/dashboard/AdminCommentPost/CommentPostbyIdPost.jsx @@ -0,0 +1,247 @@ +import React from 'react' +import { useState } from 'react' +import { useEffect } from 'react' +import { BaseUrl } from '@/ipconfig' +import { useParams } from 'react-router-dom' +import axios from 'axios' +import { Audio } from 'react-loader-spinner' +import { Typography } from '@material-tailwind/react' +export const CommentPostbyIdPost = () => { + + const [CommentByIdPost,setCommentByIdPost]=useState([]) + const [loading, setLoading] = useState(true) + const tokenUser = localStorage.getItem('token'); + const [editCommentIndex, setEditCommentIndex] = useState(null); + const [editComment, setEditComment] = useState(null); + const [editContent, setEditContent] = useState(""); + const [post, setPost] = useState(""); + + + const {postId}=useParams() + const getCommentById=async()=>{ +try { + const res= await axios.get(`${BaseUrl}/admin-comment-post/post/${postId}`, + {headers:{Authorization:`Bearer ${tokenUser}` }}) + if(res.status===200){ + setCommentByIdPost(res.data) + + } + +} catch (error) { + console.log("Lỗi nè",error) + setLoading(false) +} + } +const handleEditComment=async(comment,index)=>{ + // console.log(comment) + setEditComment(comment) + setEditCommentIndex(index); +setEditContent(comment.comment) +} + +const callPost=async()=>{ +try { + + const res = await axios.get(`${BaseUrl}/admin-post-feed/${postId}`, { + headers: { Authorization: `Bearer ${tokenUser}` } + }); + +if(res.status===200){ +setPost(res.data) +// setTotalPages(res.data.totalPages) + setLoading(false) +} +} catch (error) { + console.log("Lỗi nè",error) + setLoading(false) +} + +} + +console.log(post) + const handleUpdateComment=async()=>{ +try { + const res= await axios.put(`${BaseUrl}/admin-comment-post/${postId}/comment/${editCommentIndex}`, + { comment: editContent }, + {headers:{Authorization:`Bearer ${tokenUser}` }}) + if(res.status===200){ +setEditComment(null); + getCommentById(); + } + +} catch (error) { + alert("Lỗi khi cập nhật bình luận"); + setLoading(false) +} + } + + const handleDeleteComment=async(comment,index)=>{ +try { + const res= await axios.delete(`${BaseUrl}/admin-comment-post/${postId}/comment/${index}`, + { data: { status: false }, + headers:{Authorization:`Bearer ${tokenUser}` }}) + if(res.status===200){ + getCommentById(); + alert("Xóa thành công") + } + +} catch (error) { + alert("Lỗi khi xóa bình luận"); + setLoading(false) +} + } + + +// console.log("data nè",CommentByIdPost.comments.length) + + useEffect(()=>{ +getCommentById() +callPost() + setLoading(false) + + },[]) + + return ( +
+ + Quản lý Bình luận + + {loading ? ( +
+
+ ) : ( +
+
+
+ Bài viết: {post.title} +
+
+ Ngày đăng: {CommentByIdPost.createdAt ? new Date(CommentByIdPost.createdAt).toLocaleDateString() : ""} +
+
+ Chỉnh sửa: {CommentByIdPost.updatedAt ? new Date(CommentByIdPost.updatedAt).toLocaleDateString() : ""} +
+ {CommentByIdPost.content && ( +
+ {CommentByIdPost.content} +
+ )} +
+{editComment && ( +
+
+

Sửa bình luận

+