-
- onChangeInput(e, 'email')}
- readOnly
- aria-readonly
- />
- onChangeInput(e, 'password')}
- icon={{
- type: isHidden ? 'eyeOff' : 'eyeOn',
- stroke: 'blueGrey-400',
- onClick: () => setIsHidden((prev) => !prev),
- }}
- />
-
+
+
+
+ {loginMutate.isPending && (
+
+ )}
>
);
}
-export default LoginPasswordBox;
+const MemoizedLoginPasswordBox = memo(LoginPasswordBox);
+
+export default MemoizedLoginPasswordBox;
diff --git a/src/web/src/components/login/loginReducer.ts b/src/web/src/components/login/loginReducer.ts
index 1b874307..23635c0b 100644
--- a/src/web/src/components/login/loginReducer.ts
+++ b/src/web/src/components/login/loginReducer.ts
@@ -26,8 +26,3 @@ export type LoginStepDispatcher = Dispatch<{
export interface NavigationEvent {
onNextStep: VoidFunction;
}
-
-export interface LoginInfo {
- email: string;
- password: string;
-}
diff --git a/src/web/src/components/skeleton/FullScreenSpinner.tsx b/src/web/src/components/skeleton/FullScreenSpinner.tsx
new file mode 100644
index 00000000..3e6f9b24
--- /dev/null
+++ b/src/web/src/components/skeleton/FullScreenSpinner.tsx
@@ -0,0 +1,21 @@
+ import { cn } from '@/utils';
+import Spinner from './Spinner';
+
+interface FullScreenSpinnerProps {
+ className?: string;
+}
+
+function FullScreenSpinner({ className }: FullScreenSpinnerProps) {
+ return (
+
+
+
+ );
+}
+
+export default FullScreenSpinner;
diff --git a/src/web/src/components/skeleton/Spinner.tsx b/src/web/src/components/skeleton/Spinner.tsx
new file mode 100644
index 00000000..96a72b8b
--- /dev/null
+++ b/src/web/src/components/skeleton/Spinner.tsx
@@ -0,0 +1,42 @@
+import { cn } from '@/utils';
+import VisuallyHidden from '../common/VisuallyHidden';
+
+interface SpinnerProps {
+ className?: string;
+ spinnerClassName?: string;
+}
+
+function Spinner({ className, spinnerClassName }: SpinnerProps) {
+ return (
+
+ );
+}
+
+export default Spinner;
diff --git a/src/web/src/components/skeleton/TimelineItemBoxSkeleton.tsx b/src/web/src/components/skeleton/TimelineItemBoxSkeleton.tsx
new file mode 100644
index 00000000..259faf03
--- /dev/null
+++ b/src/web/src/components/skeleton/TimelineItemBoxSkeleton.tsx
@@ -0,0 +1,25 @@
+import { cn } from '@/utils';
+
+interface TimelineItemBoxSkeletonProps {
+ className?: string;
+}
+
+function TimelineItemBoxSkeleton({ className }: TimelineItemBoxSkeletonProps) {
+ return (
+
+ );
+}
+
+export default TimelineItemBoxSkeleton;
diff --git a/src/web/src/components/skeleton/TimelineItemListSkeleton.tsx b/src/web/src/components/skeleton/TimelineItemListSkeleton.tsx
new file mode 100644
index 00000000..d557bda6
--- /dev/null
+++ b/src/web/src/components/skeleton/TimelineItemListSkeleton.tsx
@@ -0,0 +1,54 @@
+import { cn } from '@/utils';
+
+interface TimelineItemListSkeletonProps {
+ className?: string;
+}
+
+function TimelineItemListSkeleton({
+ className,
+}: TimelineItemListSkeletonProps) {
+ return (
+
+ );
+}
+
+export default TimelineItemListSkeleton;
diff --git a/src/web/src/components/skeleton/UserListSkeleton.tsx b/src/web/src/components/skeleton/UserListSkeleton.tsx
new file mode 100644
index 00000000..fe7d71f3
--- /dev/null
+++ b/src/web/src/components/skeleton/UserListSkeleton.tsx
@@ -0,0 +1,75 @@
+import { cn } from '@/utils';
+
+interface UserListSkeletonProps {
+ className?: string;
+}
+
+function UserListSkeleton({ className }: UserListSkeletonProps) {
+ return (
+
+ );
+}
+
+export default UserListSkeleton;
diff --git a/src/web/src/components/skeleton/index.ts b/src/web/src/components/skeleton/index.ts
new file mode 100644
index 00000000..3fad9c40
--- /dev/null
+++ b/src/web/src/components/skeleton/index.ts
@@ -0,0 +1,5 @@
+export { default as FullScreenSpinner } from './FullScreenSpinner';
+export { default as Spinner } from './Spinner';
+export { default as TimelineItemBoxSkeleton } from './TimelineItemBoxSkeleton';
+export { default as TimelineItemListSkeleton } from './TimelineItemListSkeleton';
+export { default as UserListSkeleton } from './UserListSkeleton';
diff --git a/src/web/src/constants/defaultPagination.ts b/src/web/src/constants/defaultPagination.ts
new file mode 100644
index 00000000..a8c49ecf
--- /dev/null
+++ b/src/web/src/constants/defaultPagination.ts
@@ -0,0 +1,2 @@
+export const DEFAULT_PAGE = 0;
+export const DEFAULT_PAGE_SIZE = 20;
diff --git a/src/web/src/constants/env.ts b/src/web/src/constants/env.ts
new file mode 100644
index 00000000..3e54875c
--- /dev/null
+++ b/src/web/src/constants/env.ts
@@ -0,0 +1,11 @@
+export const env = {
+ VITE_BASE_SERVER_URL: String(process.env.VITE_BASE_SERVER_URL),
+ VITE_CDN_BASE_URL: String(process.env.VITE_CDN_BASE_URL),
+ VITE_CLOUD_NAME: String(process.env.VITE_CLOUD_NAME),
+ VITE_CLD_API_KEY: String(process.env.VITE_CLD_API_KEY),
+ VITE_CLD_PRESET_NAME: String(process.env.VITE_CLD_PRESET_NAME),
+ VITE_CLD_SECRET: String(process.env.VITE_CLD_SECRET),
+ VITE_CLD_ENVIRONMENT_VARIABLE: String(
+ process.env.VITE_CLD_ENVIRONMENT_VARIABLE,
+ ),
+} as const;
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
new file mode 100644
index 00000000..77ea101b
--- /dev/null
+++ b/src/web/src/constants/index.ts
@@ -0,0 +1,3 @@
+export * from './env';
+export * from './image';
+export * from './defaultPagination';
diff --git a/src/web/src/hooks/index.ts b/src/web/src/hooks/index.ts
index 37e633b6..9d93aced 100644
--- a/src/web/src/hooks/index.ts
+++ b/src/web/src/hooks/index.ts
@@ -1,4 +1,8 @@
-export * from './useThrottle';
+export * from './useAutoHeightTextArea';
+export * from './useLazyImage';
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/useAutoHeightTextArea.ts b/src/web/src/hooks/useAutoHeightTextArea.ts
new file mode 100644
index 00000000..1c0ce287
--- /dev/null
+++ b/src/web/src/hooks/useAutoHeightTextArea.ts
@@ -0,0 +1,23 @@
+import { useEffect, useRef } from 'react';
+import type { MutableRefObject } from 'react';
+
+const DEFAULT_MAX_HEIGHT = 264;
+
+export const useAutoHeightTextArea = (
+ text: string,
+ options: {
+ maxHeight: number;
+ } = { maxHeight: DEFAULT_MAX_HEIGHT },
+): MutableRefObject
=> {
+ const ref = useRef(null);
+
+ useEffect(() => {
+ if (!ref.current) return;
+
+ ref.current.style.height = '0px';
+ const { scrollHeight } = ref.current;
+ ref.current.style.height = `${Math.min(scrollHeight, options.maxHeight)}px`;
+ }, [text]);
+
+ return ref as MutableRefObject;
+};
diff --git a/src/web/src/hooks/useLazyImage.ts b/src/web/src/hooks/useLazyImage.ts
new file mode 100644
index 00000000..7268531b
--- /dev/null
+++ b/src/web/src/hooks/useLazyImage.ts
@@ -0,0 +1,119 @@
+import type { RefObject } from 'react';
+import { useCallback, useEffect, useRef, useState } from 'react';
+
+import { usePreservedCallback } from './usePreservedCallback';
+
+const isIntersectionObserverSupported = () =>
+ 'IntersectionObserver' in window &&
+ 'IntersectionObserverEntry' in window &&
+ 'intersectionRatio' in window.IntersectionObserverEntry.prototype;
+
+interface UseLazyImageProps {
+ src: string;
+ options: {
+ root?: IntersectionObserver['root'];
+ rootMargin?: IntersectionObserver['rootMargin'];
+ threshold?: number | number[];
+ onLoadComplete?: VoidFunction;
+ onInView?: VoidFunction;
+ };
+}
+
+const noop = () => {};
+
+const isHTMLImageElement = ($element: Element): $element is HTMLImageElement =>
+ $element instanceof HTMLImageElement;
+
+export const useLazyImage = ({
+ src,
+ options: {
+ rootMargin,
+ threshold,
+ root,
+ onInView = noop,
+ onLoadComplete = noop,
+ },
+}: UseLazyImageProps): {
+ ref: RefObject;
+ isLoading: boolean;
+} => {
+ const ref = useRef(null);
+ const [isLoading, setIsLoading] = useState(false);
+
+ const onInViewEvent = usePreservedCallback(onInView ?? noop);
+ const onLoadCompleteEvent = usePreservedCallback(onLoadComplete ?? noop);
+
+ const registerLoadImageEvent = useCallback(
+ ($image: HTMLImageElement) => {
+ setIsLoading(true);
+
+ $image.onload = () => {
+ onLoadCompleteEvent();
+ setIsLoading(false);
+ };
+ },
+ [onLoadCompleteEvent],
+ );
+
+ const injectSrcOnImage = useCallback(
+ ($image: HTMLImageElement) => {
+ $image.src = src;
+
+ if ($image.complete) {
+ onLoadCompleteEvent();
+ return;
+ }
+ registerLoadImageEvent($image);
+ },
+ [src, onLoadComplete, registerLoadImageEvent],
+ );
+
+ const intersectionAction = useCallback(
+ ([entry]: IntersectionObserverEntry[], observer: IntersectionObserver) => {
+ if (!entry || !entry.isIntersecting) return;
+
+ const $target = entry.target;
+
+ if (isHTMLImageElement($target)) {
+ injectSrcOnImage($target);
+ onInViewEvent();
+
+ observer.unobserve($target);
+ }
+ },
+ [injectSrcOnImage, onInViewEvent],
+ );
+
+ useEffect(() => {
+ const $image = ref.current;
+
+ if (!$image) return;
+
+ if (!isIntersectionObserverSupported()) return;
+
+ const observer = new IntersectionObserver(intersectionAction, {
+ root,
+ rootMargin,
+ threshold,
+ });
+
+ observer.observe($image);
+
+ // eslint-disable-next-line consistent-return
+ return () => {
+ observer.unobserve($image);
+ };
+ }, [
+ root,
+ threshold,
+ rootMargin,
+ registerLoadImageEvent,
+ injectSrcOnImage,
+ intersectionAction,
+ ]);
+
+ return {
+ ref,
+ isLoading,
+ };
+};
diff --git a/src/web/src/hooks/usePaintAction.ts b/src/web/src/hooks/usePaintAction.ts
new file mode 100644
index 00000000..f7738a47
--- /dev/null
+++ b/src/web/src/hooks/usePaintAction.ts
@@ -0,0 +1,118 @@
+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 { usePreservedCallback } from './usePreservedCallback';
+
+interface BottomSheetState {
+ reply: boolean;
+ views: boolean;
+ share: boolean;
+}
+
+const INITIAL_BOTTOM_SHEET_OPEN: BottomSheetState = {
+ reply: false,
+ views: false,
+ share: false,
+};
+
+const INITIAL_SHOW_MORE_MENU = {
+ id: '',
+ show: false,
+} as const;
+
+export const usePaintAction = ({ userId }: { userId: string }) => {
+ const navigate = useNavigate();
+ const [isBottomSheetOpen, setIsBottomSheetOpen] = useState(
+ INITIAL_BOTTOM_SHEET_OPEN,
+ );
+ const [selectedPostId, setSelectedPostId] = useState('');
+ const [isShowMoreMenu, setIsShowMoreMenu] = useState<{
+ id: string;
+ 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,
+ ) => {
+ setSelectedPostId(id);
+ setIsBottomSheetOpen((prev) => ({ ...prev, [type]: !prev[type] }));
+ };
+
+ const handleScrollLayout = useThrottle(() => {
+ setIsShowMoreMenu({ id: '', show: false });
+ }, 500);
+
+ const handleClickRetweet = usePreservedCallback((id: TimelineItem['id']) => {
+ handleClickTimelineActionIcon(id, 'reply');
+ });
+
+ const handleClickViews = usePreservedCallback((id: TimelineItem['id']) => {
+ handleClickTimelineActionIcon(id, 'views');
+ });
+
+ const handleClickShare = usePreservedCallback((id: TimelineItem['id']) => {
+ handleClickTimelineActionIcon(id, 'share');
+ });
+
+ const handleClickReply = usePreservedCallback((id: TimelineItem['id']) => {
+ navigate({
+ to: '/post/edit',
+ search: { postId: 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) => ({
+ id: prev.id ? '' : id,
+ show: prev.id !== id,
+ }));
+ }, []);
+
+ const handleClickCloseBottomSheet = useCallback(
+ (type: keyof BottomSheetState) => {
+ setIsBottomSheetOpen((prev) => ({ ...prev, [type]: false }));
+ },
+ [],
+ );
+
+ return {
+ selectedPostId,
+ isShowMoreMenu,
+ isBottomSheetOpen,
+ onScrollLayout: handleScrollLayout,
+ onClickRetweet: handleClickRetweet,
+ onClickReply: handleClickReply,
+ onClickHeart: handleClickHeart,
+ onClickShare: handleClickShare,
+ onClickViews: handleClickViews,
+ onClickMore: handleClickMore,
+ onCloseBottomSheet: handleClickCloseBottomSheet,
+ };
+};
+
+export type PaintAction = ReturnType;
diff --git a/src/web/src/hooks/useProfileId.ts b/src/web/src/hooks/useProfileId.ts
new file mode 100644
index 00000000..7cc469d6
--- /dev/null
+++ b/src/web/src/hooks/useProfileId.ts
@@ -0,0 +1,23 @@
+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: !userIdFromStorage,
+ });
+
+ useEffect(() => {
+ if (me?.id) {
+ userIdStorage.set(me.id);
+ }
+ }, [me?.id]);
+
+ return me?.id ?? userIdFromStorage ?? '';
+};
diff --git a/src/web/src/index.css b/src/web/src/index.css
index a222129f..6812bf71 100644
--- a/src/web/src/index.css
+++ b/src/web/src/index.css
@@ -10,8 +10,7 @@
url(https://abs.twimg.com/responsive-web/client-web/Chirp-Regular.60b215ba.woff)
format('woff');
font-weight: 400;
- font-style: 'normal';
- font-display: 'swap';
+ font-display: swap;
}
@font-face {
@@ -22,8 +21,7 @@
url(https://abs.twimg.com/responsive-web/client-web/Chirp-Medium.20fc288a.woff)
format('woff');
font-weight: 500;
- font-style: 'normal';
- font-display: 'swap';
+ font-display: swap;
}
@font-face {
@@ -34,8 +32,7 @@
url(https://abs.twimg.com/responsive-web/client-web/Chirp-Bold.a573679a.woff)
format('woff');
font-weight: 700;
- font-style: 'normal';
- font-display: 'swap';
+ font-display: swap;
}
@layer base {
diff --git a/src/web/src/pages/ChangePasswordPage.tsx b/src/web/src/pages/ChangePasswordPage.tsx
index d8adedb1..aecbceaa 100644
--- a/src/web/src/pages/ChangePasswordPage.tsx
+++ b/src/web/src/pages/ChangePasswordPage.tsx
@@ -1,6 +1,7 @@
import { useState } from 'react';
import { toast } from 'react-toastify';
import { useNavigate } from '@tanstack/react-router';
+import { Helmet, HelmetProvider } from 'react-helmet-async';
import {
Button,
@@ -22,6 +23,12 @@ function ChangePasswordPage() {
return (
<>
+
+
+ Easel | 비밀번호 변경
+
+
+
-
+
{
+ toast('아직 지원되지 않는 기능입니다.');
+ };
+
return (
<>
+
+
+ Easel | 채팅
+
+
+
-
- CHAT
+
+
>
);
diff --git a/src/web/src/pages/ErrorFallbackPage.tsx b/src/web/src/pages/ErrorFallbackPage.tsx
index 7ed2601f..751311f0 100644
--- a/src/web/src/pages/ErrorFallbackPage.tsx
+++ b/src/web/src/pages/ErrorFallbackPage.tsx
@@ -1,10 +1,17 @@
+import { Helmet, HelmetProvider } from 'react-helmet-async';
import type { FallbackProps } from 'react-error-boundary';
-import { AsyncBoundary, Button, Typography } from '@/components';
+import { Button, Typography } from '@/components';
function ErrorFallbackPage({ error }: FallbackProps) {
return (
-
+ <>
+
+
+ Easel | 오류
+
+
+
@@ -13,7 +20,7 @@ function ErrorFallbackPage({ error }: FallbackProps) {
- 잘못된 접근입니다.
+ 오류가 발생했습니다.
메시지:{' '}
@@ -31,7 +38,7 @@ function ErrorFallbackPage({ error }: FallbackProps) {
-
+ >
);
}
diff --git a/src/web/src/pages/ErrorPage.tsx b/src/web/src/pages/ErrorPage.tsx
index 0621992a..e5770572 100644
--- a/src/web/src/pages/ErrorPage.tsx
+++ b/src/web/src/pages/ErrorPage.tsx
@@ -1,5 +1,5 @@
function ErrorPage(): JSX.Element {
- throw new Error('무엇인가 잘못되었습니다.');
+ throw new Error('잘못된 페이지 접근입니다.');
}
export default ErrorPage;
diff --git a/src/web/src/pages/HomePage.tsx b/src/web/src/pages/HomePage.tsx
index 12ffc770..1135adc5 100644
--- a/src/web/src/pages/HomePage.tsx
+++ b/src/web/src/pages/HomePage.tsx
@@ -1,67 +1,29 @@
-import { useState } from 'react';
import { toast } from 'react-toastify';
-import { useNavigate } from '@tanstack/react-router';
+import { Helmet, HelmetProvider } from 'react-helmet-async';
-import type { TimelineItem } from '@/@types';
-import { createDummyTimelineItem } from '@/utils';
import {
Tabs,
Header,
ContentLayout,
- TimelineItemBox,
- FloatingButton,
+ TimelineItemList,
+ AsyncBoundary,
+ ErrorWithResetBox,
} from '@/components';
-import {
- ReplyBottomSheet,
- ShareBottomSheet,
- ViewsBottomSheet,
-} from '@/components/bottomSheet';
-import { useThrottle } from '@/hooks';
-
-interface BottomSheetState {
- reply: boolean;
- views: boolean;
- share: boolean;
-}
-
-const INITIAL_BOTTOM_SHEET_OPEN: BottomSheetState = {
- reply: false,
- views: false,
- share: false,
-};
-
-const INITIAL_SHOW_MORE_MENU = {
- id: '',
- show: false,
-} as const;
+import { TimelineItemListSkeleton } from '@/components/skeleton';
function HomePage() {
- const navigate = useNavigate();
-
- const [isBottomSheetOpen, setIsBottomSheetOpen] = useState
(
- INITIAL_BOTTOM_SHEET_OPEN,
- );
- const [paints] = useState(() => createDummyTimelineItem(10));
- const [selectedPostId, setSelectedPostId] = useState('');
- const [isShowMoreMenu, setIsShowMoreMenu] = useState<{
- id: string;
- show: boolean;
- }>(INITIAL_SHOW_MORE_MENU);
-
- const handleClickTimelineActionIcon = (
- id: string,
- type: keyof BottomSheetState,
- ) => {
- setSelectedPostId(id);
- setIsBottomSheetOpen((prev) => ({ ...prev, [type]: !prev[type] }));
+ const handleNotSupport = () => {
+ toast('아직 지원되지 않는 기능입니다.');
};
- const handleScrollLayout = useThrottle(() => {
- setIsShowMoreMenu({ id: '', show: false });
- }, 500);
-
return (
<>
+
+
+ Easel | 메인
+
+
+
- {paints.map((paint) => (
-
- navigate({
- to: '/post/edit',
- search: { postId: paint.id },
- })
- }
- onClickRetweet={() =>
- handleClickTimelineActionIcon(paint.id, 'reply')
- }
- onClickHeart={() => toast('아직 지원되지 않는 기능입니다.')}
- onClickViews={() =>
- handleClickTimelineActionIcon(paint.id, 'views')
- }
- onClickShare={() =>
- handleClickTimelineActionIcon(paint.id, 'share')
- }
- onClickMore={() =>
- setIsShowMoreMenu((prev) => ({
- id: prev.id ? '' : paint.id,
- show: prev.id !== paint.id,
- }))
- }
- />
- ))}
+
+ }
+ rejectedFallback={(props) => }
+ >
+
+
),
},
{
label: '팔로우 중',
content: (
-
- {[...paints]
- .sort((a, b) => Number(b.id) - Number(a.id))
- .map((paint) => (
-
- navigate({
- to: '/post/edit',
- search: { postId: paint.id },
- })
- }
- onClickRetweet={() =>
- handleClickTimelineActionIcon(paint.id, 'reply')
- }
- onClickHeart={() =>
- toast('아직 지원되지 않는 기능입니다.')
- }
- onClickViews={() =>
- handleClickTimelineActionIcon(paint.id, 'views')
- }
- onClickShare={() =>
- handleClickTimelineActionIcon(paint.id, 'share')
- }
- onClickMore={() =>
- setIsShowMoreMenu((prev) => ({
- id: prev.id ? '' : paint.id,
- show: prev.id !== paint.id,
- }))
- }
- />
- ))}
+
+ }
+ rejectedFallback={(props) => }
+ >
+
+
),
},
]}
className="mt-[44px]"
/>
-
- setIsBottomSheetOpen((prev) => ({ ...prev, reply: false }))
- }
- />
-
- setIsBottomSheetOpen((prev) => ({ ...prev, views: false }))
- }
- />
-
- setIsBottomSheetOpen((prev) => ({ ...prev, share: false }))
- }
- />
-
>
);
}
diff --git a/src/web/src/pages/JoinPage.tsx b/src/web/src/pages/JoinPage.tsx
index 980e3f9d..f10c7331 100644
--- a/src/web/src/pages/JoinPage.tsx
+++ b/src/web/src/pages/JoinPage.tsx
@@ -2,6 +2,8 @@ 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 { Header, ContentLayout } from '@/components';
import type { JoinInfo } from '@/components/join/joinReducer';
@@ -13,30 +15,39 @@ import {
JoinPasswordBox,
JoinProfileImageBox,
} from '@/components/join';
+import { apis } from '@/api';
-const DUMMY_VERIFIED = 'abc123';
+const MAX_PASSWORD_LENGTH = 8;
function JoinPage() {
const [state, dispatch] = useReducer(joinStepReducer, JoinStep.INFORMATION);
- const [JoinInfo, setJoinInfo] = useState({
+ const [joinInfo, setJoinInfo] = useState({
nickname: '',
username: '',
email: '',
emailVerifyCode: '',
password: '',
- profilePath: '',
+ profileImagePath: '',
});
const navigate = useNavigate();
- const handleJoin = async () => {
- try {
- // TODO: api 연동
- toast(`${JoinInfo.username}님 회원가입이 완료되었습니다.`);
+ const registerMutate = useMutation({
+ mutationKey: ['register', joinInfo.username],
+ mutationFn: () =>
+ apis.users.join({
+ email: joinInfo.email,
+ password: joinInfo.password,
+ username: joinInfo.username,
+ profileImagePath: joinInfo.profileImagePath,
+ }),
+ onSuccess: () => {
+ toast(`${joinInfo.username}님 회원가입이 완료되었습니다.`);
navigate({ to: '/' });
- } catch (err) {
- toast.error('서버에 잠시 문제가 생겼습니다.');
- }
- };
+ },
+ onError: () => {
+ toast('회원가입에 문제가 생겼습니다.');
+ },
+ });
const onNextPage = () => dispatch({ direction: 'next' });
const onPrevPage = () => {
@@ -59,9 +70,8 @@ function JoinPage() {
case JoinStep.INFORMATION:
return (
@@ -69,9 +79,9 @@ function JoinPage() {
case JoinStep.EMAIL_VERIFY:
return (
@@ -79,8 +89,8 @@ function JoinPage() {
case JoinStep.PASSWORD:
return (
@@ -88,28 +98,29 @@ function JoinPage() {
case JoinStep.PROFILE_IMAGE:
return (
- setJoinInfo((prev) => ({ ...prev, profilePath: path }))
+ setJoinInfo((prev) => ({ ...prev, profileImagePath: path }))
}
- onJoin={handleJoin}
+ onJoin={() => registerMutate.mutate()}
/>
);
case JoinStep.NAME:
return (
registerMutate.mutate()}
onChangeInput={handleChangeInput}
/>
);
default:
return (
@@ -121,6 +132,12 @@ function JoinPage() {
return (
<>
+
+
+ Easel | 회원가입
+
+
+
-
+
{children}
diff --git a/src/web/src/pages/LoginPage.tsx b/src/web/src/pages/LoginPage.tsx
index f8960558..b303f10d 100644
--- a/src/web/src/pages/LoginPage.tsx
+++ b/src/web/src/pages/LoginPage.tsx
@@ -1,10 +1,10 @@
-import { toast } from 'react-toastify';
import type { ChangeEvent } from 'react';
import { useReducer, useState } from 'react';
import { useNavigate } from '@tanstack/react-router';
+import { Helmet, HelmetProvider } from 'react-helmet-async';
+import type { LoginInfo } from '@/@types';
import { ContentLayout, Header } from '@/components';
-import type { LoginInfo } from '@/components/login/loginReducer';
import { LoginStep, LoginStepReducer } from '@/components/login/loginReducer';
import { LoginEmailBox, LoginPasswordBox } from '@/components/login';
@@ -16,15 +16,6 @@ function LoginPage() {
});
const navigate = useNavigate();
- const handleLogin = async () => {
- try {
- // TODO: api 연동
- toast(`${loginInfo.email}님 로그인이 완료되었습니다.`);
- navigate({ to: '/home' });
- } catch (err) {
- toast.error('서버에 잠시 문제가 생겼습니다.');
- }
- };
const handleChangeInput = (
e: ChangeEvent,
@@ -60,7 +51,6 @@ function LoginPage() {
disabled={loginInfo.password === ''}
email={loginInfo.email}
password={loginInfo.password}
- onLogin={handleLogin}
onChangeInput={handleChangeInput}
onClickForgetPassword={() => navigate({ to: '/change-password' })}
/>
@@ -82,6 +72,12 @@ function LoginPage() {
return (
<>
+
+
+ Easel | 로그인
+
+
+
-
+
{children}
diff --git a/src/web/src/pages/MembershipEntryPage.tsx b/src/web/src/pages/MembershipEntryPage.tsx
index 347b2570..d5547766 100644
--- a/src/web/src/pages/MembershipEntryPage.tsx
+++ b/src/web/src/pages/MembershipEntryPage.tsx
@@ -1,79 +1,93 @@
import Lottie from 'react-lottie';
import { toast } from 'react-toastify';
import { useNavigate } from '@tanstack/react-router';
+import { Helmet, HelmetProvider } from 'react-helmet-async';
-import earthLottie from '../../public/earth.json';
+import earthLottie from '@/components/common/lottie/earth.json';
import { Button, Typography, ContentLayout } from '@/components';
function MembershipEntryPage() {
const navigate = useNavigate();
return (
-
-
-
- 지금 세계에서 무슨 일이 일어나고 있는지 알아보세요.
-
-
-
-
-
-
+
+ >
);
}
diff --git a/src/web/src/pages/MyProfilePage.tsx b/src/web/src/pages/MyProfilePage.tsx
new file mode 100644
index 00000000..016b4fea
--- /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 | 프로필
+
+
+
+
+
+
+ {isExpandImage ? (
+
+
+
+
+
+ 프로필 수정
+
+
+
+ ) : (
+
+
+ {user?.nickname}
+
+
+ 게시물 {paints.length}개
+
+
+ )}
+
+
+ >
+ );
+}
+
+export default MyProfilePage;
diff --git a/src/web/src/pages/NotificationPage.tsx b/src/web/src/pages/NotificationPage.tsx
index 4e131b95..0aa93bc1 100644
--- a/src/web/src/pages/NotificationPage.tsx
+++ b/src/web/src/pages/NotificationPage.tsx
@@ -1,8 +1,21 @@
-import { ContentLayout, Header } from '@/components';
+import { toast } from 'react-toastify';
+import { Helmet, HelmetProvider } from 'react-helmet-async';
+
+import { ContentLayout, Header, NotSupportBox } from '@/components';
function NotificationPage() {
+ const handleNotSupport = () => {
+ toast('아직 지원되지 않는 기능입니다.');
+ };
+
return (
<>
+
+
+ Easel | 알람
+
+
+
-
- NOTIFICATION
+
+
>
);
diff --git a/src/web/src/pages/PostDetailPage.tsx b/src/web/src/pages/PostDetailPage.tsx
new file mode 100644
index 00000000..6e7a7915
--- /dev/null
+++ b/src/web/src/pages/PostDetailPage.tsx
@@ -0,0 +1,132 @@
+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,
+ BeforeTimelineList,
+ ContentLayout,
+ Header,
+ MainPostBox,
+ Typography,
+} from '@/components';
+import { forCloudinaryImage } from '@/utils';
+import {
+ ReplyBottomSheet,
+ ShareBottomSheet,
+ ViewsBottomSheet,
+} from '@/components/bottomSheet';
+import { Spinner, TimelineItemBoxSkeleton } from '@/components/skeleton';
+
+function PostDetailPage() {
+ const { data: me } = useQuery({
+ queryKey: ['user-profile', 'me'],
+ queryFn: () => apis.users.getMyProfile(),
+ });
+ const router = useRouter();
+ const navigate = useNavigate();
+ const params = postDetailRoute.useParams();
+
+ const parentRef = useRef(null);
+ const mainPostRef = useRef(null);
+
+ const paintAction = usePaintAction({ userId: me?.id ?? '' });
+
+ return (
+ <>
+
+
+ Easel | 게시물
+
+
+
+ router.history.back(),
+ }}
+ center={{
+ type: 'text',
+ label: '게시',
+ }}
+ />
+
+
+
}
+ rejectedFallback={() =>
}
+ >
+
+
+
+
}>
+
+
+
+
}
+ rejectedFallback={() =>
}
+ >
+
+
+
+
+
+ {/* 게시글 작성 */}
+
+ navigate({ to: '/post/edit', search: { postId: params.postId } })
+ }
+ >
+
+
+
+ 다른 게시물 추가
+
+
+
+ paintAction.onCloseBottomSheet('reply')}
+ />
+ paintAction.onCloseBottomSheet('views')}
+ />
+ paintAction.onCloseBottomSheet('share')}
+ />
+ >
+ );
+}
+
+export default PostDetailPage;
diff --git a/src/web/src/pages/PostEditPage.tsx b/src/web/src/pages/PostEditPage.tsx
index 1582f2c5..070e2f6a 100644
--- a/src/web/src/pages/PostEditPage.tsx
+++ b/src/web/src/pages/PostEditPage.tsx
@@ -1,9 +1,396 @@
+import { useState } from 'react';
+import { toast } from 'react-toastify';
+import type { ChangeEvent } from 'react';
+import { useNavigate, useRouter } from '@tanstack/react-router';
+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 {
+ convertToMedia,
+ countByte,
+ forCloudinaryImage,
+ forEditPaint,
+ generateLocalStorage,
+} from '@/utils';
+import {
+ AccessibleIconButton,
+ Button,
+ CircularProgress,
+ Header,
+ Icon,
+ TagSearchUserModal,
+ TempSavedPostModal,
+ Typography,
+} from '@/components';
import { editPostRoute } from '@/routes';
+import { apis } from '@/api';
+import { FullScreenSpinner } from '@/components/skeleton';
+
+const EMPTY_LENGTH = 0;
+const MAX_BYTE = 280;
+
+const showOnlyOneToast = () => {
+ if (typeof window === 'undefined') return;
+
+ const $toast = window.document.querySelector('.Toastify__toast-container');
+ if (Number($toast?.children.length ?? 0) === 1) return;
+
+ toast(`최대 ${MAX_BYTE}만큼 적을 수 있습니다.`);
+};
+
+const tempSavedStorage =
+ generateLocalStorage('temp-saved-storage');
function PostEditPage() {
+ const router = useRouter();
+ const navigate = useNavigate();
const search = editPostRoute.useSearch();
+ const [tags, setTags] = useState[]>([]);
+ const [editPostInfo, setEditPostInfo] = useState(forEditPaint({}));
+ const textAreaRef = useAutoHeightTextArea(editPostInfo.text);
+ const [image, setImage] = useState('');
+ 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;
+ tag: boolean;
+ }>({ cancel: false, tempSaved: false, tag: false });
+ const hasTempSavedPost = !!tempSavedStorage.get()?.length;
+ const [tempSavedPost] = useState(
+ () => 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),
+ }),
+ ),
+ onSuccess: () => {
+ navigate({ to: '/home' });
+ },
+ });
+
+ const uploadMutation = useMutation({
+ mutationKey: ['image-upload'],
+ mutationFn: (imageFile: File) =>
+ apis.images.uploadImage(imageFile, Math.round(Date.now() / 1000), {
+ folder: 'posts',
+ }),
+ onSuccess: (res) => setImage(res.public_id),
+ onError: () => toast.error('업로드에 실패했습니다.'),
+ });
+
+ const handleUploadImage = async (e: ChangeEvent) => {
+ const file = e.target.files?.[0];
+
+ if (file) {
+ uploadMutation.mutate(file);
+ }
+ };
+
+ const handleClickBackButton = () => {
+ if (isNotDirty) {
+ router.history.back();
+ return;
+ }
+ setIsModalOpen((prev) => ({ ...prev, cancel: true }));
+ };
+
+ const handleChangeTextInput = (e: ChangeEvent) => {
+ // limit maxByte
+ if (countByte(e.target.value) > MAX_BYTE) {
+ showOnlyOneToast();
+ return;
+ }
+ setEditPostInfo((prev) => ({ ...prev, text: e.target.value }));
+ };
+
+ const handleClickNotSupport = () => toast('아직 지원되지 않는 기능입니다.');
+
+ const handleSubmitPost = () => {
+ createPaintMutation.mutate();
+ };
+
+ return (
+ <>
+
+
+ Easel | 게시글 작성
+
+
+
+
+
+
+ 게시하기
+
+
+ {hasTempSavedPost && (
+
+ setIsModalOpen((prev) => ({
+ ...prev,
+ tempSaved: true,
+ }))
+ }
+ >
+ 임시 보관함
+
+ )}
+
+ ),
+ }}
+ />
+
+
+
+
+
+ {/* 이미지 */}
+ {image && (
+
+
+
setImage('')}
+ />
+
+ setIsModalOpen((prev) => ({ ...prev, tag: true }))
+ }
+ >
+ {tags.length === 0 ? (
+ <>
+
+
+ 사람 태그하기
+
+ >
+ ) : (
+ tags.map((tag) => (
+
+ {tag.nickname}
+
+ ))
+ )}
+
+
+ )}
+
+
+
+ {/* 메뉴 */}
+
+
+
+
+ 모든 사람이 답글을 달 수 있습니다.
+
+
+
+
+
+
+ {/* 모달 */}
+ {isModalOpen.cancel && (
+ setIsModalOpen((prev) => ({ ...prev, cancel: false }))}
+ onClickDelete={() => {
+ router.history.back();
+ setIsModalOpen((prev) => ({ ...prev, cancel: false }));
+ }}
+ onClickSave={() => {
+ router.history.back();
+ tempSavedStorage.set([
+ ...tempSavedPost,
+ {
+ ...forEditPaint({ text: editPostInfo.text }),
+ medias: image ? [convertToMedia(image, 'image')] : [],
+ },
+ ]);
+ setIsModalOpen((prev) => ({ ...prev, cancel: false }));
+ }}
+ />
+ )}
+
+ {isModalOpen.tempSaved && (
+
+ setIsModalOpen((prev) => ({ ...prev, tempSaved: false }))
+ }
+ setImage={setImage}
+ setEditPostInfo={setEditPostInfo}
+ />
+ )}
+
+ {isModalOpen.tag && (
+ setIsModalOpen((prev) => ({ ...prev, tag: false }))}
+ />
+ )}
- return POST EDIT{search?.postId}
;
+ {(uploadMutation.isPending || createPaintMutation.isPending) && (
+
+ )}
+ >
+ );
}
export default PostEditPage;
diff --git a/src/web/src/pages/ProfilePage.tsx b/src/web/src/pages/ProfilePage.tsx
index 7715190d..d04a4607 100644
--- a/src/web/src/pages/ProfilePage.tsx
+++ b/src/web/src/pages/ProfilePage.tsx
@@ -1,27 +1,114 @@
-import { useParams } from '@tanstack/react-router';
+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 { ContentLayout, Header, Typography } from '@/components';
+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 { profileRoute } from '@/routes';
+import { DEFAULT_BACKGROUND_IMAGE } from '@/constants';
+const MIN_IMAGE_HEIGHT = 50;
+const DEFAULT_IMAGE_HEIGHT = 124;
function ProfilePage() {
- const params = useParams({ from: profileRoute.fullPath });
+ 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);
+ 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 (
<>
-
-
- PROFILE PAGE
- {params.userId}
-
+
+
+ Easel | 프로필
+
+
+
+
+
+
+ {isExpandImage ? (
+
+
+
+
+
+ 프로필 수정
+
+
+
+ ) : (
+
+
+ {user?.nickname}
+
+
+ 게시물 {paints.length}개
+
+
+ )}
+
+
>
);
}
diff --git a/src/web/src/pages/SearchPage.tsx b/src/web/src/pages/SearchPage.tsx
index ccb926b3..853541e7 100644
--- a/src/web/src/pages/SearchPage.tsx
+++ b/src/web/src/pages/SearchPage.tsx
@@ -1,26 +1,83 @@
-import { ContentLayout, Header } from '@/components';
+import { useState } from 'react';
+import { useNavigate } from '@tanstack/react-router';
+import { Helmet, HelmetProvider } from 'react-helmet-async';
+
+import { Button, ContentLayout, Header, Typography } from '@/components';
function SearchPage() {
+ const navigate = useNavigate();
+ const [keyword, setKeyword] = useState('');
+
return (
<>
+
+
+ Easel | 검색
+
+
+
setKeyword(e.target.value)}
+ onKeyUp={(e) => {
+ if (e.key === 'Enter') {
+ navigate({
+ to: '/search/result',
+ search: { keyword },
+ });
+ }
+ }}
+ />
+ ),
}}
right={{
- type: 'setting',
- label: '로고',
+ type: 'blingStar',
+ label: '아이콘',
}}
/>
-
- SEARCH
+
+
+ 사용자를 위한 추천
+
+
+
+ {`새로운 트렌드가\n준비되지 않았습니다!`}
+
+
+ 트렌드를 계속 만들고 있어요. 추후 버전에서의 업데이트될 예정입니다.
+
+
+
+ Premium + | 결제하러 가기
+
+
>
);
diff --git a/src/web/src/pages/SearchResultPage.tsx b/src/web/src/pages/SearchResultPage.tsx
new file mode 100644
index 00000000..f991cafd
--- /dev/null
+++ b/src/web/src/pages/SearchResultPage.tsx
@@ -0,0 +1,135 @@
+import { useState } from 'react';
+import { useNavigate } from '@tanstack/react-router';
+import { Helmet, HelmetProvider } from 'react-helmet-async';
+
+import { searchResultRoute } from '@/routes';
+import {
+ AsyncBoundary,
+ ContentLayout,
+ ErrorWithResetBox,
+ Header,
+ Tabs,
+ TimelineItemList,
+ UserList,
+} from '@/components';
+import {
+ TimelineItemListSkeleton,
+ UserListSkeleton,
+} from '@/components/skeleton';
+
+function SearchResultPage() {
+ const navigate = useNavigate();
+ const search = searchResultRoute.useSearch();
+ const [keyword, setKeyword] = useState(() => search.keyword ?? '');
+
+ return (
+ <>
+
+
+ Easel | 검색 결과
+
+
+
+ setKeyword(e.target.value)}
+ onKeyUp={(e) => {
+ if (e.key === 'Enter') {
+ navigate({ to: '/search/result', search: { keyword } });
+ }
+ }}
+ />
+ ),
+ }}
+ right={{
+ type: 'blingStar',
+ label: '아이콘',
+ }}
+ />
+
+
+ }
+ rejectedFallback={(props) => }
+ >
+
+
+ ),
+ },
+ {
+ label: '최근',
+ content: (
+
+ }
+ rejectedFallback={(props) => }
+ >
+
+
+ ),
+ },
+ {
+ label: '사용자',
+ content: (
+
+ }
+ rejectedFallback={(props) => }
+ >
+
+
+ ),
+ },
+ {
+ label: '미디어',
+ content: (
+
+ }
+ rejectedFallback={(props) => }
+ >
+
+
+ ),
+ },
+ ]}
+ />
+
+ >
+ );
+}
+
+export default SearchResultPage;
diff --git a/src/web/src/pages/index.ts b/src/web/src/pages/index.ts
index ad54f4cb..83563781 100644
--- a/src/web/src/pages/index.ts
+++ b/src/web/src/pages/index.ts
@@ -5,8 +5,11 @@ 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';
+export { default as PostDetailPage } from './PostDetailPage';
export { default as PostEditPage } from './PostEditPage';
export { default as ProfilePage } from './ProfilePage';
export { default as JoinPage } from './JoinPage';
diff --git a/src/web/src/routes/index.tsx b/src/web/src/routes/index.tsx
index ccba64b2..50992c7f 100644
--- a/src/web/src/routes/index.tsx
+++ b/src/web/src/routes/index.tsx
@@ -18,8 +18,12 @@ import {
JoinPage,
PostEditPage,
ProfilePage,
+ PostDetailPage,
+ SearchResultPage,
+ MyProfilePage,
} from '@/pages';
import { AsyncBoundary } from '@/components';
+import { DEFAULT_PAGE, DEFAULT_PAGE_SIZE } from '@/constants';
export const rootRoute = new RootRoute({
component: () => (
@@ -50,11 +54,35 @@ const chatRoute = new Route({
path: '/chat',
component: () => ,
});
+
const searchRoute = new Route({
getParentRoute: () => rootRoute,
path: '/search',
component: () => ,
});
+export const searchResultRoute = new Route({
+ getParentRoute: () => rootRoute,
+ path: '/search/result',
+ component: () => ,
+ validateSearch: (
+ search: Record,
+ ): {
+ keyword: string;
+ page?: number;
+ size?: number;
+ category?: 'all' | 'recent' | 'user' | 'media';
+ } => ({
+ keyword: search.keyword,
+ page: Number.isNaN(Number(search.page))
+ ? DEFAULT_PAGE
+ : Number(search.page),
+ size: Number.isNaN(Number(search.size))
+ ? DEFAULT_PAGE_SIZE
+ : Number(search.size),
+ category: (search.category as 'all' | 'recent' | 'user' | 'media') ?? 'all',
+ }),
+});
+
const membershipEntryRoute = new Route({
getParentRoute: () => rootRoute,
path: '/',
@@ -76,10 +104,28 @@ const postRoute = new Route({
path: '/post',
});
+export const postDetailRoute = new Route({
+ getParentRoute: () => postRoute,
+ path: '/$postId',
+ component: () => ,
+ parseParams: (parse): { postId: string } => ({
+ postId: parse.postId || '1',
+ }),
+});
+
export const profileRoute = new Route({
getParentRoute: () => rootRoute,
path: '/profile/$userId',
component: () => ,
+ parseParams: (parse): { userId: string } => ({
+ userId: parse.userId || '1',
+ }),
+});
+
+const myProfileRoute = new Route({
+ getParentRoute: () => rootRoute,
+ path: '/profile/me',
+ component: () => ,
});
export const editPostRoute = new Route({
@@ -87,7 +133,7 @@ export const editPostRoute = new Route({
path: '/edit',
component: () => ,
validateSearch: (search: Record): { postId?: string } => ({
- postId: search.postId || '0',
+ postId: search.postId,
}),
});
@@ -105,12 +151,13 @@ const routeTree = rootRoute.addChildren([
loginRoute,
notificationRoute,
chatRoute,
- searchRoute,
+ searchRoute.addChildren([searchResultRoute]),
membershipEntryRoute,
joinRoute,
changePasswordRoute,
profileRoute,
- postRoute.addChildren([editPostRoute]),
+ myProfileRoute,
+ postRoute.addChildren([editPostRoute, postDetailRoute]),
]);
export const router = new Router({ routeTree, notFoundRoute });
diff --git a/src/web/src/utils/__tests__/countByte.spec.ts b/src/web/src/utils/__tests__/countByte.spec.ts
new file mode 100644
index 00000000..bcd40458
--- /dev/null
+++ b/src/web/src/utils/__tests__/countByte.spec.ts
@@ -0,0 +1,33 @@
+import { countByte } from '..';
+
+describe('countByte', () => {
+ it('should 3byte when given korea', () => {
+ expect(countByte('가')).toBe(3);
+ expect(countByte('가나다라마바사아자차')).toBe(30);
+ expect(countByte('각난닫랄맘밥삿앙잦찿')).toBe(30);
+ });
+
+ it('should 1byte when given english', () => {
+ expect(countByte('a')).toBe(1);
+ expect(countByte('abcdefghij')).toBe(10);
+ });
+
+ it('should 4byte when given emoji in UTF-8', () => {
+ expect(countByte('😎')).toBe(4);
+ expect(countByte('🔥')).toBe(4);
+ expect(countByte('😭')).toBe(4);
+ expect(countByte('😱')).toBe(4);
+ expect(countByte('🥳')).toBe(4);
+ expect(countByte('😢')).toBe(4);
+ expect(countByte('🤗')).toBe(4);
+ expect(countByte('😍')).toBe(4);
+ });
+
+ it('should specific byte when given other emoji', () => {
+ expect(countByte('👨👨👧👦')).toBe(25);
+ expect(countByte('👯♂️')).toBe(13);
+ expect(countByte('☀️')).toBe(6);
+ expect(countByte('‼️')).toBe(6);
+ expect(countByte('#️⃣')).toBe(7);
+ });
+});
diff --git a/src/web/src/utils/__tests__/helperIcon.spec.ts b/src/web/src/utils/__tests__/helperIcon.spec.ts
new file mode 100644
index 00000000..56c3495a
--- /dev/null
+++ b/src/web/src/utils/__tests__/helperIcon.spec.ts
@@ -0,0 +1,15 @@
+import { iconOpacity } from '../helperIcon';
+
+describe('iconOpacity', () => {
+ it('should return opacity-95 if direction is up', () => {
+ expect(iconOpacity('up')).toBe('opacity-95');
+ });
+
+ it('should return opacity-80 if direction is down', () => {
+ expect(iconOpacity('down')).toBe('opacity-80');
+ });
+
+ it('should return empty string if direction is stop', () => {
+ expect(iconOpacity('stop')).toBe('');
+ });
+});
diff --git a/src/web/src/utils/__tests__/helperPost.spec.ts b/src/web/src/utils/__tests__/helperPost.spec.ts
new file mode 100644
index 00000000..07838327
--- /dev/null
+++ b/src/web/src/utils/__tests__/helperPost.spec.ts
@@ -0,0 +1,155 @@
+import { convertToMedia, forEditPaint } from '..';
+
+describe('forEditPaint', () => {
+ it('should default value if given null', () => {
+ expect(forEditPaint({})).toStrictEqual({
+ text: '',
+ taggedUserIds: [],
+ quotePaintId: '',
+ inReplyToPaintId: '',
+ hashtags: [],
+ mentions: [],
+ links: [],
+ medias: [],
+ });
+ });
+
+ it('should media image value if given image', () => {
+ expect(
+ forEditPaint({
+ medias: [convertToMedia('https://www.naver.com', 'image')],
+ }),
+ ).toStrictEqual({
+ text: '',
+ taggedUserIds: [],
+ quotePaintId: '',
+ inReplyToPaintId: '',
+ hashtags: [],
+ mentions: [],
+ links: [],
+ medias: [{ type: 'image', path: 'https://www.naver.com' }],
+ });
+ });
+
+ it('should extract hashtag value in text', () => {
+ expect(
+ forEditPaint({
+ text: '#abc #bcd hello world',
+ }),
+ ).toStrictEqual({
+ text: '#abc #bcd hello world',
+ taggedUserIds: [],
+ quotePaintId: '',
+ inReplyToPaintId: '',
+ hashtags: [
+ { tag: 'abc', start: 0, end: 3 },
+ { tag: 'bcd', start: 5, end: 8 },
+ ],
+ mentions: [],
+ links: [],
+ medias: [],
+ });
+ });
+
+ it('should extract mention value in text', () => {
+ expect(
+ forEditPaint({
+ text: '@abc @bcd hello world',
+ }),
+ ).toStrictEqual({
+ text: '@abc @bcd hello world',
+ taggedUserIds: [],
+ quotePaintId: '',
+ inReplyToPaintId: '',
+ hashtags: [],
+ mentions: [
+ { mention: 'abc', start: 0, end: 3, userId: '' },
+ { mention: 'bcd', start: 5, end: 8, userId: '' },
+ ],
+ links: [],
+ medias: [],
+ });
+ });
+
+ it('should extract link value in text', () => {
+ expect(
+ forEditPaint({
+ text: 'hello world https://www.naver.com should',
+ }),
+ ).toStrictEqual({
+ text: 'hello world https://www.naver.com should',
+ taggedUserIds: [],
+ quotePaintId: '',
+ inReplyToPaintId: '',
+ hashtags: [],
+ mentions: [],
+ links: [
+ {
+ link: 'https://www.naver.com',
+ start: 12,
+ end: 33,
+ },
+ ],
+ medias: [],
+ });
+ });
+
+ it('should extract complex case properly in text', () => {
+ expect(
+ forEditPaint({
+ text: 'hello world https://www.naver.com should @sangmin return #opt zozo',
+ medias: [convertToMedia('https://www.naver.com', 'image')],
+ }),
+ ).toStrictEqual({
+ text: 'hello world https://www.naver.com should @sangmin return #opt zozo',
+ taggedUserIds: [],
+ quotePaintId: '',
+ inReplyToPaintId: '',
+ hashtags: [{ tag: 'opt', start: 57, end: 60 }],
+ mentions: [{ mention: 'sangmin', start: 41, end: 48, userId: '' }],
+ links: [
+ {
+ link: 'https://www.naver.com',
+ start: 12,
+ end: 33,
+ },
+ ],
+ medias: [{ type: 'image', path: 'https://www.naver.com' }],
+ });
+ });
+
+ it('should index shift in with long-link', () => {
+ expect(
+ forEditPaint({
+ text: 'hello world https://www.naver.com/12345678901 should @sangmin1 return #opt zozo https://www.naver.com/98765432101 @sangmin2 #opt',
+ medias: [convertToMedia('https://www.naver.com', 'image')],
+ }),
+ ).toStrictEqual({
+ text: 'hello world https://www.naver.com/12345678901 should @sangmin1 return #opt zozo https://www.naver.com/98765432101 @sangmin2 #opt',
+ taggedUserIds: [],
+ quotePaintId: '',
+ inReplyToPaintId: '',
+ hashtags: [
+ { tag: 'opt', start: 60, end: 63 },
+ { tag: 'opt', start: 104, end: 107 },
+ ],
+ mentions: [
+ { mention: 'sangmin1', start: 43, end: 51, userId: '' },
+ { mention: 'sangmin2', start: 94, end: 102, userId: '' },
+ ],
+ links: [
+ {
+ link: 'https://www.naver.com/12345678901',
+ start: 12,
+ end: 35,
+ },
+ {
+ link: 'https://www.naver.com/98765432101',
+ start: 70,
+ end: 93,
+ },
+ ],
+ medias: [{ type: 'image', path: 'https://www.naver.com' }],
+ });
+ });
+});
diff --git a/src/web/src/utils/__tests__/sample.ts b/src/web/src/utils/__tests__/sample.ts
deleted file mode 100644
index e20a3dc6..00000000
--- a/src/web/src/utils/__tests__/sample.ts
+++ /dev/null
@@ -1,6 +0,0 @@
-
-describe('sample', () => {
- it('should be success every time', () => {
- expect(true).toBe(true);
- });
-});
diff --git a/src/web/src/utils/__tests__/storage.spec.ts b/src/web/src/utils/__tests__/storage.spec.ts
new file mode 100644
index 00000000..b37cdf05
--- /dev/null
+++ b/src/web/src/utils/__tests__/storage.spec.ts
@@ -0,0 +1,108 @@
+import { LocalStorage, MemoStorage, generateLocalStorage } from '../storage';
+
+describe('typed-local-storage', () => {
+ it('should return LocalStorage when can use localStorage', () => {
+ const GENERATE_KEY = 'generate-key';
+ expect(generateLocalStorage(GENERATE_KEY)).toBeInstanceOf(LocalStorage);
+ });
+
+ it('should return null when given no initial options', () => {
+ const GET_KEY = 'get-empty-key';
+ const storage = generateLocalStorage(GET_KEY);
+
+ expect(storage.get()).toBe(null);
+ });
+
+ it('should return initialValue when given initial value', () => {
+ const INITIAL_VALUE = {
+ a: 1,
+ };
+ const GET_KEY = 'get-key';
+ const storage = generateLocalStorage(GET_KEY, {
+ value: INITIAL_VALUE,
+ });
+
+ expect(storage.get()).toStrictEqual(INITIAL_VALUE);
+ });
+
+ it('should return setting value when use set method', () => {
+ const SET_KEY = 'set-key';
+ const storage = generateLocalStorage(SET_KEY, {
+ value: 'value',
+ });
+ storage.set('setting-value');
+
+ expect(storage.get()).toBe('setting-value');
+ });
+
+ it('should return null when use clear method', () => {
+ const CLEAR_KEY = 'clear-key';
+ const storage = generateLocalStorage(CLEAR_KEY, {
+ value: 'value',
+ });
+ storage.clear();
+
+ expect(storage.get()).toBe(null);
+ });
+
+ it('should return null when use remove method', () => {
+ const REMOVE_KEY = 'remove-key';
+ const storage = generateLocalStorage(REMOVE_KEY, {
+ value: 'value',
+ });
+ storage.remove();
+
+ expect(storage.get()).toBe(null);
+ });
+});
+
+describe('memo-storage', () => {
+ it('should return null when given no initial options', () => {
+ const GET_KEY = 'get-empty-key';
+ const storage = new MemoStorage(GET_KEY);
+
+ expect(storage.get()).toBe(null);
+ });
+
+ it('should return initialValue when given initial value', () => {
+ const INITIAL_VALUE = {
+ a: 1,
+ };
+ const GET_KEY = 'get-key';
+ const storage = new MemoStorage(GET_KEY, {
+ value: INITIAL_VALUE,
+ });
+
+ expect(storage.get()).toStrictEqual(INITIAL_VALUE);
+ });
+
+ it('should return setting value when use set method', () => {
+ const SET_KEY = 'set-key';
+ const storage = new MemoStorage(SET_KEY, {
+ value: 'value',
+ });
+ storage.set('setting-value');
+
+ expect(storage.get()).toBe('setting-value');
+ });
+
+ it('should return null when use clear method', () => {
+ const CLEAR_KEY = 'clear-key';
+ const storage = new MemoStorage(CLEAR_KEY, {
+ value: 'value',
+ });
+ storage.clear();
+
+ expect(storage.get()).toBe(null);
+ });
+
+ it('should return null when use remove method', () => {
+ const REMOVE_KEY = 'remove-key';
+ const storage = new MemoStorage(REMOVE_KEY, {
+ value: 'value',
+ });
+ storage.remove();
+
+ expect(storage.get()).toBe(null);
+ });
+});
diff --git a/src/web/src/utils/cn.ts b/src/web/src/utils/cn.ts
index 4f3199af..326ab58f 100644
--- a/src/web/src/utils/cn.ts
+++ b/src/web/src/utils/cn.ts
@@ -39,8 +39,9 @@ export const extendTwMerge = extendTailwindMerge({
'text-green-200',
'text-purple-100',
'text-purple-200',
+ 'text-pink-100',
+ 'text-pink-200',
'text-red-100',
- 'text-red-200',
'text-yellow-100',
'text-blue-100',
'text-blue-200',
diff --git a/src/web/src/utils/countByte.ts b/src/web/src/utils/countByte.ts
new file mode 100644
index 00000000..ac341af3
--- /dev/null
+++ b/src/web/src/utils/countByte.ts
@@ -0,0 +1 @@
+export const countByte = (message: string): number => new Blob([message]).size;
diff --git a/src/web/src/utils/dummyTimelineItem.ts b/src/web/src/utils/dummyTimelineItem.ts
index 4936b0a2..da1ab10b 100644
--- a/src/web/src/utils/dummyTimelineItem.ts
+++ b/src/web/src/utils/dummyTimelineItem.ts
@@ -1,24 +1,23 @@
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 = {
id: '12',
isReply: false,
- authorId: '',
+ authorId: '1',
authorUsername: '@sangmin',
authorNickname: '이상민',
- authorImagePath:
- 'https://pbs.twimg.com/profile_images/1734036193585893376/BkzwxOn2_400x400.png',
+ 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,
@@ -27,6 +26,7 @@ const DUMMY_ITEM: TimelineItem = {
like: true,
marked: true,
repainted: true,
+ quotePaint: null,
entities: {
hashtags: [{ start: 0, end: 2, tag: 'tag' }],
mentions: [{ start: 5, end: 7, mention: 'lee', userId: '12' }],
@@ -35,18 +35,19 @@ const DUMMY_ITEM: TimelineItem = {
medias: [
{
type: 'image',
- path: 'https://pbs.twimg.com/media/GEFlR__bMAAr2MA?format=jpg&name=small',
+ path: 'posts/ie8fjbk8ejmvxcihxo1e',
},
],
- paint: null,
users: [
{
id: '123',
nickname: '상민',
username: '@sangmin',
- createdAt: new Date(),
+ status: 'public',
+ imagePath: 'profile/k3cvomo4mknrzsub83n7',
},
],
+ links: [],
},
};
@@ -61,11 +62,11 @@ export const createDummyTimelineItem = (length: number): TimelineItem[] =>
index % 5
],
authorImagePath: [
- 'https://avatars.githubusercontent.com/u/51396905?s=96&v=4',
- 'https://avatars.githubusercontent.com/u/60564431?s=96&v=4',
- 'https://avatars.githubusercontent.com/u/80496838?s=96&v=4',
- 'https://avatars.githubusercontent.com/u/43488305?s=40&v=4',
- 'https://avatars.githubusercontent.com/u/74983448?s=96&v=4',
+ 'profile/t1dyeoponhi213q45ilc',
+ 'profile/ydep7rd33mmmpu99c2wc',
+ 'profile/qxbdyyqmtubjuadeb6is',
+ 'profile/s76oz6lo7bdpwvkdymmz',
+ 'profile/lnminict8arehfeqwfd7',
][index % 5],
replyCount: Math.floor(Math.random() * 1000),
repaintCount: Math.floor(Math.random() * 1000),
@@ -76,8 +77,8 @@ export const createDummyTimelineItem = (length: number): TimelineItem[] =>
repainted: index % 3 === 0,
createdAt: getRandomAdjustedDate(),
text: [
- `캠프가 진행되면서 디스코드 통해서 서로 유의미한 정보도 공유하고, 대화하는 모습이 아주 보기 좋습니다 (엄마미소) 🥰\n본 채널에 정보가 섞이는 것 같아서 채널을 분리해 보았어요.\n앞으로 공지 드리는 내용 놓치지 않도록! 정보 공유가 더 원활할 수 있도록!\n아래와 같이 채널을 활용해 주세요.`,
- `안녕하세요, @2023 개발캠프 여러분!\n지난 주에 이어서 오늘은 리사이클 팀의 현우 님(React-Query), 규민 님(상태관리)의 세미나가 진행됩니다.\n점심 식사하시고 1시 30분에 미팅룸 6번에서 만나요`,
+ `캠프가 진행되면서 디스코드 통해서 서로 유의미한 정보도 공유하고, 대화하는 모습이 아주 보기 좋습니다 (엄마미소) 🥰\n본 채널에 정보가 섞이는 것 같아서 채널을 분리해 보았어요.\n앞으로 공지 드리는 내용 놓치지 않도록! 정보 공유가 더 원활할 수 있도록!\n아래와 같이 채널을 활용해 주세요.\n@윈터2기_안재진 재진님 안녕하세요! 가능해요. 세미나 목적인가요?`,
+ `안녕하세요, @2023 개발캠프 여러분!\n지난 주에 이어서 오늘은 리사이클 팀의 현우 님(React-Query), 규민 님(상태관리)의 세미나가 진행됩니다.\n점심 식사하시고 1시 30분에 미팅룸 6번에서 만나요\n@윈터2기_안재진 재진님 안녕하세요! 가능해요. 세미나 목적인가요?`,
`📣ㅣ2023-개발캠프-공지 캠프장님과 운영진의 공지를 확인하는 채널\n🗣ㅣsmalltalk 아무얘기 자유롭게 나누는 채널\n🔍ㅣ테크-공유 공유하고 싶은 기술정보 남기는 채널`,
`그리고 채널이 추가적으로 필요하신 경우, 원하는 채널명과 채널이 필요한 이유를 담아 🗣ㅣsmalltalk 에 요청주시면\n운영진 확인 + 개발캠프 멤버의 좋아요👍 5개 받으면 생성해 드릴게요!`,
`디스코드에서 더 많은 소통이 일어나길 바라며 ㅎㅎ 오늘도 미리 수고 많으셨어요 🧡\n`,
@@ -89,6 +90,7 @@ export const createDummyTimelineItem = (length: number): TimelineItem[] =>
][index % 10],
includes: {
...DUMMY_ITEM.includes,
+ paint: Math.random() < 0.2 ? DUMMY_ITEM : null,
medias:
Math.random() < 0.5
? [
@@ -96,34 +98,57 @@ export const createDummyTimelineItem = (length: number): TimelineItem[] =>
[
{
type: 'image',
- path: 'https://pbs.twimg.com/media/GEFlR__bMAAr2MA?format=jpg&name=small',
+ path: 'posts/ysswlagsxsahhxphe1kn',
},
] as TimelineItem['includes']['medias'],
[
{
type: 'image',
- path: 'https://pbs.twimg.com/media/GBCjAhlbQAALcOl?format=jpg&name=small',
+ path: 'posts/uak4thmr0si4ep5uxn0z',
},
] as TimelineItem['includes']['medias'],
[
{
type: 'image',
- path: 'https://pbs.twimg.com/media/GDEGSwna4AAm-gj?format=jpg&name=small',
+ path: 'posts/ukd1z89ccqqpynfc4r0w',
},
] as TimelineItem['includes']['medias'],
[
{
type: 'image',
- path: 'https://pbs.twimg.com/media/GDDX4BjbAAAR1qX?format=jpg&name=small',
+ path: 'posts/do2wvwps5alg7teihwly',
},
] as TimelineItem['includes']['medias'],
[
{
type: 'image',
- path: 'https://pbs.twimg.com/media/GC-e1lPb0AAGro0?format=jpg&name=small',
+ path: 'posts/zooatvuplafl2bmjyzbo',
},
] as TimelineItem['includes']['medias'],
][index % 5]
: [],
},
}));
+
+export const dummyPosts = createDummyTimelineItem(10);
+export const dummyMainPost = dummyPosts[0];
+export const dummyBeforePosts = dummyPosts.slice(1, 4);
+export const dummyAfterPosts = dummyPosts.slice(4, 7);
+export const fetchMainPost: () => Promise = () =>
+ new Promise((res) => {
+ setTimeout(() => {
+ res(dummyMainPost);
+ }, 1000);
+ });
+export const fetchAfterPost: () => Promise = () =>
+ new Promise((res) => {
+ setTimeout(() => {
+ res(dummyAfterPosts);
+ }, 2500);
+ });
+export const fetchBeforePost: () => Promise = () =>
+ new Promise((res) => {
+ setTimeout(() => {
+ res(dummyBeforePosts);
+ }, 2500);
+ });
diff --git a/src/web/src/utils/dummyUser.ts b/src/web/src/utils/dummyUser.ts
index 1610d4f7..7ff78c79 100644
--- a/src/web/src/utils/dummyUser.ts
+++ b/src/web/src/utils/dummyUser.ts
@@ -3,15 +3,37 @@ import type { User } from '@/@types';
export const DUMMY_USER: User = {
id: '123',
email: 'poiu694@naver.com',
- profileImagePath:
- 'https://pbs.twimg.com/profile_images/1734036193585893376/BkzwxOn2_400x400.png',
- backgroundImagePath: '',
+ password: '',
+ profileImagePath: 'profile/k3cvomo4mknrzsub83n7',
+ backgroundImagePath: 'background/msqoll4kckvhw5gfgqgx',
status: 'public',
username: '@sangmin',
nickname: '상민',
introduce: '상민이의 소개 !',
websitePath: 'https://github.com/poiu694',
- createdAt: new Date(),
- followers: 423,
- followings: 12,
+ joinedAt: new Date().toISOString(),
+ followerCount: 423,
+ followingCount: 12,
};
+
+export const createDummyUsers = (length: number): User[] =>
+ Array.from({ length }).map((_, index) => ({
+ ...DUMMY_USER,
+ id: String(index),
+ nickname: ['김도율', '김도현', '박희원', '이상민', '이원영'][index % 5],
+ username: ['@doyul', '@dohyeon', '@heewon', '@sangmini', '@210'][index % 5],
+ profileImagePath: [
+ 'profile/t1dyeoponhi213q45ilc',
+ 'profile/ydep7rd33mmmpu99c2wc',
+ 'profile/qxbdyyqmtubjuadeb6is',
+ 'profile/s76oz6lo7bdpwvkdymmz',
+ 'profile/lnminict8arehfeqwfd7',
+ ][index % 5],
+ introduce: [
+ `그리고 채널이 추가적으로 필요하신 경우, 원하는 채널명과 채널이 필요한 이유를 담아 🗣ㅣ`,
+ `디스코드에서 더 많은 소통이 일어나길 바라며 ㅎㅎ 오늘도 미리 수고 많으셨어요 🧡\n`,
+ `아, 테크에 올린 줄 알았어요!\nㅋㅋㅋㅋ`,
+ `제 동생 찐빵이에오.. 귀엽쬬... 7개월된 자브종 입니다...멍`,
+ `여러분 이번주에는 개인별 업무 담당 영역, 아키텍쳐, 설계 등에 대해 얘기를 나눌 예정이에요!`,
+ ][index % 5],
+ }));
diff --git a/src/web/src/utils/helperIcon.ts b/src/web/src/utils/helperIcon.ts
new file mode 100644
index 00000000..808bd6e2
--- /dev/null
+++ b/src/web/src/utils/helperIcon.ts
@@ -0,0 +1,7 @@
+import type { Direction } from '@/@types';
+
+export const iconOpacity = (direction: Direction) => {
+ if (direction === 'up') return 'opacity-95';
+ if (direction === 'down') return 'opacity-80';
+ return '';
+};
diff --git a/src/web/src/utils/helperPost.ts b/src/web/src/utils/helperPost.ts
new file mode 100644
index 00000000..48d78cbd
--- /dev/null
+++ b/src/web/src/utils/helperPost.ts
@@ -0,0 +1,102 @@
+import type { EditPaint } from '@/@types';
+
+type ValueWithIndex = { start: number; end: number; value: string };
+export const TagMentionLinkRegex =
+ /(http(s)?:\/\/.)?(www\.)?[-a-zA-Z0-9@:%._+~#=]{2,256}\.[a-z]{2,6}\b([-a-zA-Z0-9@:%_+.~#?&//=]*)|@(\w+)|#(\w+)/g;
+const extractTagMentionLink = (
+ text?: string,
+): Record<'links' | 'mentions' | 'hashtags', ValueWithIndex[]> => {
+ const links: ValueWithIndex[] = [];
+ const mentions: ValueWithIndex[] = [];
+ const hashtags: ValueWithIndex[] = [];
+ if (!text) {
+ return {
+ links,
+ mentions,
+ hashtags,
+ };
+ }
+
+ const LIMIT_URL_LENGTH = 23;
+ const matches = Array.from(text.matchAll(TagMentionLinkRegex));
+ let delta = 0;
+ for (const match of matches) {
+ const value = match[0];
+ const start = Number(match.index ?? 0);
+ const end = start + value.length;
+
+ if (value.startsWith('http')) {
+ const linkEnd = Math.min(start + LIMIT_URL_LENGTH, end);
+ links.push({ start: start - delta, end: linkEnd - delta, value });
+ delta += end - linkEnd;
+ } else if (value[0] === '#') {
+ hashtags.push({
+ start: start - delta,
+ end: end - delta - 1,
+ value: value.slice(1),
+ });
+ } else if (value[0] === '@') {
+ mentions.push({
+ start: start - delta,
+ end: end - delta - 1,
+ value: value.slice(1),
+ });
+ }
+ }
+
+ return {
+ links,
+ mentions,
+ hashtags,
+ };
+};
+
+const forLinkFromValue = (links: ValueWithIndex[]): EditPaint['links'] =>
+ links.map((item) => ({
+ start: item.start,
+ end: item.end,
+ link: item.value,
+ }));
+
+const forMentionFromValue = (
+ mentions: ValueWithIndex[],
+): EditPaint['mentions'] =>
+ mentions.map((item) => ({
+ start: item.start,
+ end: item.end,
+ mention: item.value,
+ userId: '',
+ }));
+
+const forTagFromValue = (tags: ValueWithIndex[]): EditPaint['hashtags'] =>
+ tags.map((item) => ({
+ start: item.start,
+ end: item.end,
+ tag: item.value,
+ }));
+
+/**
+ * 기본 값으로 채워주면서 EditPaint 타입으로 만들어줍니다.
+ */
+export const forEditPaint = (paint: Partial): EditPaint => {
+ const { links, mentions, hashtags } = extractTagMentionLink(paint.text);
+
+ return {
+ text: paint.text ?? '',
+ taggedUserIds: paint.taggedUserIds ?? [],
+ quotePaintId: paint.quotePaintId ?? '',
+ inReplyToPaintId: paint.inReplyToPaintId ?? '',
+ links: forLinkFromValue(links),
+ mentions: forMentionFromValue(mentions),
+ hashtags: forTagFromValue(hashtags),
+ medias: paint.medias ?? [],
+ };
+};
+
+export const convertToMedia = (
+ url: string,
+ type: EditPaint['medias'][number]['type'],
+): EditPaint['medias'][number] => ({
+ path: url,
+ type,
+});
diff --git a/src/web/src/utils/imageCDN.ts b/src/web/src/utils/imageCDN.ts
new file mode 100644
index 00000000..3232f3b3
--- /dev/null
+++ b/src/web/src/utils/imageCDN.ts
@@ -0,0 +1,69 @@
+import { Cloudinary } from '@cloudinary/url-gen';
+import { Resize } from '@cloudinary/url-gen/actions';
+
+import { DEFAULT_PROFILE_IMAGE, env } from '@/constants';
+import type { ImageSize } from '@/@types';
+
+export const cld = new Cloudinary({
+ cloud: {
+ cloudName: env.VITE_CLOUD_NAME,
+ },
+ url: {
+ secure: true,
+ },
+});
+
+class ImageNotFoundError extends Error {
+ constructor() {
+ super('이미지 경로가 잘못 되었습니다.');
+ }
+}
+
+type ImageQuality =
+ | 'auto'
+ | 'auto:best'
+ | 'auto:eco'
+ | 'auto:good'
+ | 'auto:low'
+ | 'jpegmini'
+ | 'jpegmini:best'
+ | 'jpegmini:high'
+ | 'jpegmini:medium';
+
+export const forCloudinaryImage = (
+ id: string | undefined,
+ options:
+ | {
+ resize: true;
+ quality?: ImageQuality;
+ ratio?: '16:9' | '3:4' | '1:1' | false;
+ width: ImageSize['width'];
+ height: ImageSize['height'];
+ defaultImage?: string;
+ }
+ | { resize: false; quality?: ImageQuality; defaultImage?: string } = {
+ resize: true,
+ quality: 'auto',
+ ratio: '1:1',
+ width: 400,
+ height: 400,
+ },
+): string => {
+ const image = cld.image(id ?? options.defaultImage ?? DEFAULT_PROFILE_IMAGE);
+ if (!image) {
+ throw new ImageNotFoundError();
+ }
+
+ if (options.quality) {
+ image.quality(options.quality);
+ }
+
+ if (options.resize && options.ratio) {
+ image.resize(
+ Resize.scale().width(options.width).aspectRatio(options.ratio),
+ );
+ } else if (options.resize && !options.ratio) {
+ image.resize(Resize.scale().width(options.width).height(options.height));
+ }
+ return image.toURL();
+};
diff --git a/src/web/src/utils/index.ts b/src/web/src/utils/index.ts
index 50b03ff8..f2d0de0e 100644
--- a/src/web/src/utils/index.ts
+++ b/src/web/src/utils/index.ts
@@ -1,5 +1,10 @@
export * from './cn';
+export * from './countByte';
export * from './dummyUser';
export * from './dummyTimelineItem';
export * from './helperDate';
+export * from './helperIcon';
+export * from './helperPost';
+export * from './imageCDN';
export * from './isValidEmail';
+export * from './storage';
diff --git a/src/web/src/utils/storage.ts b/src/web/src/utils/storage.ts
new file mode 100644
index 00000000..bb14e796
--- /dev/null
+++ b/src/web/src/utils/storage.ts
@@ -0,0 +1,122 @@
+interface Storage {
+ get(): T | null;
+
+ set(value: T): void;
+
+ remove(): void;
+
+ clear(): void;
+}
+
+interface StorageOption {
+ value?: T;
+}
+
+export class MemoStorage implements Storage {
+ constructor(
+ private readonly key: string,
+ options: StorageOption = {},
+ ) {
+ if (options.value != null && this.get() == null) {
+ this.set(options.value);
+ }
+ }
+
+ private storage = new Map();
+
+ public get(): T | null {
+ const value = this.storage.get(this.key);
+ return value ? this.deserialize(value) : null;
+ }
+
+ public set(value: T): void {
+ this.storage.set(this.key, this.serialize(value));
+ }
+
+ public remove(): void {
+ this.storage.delete(this.key);
+ }
+
+ public clear(): void {
+ this.storage.clear();
+ }
+
+ private serialize(value: T): string {
+ return JSON.stringify(value);
+ }
+
+ private deserialize(value: string): T | null {
+ try {
+ return JSON.parse(value);
+ } catch (e) {
+ return null;
+ }
+ }
+}
+
+function generateKey(): string {
+ return Math.random().toString(36);
+}
+
+export class LocalStorage implements Storage {
+ constructor(
+ private readonly key: string,
+ options: StorageOption = {},
+ ) {
+ if (options.value != null && this.get() == null) {
+ this.set(options.value);
+ }
+ }
+
+ public static canUse(): boolean {
+ const TEST_KEY = generateKey();
+
+ // LocalStorage를 사용할 수 없는 경우에 대응합니다.
+ try {
+ window.localStorage.setItem(TEST_KEY, 'test');
+ window.localStorage.removeItem(TEST_KEY);
+ return true;
+ } catch (err) {
+ return false;
+ }
+ }
+
+ public get(): T | null {
+ const value = window.localStorage.getItem(this.key);
+ return value ? this.deserialize(value) : null;
+ }
+
+ public set(value: T): void {
+ window.localStorage.setItem(this.key, this.serialize(value));
+ }
+
+ public remove(): void {
+ window.localStorage.removeItem(this.key);
+ }
+
+ public clear(): void {
+ window.localStorage.clear();
+ }
+
+ private serialize(value: T): string {
+ return JSON.stringify(value);
+ }
+
+ private deserialize(value: string): T | null {
+ try {
+ return JSON.parse(value);
+ } catch (e) {
+ return null;
+ }
+ }
+}
+
+export const generateLocalStorage = (
+ key: string,
+ options?: StorageOption,
+): Storage => {
+ if (LocalStorage.canUse()) {
+ return new LocalStorage(key, options);
+ }
+ return new MemoStorage(key, options);
+};
diff --git a/src/web/tailwind.config.js b/src/web/tailwind.config.js
index d6afa821..72fbf8d7 100644
--- a/src/web/tailwind.config.js
+++ b/src/web/tailwind.config.js
@@ -107,6 +107,9 @@ export default {
100: '#FFE042',
},
red: {
+ 100: '#F4212E',
+ },
+ pink: {
100: '#F91880',
200: '#CE395F',
},
diff --git a/src/web/vite.config.ts b/src/web/vite.config.ts
index 36f78e9b..40aab2e8 100644
--- a/src/web/vite.config.ts
+++ b/src/web/vite.config.ts
@@ -1,7 +1,8 @@
-import { defineConfig } from 'vite';
+import { splitVendorChunkPlugin, defineConfig } from 'vite';
import path from 'path';
import react from '@vitejs/plugin-react';
import svgr from 'vite-plugin-svgr';
+import EnvironmentPlugin from 'vite-plugin-environment';
// https://vitejs.dev/config/
export default defineConfig({
@@ -15,10 +16,17 @@ export default defineConfig({
loader: 'tsx',
},
}),
+ EnvironmentPlugin('all'),
+ splitVendorChunkPlugin(),
],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
+ build: {
+ target: 'modules',
+ cssMinify: true,
+ sourcemap: true,
+ },
});