[개인 포트폴리오] [트위터 클론 SNS Deli] 분석 Part. 10 'profile.tsx'
2024. 7. 8. 18:46ㆍ웹개발 포트폴리오
728x90
반응형
profile.tsx
import { styled } from "styled-components";
import { auth, db, storage } from "../firebase";
import { useEffect, useState } from "react";
import { getDownloadURL, ref, uploadBytes } from "firebase/storage";
import { updateProfile, getAuth } from "firebase/auth";
import { Timestamp, collection, deleteDoc, doc, getDoc, getDocs, limit, onSnapshot, orderBy, query, setDoc, where, updateDoc } from "firebase/firestore";
import Tweet from "../component/post";
import { ITweet } from "../component/timeline";
import { useParams } from "react-router-dom";
import Modal, { Styles } from "react-modal";
Modal.setAppElement('#root');
const Wrapper = styled.div`
display: flex;
align-items: center;
flex-direction: column;
gap: 20px;
`;
const AvatarUpload = styled.label<{ disabled?: boolean }>`
width: 80px;
overflow: hidden;
height: 80px;
border-radius: 50%;
background-color: ${(props) => (props.disabled ? "#ccc" : "#9D80F5")};
cursor: ${(props) => (props.disabled ? "not-allowed" : "pointer")};
display: flex;
justify-content: center;
align-items: center;
pointer-events: ${(props) => (props.disabled ? "none" : "auto")}; /* 클릭 방지 */
svg {
width: 50px;
}
`;
const AvatarImg = styled.img`
width: 100%;
`;
const AvatarInput = styled.input`
display: none;
`;
const Tweets = styled.div`
display: flex;
flex-direction: column;
gap: 10px;
width: 100%;
max-height: 500px;
overflow-y: scroll;
&::-webkit-scrollbar {
display: none;
}
-ms-overflow-style: none;
scrollbar-width: none;
padding-bottom: 350px;
`;
const Name = styled.span`
font-family: pretendard-regular;
font-size: 22px;
display: flex;
align-items: center;
gap: 10px;
color: black;
`;
const Input = styled.input`
font-size: 22px;
font-family: pretendard-regular;
padding: 5px;
`;
const EditButton = styled.div`
display: flex;
justify-content: center;
align-items: center;
cursor: pointer;
svg {
width: 30px;
height: 30px;
}
`;
interface IButton {
$isRed?: boolean;
}
const Button = styled.button<IButton>`
border: none;
background-color: ${(props) => (props.$isRed ? "#F5957F" : "#9D7FF5")};
color: black;
cursor: pointer;
padding: 5px 10px;
border-radius: 5px;
`;
interface ICloseButton {
backgroundColor?: string;
}
const CloseButton = styled.button<ICloseButton>`
background-color: none;
color: #8F89A1;
border: none;
border-radius: 5px;
cursor: pointer;
font-size: 15px;
margin-top: auto; /* auto로 설정하여 하단에 위치 */
align-self: flex-end; /* 오른쪽 하단에 위치 */
`;
interface IFriend {
userId: string;
username: string;
}
const FriendsListButton = styled(Button)`
background-color: #8E88A0;
color: white;
margin-top: 20px;
`;
const customStyles: Styles = {
overlay: {
backgroundColor: 'rgba(0, 0, 0, 0.5)', // 배경 색상 설정
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
},
content: {
top: 'auto',
left: 'auto',
right: 'auto',
bottom: 'auto',
margin: '0 auto',
width: '30%', // 모달 너비
height: '50%', // 모달 높이
backgroundColor: '#fff',
borderRadius: '10px',
padding: '20px',
boxShadow: '0 4px 8px rgba(0, 0, 0, 0.1)',
display: 'flex',
flexDirection: 'column',
color: 'black',
},
};
// 프로필 컴포넌트 정의
const Profile: React.FC = () => {
const { userId } = useParams<{ userId: string }>(); // URL 파라미터에서 userId를 가져옴
const user = auth.currentUser; // 현재 로그인한 사용자 정보
const [editMode, setEditMode] = useState(false); // 편집 모드 상태
const [username, setUsername] = useState<string | null>(null); // 사용자 이름 상태
const [lastValue, setLastValue] = useState<string | null>(null); // 마지막 값 상태
const [nameLoading, setNameLoading] = useState(false); // 이름 저장 중 상태
const [avatar, setAvatar] = useState<string | null>(null); // 아바타 URL 상태
const [tweets, setTweets] = useState<ITweet[]>([]); // 트윗 리스트 상태
const [friends, setFriends] = useState<IFriend[]>([]); // 친구 리스트 상태
const [isFriend, setIsFriend] = useState<boolean>(false); // 친구 여부 상태
const [showFriendsList, setShowFriendsList] = useState<boolean>(false); // 친구 리스트 표시 상태
// 아바타 변경 함수
const onAvatarChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
if (userId !== user?.uid) return; // 현재 사용자만 아바타 변경 가능
const { files } = e.target;
if (files && files.length === 1) {
const file = files[0];
const locationRef = ref(storage, `/avatars/${userId}`);
const result = await uploadBytes(locationRef, file);
const avatarUrl = await getDownloadURL(result.ref);
setAvatar(avatarUrl);
if (user) {
await updateProfile(user, {
photoURL: avatarUrl,
});
// Firestore에 photoURL 업데이트
if (userId) {
const userRef = doc(db, "users", userId);
await setDoc(userRef, { photoURL: avatarUrl }, { merge: true });
}
}
}
};
// 프로필 정보 가져오기
const fetchProfile = () => {
if (!userId) return;
const userRef = doc(db, "users", userId);
onSnapshot(userRef, (userSnap) => {
if (userSnap.exists()) {
const userData = userSnap.data();
console.log("User Data:", userData);
setUsername(userData.displayName || userData.username || null); // null로 설정하여 조건을 쉽게 만듦
setLastValue(userData.displayName || userData.username || "Anonymous");
// Firebase Storage에서 사용자 ID를 파일명으로 하는 프로필 이미지 URL 가져오기
const avatarRef = ref(storage, `/avatars/${userId}`);
getDownloadURL(avatarRef)
.then((avatarUrl) => {
setAvatar(avatarUrl);
})
.catch((error) => {
console.error("Error fetching avatar URL:", error);
setAvatar(null); // 오류가 발생하면 기본 이미지를 설정할 수 있습니다.
});
} else {
console.log("No such user!");
}
});
};
// 트윗 정보 가져오기
const fetchTweets = async () => {
if (!userId) return;
const tweetQuery = query(
collection(db, "tweets"),
where("userId", "==", userId),
orderBy("createdAt", "desc"),
limit(25)
);
const snapshot = await getDocs(tweetQuery);
const tweets = await Promise.all(
snapshot.docs.map(async (doc) => {
const { tweet, createdAt, userId, username, photos } = doc.data();
const profileImage = await getProfileImage(userId);
return {
tweet,
createdAt: createdAt instanceof Timestamp ? createdAt : Timestamp.fromDate(new Date(createdAt)),
userId,
username,
photos,
id: doc.id,
profileImage,
};
})
);
setTweets(tweets);
};
// 친구 정보 가져오기
const fetchFriends = async () => {
if (!userId) return;
const friendsRef = collection(db, "friends");
const q = query(friendsRef, where("userId", "==", userId));
const querySnapshot = await getDocs(q);
const friendsList: IFriend[] = [];
for (const friendDoc of querySnapshot.docs) {
const friendData = friendDoc.data();
const friendUserId = friendData.friendId;
const friendUserRef = doc(db, "users", friendUserId);
const friendUserSnap = await getDoc(friendUserRef);
if (friendUserSnap.exists()) {
const friendUserData = friendUserSnap.data();
friendsList.push({
userId: friendUserId,
username: friendUserData.displayName || friendUserData.username || "Anonymous",
});
}
}
setFriends(friendsList);
setIsFriend(friendsList.some((friend) => friend.userId === auth.currentUser?.uid));
};
// 친구 추가 함수
const addFriend = async () => {
if (!userId) return;
const friendsRef = collection(db, "friends");
await setDoc(doc(friendsRef), {
userId,
friendId: auth.currentUser?.uid,
});
fetchFriends(); // 친구 목록을 다시 불러옵니다.
};
// 친구 제거 함수
const removeFriend = async () => {
if (!userId) return;
const friendsRef = collection(db, "friends");
const q = query(friendsRef, where("userId", "==", userId), where("friendId", "==", auth.currentUser?.uid));
const querySnapshot = await getDocs(q);
for (const friendDoc of querySnapshot.docs) {
await deleteDoc(friendDoc.ref);
}
fetchFriends(); // 친구 목록을 다시 불러옵니다.
};
// 프로필 이미지 가져오기
const getProfileImage = async (userId: string) => {
try {
return await getDownloadURL(ref(storage, `/avatars/${userId}`));
} catch {
return "";
}
};
// 트윗 삭제 함수
const onDeleteTweet = async (tweetId: string) => {
const ok = confirm("포스트를 삭제하시겠습니까?");
if (!ok || !auth.currentUser) return;
try {
await deleteDoc(doc(db, "tweets", tweetId));
setTweets(tweets.filter((tweet) => tweet.id !== tweetId));
} catch (error) {
console.error("Error deleting tweet:", error);
}
};
// 이름 입력 변경 핸들러
const onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setUsername(e.target.value);
};
// 편집 모드 활성화 함수
const onEdit = () => {
setEditMode(true);
};
// 편집 취소 함수
const onCancel = () => {
setUsername(lastValue);
setEditMode(false);
};
// 이름 저장 함수
const onSave = async () => {
if (nameLoading || !username || !user) return;
try {
setNameLoading(true);
const auth = getAuth();
const currentUser = auth.currentUser;
if (currentUser) {
await updateProfile(currentUser, { displayName: username });
// Firestore에 displayName 업데이트
if (userId) {
const userRef = doc(db, "users", userId);
await updateDoc(userRef, { displayName: username, username: username });
}
}
} catch (e) {
console.error(e);
} finally {
setNameLoading(false);
setEditMode(false);
setLastValue(username);
}
};
// 컴포넌트가 마운트될 때 프로필, 트윗 및 친구 목록을 가져옴
useEffect(() => {
if (userId) {
fetchProfile();
fetchTweets();
fetchFriends();
}
}, [userId]);
return (
<Wrapper>
<AvatarUpload htmlFor="avatar" disabled={userId !== user?.uid}>
{avatar ? (
<AvatarImg src={avatar} />
) : (
<svg
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<path d="M10 8a3 3 0 100-6 3 3 0 000 6zM3.465 14.493a1.23 1.23 0 00.41 1.412A9.957 9.957 0 0010 18c2.31 0 4.438-.784 6.131-2.1.43-.333.604-.903.408-1.41a7.002 7.002 0 00-13.074.003z" />
</svg>
)}
</AvatarUpload>
<AvatarInput onChange={onAvatarChange} id="avatar" type="file" accept="image/*" />
<Name>
{editMode ? (
<Input value={username ?? ""} onChange={onChange} placeholder="새로운 이름을 입력해 주세요." required />
) : (
<>
{username ?? "Anonymous"}
{userId === user?.uid && (
<EditButton onClick={onEdit}>
<svg
fill="none"
strokeWidth={1}
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L10.582 16.07a4.5 4.5 0 0 1-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 0 1 1.13-1.897l8.932-8.931Zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0 1 15.75 21H5.25A2.25 2.25 0 0 1 3 18.75V8.25A2.25 2.25 0 0 1 5.25 6H10"
/>
</svg>
</EditButton>
)}
</>
)}
{editMode && (
<>
<Button $isRed={true} onClick={onCancel}>
취소
</Button>
<Button onClick={onSave}>{nameLoading ? "저장 중.." : "저장"}</Button>
</>
)}
</Name>
{username && avatar && userId !== user?.uid && (
<Button onClick={isFriend ? removeFriend : addFriend}>
{isFriend ? "언팔로우" : "팔로우"}
</Button>
)}
<FriendsListButton onClick={() => setShowFriendsList((prev) => !prev)}>
팔로워 리스트
</FriendsListButton>
<Modal
isOpen={showFriendsList}
onRequestClose={() => setShowFriendsList(false)}
contentLabel="Friends List"
style={customStyles}
>
{friends.length > 0 ? (
<ul>
{friends.map((friend) => (
<li key={friend.userId}>{friend.username}</li>
))}
</ul>
) : (
<p>팔로워 목록이 비어 있습니다.</p>
)}
<CloseButton onClick={() => setShowFriendsList(false)}>X</CloseButton>
</Modal>
<Tweets>
{tweets.map((tweet) => (
<Tweet
key={tweet.id}
id={tweet.id}
photos={tweet.photos || []}
tweet={tweet.tweet}
userId={tweet.userId}
username={username || tweet.username} // 업데이트된 username을 사용
createdAt={tweet.createdAt}
profileImage={tweet.profileImage || ""}
onDeleteTweet={onDeleteTweet}
/>
))}
</Tweets>
</Wrapper>
);
};
export default Profile;
위 코드는 사용자 프로필 페이지를 구현한 React 컴포넌트입니다. 주요 기능은 다음과 같습니다:
- 프로필 정보 및 트윗 불러오기: fetchProfile, fetchTweets, fetchFriends 함수를 사용하여 Firestore에서 사용자 프로필 정보, 트윗 및 친구 목록을 가져옵니다.
- 프로필 수정: 사용자가 자신의 프로필 사진과 이름을 변경할 수 있습니다. onAvatarChange, onEdit, onCancel, onSave 함수가 관련 작업을 수행합니다.
- 팔로우/언팔로우 기능: 다른 사용자를 팔로우하거나 언팔로우할 수 있습니다. addFriend, removeFriend 함수가 이를 처리합니다.
- 트윗 삭제: 사용자가 자신의 트윗을 삭제할 수 있습니다. onDeleteTweet 함수가 이를 처리합니다.
- 모달 창: 친구 목록을 모달 창으로 표시합니다. Modal 라이브러리를 사용하여 구현되었습니다.
어려웠던 부분 상세 설명
const AvatarUpload = styled.label<{ disabled?: boolean }>
<{ disabled?: boolean }>: 이 부분은 TypeScript의 제네릭을 사용하여 styled.label 컴포넌트가 받을 수 있는 props의 타입을 정의합니다
- disabled?: boolean: disabled prop은 선택적(optional) prop으로, boolean 타입입니다. 이 prop이 전달되면 true 또는 false 값을 가질 수 있으며, 전달되지 않으면 undefined일 수 있습니다.
background-color: ${(props) => (props.$isRed ? "#F5957F" : "#9D7FF5")};
(props) => (props.$isRed ? "#F5957F" : "#9D7FF5")
- 이 부분은 템플릿 리터럴 안에서 JavaScript 표현식을 사용하여 동적으로 값을 설정하는 것입니다.
(props) => (props.$isRed ? "#F5957F" : "#9D7FF5")
- props: 컴포넌트가 받은 props 객체입니다.
- props.$isRed: $isRed라는 boolean 타입의 prop을 확인합니다. 여기서 $ 기호는 단순히 prop 이름의 일부로, 특별한 의미는 없습니다. 하지만 가독성을 위해 프레임워크나 팀에 따라 사용하는 관습일 수 있습니다.
- isRed는 특정 조건에 따라 스타일이나 동작을 제어하기 위해 사용되는 boolean 타입의 prop입니다.(true, false) 이 prop의 값에 따라 컴포넌트의 스타일이 동적으로 변경될 수 있습니다.
Q. interface?
- 객체의 스펙(속성과 속성의 타입)
- 함수의 파라미터
- 함수의 스펙(파라미터, 반환 타입 등)
- 배열과 객체를 접근하는 방식
- 클래스
Q. null > (null)?
= 0 = false
Q. desc?
설명 문자열을 의미한다. 객체의 속성이 되기도 합니다.
Q. Promise?
비동기 작업을 처리하고, 해당 작업이 성공했는지 실패했는지를 나타내는 객체입니다. JavaScript의 Promise와 동일하게 작동하지만, TypeScript에서는 추가적으로 타입을 명시할 수 있어 더욱 안전하게 비동기 작업을 처리할 수 있습니다.
Q. snapshot?
NoSQL인 firebase에선 특정 시점의 데이터 상태를 나타내는 객체입니다.
onSnapshot 함수:
- onSnapshot 함수는 Firestore 컬렉션의 변경 사항을 실시간으로 수신합니다.
- 첫 번째 인자로 컬렉션 참조를 받고, 두 번째 인자로 QuerySnapshot을 인자로 받는 콜백 함수를 사용합니다.
- 콜백 함수 내부에서 snapshot.docs.map을 사용하여 문서를 User 타입으로 변환하고, 이를 출력합니다.
Q. 모달이란?
모달(Modal)은 웹 애플리케이션 및 사용자 인터페이스 디자인에서 많이 사용되는 UI 요소로, 현재 화면 위에 겹쳐서 표시되는 창입니다. 모달은 보통 사용자의 주의를 끌거나 중요한 정보를 전달하기 위해 사용되며, 사용자가 모달을 닫기 전까지는 다른 상호작용을 할 수 없도록 하는 것이 일반적입니다.
728x90
반응형