@@ -24,7 +25,6 @@ const InterviewVideoPageLayout: React.FC
= ({
css={css`
align-items: center;
row-gap: 1rem;
- padding: 1rem;
height: auto;
`}
>
diff --git a/FE/src/components/interviewVideoPage/Modal/ShareRangeIcon.tsx b/FE/src/components/interviewVideoPage/Modal/ShareRangeIcon.tsx
new file mode 100644
index 0000000..e1b4f5b
--- /dev/null
+++ b/FE/src/components/interviewVideoPage/Modal/ShareRangeIcon.tsx
@@ -0,0 +1,25 @@
+import Icon from '@foundation/Icon/Icon';
+import { css } from '@emotion/react';
+import { theme } from '@styles/theme';
+
+type ShareRangeIconProps = {
+ isPublic: boolean;
+};
+const ShareRangeIcon: React.FC = ({ isPublic }) => {
+ return (
+
+
+
+ );
+};
+
+export default ShareRangeIcon;
diff --git a/FE/src/components/interviewVideoPage/Modal/ShareRangeSetting.tsx b/FE/src/components/interviewVideoPage/Modal/ShareRangeSetting.tsx
new file mode 100644
index 0000000..981df4b
--- /dev/null
+++ b/FE/src/components/interviewVideoPage/Modal/ShareRangeSetting.tsx
@@ -0,0 +1,64 @@
+import Typography from '@foundation/Typography/Typography';
+import ShareRangeIcon from '@components/interviewVideoPage/Modal/ShareRangeIcon';
+import { css } from '@emotion/react';
+import { theme } from '@styles/theme';
+import Toggle from '@foundation/Toggle/Toggle';
+
+type ShareRangeSettingProps = {
+ isPublic: boolean;
+ onClick: () => void;
+};
+const ShareRangeSetting: React.FC = ({
+ isPublic,
+ onClick,
+}) => {
+ return (
+
+
공개 범위
+
+
+
+
+
+ {isPublic ? '링크가 있는 모든 사용자' : '비공개'}
+
+
+ {isPublic
+ ? '링크가 있는 인터넷상의 모든 사용자가 볼 수 있음'
+ : '나만 볼 수 있음'}
+
+
+
+
+
+
+ );
+};
+
+export default ShareRangeSetting;
diff --git a/FE/src/components/interviewVideoPage/Modal/VideoShareModal.tsx b/FE/src/components/interviewVideoPage/Modal/VideoShareModal.tsx
new file mode 100644
index 0000000..5e3f7bf
--- /dev/null
+++ b/FE/src/components/interviewVideoPage/Modal/VideoShareModal.tsx
@@ -0,0 +1,59 @@
+import Modal from '@foundation/Modal';
+import Typography from '@foundation/Typography/Typography';
+import { css } from '@emotion/react';
+import ShareRangeSetting from '@components/interviewVideoPage/Modal/ShareRangeSetting';
+import VideoShareModalFooter from '@components/interviewVideoPage/Modal/VideoShareModalFooter';
+import useToggleVideoPublicMutation from '@hooks/mutations/video/useToggleVideoPublicMutation';
+import { truncateText } from '@/utils/textUtils';
+
+type VideoShareModalProps = {
+ videoId: number;
+ videoName: string;
+ isPublic: boolean;
+ isOpen: boolean;
+ closeModal: () => void;
+};
+const VideoShareModal: React.FC = ({
+ videoId,
+ videoName,
+ isPublic,
+ isOpen,
+ closeModal,
+}) => {
+ const { mutate, data, isPending } = useToggleVideoPublicMutation(videoId);
+
+ const handleVideoShareToggleClick = () => {
+ if (!isPublic) {
+ mutate();
+ }
+ };
+
+ return (
+
+
+
+ {`"${truncateText(videoName, 15)}" 공유`}
+
+ {isPending ? (
+ '로딩중' //TODO 디자인은 임시입니다.
+ ) : (
+
+ )}
+
+
+
+
+ );
+};
+
+export default VideoShareModal;
diff --git a/FE/src/components/interviewVideoPage/Modal/VideoShareModalFooter.tsx b/FE/src/components/interviewVideoPage/Modal/VideoShareModalFooter.tsx
new file mode 100644
index 0000000..81f140d
--- /dev/null
+++ b/FE/src/components/interviewVideoPage/Modal/VideoShareModalFooter.tsx
@@ -0,0 +1,49 @@
+import Button from '@foundation/Button/Button';
+import { theme } from '@styles/theme';
+import { css } from '@emotion/react';
+import LightButton from '@common/LightButton/LightButton';
+
+type VideoShareModalFooterProps = {
+ hashUrl?: string | null;
+ closeModal: () => void;
+};
+
+const VideoShareModalFooter: React.FC = ({
+ hashUrl,
+ closeModal,
+}) => {
+ const handleCopyLink = async () => {
+ if (hashUrl) {
+ try {
+ await navigator.clipboard.writeText(hashUrl);
+ alert('링크 복사됨'); //TODO 현재는 alert이지만 추후에 Toast로 변경 예정
+ } catch (e) {
+ alert('복사 실패');
+ }
+ }
+ };
+
+ return (
+
+ void handleCopyLink()}
+ disabled={!hashUrl}
+ css={css`
+ border: 1px solid ${theme.colors.border.default};
+ background-color: transparent;
+ `}
+ >
+ 링크 복사
+
+
+
+ );
+};
+
+export default VideoShareModalFooter;
diff --git a/FE/src/components/interviewVideoPage/PrivateVideoPlayer.tsx b/FE/src/components/interviewVideoPage/PrivateVideoPlayer.tsx
new file mode 100644
index 0000000..8e3bd25
--- /dev/null
+++ b/FE/src/components/interviewVideoPage/PrivateVideoPlayer.tsx
@@ -0,0 +1,19 @@
+import { VideoItemResDto } from '@/types/video';
+import VideoPlayerFrame from '@common/VideoPlayer/VideoPlayerFrame';
+import VideoPlayer from '@common/VideoPlayer/VideoPlayer';
+
+type PrivateVideoPlayerProps = Omit;
+const PrivateVideoPlayer: React.FC = ({
+ id,
+ url,
+ videoName,
+ createdAt,
+}) => {
+ return (
+
+
+
+ );
+};
+
+export default PrivateVideoPlayer;
diff --git a/FE/src/components/interviewVideoPage/VideoPlayer.tsx b/FE/src/components/interviewVideoPage/VideoPlayer.tsx
deleted file mode 100644
index 43a0979..0000000
--- a/FE/src/components/interviewVideoPage/VideoPlayer.tsx
+++ /dev/null
@@ -1,31 +0,0 @@
-import VideoItem from '@common/VideoItem/VideoItem';
-import { css } from '@emotion/react';
-
-type VideoPlayerProps = {
- videoName: string;
- date: string;
- url: string;
-};
-const VideoPlayer: React.FC = ({ videoName, date, url }) => {
- return (
-
-
-
- );
-};
-
-export default VideoPlayer;
diff --git a/FE/src/components/interviewVideoPublicPage/InterviewVideoPublicPageLayout.tsx b/FE/src/components/interviewVideoPublicPage/InterviewVideoPublicPageLayout.tsx
new file mode 100644
index 0000000..b55c8d7
--- /dev/null
+++ b/FE/src/components/interviewVideoPublicPage/InterviewVideoPublicPageLayout.tsx
@@ -0,0 +1,38 @@
+import Layout from '@/components/layout/Layout';
+import Logo from '@common/Logo/Logo';
+import { css } from '@emotion/react';
+
+type InterviewVideoPublicPageLayoutProps = {
+ children: React.ReactNode;
+};
+
+const InterviewVideoPublicPageLayout: React.FC<
+ InterviewVideoPublicPageLayoutProps
+> = ({ children }) => {
+ return (
+
+ );
+};
+
+export default InterviewVideoPublicPageLayout;
diff --git a/FE/src/components/interviewVideoPublicPage/PublicVideoPlayer.tsx b/FE/src/components/interviewVideoPublicPage/PublicVideoPlayer.tsx
new file mode 100644
index 0000000..6eaee0c
--- /dev/null
+++ b/FE/src/components/interviewVideoPublicPage/PublicVideoPlayer.tsx
@@ -0,0 +1,19 @@
+import { VideoItemResDto } from '@/types/video';
+import VideoPlayerFrame from '@common/VideoPlayer/VideoPlayerFrame';
+import VideoPlayer from '@common/VideoPlayer/VideoPlayer';
+
+type PublicVideoPlayerProps = Omit;
+const PublicVideoPlayer: React.FC = ({
+ id,
+ url,
+ videoName,
+ createdAt,
+}) => {
+ return (
+
+
+
+ );
+};
+
+export default PublicVideoPlayer;
diff --git a/FE/src/components/layout/CenterLayout.tsx b/FE/src/components/layout/CenterLayout.tsx
new file mode 100644
index 0000000..1a3034c
--- /dev/null
+++ b/FE/src/components/layout/CenterLayout.tsx
@@ -0,0 +1,20 @@
+import { PropsWithChildren } from 'react';
+import { css } from '@emotion/react';
+
+const CenterLayout: React.FC = ({ children }) => {
+ return (
+
+ {children}
+
+ );
+};
+
+export default CenterLayout;
diff --git a/FE/src/components/myPage/TabPanel/VideoListTabPanel.tsx b/FE/src/components/myPage/TabPanel/VideoListTabPanel.tsx
index 0adb2df..66e9b6a 100644
--- a/FE/src/components/myPage/TabPanel/VideoListTabPanel.tsx
+++ b/FE/src/components/myPage/TabPanel/VideoListTabPanel.tsx
@@ -1,6 +1,6 @@
import Box from '@foundation/Box/Box';
import { css } from '@emotion/react';
-import VideoItem from '@common/VideoItem/VideoItem';
+import VideoItem from '@components/myPage/VideoItem/VideoItem';
import Thumbnail from '@components/myPage/Thumbnail';
import CardCover from '@foundation/CardCover/CardCover';
diff --git a/FE/src/components/common/VideoItem/VideoItem.tsx b/FE/src/components/myPage/VideoItem/VideoItem.tsx
similarity index 100%
rename from FE/src/components/common/VideoItem/VideoItem.tsx
rename to FE/src/components/myPage/VideoItem/VideoItem.tsx
diff --git a/FE/src/constants/path.ts b/FE/src/constants/path.ts
index ae8f67e..1bedeec 100644
--- a/FE/src/constants/path.ts
+++ b/FE/src/constants/path.ts
@@ -13,6 +13,8 @@ export const PATH = {
INTERVIEW_SETTING_RECORD: `/${INTERVIEW}/${SETTING}/${RECORD}`,
MYPAGE: `/${MYPAGE}`,
INTERVIEW_VIDEO: `/${INTERVIEW}/:videoId`,
+ INTERVIEW_VIDEO_PUBLIC: `/${INTERVIEW}/public/:videoHash`,
+ NOT_FOUND: `/404`,
};
export const SETTING_PATH = {
diff --git a/FE/src/mocks/handlers/video.ts b/FE/src/mocks/handlers/video.ts
index ac9f9ba..739f1b5 100644
--- a/FE/src/mocks/handlers/video.ts
+++ b/FE/src/mocks/handlers/video.ts
@@ -1,5 +1,5 @@
import { API } from '@/constants/api';
-import { http, HttpResponse } from 'msw';
+import { delay, http, HttpResponse } from 'msw';
const videoHandlers = [
http.post(API.VIDEO, ({ request }) => {
@@ -51,26 +51,31 @@ const videoHandlers = [
}),
http.get(API.VIDEO_ID(), () => {
return HttpResponse.json(
- [
- {
- id: 1,
- url: 'http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4',
- hash: 'asdfasdfasdfsfasdfasf',
- videoName: 'CSS 선택자의 종류에 대해 설명해주세요.',
- createdAt: '1699941626145',
- },
- ],
+ {
+ id: 1,
+ url: 'https://u2e0.c18.e2-4.dev/videos/%EB%A3%A8%EC%9D%B4%EB%B7%94%ED%86%B5%ED%86%B5%ED%8A%80%EA%B8%B0%EB%84%A4_test_07ab3e8a-1a0a-453f-8d60-afacb57b0075.webm',
+ hash: null,
+ videoName: 'CSS 선택자의 종류에 대해 설명해주세요.',
+ createdAt: '1699941626145',
+ },
{ status: 200 }
);
}),
http.get(API.VIDEO_HASH(), () => {
return HttpResponse.json(
- { hash: 'hashstringstringsrting' },
+ {
+ id: 1,
+ url: 'https://u2e0.c18.e2-4.dev/videos/%EB%A3%A8%EC%9D%B4%EB%B7%94%ED%86%B5%ED%86%B5%ED%8A%80%EA%B8%B0%EB%84%A4_test_07ab3e8a-1a0a-453f-8d60-afacb57b0075.webm',
+ hash: 'asdfasdfasdfsfasdfasf',
+ videoName: 'CSS 선택자의 종류에 대해 설명해주세요.',
+ createdAt: '1699941626145',
+ },
{ status: 200 }
);
}),
- http.patch(API.VIDEO_ID(), () => {
- return HttpResponse.json(null, { status: 204 });
+ http.patch(API.VIDEO_ID(), async () => {
+ await delay(1000);
+ return HttpResponse.json(null, { status: 200 });
}),
http.delete(API.VIDEO_ID(), ({ request }) => {
return HttpResponse.json(null, { status: 204 });
diff --git a/FE/src/page/LandingPage/index.tsx b/FE/src/page/LandingPage/index.tsx
index a559f9b..4a02eef 100644
--- a/FE/src/page/LandingPage/index.tsx
+++ b/FE/src/page/LandingPage/index.tsx
@@ -1,5 +1,5 @@
import LandingPageLayout from '@/components/landingPage/LandingPageLayout';
-import StartButton from '@components/landingPage/StartButton';
+import StartButton from '@common/StartButton/StartButton';
import WelcomeBlurb from '@components/landingPage/WelcomeBlurb';
import LandingImage from '@components/landingPage/LandingImage';
import GoogleLoginButton from '@components/landingPage/GoogleLoginButton';
diff --git a/FE/src/page/interviewVideoPage/index.tsx b/FE/src/page/interviewVideoPage/index.tsx
index 3eebef9..9c0de20 100644
--- a/FE/src/page/interviewVideoPage/index.tsx
+++ b/FE/src/page/interviewVideoPage/index.tsx
@@ -1,24 +1,51 @@
import InterviewVideoPageLayout from '@components/interviewVideoPage/InterviewVideoPageLayout';
-import VideoPlayer from '@components/interviewVideoPage/VideoPlayer';
-import Typography from '@foundation/Typography/Typography';
-import Button from '@foundation/Button/Button';
-import { Link } from 'react-router-dom';
-import { PATH } from '@constants/path';
+import { useParams } from 'react-router-dom';
+import useVideoItemQuery from '@hooks/queries/video/useVideoItemQuery';
+import LoadingBounce from '@common/Loading/LoadingBounce';
+import CenterLayout from '@components/layout/CenterLayout';
+import StartButton from '@common/StartButton/StartButton';
+import { useState } from 'react';
+import IconButton from '@common/VideoPlayer/IconButton';
+import { css } from '@emotion/react';
+import PrivateVideoPlayer from '@components/interviewVideoPage/PrivateVideoPlayer';
+import VideoShareModal from '@components/interviewVideoPage/Modal/VideoShareModal';
const InterviewVideoPage: React.FC = () => {
- const dummyData = {
- videoName: '비디오 이름',
- date: '2001.07.17',
- url: 'https://u2e0.c18.e2-4.dev/videos/%EB%A3%A8%EC%9D%B4%EB%B7%94%ED%86%B5%ED%86%B5%ED%8A%80%EA%B8%B0%EB%84%A4_test_07ab3e8a-1a0a-453f-8d60-afacb57b0075.webm',
- };
+ const { videoId } = useParams();
+ const { data, isFetching } = useVideoItemQuery(Number(videoId));
+ const [isOpen, setIsOpen] = useState(false);
return (
- {dummyData.videoName}
-
-
-
-
+
+ setIsOpen(!isOpen)}
+ />
+
+ {!data ? (
+ //TODO 로딩화면 일단 임시로 처리
+
+
+
+ ) : (
+ <>
+
+ setIsOpen(false)}
+ />
+ >
+ )}
+
);
};
diff --git a/FE/src/page/interviewVideoPublicPage/index.tsx b/FE/src/page/interviewVideoPublicPage/index.tsx
new file mode 100644
index 0000000..941e373
--- /dev/null
+++ b/FE/src/page/interviewVideoPublicPage/index.tsx
@@ -0,0 +1,26 @@
+import { Navigate, useParams } from 'react-router-dom';
+import { PATH } from '@constants/path';
+import InterviewVideoPublicPageLayout from '@components/interviewVideoPublicPage/InterviewVideoPublicPageLayout';
+import PublicVideoPlayer from '@components/interviewVideoPublicPage/PublicVideoPlayer';
+import { useQueryClient } from '@tanstack/react-query';
+import { QUERY_KEY } from '@constants/queryKey';
+import { VideoItemResDto } from '@/types/video';
+import StartButton from '@common/StartButton/StartButton';
+
+const InterviewVideoPublicPage: React.FC = () => {
+ const { videoHash = '' } = useParams();
+ const data = useQueryClient().getQueryData(
+ QUERY_KEY.VIDEO_HASH(videoHash)
+ );
+
+ if (!data) return ;
+
+ return (
+
+
+
+
+ );
+};
+
+export default InterviewVideoPublicPage;
diff --git a/FE/src/routes/interviewVideoPublicLoader.ts b/FE/src/routes/interviewVideoPublicLoader.ts
new file mode 100644
index 0000000..8bff597
--- /dev/null
+++ b/FE/src/routes/interviewVideoPublicLoader.ts
@@ -0,0 +1,23 @@
+import { QueryClient } from '@tanstack/react-query/build/modern/index';
+import { QUERY_KEY } from '@constants/queryKey';
+import { Params, redirect } from 'react-router-dom';
+import { PATH } from '@constants/path';
+import { getVideoByHash } from '@/apis/video';
+
+const interviewVideoPublicLoader = async ({
+ params,
+ queryClient,
+}: {
+ params: Params;
+ queryClient: QueryClient;
+}) => {
+ const { videoHash = '' } = params;
+ await queryClient.ensureQueryData({
+ queryKey: QUERY_KEY.VIDEO_HASH(videoHash),
+ queryFn: () => getVideoByHash(videoHash),
+ });
+ const queryState = queryClient.getQueryState(QUERY_KEY.VIDEO_HASH(videoHash));
+ return queryState?.data ? null : redirect(PATH.NOT_FOUND);
+};
+
+export default interviewVideoPublicLoader;
diff --git a/FE/src/routes/myPageLoader.ts b/FE/src/routes/myPageLoader.ts
index 9061fe0..c8a77b4 100644
--- a/FE/src/routes/myPageLoader.ts
+++ b/FE/src/routes/myPageLoader.ts
@@ -2,10 +2,14 @@ import { QUERY_KEY } from '@constants/queryKey';
import { QueryClient } from '@tanstack/react-query';
import { redirect } from 'react-router-dom';
import { PATH } from '@constants/path';
+import { getMemberInfo } from '@/apis/member';
-const myPageLoader = ({ queryClient }: { queryClient: QueryClient }) => {
+const myPageLoader = async ({ queryClient }: { queryClient: QueryClient }) => {
+ await queryClient.ensureQueryData({
+ queryKey: QUERY_KEY.MEMBER,
+ queryFn: getMemberInfo,
+ });
const queryState = queryClient.getQueryState(QUERY_KEY.MEMBER);
-
return queryState?.data ? null : redirect(PATH.ROOT);
};
diff --git a/FE/src/styles/_colors.ts b/FE/src/styles/_colors.ts
index b5c228e..173dedd 100644
--- a/FE/src/styles/_colors.ts
+++ b/FE/src/styles/_colors.ts
@@ -72,8 +72,9 @@ export const colors = {
default: colorChips.grayscaleWhite, // #FFFFFF
inner: colorChips.grayscale100, // #F5F5F5
weak: colorChips.grayscale300, // #EDEDED
- black50: colorChips.grayscaleBlack, // #000000
+ weakHover: colorChips.grayscale500,
black100: colorChips.grayscaleBlack, // #000000
+ active: colorChips.green50,
},
point: {
primary: {
diff --git a/FE/src/styles/_typography.ts b/FE/src/styles/_typography.ts
index ddc36b1..ccdce79 100644
--- a/FE/src/styles/_typography.ts
+++ b/FE/src/styles/_typography.ts
@@ -80,4 +80,15 @@ export const typography = {
fontWeight: 600,
fontSize: '0.75rem',
},
+
+ /**
+ * 영상 공유 모달의 링크에 대한 부연설명에 사용됩니다.
+ * 크게 강조하지 않을 설명 텍스트에 사용하세요
+ */
+ captionWeak: {
+ fontFamily: 'Pretendard',
+ fontStyle: 'normal',
+ fontWeight: 400,
+ fontSize: '0.75rem',
+ },
};
diff --git a/FE/src/utils/getAPIResponseData.ts b/FE/src/utils/getAPIResponseData.ts
index a23cfb1..bff87d9 100644
--- a/FE/src/utils/getAPIResponseData.ts
+++ b/FE/src/utils/getAPIResponseData.ts
@@ -9,6 +9,7 @@ const getAPIResponseData = async (option: AxiosRequestConfig) => {
if (axios.isAxiosError(e)) {
process.env.NODE_ENV === 'development' && console.error(e.toJSON());
}
+ throw e;
}
};
diff --git a/FE/src/utils/textUtils.ts b/FE/src/utils/textUtils.ts
new file mode 100644
index 0000000..7623ca2
--- /dev/null
+++ b/FE/src/utils/textUtils.ts
@@ -0,0 +1,3 @@
+export const truncateText = (text: string, length: number) => {
+ return text.substring(0, length) + '...';
+};