[개인 포트폴리오] [클라이밍 커뮤니티 SNS] 2. 코드 분석 <profile.js>
2024. 12. 20. 10:57ㆍ웹개발 포트폴리오
728x90
반응형
import styled from "styled-components";
import { db, storage } from "../firebase";
import React, { useState, useEffect, useCallback } from "react";
import { getDownloadURL, ref, uploadBytes } from "firebase/storage";
import { updateProfile } from "firebase/auth";
import { getDoc, updateDoc, doc, collection, query, where, getDocs, deleteDoc } from "firebase/firestore";
import { useParams } from "react-router-dom";
import { getAuth } from "firebase/auth";
import Modal from "react-modal";
Modal.setAppElement("#root");
const Wrapper = styled.div`
margin-top: 15vh;
margin-left: 20vw;
display: flex;
align-items: center;
display: flex;
flex-direction: column;
gap: 20px;
height: auto;
overflow-y: scroll;
::-webkit-scrollbar {
display: none;
}
@media (max-width: 600px) {
margin-top: 5vh;
}
`;
const AvatarUpload = styled.label`
width: 150px;
height: 150px;
background-color: ${(props) => (props.disabled ? "#ccc" : "white")};
cursor: ${(props) => (props.disabled ? "not-allowed" : "pointer")};
display: flex;
justify-content: center;
border-radius: 50%;
align-items: center;
pointer-events: ${(props) => (props.disabled ? "none" : "auto")};
`;
const AvatarImg = styled.img`
width: 100%;
border-radius: 50%;
height: 100%;
border-radius: 50%; /* 부모 요소에 맞게 둥글게 */
object-fit: cover; /* 이미지가 영역을 꽉 채우며 비율 유지 */
object-position: center;
`;
const AvatarInput = styled.input`
display: none;
border-radius: 50%;
`;
const Name = styled.div`
font-size: 22px;
display: flex;
align-items: center;
gap: 10px;
color: black;
margin-top: 10px;
`;
const PostsWrapper = styled.div`
margin-top: 10vh;
width: 80vw;
padding: 10px;
align-items: center;
display: grid;
grid-template-columns: repeat(3, 1fr); /* 3열 그리드 */
text-align: center;
margin-left: 10vw;
flex-direction: column;
@media (max-width: 600px) {
grid-template-columns: repeat(1, 1fr);
margin-top: 2vh;
justify-items: center;
}
`;
const PostItem = styled.div`
background-color: #f0f0f0;
margin-bottom: 10px;
border-radius: 8px;
width: 18vw;
height: 100vh;
flex-direction: column;
text-align: center;
justify-items: center;
position: relative;
img {
width: 70%;
}
@media (max-width: 600px) {
width: 60vw;
height: 80vh;
justify-content: center; /* Flexbox 세로 정렬 가운데 */
gap: 10px; /* 요소 간 간격 추가 */
padding: 20px; /* 내부 여백 추가 */
img {
width: 80%; /* 이미지가 부모 너비의 80%를 차지 */
height: auto; /* 이미지 비율 유지 */
border-radius: 5%; /* 모서리를 부드럽게 */
margin-top: 5px; /* 상단 여백 */
}
}
`;
const Likes = styled.button`
font-size: 1vw;
background-color: ${(props) => (props.disabled ? "#ccc" : "#ff5a5f")};
color: white;
border: 0;
padding: 5px 10px;
border-radius: 5px;
cursor: ${(props) => (props.disabled ? "not-allowed" : "pointer")};
margin-top: 10px;
width: 12vw;
height: 5vh;
@media (max-width: 600px) {
font-size: 0.8rem;
width: 30vw;
height: 3vh;
}
`;
const DeleteButton = styled.button`
background-color: #f5957f;
color: white;
border: 0;
padding: 5px 10px;
border-radius: 5px;
cursor: pointer;
margin-top: 10px;
`;
const DateSpan = styled.span`
display: block;
color: #333;
width: 100%;
margin-top: 15px;
`;
const Items = styled.div`
display: flex;
justify-content: center; /* 수평 가운데 정렬 */
align-items: center;
margin-bottom: 2vh;
flex-direction: column;
width: 100%;
position: absolute;
bottom: 5vh;
@media (max-width: 600px) {
gap: 10px;
}
`;
const Payload = styled.div`
padding: 10px 15px 15px 0;
color: #333;
width: 80%;
text-align: left;
align-items: center;
word-break: break-word;
white-space: pre-wrap;
@media (max-width: 600px) {
font-size: 1.1rem;
width: 80%;
}
`;
const CloseButton = styled.button`
background-color: none;
color: #8f89a1;
border: none;
cursor: pointer;
position: absolute;
top: 10px;
right: 10px;
`;
const ModalImage = styled.img`
width: 100vw;
height: 70vh;
max-width: 100%;
max-height: 100%;
object-fit: contain;
`;
const ModalNavigation = styled.div`
display: flex;
justify-content: space-between;
margin-top: 10px;
`;
const ModalButton = styled.button`
background-color: #333;
color: white;
border: none;
padding: 10px;
border-radius: 5px;
cursor: pointer;
`;
const Profile = () => {
const { userId } = useParams();
// useParams : React Router에서 제공하는 훅으로, 현재 URL의 동적 세그먼트(경로 변수) 값을 가져온다
const [editMode, setEditMode] = useState(false);
const [username, setUsername] = useState("");
const [avatar, setAvatar] = useState(null);
const [posts, setPosts] = useState([]);
const [modalIsOpen, setIsOpen] = useState(false);
const [selectedPhotos, setSelectedPhotos] = useState([]);
const [currentPhotoIndex, setCurrentPhotoIndex] = useState(0);
const auth = getAuth();
const user = auth.currentUser;
// 사용자 프로필 데이터 불러오기
const fetchProfile = useCallback(async () => {
try {
const userDoc = await getDoc(doc(db, "users", userId));
if (userDoc.exists()) {
const userData = userDoc.data();
setUsername(userData.displayName || "Anonymous");
setAvatar(userData.photoURL || "/guest.png");
}
} catch (error) {
console.error("Error fetching profile:", error);
}
}, [userId]);
// 사용자 포스트 데이터 불러오기
const fetchUserPosts = useCallback(async () => {
try {
const q = query(collection(db, "posts"), where("userId", "==", userId));
const querySnapshot = await getDocs(q);
const userPosts = querySnapshot.docs.map((doc) => ({
id: doc.id,
...doc.data(),
createdAt: doc.data().createdAt?.toDate() || null,
}));
setPosts(userPosts);
} catch (error) {
console.error("Error fetching user posts:", error);
}
}, [userId]);
// 데이터 로드
useEffect(() => {
if (userId) {
fetchProfile();
fetchUserPosts();
}
}, [userId, fetchProfile, fetchUserPosts]);
// 아바타 변경
const onAvatarChange = async (e) => {
const file = e.target.files[0];
if (!file) return;
try {
const storageRef = ref(storage, `avatars/${userId}`);
await uploadBytes(storageRef, file);
const avatarUrl = await getDownloadURL(storageRef);
setAvatar(avatarUrl);
await updateProfile(user, { photoURL: avatarUrl });
await updateDoc(doc(db, "users", userId), { photoURL: avatarUrl });
} catch (error) {
console.error("Error updating avatar:", error);
}
};
// 사용자 이름 저장
const saveUsername = async () => {
try {
await updateProfile(user, { displayName: username });
await updateDoc(doc(db, "users", userId), { displayName: username });
setEditMode(false);
} catch (error) {
console.error("Error saving username:", error);
}
};
// 포스트 삭제
const onDeletePost = async (id) => {
const confirmDelete = window.confirm("정말 이 포스트를 삭제하시겠습니까?");
if (!confirmDelete) return;
try {
await deleteDoc(doc(db, "posts", id));
setPosts((prevPosts) => prevPosts.filter((post) => post.id !== id));
} catch (error) {
console.error("삭제 중 오류 발생:", error);
}
};
// 사진 확대 모달
const openModal = (photos) => {
setSelectedPhotos(photos);
setCurrentPhotoIndex(0);
setIsOpen(true);
};
const closeModal = () => {
setIsOpen(false);
setSelectedPhotos([]);
setCurrentPhotoIndex(0);
};
const showNextPhoto = () => {
setCurrentPhotoIndex((prevIndex) =>
prevIndex === selectedPhotos.length - 1 ? 0 : prevIndex + 1
);
};
const showPreviousPhoto = () => {
setCurrentPhotoIndex((prevIndex) =>
prevIndex === 0 ? selectedPhotos.length - 1 : prevIndex - 1
);
};
return (
<Wrapper>
<AvatarUpload htmlFor="avatar">
<AvatarImg src={avatar || auth.currentUser?.photoURL || "/guest.png"} alt="User Avatar" />
</AvatarUpload>
<AvatarInput id="avatar" type="file" accept="image/*" onChange={onAvatarChange} />
<Name>
{editMode ? (
<input value={username} onChange={(e) => setUsername(e.target.value)} />
) : (
<span>{username}</span>
)}
{user?.uid === userId && (
<svg style = {{cursor: "pointer"}} alt = "username edit" onClick={() => (editMode ? saveUsername() : setEditMode(true))}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 256 256"
width="24px"
height="24px"
fillRule="nonzero"
>
<g fill="#333">
<g transform="scale(10.66667,10.66667)">
<path d="M18.41406,2c-0.256,0 -0.51203,0.09797 -0.70703,0.29297l-2,2l-1.41406,1.41406l-11.29297,11.29297v4h4l14.70703,-14.70703c0.391,-0.391 0.391,-1.02406 0,-1.41406l-2.58594,-2.58594c-0.195,-0.195 -0.45103,-0.29297 -0.70703,-0.29297zM18.41406,4.41406l1.17188,1.17188l-1.29297,1.29297l-1.17187,-1.17187zM15.70703,7.12109l1.17188,1.17188l-10.70703,10.70703h-1.17187v-1.17187z"></path>
</g>
</g>
</svg>
)}
</Name>
<PostsWrapper>
{posts.map((post) => (
<PostItem key={post.id}>
{post.photos &&
Array.isArray(post.photos) &&
post.photos.length > 0 && (
<img
src={post.photos[0]} // 배열의 첫 번째 사진만 표시
alt="Post Thumbnail"
style={{
marginTop: "5vh",
height: "auto",
borderRadius: "5%",
display: "block",
cursor: "pointer", // 클릭 가능 표시
}}
onClick={() => openModal(post.photos)} // 모든 사진 전달
/>
)}
<Payload dangerouslySetInnerHTML={{ __html: post.post }} />
<Items>
<Likes>함께 하고 싶어요 {posts?.likes || 0}</Likes>
<DateSpan>{post.createdAt ? post.createdAt.toLocaleString() : "알 수 없는 시간"}</DateSpan>
<DeleteButton onClick={() => onDeletePost(post.id)}>지우기</DeleteButton>
</Items>
</PostItem>
))}
</PostsWrapper>
<Modal
isOpen={modalIsOpen}
onRequestClose={closeModal}
style={{
overlay: {
zIndex: 1000,
backgroundColor: "rgba(0, 0, 0, 0.5)", // 반투명 배경
display: "flex", // 플렉스 박스 활성화
justifyContent: "center", // 가로 중앙 정렬
alignItems: "center", // 세로 중앙 정렬
},
content: {
position: "relative",
width: "80vw",
height: "80vh",
margin: "0 auto",
background: "#fff",
borderRadius: "10px",
padding: "20px",
overflow: "hidden",
},
}}
>
<CloseButton onClick={closeModal}>X</CloseButton>
{selectedPhotos.length > 0 && (
<>
<ModalImage src={selectedPhotos[currentPhotoIndex]} />
<ModalNavigation>
<ModalButton onClick={showPreviousPhoto}>이전</ModalButton>
<ModalButton onClick={showNextPhoto}>다음</ModalButton>
</ModalNavigation>
</>
)}
</Modal>
</Wrapper>
);
};
export default Profile;
728x90
반응형
'웹개발 포트폴리오' 카테고리의 다른 글
[개인 포트폴리오] [클라이밍 커뮤니티 SNS] 2. 코드 분석 <posting.js> (0) | 2024.12.20 |
---|---|
[개인 포트폴리오] [클라이밍 커뮤니티 SNS] 2. 코드 분석 <post.js> (0) | 2024.12.20 |
[개인 포트폴리오] [클라이밍 커뮤니티 SNS] 2. 코드 분석 <loading-screen.js> (0) | 2024.12.20 |
[개인 포트폴리오] [클라이밍 커뮤니티 SNS] 2. 코드 분석 <github-btn.js> (0) | 2024.12.20 |
[개인 포트폴리오] [클라이밍 커뮤니티 SNS] 2. 코드 분석 <create-account.js> (0) | 2024.12.20 |