diff --git a/src/components/edit-profile-form.tsx b/src/components/edit-profile-form.tsx new file mode 100644 index 0000000..2927871 --- /dev/null +++ b/src/components/edit-profile-form.tsx @@ -0,0 +1,133 @@ +import React, { useState } from 'react'; +import { updateProfile } from 'firebase/auth'; +import { doc, updateDoc } from 'firebase/firestore'; +import { + deleteObject, + getDownloadURL, + ref, + uploadBytes, +} from 'firebase/storage'; +import { auth, db, storage } from '../firebase.ts'; +import IUser from '../interfaces/IUser.ts'; +import CompressImage from '../utils/compress-image.tsx'; +import useEscClose from '../utils/use-esc-close.tsx'; +import * as S from '../styles/profile-form.ts'; +import { ReactComponent as IconUser } from '../assets/images/i-user.svg'; +import { ReactComponent as LoadingSpinner } from '../assets/images/loading-spinner-mini.svg'; + +interface IEditProfileForm extends Pick { + onClose: () => void; +} + +export default function EditProfileForm({ + userAvatar: initialAvatar, + userName: initialName, + onClose, +}: IEditProfileForm) { + const user = auth.currentUser; + const [isLoading, setLoading] = useState(false); + const [avatar, setAvatar] = useState(null); + const [avatarPreview, setAvatarPreview] = useState(user?.photoURL); + const onAvatarChange = async (e: React.ChangeEvent) => { + const images = e.target.files; + if (images && images.length === 1) { + const selectedImage = images[0]; + const compressedImage = await CompressImage({ + imageFile: selectedImage, + size: 200, + }); + setAvatar(compressedImage); + const previewUrl = compressedImage + ? URL.createObjectURL(compressedImage) + : ''; + setAvatarPreview(previewUrl); + } + }; + const onAvatarDelete = () => { + setAvatar(null); + setAvatarPreview(null); + }; + + const [name, setName] = useState(initialName); + const onNameChange = (e: React.ChangeEvent) => { + setName(e.target.value); + }; + + const onSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!user || isLoading || name === '') return; + try { + setLoading(true); + const userDocRef = doc(db, 'users', user.uid); + await updateDoc(userDocRef, { + userName: name, + }); + await updateProfile(user, { + displayName: name, + }); + const locationRef = ref(storage, `avatars/${user?.uid}`); + if (avatar) { + const result = await uploadBytes(locationRef, avatar); + const url = await getDownloadURL(result.ref); + await updateDoc(userDocRef, { + userAvatar: url, + }); + await updateProfile(user, { + photoURL: url, + }); + } else if (!avatar && initialAvatar && initialAvatar !== avatarPreview) { + await updateProfile(user, { + photoURL: '', + }); + await deleteObject(locationRef); + await updateDoc(userDocRef, { + userAvatar: null, + }); + } + setAvatarPreview(null); + setAvatar(null); + } catch (error) { + console.log(error); + } finally { + setLoading(false); + onClose(); + } + }; + useEscClose(onClose); + return ( + + {avatarPreview ? ( + <> + + + + ) : ( + + + + )} + + + + {isLoading ? : '수정'} + + + ); +} diff --git a/src/components/edit-tweet-form.tsx b/src/components/edit-tweet-form.tsx index ddb9695..4543d98 100644 --- a/src/components/edit-tweet-form.tsx +++ b/src/components/edit-tweet-form.tsx @@ -9,6 +9,7 @@ import { import { auth, db, storage } from '../firebase.ts'; import ITweet from '../interfaces/ITweet.ts'; import CompressImage from '../utils/compress-image.tsx'; +import useEscClose from '../utils/use-esc-close.tsx'; import * as S from '../styles/tweet-form.ts'; import { ReactComponent as IconPhoto } from '../assets/images/i-photo.svg'; import { ReactComponent as LoadingSpinner } from '../assets/images/loading-spinner-mini.svg'; @@ -25,12 +26,12 @@ export default function EditTweetForm({ }: IEditTweetForm) { const [isLoading, setLoading] = useState(false); const [tweet, setTweet] = useState(initialTweet); - const [image, setImage] = useState(null); - const [imagePreview, setImagePreview] = useState(initialPhoto); - - const onChange = (e: React.ChangeEvent) => { + const onTweetChange = (e: React.ChangeEvent) => { setTweet(e.target.value); }; + + const [image, setImage] = useState(null); + const [imagePreview, setImagePreview] = useState(initialPhoto); const onImageChange = async (e: React.ChangeEvent) => { const images = e.target.files; if (images && images.length === 1) { @@ -50,6 +51,7 @@ export default function EditTweetForm({ setImage(null); setImagePreview(''); }; + const onSubmit = async (e: React.FormEvent) => { e.preventDefault(); const user = auth.currentUser; @@ -85,18 +87,25 @@ export default function EditTweetForm({ onClose(); } }; + useEscClose(onClose); return ( {imagePreview ? ( <> - + ) : ( @@ -106,8 +115,8 @@ export default function EditTweetForm({ )} diff --git a/src/components/sign-in.tsx b/src/components/sign-in.tsx index c7ffc78..e3cb620 100644 --- a/src/components/sign-in.tsx +++ b/src/components/sign-in.tsx @@ -1,8 +1,9 @@ -import React, { useEffect, useState } from 'react'; +import React, { useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { FirebaseError } from 'firebase/app'; import { signInWithEmailAndPassword } from 'firebase/auth'; import { auth } from '../firebase.ts'; +import useEscClose from '../utils/use-esc-close.tsx'; import * as S from '../styles/auth.ts'; import * as P from '../styles/popup.ts'; import ImageComputer from '../assets/images/logo-small.png'; @@ -25,17 +26,6 @@ export default function SignIn({ onClose }: ISignInProps) { const [userEmail, setUserEmail] = useState(''); const [userPassword, setUserPassword] = useState(''); const [firebaseError, setFirebaseError] = useState(''); - useEffect(() => { - const handleEscKey = (e: KeyboardEvent) => { - if (e.key === 'Escape') { - onClose(); - } - }; - document.addEventListener('keydown', handleEscKey); - return () => { - document.removeEventListener('keydown', handleEscKey); - }; - }, [onClose]); const onChange = (e: React.ChangeEvent) => { const { target: { name, value }, @@ -62,6 +52,7 @@ export default function SignIn({ onClose }: ISignInProps) { setLoading(false); } }; + useEscClose(onClose); return ( diff --git a/src/components/sign-up.tsx b/src/components/sign-up.tsx index 8845235..34110a4 100644 --- a/src/components/sign-up.tsx +++ b/src/components/sign-up.tsx @@ -1,9 +1,10 @@ -import React, { useEffect, useState } from 'react'; +import React, { useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { FirebaseError } from 'firebase/app'; import { createUserWithEmailAndPassword, updateProfile } from 'firebase/auth'; import { doc, setDoc } from 'firebase/firestore'; import { auth, db } from '../firebase.ts'; +import useEscClose from '../utils/use-esc-close.tsx'; import * as S from '../styles/auth.ts'; import * as P from '../styles/popup.ts'; import ImageComputer from '../assets/images/logo-small.png'; @@ -27,17 +28,6 @@ export default function SignUp({ onClose }: ISignUpProps) { const [userEmail, setUserEmail] = useState(''); const [userPassword, setUserPassword] = useState(''); const [firebaseError, setFirebaseError] = useState(''); - useEffect(() => { - const handleEscKey = (e: KeyboardEvent) => { - if (e.key === 'Escape') { - onClose(); - } - }; - document.addEventListener('keydown', handleEscKey); - return () => { - document.removeEventListener('keydown', handleEscKey); - }; - }, [onClose]); const onChange = (e: React.ChangeEvent) => { const { target: { name, value }, @@ -80,6 +70,7 @@ export default function SignUp({ onClose }: ISignUpProps) { setLoading(false); } }; + useEscClose(onClose); return ( diff --git a/src/components/timeline.tsx b/src/components/timeline.tsx index d0d290d..8db2636 100644 --- a/src/components/timeline.tsx +++ b/src/components/timeline.tsx @@ -44,11 +44,13 @@ export default function Timeline() { } }; }, []); - return ( + return tweets.length !== 0 ? ( {tweets.map((tweet) => ( ))} + ) : ( + 작성된 글이 없습니다. ); } diff --git a/src/components/tweet.tsx b/src/components/tweet.tsx index d1b9228..730d928 100644 --- a/src/components/tweet.tsx +++ b/src/components/tweet.tsx @@ -91,7 +91,7 @@ export default function Tweet({ id={id} tweet={tweet} photo={photo} - onClose={() => setEditPopup(false)} + onClose={toggleEditPopup} /> diff --git a/src/components/user-profile.tsx b/src/components/user-profile.tsx new file mode 100644 index 0000000..ce75028 --- /dev/null +++ b/src/components/user-profile.tsx @@ -0,0 +1,61 @@ +import { useEffect, useState } from 'react'; +import { doc, getDoc } from 'firebase/firestore'; +import { auth, db } from '../firebase.ts'; +import * as S from '../styles/profile.ts'; +import * as P from '../styles/popup.ts'; +import { ReactComponent as IconUser } from '../assets/images/i-user.svg'; +import EditProfileForm from './edit-profile-form.tsx'; + +export default function UserProfile() { + const user = auth.currentUser; + const [userAvatar, setUserAvatar] = useState(''); + const [userName, setUserName] = useState(''); + useEffect(() => { + const fetchUserData = async () => { + if (!user) return; + const userDoc = await getDoc(doc(db, 'users', user?.uid)); + if (userDoc.exists()) { + const userData = userDoc.data(); + setUserAvatar(userData.userAvatar); + setUserName(userData.userName); + } + }; + fetchUserData(); + }, []); + const [editPopup, setEditPopup] = useState(false); + const toggleEditPopup = () => { + setEditPopup(!editPopup); + }; + return ( + + + {userAvatar ? ( + + ) : ( + + )} + + {userName} + + 프로필 수정 + + {editPopup ? ( + + + + + + + ) : null} + + ); +} diff --git a/src/components/user-timeline.tsx b/src/components/user-timeline.tsx index 7fd25be..25444e1 100644 --- a/src/components/user-timeline.tsx +++ b/src/components/user-timeline.tsx @@ -9,9 +9,9 @@ import { where, } from 'firebase/firestore'; import { auth, db } from '../firebase.ts'; +import ITweet from '../interfaces/ITweet.ts'; import Tweet from './tweet.tsx'; import * as S from '../styles/timeline.ts'; -import ITweet from '../interfaces/ITweet.ts'; export default function UserTimeline() { const user = auth.currentUser; @@ -47,11 +47,13 @@ export default function UserTimeline() { } }; }, []); - return ( + return tweets.length !== 0 ? ( {tweets.map((tweet) => ( ))} + ) : ( + 작성된 글이 없습니다. ); } diff --git a/src/interfaces/IUser.ts b/src/interfaces/IUser.ts new file mode 100644 index 0000000..0a26000 --- /dev/null +++ b/src/interfaces/IUser.ts @@ -0,0 +1,5 @@ +export default interface IUser { + userId: string; + userName: string; + userAvatar: string; +} diff --git a/src/routes/profile.tsx b/src/routes/profile.tsx index ea36f36..21204c6 100644 --- a/src/routes/profile.tsx +++ b/src/routes/profile.tsx @@ -1,69 +1,13 @@ -import React, { useState } from 'react'; -import { getDownloadURL, ref, uploadBytes } from 'firebase/storage'; -import { updateProfile } from 'firebase/auth'; -import { doc, updateDoc } from 'firebase/firestore'; -import { auth, db, storage } from '../firebase.ts'; -import CompressImage from '../utils/compress-image.tsx'; import WindowTop from '../components/window-top.tsx'; import UserTimeline from '../components/user-timeline.tsx'; import * as W from '../styles/window.ts'; -import * as S from '../styles/profile.ts'; -import { ReactComponent as IconUser } from '../assets/images/i-user.svg'; +import UserProfile from '../components/user-profile.tsx'; export default function Profile() { - const user = auth.currentUser; - const [avatar, setAvatar] = useState(user?.photoURL); - const updateUserAvatar = async (uid: string, newAvatarUrl: string) => { - const userDocRef = doc(db, 'users', uid); - await updateDoc(userDocRef, { - userAvatar: newAvatarUrl, - }); - }; - const onAvatarChange = async (e: React.ChangeEvent) => { - const images = e.target.files; - if (!user) return; - if (images && images.length === 1) { - const selectedImage = images[0]; - const compressedImage = await CompressImage({ - imageFile: selectedImage, - size: 120, - }); - const locationRef = ref(storage, `avatars/${user?.uid}`); - if (compressedImage) { - const result = await uploadBytes(locationRef, compressedImage); - const avatarUrl = await getDownloadURL(result.ref); - setAvatar(avatarUrl); - await updateProfile(user, { - photoURL: avatarUrl, - }); - await updateUserAvatar(user.uid, avatarUrl); - } - } - }; return ( - - - {avatar ? ( - - ) : ( - - )} - - - {user?.displayName ?? 'Anonymous'} - + ); diff --git a/src/styles/profile-form.ts b/src/styles/profile-form.ts new file mode 100644 index 0000000..8626b6c --- /dev/null +++ b/src/styles/profile-form.ts @@ -0,0 +1,78 @@ +import React from 'react'; +import { styled } from 'styled-components'; +import { primaryColor, whiteColor } from './global.ts'; +import { Input, SolidButton } from './button.ts'; +import { Avatar } from './profile.ts'; + +export const Form = styled.form` + position: relative; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; +`; + +export const AttachAvatarPreview = styled.img` + width: 120px; + height: 120px; + margin-bottom: 20px; + object-fit: cover; + border-radius: 50%; +`; + +export const AttachAvatarDelete = styled.button` + position: absolute; + top: 0; + right: 70px; + width: 25px; + height: 25px; + background-color: ${primaryColor}; + border: 2px solid ${whiteColor}; + border-radius: 50%; + &::before, + &::after { + content: ''; + position: absolute; + top: 10px; + left: 4px; + width: 13px; + height: 2px; + background-color: ${whiteColor}; + } + &::before { + transform: rotate(45deg); + } + &::after { + transform: rotate(135deg); + } +`; + +export const AttachAvatarButton: React.ComponentType< + React.HTMLProps +> = styled(Avatar).attrs(() => ({ + as: 'label', +}))` + margin-bottom: 20px; + cursor: pointer; + svg, + &::before { + transition: all 0.5s ease; + } + &:hover, + &:active { + svg { + stroke: ${primaryColor}; + } + &::before { + border: 2px dashed ${primaryColor}; + } + } +`; + +export const AttachAvatarInput = styled.input` + display: none; +`; + +export const InputText = styled(Input)``; + +export const SubmitButton = styled(SolidButton)``; diff --git a/src/styles/profile.ts b/src/styles/profile.ts index 56580a7..897e99d 100644 --- a/src/styles/profile.ts +++ b/src/styles/profile.ts @@ -1,14 +1,15 @@ import { styled } from 'styled-components'; import { grayColor } from './global.ts'; +import { LineButton } from './button.ts'; -export const Avatar = styled.div` +export const Profile = styled.article` position: relative; display: flex; flex-direction: column; justify-content: center; align-items: center; gap: 20px; - padding: 50px 0; + padding: 50px 0 60px; &::after { content: ''; position: absolute; @@ -21,7 +22,7 @@ export const Avatar = styled.div` } `; -export const AvatarUpload = styled.label` +export const Avatar = styled.div` position: relative; display: flex; justify-content: center; @@ -30,7 +31,6 @@ export const AvatarUpload = styled.label` width: 120px; height: 120px; border-radius: 50%; - cursor: pointer; svg { width: 40px; stroke: ${grayColor}; @@ -56,10 +56,11 @@ export const AvatarImage = styled.img` z-index: 10; `; -export const AvatarInput = styled.input` - display: none; -`; - export const Name = styled.h2` font-size: 30px; `; + +export const EditButton = styled(LineButton)` + width: auto; + margin: 0; +`; diff --git a/src/styles/tweet-form.ts b/src/styles/tweet-form.ts index a82b072..ad8bd37 100644 --- a/src/styles/tweet-form.ts +++ b/src/styles/tweet-form.ts @@ -1,5 +1,6 @@ import styled from 'styled-components'; import { blackColor, grayColor, primaryColor, whiteColor } from './global.ts'; +import { SolidButton } from './button.ts'; export const Form = styled.form` position: relative; @@ -8,10 +9,10 @@ export const Form = styled.form` flex-shrink: 0; gap: 10px; width: 100%; - padding-bottom: 20px; `; export const PostForm = styled(Form)` + padding-bottom: 10px; &::after { content: ''; position: absolute; @@ -97,7 +98,7 @@ export const AttachImageButton = styled.label` width: 30px; height: 30px; stroke: ${grayColor}; - transition: all 0.3s ease; + transition: all 0.5s ease; } &:hover, &:active { @@ -112,15 +113,4 @@ export const AttachImageInput = styled.input` display: none; `; -export const SubmitButton = styled.button` - background-color: ${primaryColor}; - border: none; - border-radius: 20px; - color: white; - font-size: 16px; - line-height: 36px; - svg { - width: 36px; - height: 36px; - } -`; +export const SubmitButton = styled(SolidButton)``; diff --git a/src/utils/use-esc-close.tsx b/src/utils/use-esc-close.tsx new file mode 100644 index 0000000..51b2365 --- /dev/null +++ b/src/utils/use-esc-close.tsx @@ -0,0 +1,17 @@ +import { useEffect } from 'react'; + +const useEscClose = (onClose: () => void) => { + useEffect(() => { + const handleEscKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + onClose(); + } + }; + document.addEventListener('keydown', handleEscKey); + return () => { + document.removeEventListener('keydown', handleEscKey); + }; + }, [onClose]); +}; + +export default useEscClose;