From e3065e1bf5e3e1405705b332333a86852a6738bc Mon Sep 17 00:00:00 2001 From: bokhonglo Date: Tue, 7 Apr 2026 16:20:18 +0700 Subject: [PATCH] media --- api.ts | 6 +- next.config.ts | 28 ++- package-lock.json | 31 ++- package.json | 3 +- .../(admin)/(others-pages)/profile/page.tsx | 42 ++-- src/components/auth/SignInForm.tsx | 2 +- .../user-profile/AccountDetails.tsx | 223 ++++++++++++++++++ src/components/user-profile/Media.tsx | 73 ++++++ .../user-profile/UserAddressCard.tsx | 140 ----------- src/components/user-profile/UserInfoCard.tsx | 21 +- src/components/user-profile/UserMetaCard.tsx | 29 +-- src/config/config.ts | 2 +- src/interface/media.ts | 21 ++ src/layout/AppSidebar.tsx | 2 +- src/service/auth.ts | 4 + src/service/mediaService.ts | 10 + 16 files changed, 442 insertions(+), 195 deletions(-) create mode 100644 src/components/user-profile/AccountDetails.tsx create mode 100644 src/components/user-profile/Media.tsx delete mode 100644 src/components/user-profile/UserAddressCard.tsx create mode 100644 src/interface/media.ts create mode 100644 src/service/mediaService.ts diff --git a/api.ts b/api.ts index 275793d..7da9c05 100644 --- a/api.ts +++ b/api.ts @@ -1,10 +1,14 @@ export const API_URL_ROOT = process.env.NEXT_PUBLIC_API_URL_ROOT || ""; - +export const URL_MEDIA = "https://cdn.kain.id.vn/history-app/" export const API = { User : { CURRENT: `${API_URL_ROOT}/users/current`, MEDIA: `${API_URL_ROOT}/users/current/media`, Update: (Id: number | string) => `${API_URL_ROOT}/users/${Id}`, + CHANGE_PASSWORD: (Id: number | string) => `${API_URL_ROOT}/users/${Id}/password`, + }, + Media:{ + PRESIGNED: `${API_URL_ROOT}/media/presigned` }, Auth : { SIGNUP: `${API_URL_ROOT}/auth/signup`, diff --git a/next.config.ts b/next.config.ts index 519e2e5..8e26e05 100644 --- a/next.config.ts +++ b/next.config.ts @@ -2,6 +2,17 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { /* config options here */ + images: { + remotePatterns: [ + { + protocol: 'https', + hostname: 'lh3.googleusercontent.com', + port: '', + pathname: '/**', + }, + ], + }, + webpack(config) { config.module.rules.push({ test: /\.svg$/, @@ -9,16 +20,15 @@ const nextConfig: NextConfig = { }); return config; }, - - turbopack: { - rules: { - '*.svg': { - loaders: ['@svgr/webpack'], - as: '*.js', - }, + + turbopack: { + rules: { + '*.svg': { + loaders: ['@svgr/webpack'], + as: '*.js', }, }, - + }, }; -export default nextConfig; +export default nextConfig; \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 251a7c5..2555ce0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -33,7 +33,8 @@ "react-redux": "^9.2.0", "sonner": "^2.0.7", "swiper": "^11.2.10", - "tailwind-merge": "^2.6.0" + "tailwind-merge": "^2.6.0", + "yet-another-react-lightbox": "^3.30.1" }, "devDependencies": { "@eslint/eslintrc": "^3.3.1", @@ -3524,7 +3525,7 @@ "version": "19.2.3", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", - "dev": true, + "devOptional": true, "license": "MIT", "peerDependencies": { "@types/react": "^19.2.0" @@ -9466,6 +9467,32 @@ "dev": true, "license": "ISC" }, + "node_modules/yet-another-react-lightbox": { + "version": "3.30.1", + "resolved": "https://registry.npmjs.org/yet-another-react-lightbox/-/yet-another-react-lightbox-3.30.1.tgz", + "integrity": "sha512-VYy9UZbBtHkuU6FnABF9G9UCAr56TPUcb3uFXHpgMd+FhqvPQDTbfSmwsax3cdMtSc/udu5ycfHz8jwFeWMP3g==", + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/igordanchenko" + }, + "peerDependencies": { + "@types/react": "^16 || ^17 || ^18 || ^19", + "@types/react-dom": "^16 || ^17 || ^18 || ^19", + "react": "^16.8.0 || ^17 || ^18 || ^19", + "react-dom": "^16.8.0 || ^17 || ^18 || ^19" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index e922785..e274410 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,8 @@ "react-redux": "^9.2.0", "sonner": "^2.0.7", "swiper": "^11.2.10", - "tailwind-merge": "^2.6.0" + "tailwind-merge": "^2.6.0", + "yet-another-react-lightbox": "^3.30.1" }, "devDependencies": { "@eslint/eslintrc": "^3.3.1", diff --git a/src/app/(admin)/(others-pages)/profile/page.tsx b/src/app/(admin)/(others-pages)/profile/page.tsx index cbcc5bd..258cf44 100644 --- a/src/app/(admin)/(others-pages)/profile/page.tsx +++ b/src/app/(admin)/(others-pages)/profile/page.tsx @@ -1,35 +1,36 @@ "use client"; -import UserAddressCard from "@/components/user-profile/UserAddressCard"; +import AccountDetails from "@/components/user-profile/AccountDetails"; +import MediaCard from "@/components/user-profile/Media"; import UserInfoCard from "@/components/user-profile/UserInfoCard"; import UserMetaCard from "@/components/user-profile/UserMetaCard"; +import { MediaDto } from "@/interface/media"; import { UserMetaCardProps } from "@/interface/user"; import { apiGetCurrentUser } from "@/service/auth"; import { apiGetCurrentUserMedia } from "@/service/userService"; -import { RootState } from "@/store/store"; import { useEffect, useState } from "react"; -import { useSelector } from "react-redux"; export default function Profile() { const [user, setUser] = useState(null); + const [mediaData, setMediaData] = useState(null); const [loading, setLoading] = useState(true); - useEffect(() => { - const fetchUser = async () => { - try { - const result = await apiGetCurrentUser(); - const mediaResult = await apiGetCurrentUserMedia(); - console.log("Current User:", result); - console.log("User Media:", mediaResult); - setUser(result); - } catch (err) { - console.error("Lỗi:", err); - } finally { - setLoading(false); - } - }; - fetchUser(); - }, []); + useEffect(() => { + const fetchUser = async () => { + try { + const userData = await apiGetCurrentUser(); + const mediaResponse = await apiGetCurrentUserMedia(); // Giả sử hàm này return {status, data, message} + + setMediaData(mediaResponse); + setUser(userData); + } catch (err) { + console.error("Lỗi:", err); + } finally { + setLoading(false); + } + }; + fetchUser(); +}, []); return (
@@ -40,7 +41,8 @@ export default function Profile() {
- + +
diff --git a/src/components/auth/SignInForm.tsx b/src/components/auth/SignInForm.tsx index d1a88e9..0794ca5 100644 --- a/src/components/auth/SignInForm.tsx +++ b/src/components/auth/SignInForm.tsx @@ -73,7 +73,7 @@ export default function SignInForm() { console.log("Current User Data:", data); if (data?.data) { dispatch(setUserData(data.data)); - // router.push("/profile"); + router.push("/"); } } else { toast.error("Email hoặc mật khẩu không đúng."); diff --git a/src/components/user-profile/AccountDetails.tsx b/src/components/user-profile/AccountDetails.tsx new file mode 100644 index 0000000..d9feff2 --- /dev/null +++ b/src/components/user-profile/AccountDetails.tsx @@ -0,0 +1,223 @@ +"use client"; +import React, { useState, useEffect } from "react"; +import { useModal } from "../../hooks/useModal"; +import { Modal } from "../ui/modal"; +import Button from "../ui/button/Button"; +import Input from "../form/input/InputField"; +import Label from "../form/Label"; +import { UserMetaCardProps } from "@/interface/user"; +import { useRouter } from "next/navigation"; +import { EyeCloseIcon, EyeIcon } from "@/icons"; +import { apiChangePassword } from "@/service/auth"; +import { toast } from "sonner"; + +export default function AccountDetails({ data }: { data: UserMetaCardProps }) { + const router = useRouter(); + const { isOpen, openModal, closeModal } = useModal(); + + const [formValues, setFormValues] = useState({ + old_password: "", + new_password: "", + confirm_password: "", + }); + + const [showOldPass, setShowOldPass] = useState(false); + const [error, setError] = useState(""); + + useEffect(() => { + if (!isOpen) { + setFormValues({ + old_password: "", + new_password: "", + confirm_password: "", + }); + setShowOldPass(false); + setError(""); + } + }, [isOpen]); + + const handleChange = (e: React.ChangeEvent) => { + const { name, value } = e.target; + setFormValues((prev) => ({ ...prev, [name]: value })); + }; + + const handleSave = async (e: React.FormEvent) => { + e.preventDefault(); + setError(""); + + if (formValues.new_password !== formValues.confirm_password) { + setError("Mật khẩu mới và xác nhận mật khẩu không khớp."); + return; + } + + if (formValues.new_password.length < 8) { + setError("Mật khẩu mới phải có ít nhất 8 ký tự."); + return; + } + + try { + const userId = data?.data?.id; + + if (userId) { + const payload = { + old_password: formValues.old_password, + new_password: formValues.new_password, + }; + + await apiChangePassword(userId, payload as any); + closeModal(); + toast.success("Cập nhật thành công!"); + router.refresh(); + } + } catch (err: any) { + setError( + `${err?.response?.data?.message}, Cập nhật thất bại, vui lòng kiểm tra lại thông tin. Mật khẩu tối thiểu 8 ký tự, 1 in hoa, 1 số và 1 ký tự đặc biệt.` || + "Failed to update password. Please check your current password.", + ); + toast.error(err?.response?.data?.message || "Cập nhật thất bại, vui lòng kiểm tra lại thông tin!"); + } + }; + + return ( + <> +
+
+
+

+ Account Details +

+
+
+

+ Email Address +

+

+ {data?.data?.email || "example@mail.com"} +

+
+
+

+ Password +

+

+ •••••••• +

+
+
+
+ + +
+
+ + {/* Modal Chỉnh sửa */} + +
+
+

+ Update Password +

+

+ For security, please enter your current password to set a new one. +

+
+ +
+
+
+ {/* 1. Email (Disabled) */} +
+ + +
+ + {/* 2. Current Password - CÓ NÚT HIỆN MẬT KHẨU */} +
+ +
+ + +
+
+ + {/* 3. New Password - LUÔN ẨN */} +
+ + +
+ + {/* 4. Confirm New Password - LUÔN ẨN */} +
+ + +
+
+ + {error &&

{error}

} +
+ +
+ + +
+
+
+
+ + ); +} diff --git a/src/components/user-profile/Media.tsx b/src/components/user-profile/Media.tsx new file mode 100644 index 0000000..5c73246 --- /dev/null +++ b/src/components/user-profile/Media.tsx @@ -0,0 +1,73 @@ +"use client"; +import { useState } from "react"; +import Lightbox from "yet-another-react-lightbox"; +import Zoom from "yet-another-react-lightbox/plugins/zoom"; +import Captions from "yet-another-react-lightbox/plugins/captions"; +import "yet-another-react-lightbox/styles.css"; +import "yet-another-react-lightbox/plugins/captions.css"; +import { MediaDto } from "@/interface/media"; +import { URL_MEDIA } from "../../../api"; + +export default function MediaCard({ data }: { data: MediaDto }) { + const [index, setIndex] = useState(-1); + const listMedia = data?.data || []; + + const slides = listMedia.map((item) => ({ + src: `${URL_MEDIA}${item.storage_key}`, + title: item.original_name, + description: `Size: ${(item.size / 1024).toFixed(2)} KB - Type: ${item.mime_type}`, + })); + + return ( +
+

Media Assets

+ +
+ {listMedia.map((item, idx) => ( +
setIndex(idx)} + className="group relative min-w-[150px] h-[150px] cursor-pointer overflow-hidden rounded-lg border border-gray-200" + > + {item.original_name} + +
+

+ {item.original_name} +

+
+
+ ))} +
+ + = 0} + close={() => setIndex(-1)} + slides={slides} + plugins={[Zoom, Captions]} + zoom={{ + maxZoomPixelRatio: 10, + zoomInMultiplier: 2, + doubleTapDelay: 300, + doubleClickDelay: 300, + doubleClickMaxStops: 2, + keyboardMoveDistance: 50, + wheelZoomDistanceFactor: 100, + pinchZoomDistanceFactor: 100, + }} + animation={{ zoom: 200 }} + styles={{ + root: { + zIndex: 999999999, + "--yarl__color_backdrop": "rgba(0, 0, 0, 0.85)", + }, + }} + /> +
+ ); +} diff --git a/src/components/user-profile/UserAddressCard.tsx b/src/components/user-profile/UserAddressCard.tsx deleted file mode 100644 index 0719083..0000000 --- a/src/components/user-profile/UserAddressCard.tsx +++ /dev/null @@ -1,140 +0,0 @@ -"use client"; -import React, { useState } from "react"; -import { useModal } from "../../hooks/useModal"; -import { Modal } from "../ui/modal"; -import Button from "../ui/button/Button"; -import Input from "../form/input/InputField"; -import Label from "../form/Label"; -import { Profile } from "@/interface/user"; - -export default function UserAddressCard() { - const { isOpen, openModal, closeModal } = useModal(); - const [formData, setFormData] = useState({ - display_name: "", - phone: "", - bio: "", - location: "", - website: "", - // Thêm các field khác nếu cần - }); - - useEffect(() => { - if (data?.data?.profile) { - setFormData({ - display_name: data.data.profile.display_name || "", - phone: data.data.profile.phone || "", - bio: data.data.profile.bio || "", - location: data.data.profile.location || "", - website: data.data.profile.website || "", - }); - } - }, [data, isOpen]); - - const handleChange = (e: React.ChangeEvent) => { - const { name, value } = e.target; - setFormData((prev) => ({ - ...prev, - [name]: value, - })); - }; - return ( - <> -
-
-
-

- Account details -

- -
-
-

- Username -

-

- United States -

-
- -
-

- City/State -

-

- Phoenix, Arizona, United States. -

-
-
-
- - -
-
- -
-
-

- Edit Address -

-

- Update your details to keep your profile up-to-date. -

-
-
-
-
-
- - -
- -
- - -
- -
- - -
- -
- - -
-
-
-
- - -
-
-
-
- - ); -} diff --git a/src/components/user-profile/UserInfoCard.tsx b/src/components/user-profile/UserInfoCard.tsx index ede15fc..910876f 100644 --- a/src/components/user-profile/UserInfoCard.tsx +++ b/src/components/user-profile/UserInfoCard.tsx @@ -14,14 +14,13 @@ import Link from "next/link"; export default function UserInfoCard({ data }: { data: UserMetaCardProps }) { const router = useRouter(); const { isOpen, openModal, closeModal } = useModal(); - // 1. Khởi tạo state cho form const [formData, setFormData] = useState({ display_name: "", phone: "", bio: "", location: "", website: "", - // Thêm các field khác nếu cần + avatar_url: "", }); useEffect(() => { @@ -32,6 +31,7 @@ export default function UserInfoCard({ data }: { data: UserMetaCardProps }) { bio: data.data.profile.bio || "", location: data.data.profile.location || "", website: data.data.profile.website || "", + avatar_url: data.data.profile.avatar_url || "", }); } }, [data, isOpen]); @@ -65,6 +65,14 @@ export default function UserInfoCard({ data }: { data: UserMetaCardProps }) {
+ {/*
+

+ Avatar +

+

+ {data.data?.profile?.avatar_url || "avatar_url"} +

+
*/}

Full Name @@ -164,6 +172,15 @@ export default function UserInfoCard({ data }: { data: UserMetaCardProps }) {

+
+ + +
{ - console.log("Saving changes..."); - closeModal(); - }; - console.log("UserMetaCard data:", data.data?.profile?.display_name); + // const { isOpen, openModal, closeModal } = useModal(); return ( <>
@@ -33,6 +28,11 @@ export default function UserMetaCard({ data }: { data: UserMetaCardProps }) { {data.data?.profile?.display_name || "Full Name"}
+

+ {data.data?.roles?.map((role) => role.name).join(", ") || + "No roles available"} +

+

{data.data?.profile?.bio || "No bio available"}

@@ -40,11 +40,6 @@ export default function UserMetaCard({ data }: { data: UserMetaCardProps }) {

{data.data?.profile?.location || "user location"}

-
-

- {data.data?.roles?.map((role) => role.name).join(", ") || - "No roles available"} -

{/*
diff --git a/src/config/config.ts b/src/config/config.ts index 0e507e0..a961321 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -50,7 +50,7 @@ api.interceptors.response.use( } catch (refreshErr) { processQueue(refreshErr) - // window.location.href = "/login" + window.location.href = "/signin" return Promise.reject(refreshErr) } finally { diff --git a/src/interface/media.ts b/src/interface/media.ts new file mode 100644 index 0000000..bfce0c6 --- /dev/null +++ b/src/interface/media.ts @@ -0,0 +1,21 @@ +interface item{ + file_metadata:string; + id:string; + mime_type:string; + original_name:string; + size:number; + updated_at:string; + user_id:string; + storage_key:string; +} + +export interface MediaDto { + data?: item[]; + message?:string; + status?:boolean; +} + +export interface payloadPresignedMedia { + filename:string; + contentType:string; +} \ No newline at end of file diff --git a/src/layout/AppSidebar.tsx b/src/layout/AppSidebar.tsx index a049b4e..b610ed4 100644 --- a/src/layout/AppSidebar.tsx +++ b/src/layout/AppSidebar.tsx @@ -374,7 +374,7 @@ const AppSidebar: React.FC = () => {
- {isExpanded || isHovered || isMobileOpen ? : null} + {/* {isExpanded || isHovered || isMobileOpen ? : null} */}
); diff --git a/src/service/auth.ts b/src/service/auth.ts index 4beadf8..bdbe132 100644 --- a/src/service/auth.ts +++ b/src/service/auth.ts @@ -31,3 +31,7 @@ export const apiGetCurrentUser = async () => { return response?.data; }; +export const apiChangePassword = async (id:string,payload: any) => { + const response = await api.patch(API.User.CHANGE_PASSWORD(id), payload); + return response?.data; +}; diff --git a/src/service/mediaService.ts b/src/service/mediaService.ts new file mode 100644 index 0000000..3cde746 --- /dev/null +++ b/src/service/mediaService.ts @@ -0,0 +1,10 @@ +import api from "@/config/config"; +import { API } from "../../api"; +import { payloadPresignedMedia } from "@/interface/media"; + +export const apiGetCurrentUserMedia = async (payload: payloadPresignedMedia) => { + const response = await api.get(API.Media.PRESIGNED, { + params: payload, + }); + return response?.data; +}; \ No newline at end of file