From 1783b5c80f2c894332262c3a4ada66f3ea0dc562 Mon Sep 17 00:00:00 2001 From: Lee <43488305+poiu694@users.noreply.github.com> Date: Thu, 15 Feb 2024 11:23:53 +0900 Subject: [PATCH 01/23] feat: email re-send api --- src/web/src/api/index.ts | 2 ++ .../src/components/join/JoinEmailVerifyBox.tsx | 15 +++++++++++---- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/src/web/src/api/index.ts b/src/web/src/api/index.ts index efdbb297..4c558a74 100644 --- a/src/web/src/api/index.ts +++ b/src/web/src/api/index.ts @@ -18,6 +18,8 @@ const client = { const auth = createApiWrappers({ verifyEmailCode: (request: { email: User['email']; payload: string }) => client.public.post('/auth', request), + reSendEmailCode: (request: { email: User['email'] }) => + client.public.post('/auth/re-send', request), login: (request: LoginInfo) => client.public.post('/auth/web', request), logout: () => client.private.post('/auth/web-logout'), }); diff --git a/src/web/src/components/join/JoinEmailVerifyBox.tsx b/src/web/src/components/join/JoinEmailVerifyBox.tsx index bda430ad..60765350 100644 --- a/src/web/src/components/join/JoinEmailVerifyBox.tsx +++ b/src/web/src/components/join/JoinEmailVerifyBox.tsx @@ -1,7 +1,9 @@ import { toast } from 'react-toastify'; import type { FormEvent, ChangeEvent } from 'react'; import { useState, memo, useCallback } from 'react'; +import { useMutation } from '@tanstack/react-query'; +import { apis } from '@/api'; import { StepTitle } from '..'; import { Button, Input, Typography } from '../common'; import type { NavigationEvent, JoinInfo } from './joinReducer'; @@ -30,9 +32,13 @@ function JoinEmailVerifyBox({ onNextStep(); }, []); - const handleClickResendButton = () => { - toast('아직 구현이 안되어 있어요!'); - }; + const reSendEmailMutate = useMutation({ + mutationKey: ['re-send', email], + mutationFn: () => apis.auth.reSendEmailCode({ email }), + onSettled: () => { + toast('이메일이 다시 발송되었습니다.'); + }, + }); return ( <> @@ -60,8 +66,9 @@ function JoinEmailVerifyBox({ as="span" size="body-3" role="button" + tabIndex={0} color="blue-500" - onClick={handleClickResendButton} + onClick={() => reSendEmailMutate.mutate()} > 이메일을 받지 못하셨나요? From 2b705c39ba4dff018cff867bfe5512919b06c300 Mon Sep 17 00:00:00 2001 From: Lee <43488305+poiu694@users.noreply.github.com> Date: Thu, 15 Feb 2024 16:33:55 +0900 Subject: [PATCH 02/23] feat: define paints apis --- src/web/src/api/index.ts | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/src/web/src/api/index.ts b/src/web/src/api/index.ts index 4c558a74..10164e5e 100644 --- a/src/web/src/api/index.ts +++ b/src/web/src/api/index.ts @@ -1,8 +1,10 @@ import { env } from '@/constants'; import { createApiWrappers } from './handler'; import type { + EditPaint, JoinInfo, LoginInfo, + TimelineItem, User, UserProfile, UserSearchResult, @@ -117,8 +119,29 @@ const images = createApiWrappers({ }, }); +const paints = createApiWrappers({ + getPaintById: (paintId: TimelineItem['id']) => + client.public.get(`/paints/${paintId}`), + getBeforePaintsById: (paintId: TimelineItem['id']) => + client.public.get(`/paints/${paintId}/before`), + getAfterPaintsById: (paintId: TimelineItem['id']) => + client.public.get(`/paints/${paintId}/after`), + getPaints: (paintId: TimelineItem['id']) => + client.public.get(`/paints/${paintId}`), + createPaint: (request: EditPaint) => client.public.post('/paints', request), + getQuotePaintList: (paintId: TimelineItem['id']) => + client.public.get< + (Pick & { + includes: { + paints: Pick; + }; + })[] + >(`/paints/${paintId}/quote-paints`), +}); + export const apis = { auth, users, images, + paints, } as const; From ba587cf2f365c7bf1d9c5a398a93443025911086 Mon Sep 17 00:00:00 2001 From: Lee <43488305+poiu694@users.noreply.github.com> Date: Thu, 15 Feb 2024 16:35:15 +0900 Subject: [PATCH 03/23] fix: delete onClick in button for using submit type with form tag --- src/web/src/components/join/JoinEmailBox.tsx | 61 +++++++++++-------- .../components/join/JoinEmailVerifyBox.tsx | 29 ++++++--- src/web/src/components/join/JoinNameBox.tsx | 1 - .../src/components/join/JoinPasswordBox.tsx | 1 - src/web/src/pages/JoinPage.tsx | 35 +---------- 5 files changed, 58 insertions(+), 69 deletions(-) diff --git a/src/web/src/components/join/JoinEmailBox.tsx b/src/web/src/components/join/JoinEmailBox.tsx index 9da34b8c..b6dd4847 100644 --- a/src/web/src/components/join/JoinEmailBox.tsx +++ b/src/web/src/components/join/JoinEmailBox.tsx @@ -1,10 +1,14 @@ +import { toast } from 'react-toastify'; +import { memo, useCallback, useState } from 'react'; import type { FormEvent, ChangeEvent } from 'react'; -import { useState, useMemo, memo, useCallback } from 'react'; +import { useMutation } from '@tanstack/react-query'; +import { apis } from '@/api'; import { isValidEmail } from '@/utils'; import { NotSupportText, StepTitle } from '..'; import { Button, Input, Typography } from '../common'; import type { NavigationEvent, JoinInfo } from './joinReducer'; +import { FullScreenSpinner } from '../skeleton'; interface JoinEmailBoxProps extends NavigationEvent { nickname: string; @@ -23,32 +27,36 @@ function JoinEmailBox({ onNextStep, onChangeInput, }: JoinEmailBoxProps) { - const [isDirty, setIsDirty] = useState<'initial' | 'dirty' | 'not-dirty'>( - 'initial', - ); - const [isInValidEmail, setIsInValidEmail] = useState(false); - - const emailStatus = useMemo((): Parameters[0]['status'] => { - if (isDirty === 'initial') return 'normal'; - if (isDirty === 'dirty') return 'dirty'; - if (isInValidEmail) return 'error'; - return 'success'; - }, [isDirty, isInValidEmail]); + const [emailStatus, setEmailStatus] = useState< + 'normal' | 'success' | 'error' + >('normal'); - const handleSubmitForm = useCallback((e: FormEvent) => { - e.preventDefault(); - if (isValidEmail(email) && nickname !== '') { + const tempRegisterMutate = useMutation({ + mutationKey: ['temp-register', nickname], + mutationFn: () => + apis.users.temporaryJoin({ + email, + nickname, + }), + onError: (error) => toast(`서버에 문제가 생겼습니다. ${error.message}`), + onSuccess: () => { onNextStep(); - } - }, []); + }, + }); - const handleFocusOnEmail = () => { - setIsDirty('dirty'); - }; + const handleSubmitForm = useCallback( + (e: FormEvent) => { + e.preventDefault(); + + if (isValidEmail(email) && nickname !== '') { + tempRegisterMutate.mutate(); + } + }, + [email, nickname], + ); const handleBlurOnEmail = () => { - setIsDirty('not-dirty'); - setIsInValidEmail(!isValidEmail(email)); + setEmailStatus(isValidEmail(email) ? 'success' : 'error'); }; return ( @@ -67,7 +75,6 @@ function JoinEmailBox({ label="이메일" status={emailStatus} onChange={(e) => onChangeInput(e, 'email')} - onFocus={handleFocusOnEmail} onBlur={handleBlurOnEmail} /> @@ -89,9 +96,8 @@ function JoinEmailBox({ type="submit" color="blue" variant="filled" - disabled={disabled || isInValidEmail} - aria-disabled={disabled || isInValidEmail} - onClick={onNextStep} + disabled={disabled || tempRegisterMutate.isPending} + aria-disabled={disabled || tempRegisterMutate.isPending} > 다음 @@ -99,6 +105,9 @@ function JoinEmailBox({ + {tempRegisterMutate.isPending && ( + + )} ); } diff --git a/src/web/src/components/join/JoinEmailVerifyBox.tsx b/src/web/src/components/join/JoinEmailVerifyBox.tsx index 60765350..37689502 100644 --- a/src/web/src/components/join/JoinEmailVerifyBox.tsx +++ b/src/web/src/components/join/JoinEmailVerifyBox.tsx @@ -1,12 +1,13 @@ import { toast } from 'react-toastify'; +import { memo, useCallback } from 'react'; import type { FormEvent, ChangeEvent } from 'react'; -import { useState, memo, useCallback } from 'react'; import { useMutation } from '@tanstack/react-query'; import { apis } from '@/api'; import { StepTitle } from '..'; import { Button, Input, Typography } from '../common'; import type { NavigationEvent, JoinInfo } from './joinReducer'; +import { FullScreenSpinner } from '../skeleton'; interface JoinEmailVerifyBoxProps extends NavigationEvent { email: string; @@ -25,11 +26,22 @@ function JoinEmailVerifyBox({ onNextStep, onChangeInput, }: JoinEmailVerifyBoxProps) { - const [isDirty, setIsDirty] = useState(false); + const verifyEmailCodeMutate = useMutation({ + mutationKey: ['verify-email', email], + mutationFn: () => + apis.auth.verifyEmailCode({ + email, + payload: emailVerifyCode, + }), + onError: () => toast('인증코드가 다릅니다.'), + onSuccess: () => { + onNextStep(); + }, + }); const handleSubmitForm = useCallback((e: FormEvent) => { e.preventDefault(); - onNextStep(); + verifyEmailCodeMutate.mutate(); }, []); const reSendEmailMutate = useMutation({ @@ -55,9 +67,6 @@ function JoinEmailVerifyBox({ value={emailVerifyCode} maxLength={6} minLength={6} - status={isDirty ? 'dirty' : 'normal'} - onFocus={() => setIsDirty(true)} - onBlur={() => setIsDirty(false)} onChange={(e) => onChangeInput(e, 'emailVerifyCode')} /> @@ -76,9 +85,8 @@ function JoinEmailVerifyBox({ type="submit" color="blue" variant="filled" - disabled={disabled} - aria-disabled={disabled} - onClick={onNextStep} + disabled={disabled || verifyEmailCodeMutate.isPending} + aria-disabled={disabled || verifyEmailCodeMutate.isPending} > 다음 @@ -87,6 +95,9 @@ function JoinEmailVerifyBox({ + {verifyEmailCodeMutate.isPending && ( + + )} ); } diff --git a/src/web/src/components/join/JoinNameBox.tsx b/src/web/src/components/join/JoinNameBox.tsx index be8e7b06..093d116e 100644 --- a/src/web/src/components/join/JoinNameBox.tsx +++ b/src/web/src/components/join/JoinNameBox.tsx @@ -50,7 +50,6 @@ function JoinNameBox({ variant="filled" disabled={disabled} aria-disabled={disabled} - onClick={onJoin} > 가입 diff --git a/src/web/src/components/join/JoinPasswordBox.tsx b/src/web/src/components/join/JoinPasswordBox.tsx index 24c95f45..dec9aae7 100644 --- a/src/web/src/components/join/JoinPasswordBox.tsx +++ b/src/web/src/components/join/JoinPasswordBox.tsx @@ -59,7 +59,6 @@ function JoinPasswordBox({ variant="filled" disabled={disabled} aria-disabled={disabled} - onClick={onNextStep} > 다음 diff --git a/src/web/src/pages/JoinPage.tsx b/src/web/src/pages/JoinPage.tsx index ba269f00..baf165d3 100644 --- a/src/web/src/pages/JoinPage.tsx +++ b/src/web/src/pages/JoinPage.tsx @@ -20,7 +20,6 @@ import { apis } from '@/api'; const MAX_PASSWORD_LENGTH = 8; function JoinPage() { - const [isVerifyEmail, setIsVerifyEmail] = useState(false); const [state, dispatch] = useReducer(joinStepReducer, JoinStep.INFORMATION); const [joinInfo, setJoinInfo] = useState({ nickname: '', @@ -64,34 +63,6 @@ function JoinPage() { setJoinInfo((prev) => ({ ...prev, [type]: e.target.value })); }; - const tempRegisterMutate = useMutation({ - mutationKey: ['temp-register', joinInfo.username], - mutationFn: () => - apis.users.temporaryJoin({ - email: joinInfo.email, - nickname: joinInfo.nickname, - }), - onError: () => toast('서버에 문제가 생겼습니다.'), - onSuccess: () => { - setIsVerifyEmail(true); - onNextPage(); - }, - }); - - const verifyEmailCodeMutate = useMutation({ - mutationKey: ['verify-email', joinInfo.username], - mutationFn: () => - apis.auth.verifyEmailCode({ - email: joinInfo.email, - payload: joinInfo.emailVerifyCode, - }), - onError: () => toast('인증코드가 다릅니다.'), - onSuccess: () => { - setIsVerifyEmail(true); - onNextPage(); - }, - }); - const getJoinBox = (step: JoinStep): JSX.Element => { switch (step) { case JoinStep.INFORMATION: @@ -100,7 +71,7 @@ function JoinPage() { disabled={joinInfo.email === '' || joinInfo.nickname === ''} email={joinInfo.email} nickname={joinInfo.nickname} - onNextStep={() => tempRegisterMutate.mutate()} + onNextStep={onNextPage} onChangeInput={handleChangeInput} /> ); @@ -109,8 +80,8 @@ function JoinPage() { verifyEmailCodeMutate.mutate()} + disabled={joinInfo.emailVerifyCode === ''} + onNextStep={onNextPage} onChangeInput={handleChangeInput} /> ); From 67e18032080504ef1e701b5b9f58c87211c07ecc Mon Sep 17 00:00:00 2001 From: Lee <43488305+poiu694@users.noreply.github.com> Date: Thu, 15 Feb 2024 16:35:33 +0900 Subject: [PATCH 04/23] feat: apply paints apis in page --- src/web/src/components/AfterTimelineList.tsx | 5 ++-- src/web/src/components/BeforeTimelineList.tsx | 5 ++-- src/web/src/components/MainPostBox.tsx | 5 ++-- src/web/src/pages/PostEditPage.tsx | 26 +++++++++++-------- 4 files changed, 24 insertions(+), 17 deletions(-) diff --git a/src/web/src/components/AfterTimelineList.tsx b/src/web/src/components/AfterTimelineList.tsx index 5ebdc862..c7d2a552 100644 --- a/src/web/src/components/AfterTimelineList.tsx +++ b/src/web/src/components/AfterTimelineList.tsx @@ -3,9 +3,10 @@ import type { ForwardedRef } from 'react'; import { useNavigate } from '@tanstack/react-router'; import { useSuspenseQuery } from '@tanstack/react-query'; +import { apis } from '@/api'; +import { cn } from '@/utils'; import type { PaintAction } from '@/hooks'; import { postDetailRoute } from '@/routes'; -import { cn, fetchAfterPost } from '@/utils'; import TimelineItemBox from './TimelineItemBox'; interface AfterTimelineListProps { @@ -19,7 +20,7 @@ const AfterTimelineList = forwardRef( const params = postDetailRoute.useParams(); const { data: posts } = useSuspenseQuery({ queryKey: ['post', params.postId, 'before'], - queryFn: fetchAfterPost, + queryFn: () => apis.paints.getBeforePaintsById(params.postId), }); if (Array.isArray(posts) && posts.length === 0) { diff --git a/src/web/src/components/BeforeTimelineList.tsx b/src/web/src/components/BeforeTimelineList.tsx index b30bd419..0edf6cc5 100644 --- a/src/web/src/components/BeforeTimelineList.tsx +++ b/src/web/src/components/BeforeTimelineList.tsx @@ -3,9 +3,10 @@ import type { ForwardedRef, RefObject } from 'react'; import { useNavigate } from '@tanstack/react-router'; import { useSuspenseQuery } from '@tanstack/react-query'; +import { cn } from '@/utils'; +import { apis } from '@/api'; import type { PaintAction } from '@/hooks'; import { postDetailRoute } from '@/routes'; -import { cn, fetchBeforePost } from '@/utils'; import TimelineItemBox from './TimelineItemBox'; interface BeforeTimelineListProps { @@ -25,7 +26,7 @@ const BeforeTimelineList = forwardRef( const params = postDetailRoute.useParams(); const { data: posts, isSuccess } = useSuspenseQuery({ queryKey: ['post', params.postId, 'before'], - queryFn: fetchBeforePost, + queryFn: () => apis.paints.getBeforePaintsById(params.postId), }); if (Array.isArray(posts) && posts.length === 0) { diff --git a/src/web/src/components/MainPostBox.tsx b/src/web/src/components/MainPostBox.tsx index fc550114..632ea945 100644 --- a/src/web/src/components/MainPostBox.tsx +++ b/src/web/src/components/MainPostBox.tsx @@ -3,14 +3,15 @@ import type { ForwardedRef } from 'react'; import { useNavigate } from '@tanstack/react-router'; import { useSuspenseQuery } from '@tanstack/react-query'; +import { apis } from '@/api'; import { Button } from './common'; import type { PaintAction } from '@/hooks'; import { postDetailRoute } from '@/routes'; import QuotePostBox from './QuotePostBox'; import Typography from './common/Typography'; +import { cn, forCloudinaryImage } from '@/utils'; import TimelineItemMenu from './TimelineItemMenu'; import AccessibleIconButton from './AccessibleIconButton'; -import { cn, fetchMainPost, forCloudinaryImage } from '@/utils'; interface MainPostBoxProps { className?: string; @@ -24,7 +25,7 @@ const MainPostBox = forwardRef( const params = postDetailRoute.useParams(); const { data: post } = useSuspenseQuery({ queryKey: ['post', params.postId], - queryFn: fetchMainPost, + queryFn: () => apis.paints.getPaintById(params.postId), }); const hasMedia = post?.includes.medias.length > 0; diff --git a/src/web/src/pages/PostEditPage.tsx b/src/web/src/pages/PostEditPage.tsx index a9084ff0..7d5ea9a5 100644 --- a/src/web/src/pages/PostEditPage.tsx +++ b/src/web/src/pages/PostEditPage.tsx @@ -66,6 +66,19 @@ function PostEditPage() { () => tempSavedStorage.get() ?? [], ); + const createPaintMutation = useMutation({ + mutationKey: ['create-paint'], + mutationFn: () => + apis.paints.createPaint( + forEditPaint({ + text: editPostInfo.text, + medias: image ? [convertToMedia(image, 'image')] : [], + quotePaintId: search.postId, + taggedUserIds: tags.map((tag) => tag.id), + }), + ), + }); + const uploadMutation = useMutation({ mutationKey: ['image-upload'], mutationFn: (imageFile: File) => @@ -103,17 +116,8 @@ function PostEditPage() { const handleClickNotSupport = () => toast('아직 지원되지 않는 기능입니다.'); - const handleSubmitPost = async () => { - toast( - JSON.stringify( - forEditPaint({ - text: editPostInfo.text, - medias: image ? [convertToMedia(image, 'image')] : [], - quotePaintId: search.postId, - taggedUserIds: tags.map((tag) => tag.id), - }), - ), - ); + const handleSubmitPost = () => { + createPaintMutation.mutate(); }; return ( From e142c2343318f5d0d599c165be4e1be44e976cec Mon Sep 17 00:00:00 2001 From: Lee <43488305+poiu694@users.noreply.github.com> Date: Thu, 15 Feb 2024 17:00:58 +0900 Subject: [PATCH 05/23] refactor: use accesstoken with localStorage --- src/web/src/api/AuthTokenStorage.ts | 14 ++++++-------- src/web/src/api/apiFactory.ts | 4 ++-- src/web/src/api/index.ts | 6 +++++- src/web/src/pages/LoginPage.tsx | 8 +++++++- 4 files changed, 20 insertions(+), 12 deletions(-) diff --git a/src/web/src/api/AuthTokenStorage.ts b/src/web/src/api/AuthTokenStorage.ts index 2b0de832..1cd389a5 100644 --- a/src/web/src/api/AuthTokenStorage.ts +++ b/src/web/src/api/AuthTokenStorage.ts @@ -2,16 +2,13 @@ import { generateLocalStorage } from '@/utils'; import { AuthenticationRequiredError } from './AuthenticationRequiredError'; export class AuthTokenStorage { - private readonly authTokenKey = '@@@authToken'; - - private readonly storage = generateLocalStorage( - this.authTokenKey, - ); - private authToken: string | null; - constructor() { + private storage: ReturnType>; + + constructor(key: string) { this.spawn(); + this.storage = generateLocalStorage(key); this.authToken = null; } @@ -41,4 +38,5 @@ export class AuthTokenStorage { } } -export const authTokenStorage = new AuthTokenStorage(); +export const accessTokenStorage = new AuthTokenStorage('@@@accessToken'); +export const refreshTokenStorage = new AuthTokenStorage('@@@refreshToken'); diff --git a/src/web/src/api/apiFactory.ts b/src/web/src/api/apiFactory.ts index aef93c61..3ee07761 100644 --- a/src/web/src/api/apiFactory.ts +++ b/src/web/src/api/apiFactory.ts @@ -1,7 +1,7 @@ import axios from 'axios'; import { env } from '@/constants'; -import { authTokenStorage } from './AuthTokenStorage'; +import { accessTokenStorage } from './AuthTokenStorage'; export const createApiClient = ({ auth }: { auth: boolean }) => { const client = axios.create({ @@ -10,7 +10,7 @@ export const createApiClient = ({ auth }: { auth: boolean }) => { if (auth) { client.interceptors.request.use((config) => { - const token = authTokenStorage.getTokenOrThrow(); + const token = accessTokenStorage.getTokenOrThrow(); config.headers.Authorization = `Bearer ${token}`; return config; }); diff --git a/src/web/src/api/index.ts b/src/web/src/api/index.ts index 10164e5e..0e62d606 100644 --- a/src/web/src/api/index.ts +++ b/src/web/src/api/index.ts @@ -22,7 +22,11 @@ const auth = createApiWrappers({ client.public.post('/auth', request), reSendEmailCode: (request: { email: User['email'] }) => client.public.post('/auth/re-send', request), - login: (request: LoginInfo) => client.public.post('/auth/web', request), + login: (request: LoginInfo) => + client.public.post<{ accessToken: string; refreshToken: string }>( + '/auth/web', + request, + ), logout: () => client.private.post('/auth/web-logout'), }); diff --git a/src/web/src/pages/LoginPage.tsx b/src/web/src/pages/LoginPage.tsx index b43416a3..b9c0bd89 100644 --- a/src/web/src/pages/LoginPage.tsx +++ b/src/web/src/pages/LoginPage.tsx @@ -10,6 +10,10 @@ import type { LoginInfo } from '@/@types'; import { ContentLayout, Header } from '@/components'; import { LoginStep, LoginStepReducer } from '@/components/login/loginReducer'; import { LoginEmailBox, LoginPasswordBox } from '@/components/login'; +import { + accessTokenStorage, + refreshTokenStorage, +} from '@/api/AuthTokenStorage'; function LoginPage() { const [state, dispatch] = useReducer(LoginStepReducer, LoginStep.EMAIL); @@ -21,8 +25,10 @@ function LoginPage() { const loginMutate = useMutation({ mutationKey: ['login', loginInfo.email], mutationFn: () => apis.auth.login(loginInfo), - onSuccess: () => { + onSuccess: (res) => { toast(`${loginInfo.email}님 로그인이 완료되었습니다.`); + accessTokenStorage.setToken(res.accessToken); + refreshTokenStorage.setToken(res.refreshToken); navigate({ to: '/home' }); }, onError: () => toast.error('아이디 혹은 비밀번호가 틀렸습니다.'), From 64fec752ae16d046f867db2362804ba59384759f Mon Sep 17 00:00:00 2001 From: Lee <43488305+poiu694@users.noreply.github.com> Date: Thu, 15 Feb 2024 17:17:07 +0900 Subject: [PATCH 06/23] refactor: accessToken, refreshToken AuthTokenStorage --- src/web/src/api/AuthTokenStorage.ts | 5 ----- src/web/src/api/index.ts | 12 ++++++------ 2 files changed, 6 insertions(+), 11 deletions(-) diff --git a/src/web/src/api/AuthTokenStorage.ts b/src/web/src/api/AuthTokenStorage.ts index 1cd389a5..0dface95 100644 --- a/src/web/src/api/AuthTokenStorage.ts +++ b/src/web/src/api/AuthTokenStorage.ts @@ -7,12 +7,7 @@ export class AuthTokenStorage { private storage: ReturnType>; constructor(key: string) { - this.spawn(); this.storage = generateLocalStorage(key); - this.authToken = null; - } - - spawn(): void { this.authToken = this.storage.get(); } diff --git a/src/web/src/api/index.ts b/src/web/src/api/index.ts index 0e62d606..e6ccf568 100644 --- a/src/web/src/api/index.ts +++ b/src/web/src/api/index.ts @@ -125,16 +125,16 @@ const images = createApiWrappers({ const paints = createApiWrappers({ getPaintById: (paintId: TimelineItem['id']) => - client.public.get(`/paints/${paintId}`), + client.private.get(`/paints/${paintId}`), getBeforePaintsById: (paintId: TimelineItem['id']) => - client.public.get(`/paints/${paintId}/before`), + client.private.get(`/paints/${paintId}/before`), getAfterPaintsById: (paintId: TimelineItem['id']) => - client.public.get(`/paints/${paintId}/after`), + client.private.get(`/paints/${paintId}/after`), getPaints: (paintId: TimelineItem['id']) => - client.public.get(`/paints/${paintId}`), - createPaint: (request: EditPaint) => client.public.post('/paints', request), + client.private.get(`/paints/${paintId}`), + createPaint: (request: EditPaint) => client.private.post('/paints', request), getQuotePaintList: (paintId: TimelineItem['id']) => - client.public.get< + client.private.get< (Pick & { includes: { paints: Pick; From 3c7cf65d37a4de7a65142bb8b55c225755b9d313 Mon Sep 17 00:00:00 2001 From: Lee <43488305+poiu694@users.noreply.github.com> Date: Thu, 15 Feb 2024 17:40:05 +0900 Subject: [PATCH 07/23] refactor: change interface property name --- src/web/src/@types/api/auth.ts | 2 +- src/web/src/@types/api/paint.ts | 4 ++-- src/web/src/api/index.ts | 2 +- src/web/src/components/MainPostBox.tsx | 2 +- src/web/src/components/QuotePostBox.tsx | 2 +- src/web/src/components/TimelineItemBox.tsx | 2 +- src/web/src/pages/JoinPage.tsx | 5 ++++- src/web/src/pages/PostEditPage.tsx | 6 +++++- src/web/src/pages/ProfilePage.tsx | 2 +- src/web/src/utils/helperPost.ts | 2 +- 10 files changed, 18 insertions(+), 11 deletions(-) diff --git a/src/web/src/@types/api/auth.ts b/src/web/src/@types/api/auth.ts index 48601300..7b3dea94 100644 --- a/src/web/src/@types/api/auth.ts +++ b/src/web/src/@types/api/auth.ts @@ -14,7 +14,7 @@ export interface User { nickname: string; introduce: string; websitePath: string; - joinedAt: Date; + joinedAt: string; followerCount: number; followingCount: number; } diff --git a/src/web/src/@types/api/paint.ts b/src/web/src/@types/api/paint.ts index 0882002a..2aa2d1c4 100644 --- a/src/web/src/@types/api/paint.ts +++ b/src/web/src/@types/api/paint.ts @@ -8,7 +8,7 @@ export interface TimelineItem { authorNickname: User['nickname']; authorImagePath: User['profileImagePath']; authorStatus: User['status']; - createdAt: Date; + createdAt: string; text: string; replyCount: number; repaintCount: number; @@ -39,7 +39,7 @@ export interface TimelineItem { export interface EditPaint { text: string; medias: { - id: string; + path: string; type: 'image' | 'video'; }[]; taggedUserIds: string[]; diff --git a/src/web/src/api/index.ts b/src/web/src/api/index.ts index e6ccf568..778a28e7 100644 --- a/src/web/src/api/index.ts +++ b/src/web/src/api/index.ts @@ -24,7 +24,7 @@ const auth = createApiWrappers({ client.public.post('/auth/re-send', request), login: (request: LoginInfo) => client.public.post<{ accessToken: string; refreshToken: string }>( - '/auth/web', + '/auth/mobile', request, ), logout: () => client.private.post('/auth/web-logout'), diff --git a/src/web/src/components/MainPostBox.tsx b/src/web/src/components/MainPostBox.tsx index 632ea945..b3b40f47 100644 --- a/src/web/src/components/MainPostBox.tsx +++ b/src/web/src/components/MainPostBox.tsx @@ -116,7 +116,7 @@ const MainPostBox = forwardRef(
- {post.createdAt.toDateString()} · + {new Date(post.createdAt).toDateString()} · {post.authorUsername} ·{' '} - {getDiffDateText(post.createdAt, new Date())} + {getDiffDateText(new Date(post.createdAt), new Date())}
diff --git a/src/web/src/components/TimelineItemBox.tsx b/src/web/src/components/TimelineItemBox.tsx index 034ec051..86572911 100644 --- a/src/web/src/components/TimelineItemBox.tsx +++ b/src/web/src/components/TimelineItemBox.tsx @@ -75,7 +75,7 @@ function TimelineItemBox({
{post.authorUsername} ·{' '} - {getDiffDateText(post.createdAt, new Date())} + {getDiffDateText(new Date(post.createdAt), new Date())} toast('회원가입에 문제가 생겼습니다.'), + onError: (res) => { + console.log(res); + toast('회원가입에 문제가 생겼습니다.'); + }, }); const onNextPage = () => dispatch({ direction: 'next' }); diff --git a/src/web/src/pages/PostEditPage.tsx b/src/web/src/pages/PostEditPage.tsx index 7d5ea9a5..4441420b 100644 --- a/src/web/src/pages/PostEditPage.tsx +++ b/src/web/src/pages/PostEditPage.tsx @@ -1,7 +1,7 @@ import { useState } from 'react'; import { toast } from 'react-toastify'; import type { ChangeEvent } from 'react'; -import { useRouter } from '@tanstack/react-router'; +import { useNavigate, useRouter } from '@tanstack/react-router'; import { useMutation } from '@tanstack/react-query'; import { Helmet, HelmetProvider } from 'react-helmet-async'; @@ -48,6 +48,7 @@ const tempSavedStorage = function PostEditPage() { const user = DUMMY_USER; const router = useRouter(); + const navigate = useNavigate(); const search = editPostRoute.useSearch(); const [tags, setTags] = useState[]>([]); const [editPostInfo, setEditPostInfo] = useState(forEditPaint({})); @@ -77,6 +78,9 @@ function PostEditPage() { taggedUserIds: tags.map((tag) => tag.id), }), ), + onSuccess: () => { + navigate({ to: '/home' }); + }, }); const uploadMutation = useMutation({ diff --git a/src/web/src/pages/ProfilePage.tsx b/src/web/src/pages/ProfilePage.tsx index cb9e6af2..b6f26a88 100644 --- a/src/web/src/pages/ProfilePage.tsx +++ b/src/web/src/pages/ProfilePage.tsx @@ -163,7 +163,7 @@ function ProfilePage() {
- {user.joinedAt.toDateString()} + {new Date(user.joinedAt).toDateString()}
diff --git a/src/web/src/utils/helperPost.ts b/src/web/src/utils/helperPost.ts index 381881df..48d78cbd 100644 --- a/src/web/src/utils/helperPost.ts +++ b/src/web/src/utils/helperPost.ts @@ -97,6 +97,6 @@ export const convertToMedia = ( url: string, type: EditPaint['medias'][number]['type'], ): EditPaint['medias'][number] => ({ - id: url, + path: url, type, }); From d4347eeb2738e8cbf22f94774cb009ed8ed8f308 Mon Sep 17 00:00:00 2001 From: Lee <43488305+poiu694@users.noreply.github.com> Date: Thu, 15 Feb 2024 17:42:18 +0900 Subject: [PATCH 08/23] refactor: change property name --- src/web/src/components/TempSavedPostModal.tsx | 4 ++-- src/web/src/utils/dummyTimelineItem.ts | 6 +++--- src/web/src/utils/dummyUser.ts | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/web/src/components/TempSavedPostModal.tsx b/src/web/src/components/TempSavedPostModal.tsx index d9006bfb..f20140dc 100644 --- a/src/web/src/components/TempSavedPostModal.tsx +++ b/src/web/src/components/TempSavedPostModal.tsx @@ -52,7 +52,7 @@ function TempSavedPostModal({ const nextTempSavedPost = [...tempSavedPost].filter( (post) => JSON.stringify(post) !== JSON.stringify(item), ); - if (item.medias.length) setImage(item.medias[0].id); + if (item.medias.length) setImage(item.medias[0].path); setEditPostInfo(item); setTempSavedPost(nextTempSavedPost); tempSavedStorage.remove(); @@ -119,7 +119,7 @@ function TempSavedPostModal({ {post.medias.length > 0 && ( uploaded diff --git a/src/web/src/utils/dummyTimelineItem.ts b/src/web/src/utils/dummyTimelineItem.ts index 673bd75e..da1ab10b 100644 --- a/src/web/src/utils/dummyTimelineItem.ts +++ b/src/web/src/utils/dummyTimelineItem.ts @@ -1,12 +1,12 @@ import type { TimelineItem } from '@/@types'; -function getRandomAdjustedDate(): Date { +function getRandomAdjustedDate(): string { const currentDate = new Date(); const timeOffset = Math.floor(Math.random() * 1000 * 60 * 60 * 24); // 1일은 86,400,000 밀리초 const adjustedDate = new Date(currentDate.getTime() - timeOffset); - return adjustedDate; + return adjustedDate.toISOString(); } const DUMMY_ITEM: TimelineItem = { @@ -17,7 +17,7 @@ const DUMMY_ITEM: TimelineItem = { authorNickname: '이상민', authorImagePath: 'profile/k3cvomo4mknrzsub83n7', authorStatus: 'public', - createdAt: new Date(), + createdAt: new Date().toISOString(), text: `안녕하세요, @2023 개발캠프 여러분!\n지난 주에 이어서 오늘은 리사이클 팀의 현우 님(React-Query), 규민 님(상태관리)의 세미나가 진행됩니다.\n점심 식사하시고 1시 30분에 미팅룸 6번에서 만나요`, replyCount: 123, repaintCount: 34, diff --git a/src/web/src/utils/dummyUser.ts b/src/web/src/utils/dummyUser.ts index c1dedc84..7ff78c79 100644 --- a/src/web/src/utils/dummyUser.ts +++ b/src/web/src/utils/dummyUser.ts @@ -11,7 +11,7 @@ export const DUMMY_USER: User = { nickname: '상민', introduce: '상민이의 소개 !', websitePath: 'https://github.com/poiu694', - joinedAt: new Date(), + joinedAt: new Date().toISOString(), followerCount: 423, followingCount: 12, }; From 287c8fbf059da4b68562abb40e98559e3e355e09 Mon Sep 17 00:00:00 2001 From: Lee <43488305+poiu694@users.noreply.github.com> Date: Thu, 15 Feb 2024 17:56:20 +0900 Subject: [PATCH 09/23] fix: ci error (useless console, test error by changing property) --- src/web/src/pages/JoinPage.tsx | 3 +-- src/web/src/utils/__tests__/helperPost.spec.ts | 6 +++--- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/src/web/src/pages/JoinPage.tsx b/src/web/src/pages/JoinPage.tsx index c99acdf1..84300047 100644 --- a/src/web/src/pages/JoinPage.tsx +++ b/src/web/src/pages/JoinPage.tsx @@ -44,8 +44,7 @@ function JoinPage() { toast(`${joinInfo.username}님 회원가입이 완료되었습니다.`); navigate({ to: '/' }); }, - onError: (res) => { - console.log(res); + onError: () => { toast('회원가입에 문제가 생겼습니다.'); }, }); diff --git a/src/web/src/utils/__tests__/helperPost.spec.ts b/src/web/src/utils/__tests__/helperPost.spec.ts index 734074f5..07838327 100644 --- a/src/web/src/utils/__tests__/helperPost.spec.ts +++ b/src/web/src/utils/__tests__/helperPost.spec.ts @@ -27,7 +27,7 @@ describe('forEditPaint', () => { hashtags: [], mentions: [], links: [], - medias: [{ type: 'image', id: 'https://www.naver.com' }], + medias: [{ type: 'image', path: 'https://www.naver.com' }], }); }); @@ -114,7 +114,7 @@ describe('forEditPaint', () => { end: 33, }, ], - medias: [{ type: 'image', id: 'https://www.naver.com' }], + medias: [{ type: 'image', path: 'https://www.naver.com' }], }); }); @@ -149,7 +149,7 @@ describe('forEditPaint', () => { end: 93, }, ], - medias: [{ type: 'image', id: 'https://www.naver.com' }], + medias: [{ type: 'image', path: 'https://www.naver.com' }], }); }); }); From 07d9ce21146dee3fb8141d43615b4afe749f470b Mon Sep 17 00:00:00 2001 From: Lee <43488305+poiu694@users.noreply.github.com> Date: Fri, 16 Feb 2024 15:25:24 +0900 Subject: [PATCH 10/23] feat: get-my-profile api --- src/web/src/@types/api/auth.ts | 1 + src/web/src/components/Header.tsx | 7 +- src/web/src/components/MenuModal.tsx | 32 ++++++--- .../src/components/login/LoginPasswordBox.tsx | 70 +++++++++++++------ src/web/src/pages/JoinPage.tsx | 12 ++-- src/web/src/pages/LoginPage.tsx | 20 +----- src/web/src/pages/PostDetailPage.tsx | 13 ++-- src/web/src/utils/imageCDN.ts | 7 +- 8 files changed, 95 insertions(+), 67 deletions(-) diff --git a/src/web/src/@types/api/auth.ts b/src/web/src/@types/api/auth.ts index 7b3dea94..a1198da2 100644 --- a/src/web/src/@types/api/auth.ts +++ b/src/web/src/@types/api/auth.ts @@ -26,6 +26,7 @@ export type UserSearchResult = Pick & { }; export type UserProfile = Pick< User, + | 'id' | 'backgroundImagePath' | 'profileImagePath' | 'nickname' diff --git a/src/web/src/components/Header.tsx b/src/web/src/components/Header.tsx index 61cfd47f..ad7e0716 100644 --- a/src/web/src/components/Header.tsx +++ b/src/web/src/components/Header.tsx @@ -2,7 +2,7 @@ import { memo, useEffect, useState } from 'react'; import { cva } from 'class-variance-authority'; import type { MouseEvent, MouseEventHandler, ReactNode } from 'react'; -import { DUMMY_USER, cn } from '@/utils'; +import { cn } from '@/utils'; import MenuModal from './MenuModal'; import Typography from './common/Typography'; import type { IconKeyType } from './common/Icon'; @@ -148,10 +148,7 @@ function Header({ left, center, right, position, className }: HeaderProps) { {isProfile && isProfileModalOpen && ( - setIsProfileModalOpen(false)} - /> + setIsProfileModalOpen(false)} /> )} ); diff --git a/src/web/src/components/MenuModal.tsx b/src/web/src/components/MenuModal.tsx index c4b4560b..3c2c521a 100644 --- a/src/web/src/components/MenuModal.tsx +++ b/src/web/src/components/MenuModal.tsx @@ -1,23 +1,27 @@ import { memo, useState } from 'react'; -import { AnimatePresence, motion } from 'framer-motion'; import { toast } from 'react-toastify'; +import { useQuery } from '@tanstack/react-query'; import { useNavigate } from '@tanstack/react-router'; +import { AnimatePresence, motion } from 'framer-motion'; -import type { User } from '@/@types'; +import { apis } from '@/api'; import { Icon, Typography } from './common'; import { cn, forCloudinaryImage } from '@/utils'; import AccessibleIconButton from './AccessibleIconButton'; interface MenuModalProps { - user: User; onClose: VoidFunction; } const IPHONE_SE_HEIGHT = 667; -function MenuModal({ user, onClose }: MenuModalProps) { +function MenuModal({ onClose }: MenuModalProps) { const navigate = useNavigate(); const [isShowToggle, setIsShowToggle] = useState(false); + const { data: me } = useQuery({ + queryKey: ['user-profile', 'me'], + queryFn: () => apis.users.getMyProfile(), + }); const handleClickNotSupport = () => toast('아직 지원되지 않는 기능입니다.'); const isScreenHeightShort = Number(window.screen.height) <= IPHONE_SE_HEIGHT; @@ -34,7 +38,7 @@ function MenuModal({ user, onClose }: MenuModalProps) { >
user profile @@ -47,10 +51,10 @@ function MenuModal({ user, onClose }: MenuModalProps) { />
- {user.nickname} + {me?.nickname} - {user.username} + {me?.username}
{/* TODO: Following, Follower Page로 이동 */} @@ -59,11 +63,14 @@ function MenuModal({ user, onClose }: MenuModalProps) { tabIndex={0} className="flex gap-[4px]" onClick={() => - navigate({ to: '/profile/$userId', params: { userId: user.id } }) + navigate({ + to: '/profile/$userId', + params: { userId: String(me?.id) }, + }) } > - {user.followingCount} + {me?.followingCount} 팔로잉 @@ -71,7 +78,7 @@ function MenuModal({ user, onClose }: MenuModalProps) {
- {user.followerCount} + {me?.followerCount} 팔로워 @@ -86,7 +93,10 @@ function MenuModal({ user, onClose }: MenuModalProps) { type="button" className="flex gap-[24px] items-center" onClick={() => - navigate({ to: '/profile/$userId', params: { userId: user.id } }) + navigate({ + to: '/profile/$userId', + params: { userId: String(me?.id) }, + }) } > diff --git a/src/web/src/components/login/LoginPasswordBox.tsx b/src/web/src/components/login/LoginPasswordBox.tsx index 9e39485b..cf7fa7c6 100644 --- a/src/web/src/components/login/LoginPasswordBox.tsx +++ b/src/web/src/components/login/LoginPasswordBox.tsx @@ -1,9 +1,18 @@ +import { toast } from 'react-toastify'; +import { useMutation } from '@tanstack/react-query'; +import { useNavigate } from '@tanstack/react-router'; import { memo, useCallback, useState } from 'react'; import type { FormEvent, ChangeEvent } from 'react'; import { StepTitle } from '..'; import type { LoginInfo } from '@/@types'; import { Button, Input, Typography } from '../common'; +import { + accessTokenStorage, + refreshTokenStorage, +} from '@/api/AuthTokenStorage'; +import { apis } from '@/api'; +import { FullScreenSpinner } from '../skeleton'; interface LoginPasswordBoxProps { email: string; @@ -13,7 +22,6 @@ interface LoginPasswordBoxProps { e: ChangeEvent, type: keyof LoginInfo, ) => void; - onLogin: VoidFunction; onClickForgetPassword: VoidFunction; } @@ -22,18 +30,33 @@ function LoginPasswordBox({ password, disabled, onChangeInput, - onLogin, onClickForgetPassword, }: LoginPasswordBoxProps) { + const navigate = useNavigate(); const [isHidden, setIsHidden] = useState(true); - const handleSubmitForm = useCallback((e: FormEvent) => { - e.preventDefault(); + const loginMutate = useMutation({ + mutationKey: ['login', email], + mutationFn: () => apis.auth.login({ email, password }), + onSuccess: (res) => { + toast(`${email}님 로그인이 완료되었습니다.`); + accessTokenStorage.setToken(res.accessToken); + refreshTokenStorage.setToken(res.refreshToken); + navigate({ to: '/home' }); + }, + onError: () => toast.error('아이디 혹은 비밀번호가 틀렸습니다.'), + }); - if (password !== '') { - onLogin(); - } - }, []); + const handleSubmitForm = useCallback( + (e: FormEvent) => { + e.preventDefault(); + + if (password !== '') { + loginMutate.mutate(); + } + }, + [password], + ); return ( <> @@ -41,7 +64,7 @@ function LoginPasswordBox({ title="시작하려면 먼저 이메일 또는 사용자 아이디를 입력하세요." className="mt-[28px] break-keep" /> -
+
@@ -65,32 +88,37 @@ function LoginPasswordBox({ />
-
+
- - 비밀번호를 잊으셨나요? -
+ + + {loginMutate.isPending && ( + + )} ); } diff --git a/src/web/src/pages/JoinPage.tsx b/src/web/src/pages/JoinPage.tsx index 84300047..8083ce20 100644 --- a/src/web/src/pages/JoinPage.tsx +++ b/src/web/src/pages/JoinPage.tsx @@ -69,12 +69,14 @@ function JoinPage() { switch (step) { case JoinStep.INFORMATION: return ( - + setJoinInfo((prev) => ({ ...prev, profileImagePath: path })) + } + onJoin={() => registerMutate.mutate()} /> ); case JoinStep.EMAIL_VERIFY: diff --git a/src/web/src/pages/LoginPage.tsx b/src/web/src/pages/LoginPage.tsx index b9c0bd89..b303f10d 100644 --- a/src/web/src/pages/LoginPage.tsx +++ b/src/web/src/pages/LoginPage.tsx @@ -1,19 +1,12 @@ -import { toast } from 'react-toastify'; import type { ChangeEvent } from 'react'; import { useReducer, useState } from 'react'; import { useNavigate } from '@tanstack/react-router'; -import { useMutation } from '@tanstack/react-query'; import { Helmet, HelmetProvider } from 'react-helmet-async'; -import { apis } from '@/api'; import type { LoginInfo } from '@/@types'; import { ContentLayout, Header } from '@/components'; import { LoginStep, LoginStepReducer } from '@/components/login/loginReducer'; import { LoginEmailBox, LoginPasswordBox } from '@/components/login'; -import { - accessTokenStorage, - refreshTokenStorage, -} from '@/api/AuthTokenStorage'; function LoginPage() { const [state, dispatch] = useReducer(LoginStepReducer, LoginStep.EMAIL); @@ -22,17 +15,7 @@ function LoginPage() { password: '', }); const navigate = useNavigate(); - const loginMutate = useMutation({ - mutationKey: ['login', loginInfo.email], - mutationFn: () => apis.auth.login(loginInfo), - onSuccess: (res) => { - toast(`${loginInfo.email}님 로그인이 완료되었습니다.`); - accessTokenStorage.setToken(res.accessToken); - refreshTokenStorage.setToken(res.refreshToken); - navigate({ to: '/home' }); - }, - onError: () => toast.error('아이디 혹은 비밀번호가 틀렸습니다.'), - }); + const handleChangeInput = ( e: ChangeEvent, @@ -68,7 +51,6 @@ function LoginPage() { disabled={loginInfo.password === ''} email={loginInfo.email} password={loginInfo.password} - onLogin={() => loginMutate.mutate()} onChangeInput={handleChangeInput} onClickForgetPassword={() => navigate({ to: '/change-password' })} /> diff --git a/src/web/src/pages/PostDetailPage.tsx b/src/web/src/pages/PostDetailPage.tsx index f9ea56b7..bcccb4a4 100644 --- a/src/web/src/pages/PostDetailPage.tsx +++ b/src/web/src/pages/PostDetailPage.tsx @@ -1,8 +1,11 @@ import { useRef } from 'react'; +import { useQuery } from '@tanstack/react-query'; import { Helmet, HelmetProvider } from 'react-helmet-async'; import { useNavigate, useRouter } from '@tanstack/react-router'; +import { apis } from '@/api'; import { usePaintAction } from '@/hooks'; +import { postDetailRoute } from '@/routes'; import { AfterTimelineList, AsyncBoundary, @@ -12,17 +15,19 @@ import { MainPostBox, Typography, } from '@/components'; -import { DUMMY_USER, forCloudinaryImage } from '@/utils'; +import { forCloudinaryImage } from '@/utils'; import { ReplyBottomSheet, ShareBottomSheet, ViewsBottomSheet, } from '@/components/bottomSheet'; import { Spinner, TimelineItemBoxSkeleton } from '@/components/skeleton'; -import { postDetailRoute } from '@/routes'; function PostDetailPage() { - const me = DUMMY_USER; + const { data: me } = useQuery({ + queryKey: ['user-profile', 'me'], + queryFn: () => apis.users.getMyProfile(), + }); const router = useRouter(); const navigate = useNavigate(); const paintAction = usePaintAction(); @@ -89,7 +94,7 @@ function PostDetailPage() { } > your profile diff --git a/src/web/src/utils/imageCDN.ts b/src/web/src/utils/imageCDN.ts index d6635260..937f5b91 100644 --- a/src/web/src/utils/imageCDN.ts +++ b/src/web/src/utils/imageCDN.ts @@ -29,8 +29,11 @@ type ImageQuality = | 'jpegmini:best' | 'jpegmini:high' | 'jpegmini:medium'; + +const DEFAULT_IMAGE = 'profile/qliaa3hqpcqnhwiz7gcv'; + export const forCloudinaryImage = ( - id: string, + id: string | undefined, options: | { resize: true; @@ -47,7 +50,7 @@ export const forCloudinaryImage = ( height: 400, }, ): string => { - const image = cld.image(id); + const image = cld.image(id ?? DEFAULT_IMAGE); if (!image) { throw new ImageNotFoundError(); } From 7dec459d61243d4e77497e18eeb079daa31c7fc3 Mon Sep 17 00:00:00 2001 From: Lee <43488305+poiu694@users.noreply.github.com> Date: Mon, 19 Feb 2024 14:40:06 +0900 Subject: [PATCH 11/23] fix: rejectedFallback with props --- src/web/src/pages/PostDetailPage.tsx | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/web/src/pages/PostDetailPage.tsx b/src/web/src/pages/PostDetailPage.tsx index bcccb4a4..700a3f3e 100644 --- a/src/web/src/pages/PostDetailPage.tsx +++ b/src/web/src/pages/PostDetailPage.tsx @@ -63,7 +63,10 @@ function PostDetailPage() { ref={parentRef} className="flex flex-col flex-start px-[10px] pt-[44px] pb-[50px] overflow-y-scroll max-h-[calc(100vh-44px)]" > - }> + } + rejectedFallback={() => } + > - }> + } + rejectedFallback={() => } + >
From 8e00fe5a392dae2386e8a4b5ad5b9a36f8ee45f9 Mon Sep 17 00:00:00 2001 From: Lee <43488305+poiu694@users.noreply.github.com> Date: Mon, 19 Feb 2024 14:59:13 +0900 Subject: [PATCH 12/23] fix: step in join --- src/web/src/pages/JoinPage.tsx | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/src/web/src/pages/JoinPage.tsx b/src/web/src/pages/JoinPage.tsx index 8083ce20..f10c7331 100644 --- a/src/web/src/pages/JoinPage.tsx +++ b/src/web/src/pages/JoinPage.tsx @@ -69,14 +69,11 @@ function JoinPage() { switch (step) { case JoinStep.INFORMATION: return ( - - setJoinInfo((prev) => ({ ...prev, profileImagePath: path })) - } - onJoin={() => registerMutate.mutate()} + onChangeInput={handleChangeInput} /> ); case JoinStep.EMAIL_VERIFY: From cc19e9c7dfffca3d306baddfe1bd9f4076b8ae6a Mon Sep 17 00:00:00 2001 From: Lee <43488305+poiu694@users.noreply.github.com> Date: Mon, 19 Feb 2024 15:06:27 +0900 Subject: [PATCH 13/23] fix: change re-send api path --- src/web/src/api/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/web/src/api/index.ts b/src/web/src/api/index.ts index 778a28e7..e277f870 100644 --- a/src/web/src/api/index.ts +++ b/src/web/src/api/index.ts @@ -21,7 +21,7 @@ const auth = createApiWrappers({ verifyEmailCode: (request: { email: User['email']; payload: string }) => client.public.post('/auth', request), reSendEmailCode: (request: { email: User['email'] }) => - client.public.post('/auth/re-send', request), + client.public.post('/auth/resend', request), login: (request: LoginInfo) => client.public.post<{ accessToken: string; refreshToken: string }>( '/auth/mobile', From 59c533cc2f20ec7e44280c56ade1ea676e333279 Mon Sep 17 00:00:00 2001 From: Lee <43488305+poiu694@users.noreply.github.com> Date: Mon, 19 Feb 2024 15:08:49 +0900 Subject: [PATCH 14/23] refactor: create-paint with spinner --- src/web/src/pages/PostEditPage.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/web/src/pages/PostEditPage.tsx b/src/web/src/pages/PostEditPage.tsx index 4441420b..7d3a8757 100644 --- a/src/web/src/pages/PostEditPage.tsx +++ b/src/web/src/pages/PostEditPage.tsx @@ -383,7 +383,9 @@ function PostEditPage() { /> )} - {uploadMutation.isPending && } + {(uploadMutation.isPending || createPaintMutation.isPending) && ( + + )} ); } From 699ca6f9616263c6f1e5ac55a2fb69bd8a48a4d1 Mon Sep 17 00:00:00 2001 From: Lee <43488305+poiu694@users.noreply.github.com> Date: Mon, 19 Feb 2024 15:10:41 +0900 Subject: [PATCH 15/23] feat: post-edit with user image --- src/web/src/pages/PostEditPage.tsx | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/web/src/pages/PostEditPage.tsx b/src/web/src/pages/PostEditPage.tsx index 7d3a8757..070e2f6a 100644 --- a/src/web/src/pages/PostEditPage.tsx +++ b/src/web/src/pages/PostEditPage.tsx @@ -2,14 +2,13 @@ import { useState } from 'react'; import { toast } from 'react-toastify'; import type { ChangeEvent } from 'react'; import { useNavigate, useRouter } from '@tanstack/react-router'; -import { useMutation } from '@tanstack/react-query'; +import { useMutation, useQuery } from '@tanstack/react-query'; import { Helmet, HelmetProvider } from 'react-helmet-async'; import type { EditPaint, User } from '@/@types'; import { useAutoHeightTextArea } from '@/hooks'; import { EditPostCancelBottomSheet } from '@/components/bottomSheet'; import { - DUMMY_USER, convertToMedia, countByte, forCloudinaryImage, @@ -46,7 +45,6 @@ const tempSavedStorage = generateLocalStorage('temp-saved-storage'); function PostEditPage() { - const user = DUMMY_USER; const router = useRouter(); const navigate = useNavigate(); const search = editPostRoute.useSearch(); @@ -57,6 +55,11 @@ function PostEditPage() { const isNotDirty = editPostInfo.text.length === EMPTY_LENGTH && image.length === EMPTY_LENGTH; + const { data: me } = useQuery({ + queryKey: ['user-profile', 'me'], + queryFn: () => apis.users.getMyProfile(), + }); + const [isModalOpen, setIsModalOpen] = useState<{ cancel: boolean; tempSaved: boolean; @@ -182,7 +185,7 @@ function PostEditPage() {
user From 1ace0305dd059a7299b6508c7bcfdb4d20142313 Mon Sep 17 00:00:00 2001 From: Lee <43488305+poiu694@users.noreply.github.com> Date: Mon, 19 Feb 2024 15:41:18 +0900 Subject: [PATCH 16/23] feat: seperate default image path --- src/web/src/constants/image.ts | 2 ++ src/web/src/constants/index.ts | 1 + src/web/src/utils/imageCDN.ts | 9 ++++----- 3 files changed, 7 insertions(+), 5 deletions(-) create mode 100644 src/web/src/constants/image.ts diff --git a/src/web/src/constants/image.ts b/src/web/src/constants/image.ts new file mode 100644 index 00000000..4c2ac2b0 --- /dev/null +++ b/src/web/src/constants/image.ts @@ -0,0 +1,2 @@ +export const DEFAULT_BACKGROUND_IMAGE = 'background/msqoll4kckvhw5gfgqgx'; +export const DEFAULT_PROFILE_IMAGE = 'profile/qliaa3hqpcqnhwiz7gcv'; diff --git a/src/web/src/constants/index.ts b/src/web/src/constants/index.ts index 6d691b9a..77ea101b 100644 --- a/src/web/src/constants/index.ts +++ b/src/web/src/constants/index.ts @@ -1,2 +1,3 @@ export * from './env'; +export * from './image'; export * from './defaultPagination'; diff --git a/src/web/src/utils/imageCDN.ts b/src/web/src/utils/imageCDN.ts index 937f5b91..3232f3b3 100644 --- a/src/web/src/utils/imageCDN.ts +++ b/src/web/src/utils/imageCDN.ts @@ -1,7 +1,7 @@ import { Cloudinary } from '@cloudinary/url-gen'; import { Resize } from '@cloudinary/url-gen/actions'; -import { env } from '@/constants'; +import { DEFAULT_PROFILE_IMAGE, env } from '@/constants'; import type { ImageSize } from '@/@types'; export const cld = new Cloudinary({ @@ -30,8 +30,6 @@ type ImageQuality = | 'jpegmini:high' | 'jpegmini:medium'; -const DEFAULT_IMAGE = 'profile/qliaa3hqpcqnhwiz7gcv'; - export const forCloudinaryImage = ( id: string | undefined, options: @@ -41,8 +39,9 @@ export const forCloudinaryImage = ( ratio?: '16:9' | '3:4' | '1:1' | false; width: ImageSize['width']; height: ImageSize['height']; + defaultImage?: string; } - | { resize: false; quality?: ImageQuality } = { + | { resize: false; quality?: ImageQuality; defaultImage?: string } = { resize: true, quality: 'auto', ratio: '1:1', @@ -50,7 +49,7 @@ export const forCloudinaryImage = ( height: 400, }, ): string => { - const image = cld.image(id ?? DEFAULT_IMAGE); + const image = cld.image(id ?? options.defaultImage ?? DEFAULT_PROFILE_IMAGE); if (!image) { throw new ImageNotFoundError(); } From 6974ab96bc1a37acde5147862b46929b1096130b Mon Sep 17 00:00:00 2001 From: Lee <43488305+poiu694@users.noreply.github.com> Date: Mon, 19 Feb 2024 15:41:30 +0900 Subject: [PATCH 17/23] feat: seperate my-profile, user-profile --- src/web/src/components/MenuModal.tsx | 5 +- src/web/src/components/ProfileHeader.tsx | 57 +++++ .../src/components/ProfileInformationBox.tsx | 62 +++++ src/web/src/components/ProfileTabs.tsx | 103 ++++++++ src/web/src/components/index.ts | 3 + src/web/src/pages/MyProfilePage.tsx | 114 +++++++++ src/web/src/pages/ProfilePage.tsx | 223 ++---------------- src/web/src/pages/index.ts | 1 + src/web/src/routes/index.tsx | 8 + 9 files changed, 373 insertions(+), 203 deletions(-) create mode 100644 src/web/src/components/ProfileHeader.tsx create mode 100644 src/web/src/components/ProfileInformationBox.tsx create mode 100644 src/web/src/components/ProfileTabs.tsx create mode 100644 src/web/src/pages/MyProfilePage.tsx diff --git a/src/web/src/components/MenuModal.tsx b/src/web/src/components/MenuModal.tsx index 3c2c521a..c69e0541 100644 --- a/src/web/src/components/MenuModal.tsx +++ b/src/web/src/components/MenuModal.tsx @@ -57,7 +57,6 @@ function MenuModal({ onClose }: MenuModalProps) { {me?.username}
- {/* TODO: Following, Follower Page로 이동 */}
navigate({ - to: '/profile/$userId', - params: { userId: String(me?.id) }, + to: '/profile/me', }) } > @@ -189,7 +187,6 @@ function MenuModal({ onClose }: MenuModalProps) { exit={{ opacity: 0 }} className="flex flex-col gap-[24px] mt-[24px]" > - {/* TODO: 설정 페이지로 이동 */}
+ router.history.back()} + /> +
+ navigate({ to: '/search' })} + /> +
+ + ); +} + +export default ProfileHeader; diff --git a/src/web/src/components/ProfileInformationBox.tsx b/src/web/src/components/ProfileInformationBox.tsx new file mode 100644 index 00000000..79422546 --- /dev/null +++ b/src/web/src/components/ProfileInformationBox.tsx @@ -0,0 +1,62 @@ +import type { UserProfile } from '@/@types'; +import { Icon, Typography } from './common'; +import { cn } from '@/utils'; +import { memo } from 'react'; + +interface ProfileInformationBoxProps { + className?: string; + user?: UserProfile; +} + +function ProfileInformationBox({ + className, + user, +}: ProfileInformationBoxProps) { + return ( +
+ + {user?.nickname} + + + {user?.username} + + + + {user?.introduce} + + +
+ + + {(user?.joinedAt + ? new Date(user.joinedAt) + : new Date() + ).toDateString()} + +
+ +
+
+ + {user?.followingCount} + + + 팔로잉 + +
+
+ + {user?.followerCount} + + + 팔로워 + +
+
+
+ ); +} + +const MemoizedProfileInformationBox = memo(ProfileInformationBox); + +export default MemoizedProfileInformationBox; diff --git a/src/web/src/components/ProfileTabs.tsx b/src/web/src/components/ProfileTabs.tsx new file mode 100644 index 00000000..a69c93cb --- /dev/null +++ b/src/web/src/components/ProfileTabs.tsx @@ -0,0 +1,103 @@ +import { Tabs } from './common'; +import AsyncBoundary from './AsyncBoundary'; +import ContentLayout from './ContentLayout'; +import ErrorWithResetBox from './ErrorWithResetBox'; +import TimelineItemList from './TimelineItemList'; +import { TimelineItemListSkeleton } from './skeleton'; + +interface ProfileTabsProps { + className?: string; +} + +function ProfileTabs({ className }: ProfileTabsProps) { + return ( + + } + rejectedFallback={(props) => } + > + + + + ), + }, + { + label: '답글', + content: ( + + } + rejectedFallback={(props) => } + > + + + + ), + }, + { + label: '하이라이트', + content: ( + + } + rejectedFallback={(props) => } + > + + + + ), + }, + { + label: '미디어', + content: ( + + } + rejectedFallback={(props) => } + > + + + + ), + }, + { + label: '마음에 들어요', + content: ( + + } + rejectedFallback={(props) => } + > + + + + ), + }, + ]} + /> + ); +}; + +export default ProfileTabs; \ No newline at end of file diff --git a/src/web/src/components/index.ts b/src/web/src/components/index.ts index 9a5e4f89..da428123 100644 --- a/src/web/src/components/index.ts +++ b/src/web/src/components/index.ts @@ -12,6 +12,9 @@ export { default as MainPostBox } from './MainPostBox'; export { default as MenuModal } from './MenuModal'; export { default as QuotePostBox } from './QuotePostBox'; export { default as NotSupportText } from './NotSupportText'; +export { default as ProfileHeader } from './ProfileHeader'; +export { default as ProfileInformationBox } from './ProfileInformationBox'; +export { default as ProfileTabs } from './ProfileTabs'; export { default as StepTitle } from './StepTitle'; export { default as TagSearchUserModal } from './TagSearchUserModal'; export { default as TimelineItemBox } from './TimelineItemBox'; diff --git a/src/web/src/pages/MyProfilePage.tsx b/src/web/src/pages/MyProfilePage.tsx new file mode 100644 index 00000000..d9b6a7cd --- /dev/null +++ b/src/web/src/pages/MyProfilePage.tsx @@ -0,0 +1,114 @@ +import type { UIEvent } from 'react'; +import { motion } from 'framer-motion'; +import { useRef, useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { Helmet, HelmetProvider } from 'react-helmet-async'; + +import { apis } from '@/api'; +import type { TimelineItem } from '@/@types'; +import { createDummyTimelineItem, forCloudinaryImage } from '@/utils'; +import { + Button, + ProfileHeader, + ProfileInformationBox, + ProfileTabs, + Typography, +} from '@/components'; +import { useThrottle } from '@/hooks'; +import { DEFAULT_BACKGROUND_IMAGE } from '@/constants'; + +const MIN_IMAGE_HEIGHT = 50; +const DEFAULT_IMAGE_HEIGHT = 124; +function MyProfilePage() { + const { data: user } = useQuery({ + queryKey: ['user-profile', 'me'], + queryFn: () => apis.users.getMyProfile(), + }); + + const scrollRef = useRef(null); + const [paints] = useState(() => createDummyTimelineItem(10)); + const [imageHeight, setImageHeight] = useState(DEFAULT_IMAGE_HEIGHT); + const isExpandImage = imageHeight === DEFAULT_IMAGE_HEIGHT; + + const handleScroll = useThrottle((e: UIEvent) => { + const padding = 220; + const { scrollTop } = e.target as HTMLElement; + + if (scrollTop > padding && imageHeight !== MIN_IMAGE_HEIGHT) { + setImageHeight(MIN_IMAGE_HEIGHT); + } + if (scrollTop < padding && imageHeight !== DEFAULT_IMAGE_HEIGHT) { + setImageHeight(DEFAULT_IMAGE_HEIGHT); + } + }, 200); + + return ( + <> + + + Easel | 프로필 + + + +
+ + user-background + {isExpandImage ? ( +
+ user + + +
+ ) : ( + + + {user?.nickname} + + + 게시물 {paints.length}개 + + + )} +
+ + +
+
+ + ); +} + +export default MyProfilePage; diff --git a/src/web/src/pages/ProfilePage.tsx b/src/web/src/pages/ProfilePage.tsx index b6f26a88..d04a4607 100644 --- a/src/web/src/pages/ProfilePage.tsx +++ b/src/web/src/pages/ProfilePage.tsx @@ -1,35 +1,32 @@ import type { UIEvent } from 'react'; import { motion } from 'framer-motion'; -import { useEffect, useRef, useState } from 'react'; +import { useRef, useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; import { Helmet, HelmetProvider } from 'react-helmet-async'; -import { useNavigate, useRouter } from '@tanstack/react-router'; +import { apis } from '@/api'; import type { TimelineItem } from '@/@types'; +import { createDummyTimelineItem, forCloudinaryImage } from '@/utils'; import { - DUMMY_USER, - createDummyTimelineItem, - forCloudinaryImage, -} from '@/utils'; -import { - AccessibleIconButton, - AsyncBoundary, Button, - ContentLayout, - ErrorWithResetBox, - Icon, - Tabs, - TimelineItemList, + ProfileHeader, + ProfileInformationBox, + ProfileTabs, Typography, } from '@/components'; import { useThrottle } from '@/hooks'; -import { TimelineItemListSkeleton } from '@/components/skeleton'; +import { profileRoute } from '@/routes'; +import { DEFAULT_BACKGROUND_IMAGE } from '@/constants'; const MIN_IMAGE_HEIGHT = 50; const DEFAULT_IMAGE_HEIGHT = 124; -const user = DUMMY_USER; function ProfilePage() { - const navigate = useNavigate(); - const router = useRouter(); + const params = profileRoute.useParams(); + const { data: user } = useQuery({ + queryKey: ['user-profile', params.userId], + queryFn: () => apis.users.getUserProfile(params.userId), + }); + const scrollRef = useRef(null); const [paints] = useState(() => createDummyTimelineItem(10)); const [imageHeight, setImageHeight] = useState(DEFAULT_IMAGE_HEIGHT); @@ -47,8 +44,6 @@ function ProfilePage() { } }, 200); - useEffect(() => {}, []); - return ( <> @@ -58,42 +53,12 @@ function ProfilePage() {
-
- router.history.back()} - /> -
- navigate({ to: '/search' })} - /> -
-
+ user-background user @@ -124,7 +89,7 @@ function ProfilePage() { animate={{ opacity: 100 }} > - {user.nickname} + {user?.nickname} -
- - {user.nickname} - - - {user.username} - - - - {user.introduce} - - -
- - - {new Date(user.joinedAt).toDateString()} - -
- -
-
- - {user.followingCount} - - - 팔로잉 - -
-
- - {user.followerCount} - - - 팔로워 - -
-
-
- - } - rejectedFallback={(props) => ( - - )} - > - - - - ), - }, - { - label: '답글', - content: ( - - } - rejectedFallback={(props) => ( - - )} - > - - - - ), - }, - { - label: '하이라이트', - content: ( - - } - rejectedFallback={(props) => ( - - )} - > - - - - ), - }, - { - label: '미디어', - content: ( - - } - rejectedFallback={(props) => ( - - )} - > - - - - ), - }, - { - label: '마음에 들어요', - content: ( - - } - rejectedFallback={(props) => ( - - )} - > - - - - ), - }, - ]} - /> + +
diff --git a/src/web/src/pages/index.ts b/src/web/src/pages/index.ts index 56215213..83563781 100644 --- a/src/web/src/pages/index.ts +++ b/src/web/src/pages/index.ts @@ -5,6 +5,7 @@ export { default as ErrorFallbackPage } from './ErrorFallbackPage'; export { default as HomePage } from './HomePage'; export { default as LoginPage } from './LoginPage'; export { default as MembershipEntryPage } from './MembershipEntryPage'; +export { default as MyProfilePage } from './MyProfilePage'; export { default as NotificationPage } from './NotificationPage'; export { default as SearchPage } from './SearchPage'; export { default as SearchResultPage } from './SearchResultPage'; diff --git a/src/web/src/routes/index.tsx b/src/web/src/routes/index.tsx index 73356999..50992c7f 100644 --- a/src/web/src/routes/index.tsx +++ b/src/web/src/routes/index.tsx @@ -20,6 +20,7 @@ import { ProfilePage, PostDetailPage, SearchResultPage, + MyProfilePage, } from '@/pages'; import { AsyncBoundary } from '@/components'; import { DEFAULT_PAGE, DEFAULT_PAGE_SIZE } from '@/constants'; @@ -121,6 +122,12 @@ export const profileRoute = new Route({ }), }); +const myProfileRoute = new Route({ + getParentRoute: () => rootRoute, + path: '/profile/me', + component: () => , +}); + export const editPostRoute = new Route({ getParentRoute: () => postRoute, path: '/edit', @@ -149,6 +156,7 @@ const routeTree = rootRoute.addChildren([ joinRoute, changePasswordRoute, profileRoute, + myProfileRoute, postRoute.addChildren([editPostRoute, postDetailRoute]), ]); From 5df12d1d53fcc6e1c61856f82eaae57f55fe3e57 Mon Sep 17 00:00:00 2001 From: Lee <43488305+poiu694@users.noreply.github.com> Date: Mon, 19 Feb 2024 15:52:43 +0900 Subject: [PATCH 18/23] feat: user-profile apis --- src/web/src/api/index.ts | 8 +++++ src/web/src/components/ProfileTabs.tsx | 40 +++++++++++++++------ src/web/src/components/TimelineItemList.tsx | 36 ++++++++++++------- 3 files changed, 62 insertions(+), 22 deletions(-) diff --git a/src/web/src/api/index.ts b/src/web/src/api/index.ts index e277f870..510e4749 100644 --- a/src/web/src/api/index.ts +++ b/src/web/src/api/index.ts @@ -77,6 +77,14 @@ const users = createApiWrappers({ | 'websitePath' > & { userId: User['id'] } >('/users/profile', request), + getUserPaints: (userId: User['id']) => + client.private.get(`/users/${userId}/paint`), + getUserReplyPaints: (userId: User['id']) => + client.private.get(`/users/${userId}/reply`), + getUserMediaPaints: (userId: User['id']) => + client.private.get(`/users/${userId}/media`), + getUserLikePaints: (userId: User['id']) => + client.private.get(`/users/${userId}/heart`), }); const images = createApiWrappers({ diff --git a/src/web/src/components/ProfileTabs.tsx b/src/web/src/components/ProfileTabs.tsx index a69c93cb..4d99a0cf 100644 --- a/src/web/src/components/ProfileTabs.tsx +++ b/src/web/src/components/ProfileTabs.tsx @@ -24,9 +24,13 @@ function ProfileTabs({ className }: ProfileTabsProps) { > } - rejectedFallback={(props) => } + rejectedFallback={(props) => ( +
+ +
+ )} > - +
), @@ -40,9 +44,13 @@ function ProfileTabs({ className }: ProfileTabsProps) { > } - rejectedFallback={(props) => } + rejectedFallback={(props) => ( +
+ +
+ )} > - +
), @@ -56,9 +64,13 @@ function ProfileTabs({ className }: ProfileTabsProps) { > } - rejectedFallback={(props) => } + rejectedFallback={(props) => ( +
+ +
+ )} > - +
), @@ -72,7 +84,11 @@ function ProfileTabs({ className }: ProfileTabsProps) { > } - rejectedFallback={(props) => } + rejectedFallback={(props) => ( +
+ +
+ )} >
@@ -88,7 +104,11 @@ function ProfileTabs({ className }: ProfileTabsProps) { > } - rejectedFallback={(props) => } + rejectedFallback={(props) => ( +
+ +
+ )} >
@@ -98,6 +118,6 @@ function ProfileTabs({ className }: ProfileTabsProps) { ]} /> ); -}; +} -export default ProfileTabs; \ No newline at end of file +export default ProfileTabs; diff --git a/src/web/src/components/TimelineItemList.tsx b/src/web/src/components/TimelineItemList.tsx index f7b4d151..b22776ed 100644 --- a/src/web/src/components/TimelineItemList.tsx +++ b/src/web/src/components/TimelineItemList.tsx @@ -4,7 +4,7 @@ import { useSuspenseQuery } from '@tanstack/react-query'; import { apis } from '@/api'; import { usePaintAction } from '@/hooks'; -import type { TimelineItem } from '@/@types'; +import type { TimelineItem, User } from '@/@types'; import TimelineItemBox from './TimelineItemBox'; import { cn, createDummyTimelineItem } from '@/utils'; import ReplyBottomSheet from './bottomSheet/ReplyBottomSheet'; @@ -15,8 +15,8 @@ interface TimelineItemListProps { type: | 'follow' | 'recommend' - | 'my-post' - | 'my-reply' + | 'post' + | 'reply' | 'media' | 'heart' | 'search-recommend' @@ -24,6 +24,7 @@ interface TimelineItemListProps { | 'search-user' | 'search-media'; className?: string; + userId?: User['id']; } function delay(ms: number): Promise { @@ -34,22 +35,33 @@ function delay(ms: number): Promise { }); } +class UserNotFoundError extends Error { + constructor() { + super('유효하지 않는 사용자입니다.'); + } +} + function getQueryFnByType( type: TimelineItemListProps['type'], + userId?: User['id'], ): Promise { switch (type) { case 'follow': return apis.auth.logout() as unknown as Promise; case 'recommend': return delay(1250); - case 'my-post': - return delay(1250); - case 'my-reply': - return delay(1250); + case 'post': + if (!userId) throw new UserNotFoundError(); + return apis.users.getUserPaints(userId); + case 'reply': + if (!userId) throw new UserNotFoundError(); + return apis.users.getUserReplyPaints(userId); case 'media': - return delay(1250); + if (!userId) throw new UserNotFoundError(); + return apis.users.getUserMediaPaints(userId); case 'heart': - return delay(1250); + if (!userId) throw new UserNotFoundError(); + return apis.users.getUserLikePaints(userId); case 'search-recommend': return delay(1250); case 'search-recent': @@ -63,10 +75,10 @@ function getQueryFnByType( } } -function TimelineItemList({ type, className }: TimelineItemListProps) { +function TimelineItemList({ type, className, userId }: TimelineItemListProps) { const { data: paints } = useSuspenseQuery({ - queryKey: ['paint', type], - queryFn: () => getQueryFnByType(type), + queryKey: ['paint', type, userId], + queryFn: () => getQueryFnByType(type, userId), }); const navigate = useNavigate(); From 58dad3e795da28f68dc8eadf3f73f82c2ee1efc5 Mon Sep 17 00:00:00 2001 From: Lee <43488305+poiu694@users.noreply.github.com> Date: Mon, 19 Feb 2024 15:59:33 +0900 Subject: [PATCH 19/23] feat: profile highlight component --- src/web/src/components/ProfileTabs.tsx | 35 +++++++++++++++++--------- 1 file changed, 23 insertions(+), 12 deletions(-) diff --git a/src/web/src/components/ProfileTabs.tsx b/src/web/src/components/ProfileTabs.tsx index 4d99a0cf..2e101eb1 100644 --- a/src/web/src/components/ProfileTabs.tsx +++ b/src/web/src/components/ProfileTabs.tsx @@ -1,8 +1,10 @@ -import { Tabs } from './common'; +import { toast } from 'react-toastify'; + import AsyncBoundary from './AsyncBoundary'; import ContentLayout from './ContentLayout'; -import ErrorWithResetBox from './ErrorWithResetBox'; import TimelineItemList from './TimelineItemList'; +import ErrorWithResetBox from './ErrorWithResetBox'; +import { Button, Tabs, Typography } from './common'; import { TimelineItemListSkeleton } from './skeleton'; interface ProfileTabsProps { @@ -62,16 +64,25 @@ function ProfileTabs({ className }: ProfileTabsProps) { as="section" className="mt-0 pl-[12px] pr-[4px] max-h-none" > - } - rejectedFallback={(props) => ( -
- -
- )} - > - -
+
+ + 프로필에 하이라이트 추가 + + + 프로필에 게시물을 하이라이트 하려면 Premium을 구독해야 합니다. + + + +
), }, From d260caa07c011351c15a7b3dc4683ebeeb09cc20 Mon Sep 17 00:00:00 2001 From: Lee <43488305+poiu694@users.noreply.github.com> Date: Mon, 19 Feb 2024 15:59:55 +0900 Subject: [PATCH 20/23] fix: default image changed --- src/web/src/pages/MyProfilePage.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/web/src/pages/MyProfilePage.tsx b/src/web/src/pages/MyProfilePage.tsx index d9b6a7cd..016b4fea 100644 --- a/src/web/src/pages/MyProfilePage.tsx +++ b/src/web/src/pages/MyProfilePage.tsx @@ -53,7 +53,10 @@ function MyProfilePage() {
user-background user From 693806f12a5a7b26b71dd63957dcec04416c3a55 Mon Sep 17 00:00:00 2001 From: Lee <43488305+poiu694@users.noreply.github.com> Date: Mon, 19 Feb 2024 16:48:10 +0900 Subject: [PATCH 21/23] feat: react to paint --- src/web/src/api/AuthTokenStorage.ts | 1 + src/web/src/api/index.ts | 57 +++++++++++++++++++ src/web/src/components/AfterTimelineList.tsx | 2 +- src/web/src/components/BeforeTimelineList.tsx | 2 +- src/web/src/components/MainPostBox.tsx | 2 +- src/web/src/components/TimelineItemList.tsx | 2 +- .../bottomSheet/ReplyBottomSheet.tsx | 26 ++++++++- src/web/src/hooks/index.ts | 1 + src/web/src/hooks/usePaintAction.ts | 28 +++++++-- src/web/src/hooks/useProfileId.ts | 33 +++++++++++ 10 files changed, 143 insertions(+), 11 deletions(-) create mode 100644 src/web/src/hooks/useProfileId.ts diff --git a/src/web/src/api/AuthTokenStorage.ts b/src/web/src/api/AuthTokenStorage.ts index 0dface95..f4c7366c 100644 --- a/src/web/src/api/AuthTokenStorage.ts +++ b/src/web/src/api/AuthTokenStorage.ts @@ -33,5 +33,6 @@ export class AuthTokenStorage { } } +export const userIdStorage = generateLocalStorage('@@@userId'); export const accessTokenStorage = new AuthTokenStorage('@@@accessToken'); export const refreshTokenStorage = new AuthTokenStorage('@@@refreshToken'); diff --git a/src/web/src/api/index.ts b/src/web/src/api/index.ts index 510e4749..e65d1290 100644 --- a/src/web/src/api/index.ts +++ b/src/web/src/api/index.ts @@ -85,6 +85,63 @@ const users = createApiWrappers({ client.private.get(`/users/${userId}/media`), getUserLikePaints: (userId: User['id']) => client.private.get(`/users/${userId}/heart`), + followUser: (userId: User['id']) => + client.private.post(`/users/${userId}/follow`), + unFollowUser: (userId: User['id']) => + client.private.delete(`/users/${userId}/follow`), + likePaint: ({ + userId, + paintId, + }: { + userId: User['id']; + paintId: TimelineItem['id']; + }) => + client.private.post<{ paintId: TimelineItem['id'] }>( + `/users/${userId}/like`, + { paintId }, + ), + disLikePaint: ({ + userId, + paintId, + }: { + userId: User['id']; + paintId: TimelineItem['id']; + }) => + client.private.delete<{ paintId: TimelineItem['id'] }>( + `/users/${userId}/like/${paintId}`, + ), + rePaint: ({ + userId, + paintId, + }: { + userId: User['id']; + paintId: TimelineItem['id']; + }) => + client.private.post<{ paintId: TimelineItem['id'] }>( + `/users/${userId}/repaint`, + { paintId }, + ), + markPaint: ({ + userId, + paintId, + }: { + userId: User['id']; + paintId: TimelineItem['id']; + }) => + client.private.post<{ paintId: TimelineItem['id'] }>( + `/users/${userId}/mark`, + { paintId }, + ), + unMarkPaint: ({ + userId, + paintId, + }: { + userId: User['id']; + paintId: TimelineItem['id']; + }) => + client.private.delete<{ paintId: TimelineItem['id'] }>( + `/users/${userId}/mark/${paintId}`, + ), }); const images = createApiWrappers({ diff --git a/src/web/src/components/AfterTimelineList.tsx b/src/web/src/components/AfterTimelineList.tsx index c7d2a552..ad8d56e3 100644 --- a/src/web/src/components/AfterTimelineList.tsx +++ b/src/web/src/components/AfterTimelineList.tsx @@ -51,7 +51,7 @@ const AfterTimelineList = forwardRef( }) } onClickRetweet={() => paintAction.onClickRetweet(post.id)} - onClickHeart={() => paintAction.onClickHeart(post.id)} + onClickHeart={() => paintAction.onClickHeart(post.id, post.like)} onClickViews={() => paintAction.onClickViews(post.id)} onClickShare={() => paintAction.onClickShare(post.id)} onClickMore={() => paintAction.onClickMore(post.id)} diff --git a/src/web/src/components/BeforeTimelineList.tsx b/src/web/src/components/BeforeTimelineList.tsx index 0edf6cc5..6992ecf4 100644 --- a/src/web/src/components/BeforeTimelineList.tsx +++ b/src/web/src/components/BeforeTimelineList.tsx @@ -65,7 +65,7 @@ const BeforeTimelineList = forwardRef( }) } onClickRetweet={() => paintAction.onClickRetweet(post.id)} - onClickHeart={() => paintAction.onClickHeart(post.id)} + onClickHeart={() => paintAction.onClickHeart(post.id, post.like)} onClickViews={() => paintAction.onClickViews(post.id)} onClickShare={() => paintAction.onClickShare(post.id)} onClickMore={() => paintAction.onClickMore(post.id)} diff --git a/src/web/src/components/MainPostBox.tsx b/src/web/src/components/MainPostBox.tsx index b3b40f47..1f98d4c0 100644 --- a/src/web/src/components/MainPostBox.tsx +++ b/src/web/src/components/MainPostBox.tsx @@ -238,7 +238,7 @@ const MainPostBox = forwardRef( iconType={post.like ? 'solidHeart' : 'heart'} label="마음에 들어요 누르기" className="transition-colors hover:bg-grey-200 rounded-full p-1" - onClick={() => paintAction.onClickHeart(post.id)} + onClick={() => paintAction.onClickHeart(post.id, post.like)} />
diff --git a/src/web/src/components/TimelineItemList.tsx b/src/web/src/components/TimelineItemList.tsx index b22776ed..94874f6f 100644 --- a/src/web/src/components/TimelineItemList.tsx +++ b/src/web/src/components/TimelineItemList.tsx @@ -109,7 +109,7 @@ function TimelineItemList({ type, className, userId }: TimelineItemListProps) { }) } onClickRetweet={() => paintAction.onClickRetweet(paint.id)} - onClickHeart={() => paintAction.onClickHeart(paint.id)} + onClickHeart={() => paintAction.onClickHeart(paint.id, paint.like)} onClickViews={() => paintAction.onClickViews(paint.id)} onClickShare={() => paintAction.onClickShare(paint.id)} onClickMore={() => paintAction.onClickMore(paint.id)} diff --git a/src/web/src/components/bottomSheet/ReplyBottomSheet.tsx b/src/web/src/components/bottomSheet/ReplyBottomSheet.tsx index 8600cd75..95815311 100644 --- a/src/web/src/components/bottomSheet/ReplyBottomSheet.tsx +++ b/src/web/src/components/bottomSheet/ReplyBottomSheet.tsx @@ -1,7 +1,9 @@ import type { ComponentProps } from 'react'; import { Link } from '@tanstack/react-router'; +import { useMutation, useQuery } from '@tanstack/react-query'; -import type { TimelineItem } from '@/@types'; +import { apis } from '@/api'; +import type { TimelineItem, User } from '@/@types'; import { BottomSheet, Icon, Typography } from '../common'; interface ReplyBottomSheetProps { @@ -11,16 +13,34 @@ interface ReplyBottomSheetProps { } function ReplyBottomSheet({ id, isOpen, onClose }: ReplyBottomSheetProps) { + const { data: me } = useQuery({ + queryKey: ['user-profile', 'me'], + queryFn: () => apis.users.getMyProfile(), + }); + + const repaintMutate = useMutation({ + mutationKey: ['re-paint', id], + mutationFn: (userId: User['id']) => + apis.users.rePaint({ userId, paintId: id }), + }); + + const handleClickRepaint = () => { + if (!me) { + throw new Error('사용자가 없습니다.'); + } + repaintMutate.mutate(me.id); + }; + return (
- +
재게시 - +
diff --git a/src/web/src/hooks/index.ts b/src/web/src/hooks/index.ts index ea5ef457..9d93aced 100644 --- a/src/web/src/hooks/index.ts +++ b/src/web/src/hooks/index.ts @@ -4,4 +4,5 @@ export * from './useLongPress'; export * from './usePaintAction'; export * from './usePreservedCallback'; export * from './usePreservedReference'; +export * from './useProfileId'; export * from './useThrottle'; diff --git a/src/web/src/hooks/usePaintAction.ts b/src/web/src/hooks/usePaintAction.ts index 6d6a573c..9c5ed242 100644 --- a/src/web/src/hooks/usePaintAction.ts +++ b/src/web/src/hooks/usePaintAction.ts @@ -1,9 +1,11 @@ -import { toast } from 'react-toastify'; import { useCallback, useState } from 'react'; import { useNavigate } from '@tanstack/react-router'; +import { useMutation } from '@tanstack/react-query'; +import { apis } from '@/api'; import { useThrottle } from './useThrottle'; import type { TimelineItem } from '@/@types'; +import { useProfileId } from './useProfileId'; import { usePreservedCallback } from './usePreservedCallback'; interface BottomSheetState { @@ -24,6 +26,8 @@ const INITIAL_SHOW_MORE_MENU = { } as const; export const usePaintAction = () => { + const userId = useProfileId(); + const navigate = useNavigate(); const [isBottomSheetOpen, setIsBottomSheetOpen] = useState( INITIAL_BOTTOM_SHEET_OPEN, @@ -34,6 +38,18 @@ export const usePaintAction = () => { show: boolean; }>(INITIAL_SHOW_MORE_MENU); + const likePaintMutate = useMutation({ + mutationKey: ['like-paint', selectedPostId], + mutationFn: ({ paintId }: { paintId: TimelineItem['id'] }) => + apis.users.likePaint({ userId, paintId }), + }); + + const disLikePaintMutate = useMutation({ + mutationKey: ['like-paint', selectedPostId], + mutationFn: ({ paintId }: { paintId: TimelineItem['id'] }) => + apis.users.likePaint({ userId, paintId }), + }); + const handleClickTimelineActionIcon = ( id: string, type: keyof BottomSheetState, @@ -65,9 +81,13 @@ export const usePaintAction = () => { }); }); - const handleClickHeart = usePreservedCallback((id: TimelineItem['id']) => { - toast(`${id} 아직 지원되지 않는 기능입니다.`); - }); + const handleClickHeart = (id: TimelineItem['id'], isAlreadyLike: boolean) => { + if (isAlreadyLike) { + disLikePaintMutate.mutate({ paintId: id }); + } else { + likePaintMutate.mutate({ paintId: id }); + } + }; const handleClickMore = useCallback((id: TimelineItem['id']) => { setIsShowMoreMenu((prev) => ({ diff --git a/src/web/src/hooks/useProfileId.ts b/src/web/src/hooks/useProfileId.ts new file mode 100644 index 00000000..e0871bb5 --- /dev/null +++ b/src/web/src/hooks/useProfileId.ts @@ -0,0 +1,33 @@ +import { useEffect } from 'react'; +import { useQuery } from '@tanstack/react-query'; + +import { apis } from '@/api'; +import { userIdStorage } from '@/api/AuthTokenStorage'; + +export const useProfileId = (): string => { + const userIdFromStorage = userIdStorage.get(); + + const { data: me } = useQuery({ + queryKey: ['user-profile', 'me'], + queryFn: () => apis.users.getMyProfile(), + enabled: + typeof userIdFromStorage !== 'undefined' && + userIdFromStorage != null && + userIdFromStorage !== '', + }); + + useEffect(() => { + if (!me) { + return; + } + + if (me.id) { + userIdStorage.set(me.id); + } + }, [me?.id]); + + if (!me?.id || userIdFromStorage) { + throw new Error('유효하지 않은 사용자입니다.'); + } + return me.id ?? userIdFromStorage; +}; From c7591e5c92478e5b7a78007c62489beaacef6138 Mon Sep 17 00:00:00 2001 From: Lee <43488305+poiu694@users.noreply.github.com> Date: Mon, 19 Feb 2024 17:05:55 +0900 Subject: [PATCH 22/23] refactor: handling error on the top --- src/web/src/components/TimelineItemList.tsx | 12 ++++++------ src/web/src/hooks/usePaintAction.ts | 5 +---- src/web/src/hooks/useProfileId.ts | 5 +---- src/web/src/pages/PostDetailPage.tsx | 3 ++- 4 files changed, 10 insertions(+), 15 deletions(-) diff --git a/src/web/src/components/TimelineItemList.tsx b/src/web/src/components/TimelineItemList.tsx index 94874f6f..06f74b4f 100644 --- a/src/web/src/components/TimelineItemList.tsx +++ b/src/web/src/components/TimelineItemList.tsx @@ -3,7 +3,7 @@ import { useNavigate } from '@tanstack/react-router'; import { useSuspenseQuery } from '@tanstack/react-query'; import { apis } from '@/api'; -import { usePaintAction } from '@/hooks'; +import { usePaintAction, useProfileId } from '@/hooks'; import type { TimelineItem, User } from '@/@types'; import TimelineItemBox from './TimelineItemBox'; import { cn, createDummyTimelineItem } from '@/utils'; @@ -24,7 +24,6 @@ interface TimelineItemListProps { | 'search-user' | 'search-media'; className?: string; - userId?: User['id']; } function delay(ms: number): Promise { @@ -46,10 +45,10 @@ function getQueryFnByType( userId?: User['id'], ): Promise { switch (type) { - case 'follow': - return apis.auth.logout() as unknown as Promise; case 'recommend': return delay(1250); + case 'follow': + return apis.auth.logout() as unknown as Promise; case 'post': if (!userId) throw new UserNotFoundError(); return apis.users.getUserPaints(userId); @@ -75,14 +74,15 @@ function getQueryFnByType( } } -function TimelineItemList({ type, className, userId }: TimelineItemListProps) { +function TimelineItemList({ type, className }: TimelineItemListProps) { + const userId = useProfileId(); const { data: paints } = useSuspenseQuery({ queryKey: ['paint', type, userId], queryFn: () => getQueryFnByType(type, userId), }); const navigate = useNavigate(); - const paintAction = usePaintAction(); + const paintAction = usePaintAction({ userId }); return ( <> diff --git a/src/web/src/hooks/usePaintAction.ts b/src/web/src/hooks/usePaintAction.ts index 9c5ed242..f7738a47 100644 --- a/src/web/src/hooks/usePaintAction.ts +++ b/src/web/src/hooks/usePaintAction.ts @@ -5,7 +5,6 @@ import { useMutation } from '@tanstack/react-query'; import { apis } from '@/api'; import { useThrottle } from './useThrottle'; import type { TimelineItem } from '@/@types'; -import { useProfileId } from './useProfileId'; import { usePreservedCallback } from './usePreservedCallback'; interface BottomSheetState { @@ -25,9 +24,7 @@ const INITIAL_SHOW_MORE_MENU = { show: false, } as const; -export const usePaintAction = () => { - const userId = useProfileId(); - +export const usePaintAction = ({ userId }: { userId: string }) => { const navigate = useNavigate(); const [isBottomSheetOpen, setIsBottomSheetOpen] = useState( INITIAL_BOTTOM_SHEET_OPEN, diff --git a/src/web/src/hooks/useProfileId.ts b/src/web/src/hooks/useProfileId.ts index e0871bb5..80de9fd4 100644 --- a/src/web/src/hooks/useProfileId.ts +++ b/src/web/src/hooks/useProfileId.ts @@ -26,8 +26,5 @@ export const useProfileId = (): string => { } }, [me?.id]); - if (!me?.id || userIdFromStorage) { - throw new Error('유효하지 않은 사용자입니다.'); - } - return me.id ?? userIdFromStorage; + return me?.id ?? userIdFromStorage ?? ''; }; diff --git a/src/web/src/pages/PostDetailPage.tsx b/src/web/src/pages/PostDetailPage.tsx index 700a3f3e..6e7a7915 100644 --- a/src/web/src/pages/PostDetailPage.tsx +++ b/src/web/src/pages/PostDetailPage.tsx @@ -30,12 +30,13 @@ function PostDetailPage() { }); const router = useRouter(); const navigate = useNavigate(); - const paintAction = usePaintAction(); const params = postDetailRoute.useParams(); const parentRef = useRef(null); const mainPostRef = useRef(null); + const paintAction = usePaintAction({ userId: me?.id ?? '' }); + return ( <> From de202487537faddf4df3bb76d9a563b47c8201c9 Mon Sep 17 00:00:00 2001 From: Lee <43488305+poiu694@users.noreply.github.com> Date: Mon, 19 Feb 2024 17:08:54 +0900 Subject: [PATCH 23/23] fix: handling lint --- src/web/src/components/ProfileInformationBox.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/web/src/components/ProfileInformationBox.tsx b/src/web/src/components/ProfileInformationBox.tsx index 79422546..b88d8fc2 100644 --- a/src/web/src/components/ProfileInformationBox.tsx +++ b/src/web/src/components/ProfileInformationBox.tsx @@ -1,7 +1,8 @@ +import { memo } from 'react'; + +import { cn } from '@/utils'; import type { UserProfile } from '@/@types'; import { Icon, Typography } from './common'; -import { cn } from '@/utils'; -import { memo } from 'react'; interface ProfileInformationBoxProps { className?: string;