From 519767f03ebe3aaf382302a9226f916d45671eee Mon Sep 17 00:00:00 2001 From: kimhyein <70068498+henny1105@users.noreply.github.com> Date: Tue, 3 Dec 2024 14:24:02 +0900 Subject: [PATCH 01/45] =?UTF-8?q?[FE]=20[#176]=201=EC=B0=A8=20=EB=B0=B0?= =?UTF-8?q?=ED=8F=AC=20=ED=9B=84=20=ED=94=84=EB=A1=A0=ED=8A=B8=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EB=B0=8F=20=EB=A0=88=EC=9D=B4=EC=95=84=EC=9B=83=20?= =?UTF-8?q?=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81=20(#178)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Initial commit * feat: 개발환경 구축 * refactor: 디렉토리 구조 및 회원가입 관련 서비스 개선 디렉토리 구조를 올바르게 개선하였으며, ModelMapper, Swagger, QueryDSL 설정을 위한 Config 클래스를 분리하여 모듈화를 진행함. feat: Exception 처리 추가 및 User 엔티티 수정 handleUserAlreadyExistsException, handleInvalidEmailFormatException 예외 처리 추가. User 엔티티에 username 필드를 추가하고 기존 nickname을 대체하는 방식으로 Security 설정을 수정함. feat: 회원가입 서비스 구현 및 UserResponse 클래스 추가 회원가입 로직을 위한 컨트롤러, 서비스, 서비스 구현체, 리포지토리, 리포지토리 커스텀, 리포지토리 구현체를 추가하였으며, UserResponse DTO를 추가하여 응답 처리 로직을 개선함. chore: ModelMapper 설정 추가 및 DTO 필드 제외 기능 구현 ModelMapper 설정을 추가하여 DTO에서 특정 필드 변수를 제외할 수 있는 기능을 구현함. * refactor: Category entity 삭제 후 Video entity Category enum으로 대체 * refactor: 잘못된 브랜치 병합 되돌리기 및 재구축 * feat: PR 템플릿 추가 * [1.0.0] 1차 배포 진행 * chore: gpt_history 삭제 (#171) * [FE] 8080 포트 주소 변경 (#172) * [BE] 배포 환경에 맞게 시큐리티 수정 (#173) * fix: 배포 환경에 맞게 시큐리티 수정 * chore: memo 경로 수정 * chore: 구 시큐리티 클래스 , application.yaml 파일 삭제 * refactor: header 검색 디폴트 값 언어로 변경 * refactor: 1차 배포 후 프론트 기능 및 레이아웃 수정 * chore: import 수정 --------- Co-authored-by: Donguk Kim <129242580+atoonatoo@users.noreply.github.com> Co-authored-by: Jiho Co-authored-by: Jiho Kim <83708500+Jiho001@users.noreply.github.com> --- .idea/.gitignore | 8 ++ .idea/compiler.xml | 24 ++++ .idea/dataSources.xml | 19 +++ .idea/gradle.xml | 17 +++ .idea/inspectionProfiles/Project_Default.xml | 6 + .idea/jarRepositories.xml | 20 +++ .idea/misc.xml | 7 + .idea/modules.xml | 8 ++ .idea/techie.iml | 9 ++ .idea/uiDesigner.xml | 124 ++++++++++++++++++ .idea/vcs.xml | 7 + .../backend/global/security/PathConfig.java | 5 +- .../global/security/SecurityConfig.java | 2 +- front-end/.husky/pre-commit | 1 + front-end/src/app/favicon.ico | Bin 0 -> 25931 bytes front-end/src/app/globals.css | 42 ++++++ front-end/src/components/Header.tsx | 5 +- front-end/src/components/axios/apiClient.tsx | 2 +- .../src/components/memo/ConfirmModal.tsx | 27 ++++ front-end/src/components/memo/MemoForm.tsx | 49 +++++++ front-end/src/components/memo/MemoList.tsx | 54 ++++++++ .../src/components/mypage/MyMemoSection.tsx | 9 +- .../components/playlist/CategoryPlaylist.tsx | 114 +++++++--------- .../components/playlist/TeacherPlaylist.tsx | 7 +- front-end/src/hooks/memo/useInfiniteScroll.ts | 35 +++++ front-end/src/hooks/memo/useMemos.ts | 92 +++++++++++++ front-end/src/hooks/memo/useYouTubePlayer.ts | 81 ++++++++++++ .../src/hooks/playlist/useInfiniteScroll.ts | 37 ++++++ .../src/styles/pages/playlist/playlist.scss | 27 +++- front-end/src/utils/memo/timeUtils.ts | 10 ++ .../src/utils/playlist/formatDuration.ts | 17 +++ 31 files changed, 781 insertions(+), 84 deletions(-) create mode 100644 .idea/.gitignore create mode 100644 .idea/compiler.xml create mode 100644 .idea/dataSources.xml create mode 100644 .idea/gradle.xml create mode 100644 .idea/inspectionProfiles/Project_Default.xml create mode 100644 .idea/jarRepositories.xml create mode 100644 .idea/misc.xml create mode 100644 .idea/modules.xml create mode 100644 .idea/techie.iml create mode 100644 .idea/uiDesigner.xml create mode 100644 .idea/vcs.xml create mode 100644 front-end/.husky/pre-commit create mode 100644 front-end/src/app/favicon.ico create mode 100644 front-end/src/app/globals.css create mode 100644 front-end/src/components/memo/ConfirmModal.tsx create mode 100644 front-end/src/components/memo/MemoForm.tsx create mode 100644 front-end/src/components/memo/MemoList.tsx create mode 100644 front-end/src/hooks/memo/useInfiniteScroll.ts create mode 100644 front-end/src/hooks/memo/useMemos.ts create mode 100644 front-end/src/hooks/memo/useYouTubePlayer.ts create mode 100644 front-end/src/hooks/playlist/useInfiniteScroll.ts create mode 100644 front-end/src/utils/memo/timeUtils.ts create mode 100644 front-end/src/utils/playlist/formatDuration.ts diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 00000000..c3f502a1 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,8 @@ +# 디폴트 무시된 파일 +/shelf/ +/workspace.xml +# 에디터 기반 HTTP 클라이언트 요청 +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/compiler.xml b/.idea/compiler.xml new file mode 100644 index 00000000..72b0b0c2 --- /dev/null +++ b/.idea/compiler.xml @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/dataSources.xml b/.idea/dataSources.xml new file mode 100644 index 00000000..63e4ea35 --- /dev/null +++ b/.idea/dataSources.xml @@ -0,0 +1,19 @@ + + + + + mysql.8 + true + true + $PROJECT_DIR$/back-end/src/main/resources/application.yaml + com.mysql.cj.jdbc.Driver + jdbc:mysql://localhost:3306/techie + + + + + + $ProjectFileDir$ + + + \ No newline at end of file diff --git a/.idea/gradle.xml b/.idea/gradle.xml new file mode 100644 index 00000000..77e1b764 --- /dev/null +++ b/.idea/gradle.xml @@ -0,0 +1,17 @@ + + + + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 00000000..03d9549e --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/jarRepositories.xml b/.idea/jarRepositories.xml new file mode 100644 index 00000000..fdc392fe --- /dev/null +++ b/.idea/jarRepositories.xml @@ -0,0 +1,20 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 00000000..ade1ecd1 --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 00000000..cff8c3c7 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/techie.iml b/.idea/techie.iml new file mode 100644 index 00000000..d6ebd480 --- /dev/null +++ b/.idea/techie.iml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/uiDesigner.xml b/.idea/uiDesigner.xml new file mode 100644 index 00000000..2b63946d --- /dev/null +++ b/.idea/uiDesigner.xml @@ -0,0 +1,124 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 00000000..9927b2ae --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/back-end/src/main/java/com/techie/backend/global/security/PathConfig.java b/back-end/src/main/java/com/techie/backend/global/security/PathConfig.java index 44558508..41b77e28 100644 --- a/back-end/src/main/java/com/techie/backend/global/security/PathConfig.java +++ b/back-end/src/main/java/com/techie/backend/global/security/PathConfig.java @@ -23,11 +23,12 @@ public class PathConfig { // 접근 권한 : 인증된 사용자 (JWT 필요) public static final List AUTHENTICATED_PATHS = List.of( "/api/users/me", - "/api/gpt/qna" + "/api/gpt/qna", + "/api/memo/**" ); // 접근 권한 : 어드민 관리자 (ROLE_ADMIN 만 접근 가능) public static final List ADMIN_ONLY_PATHS = List.of( "/api/users/admin" ); -} \ No newline at end of file +} diff --git a/back-end/src/main/java/com/techie/backend/global/security/SecurityConfig.java b/back-end/src/main/java/com/techie/backend/global/security/SecurityConfig.java index 7a518b8e..3d8a96a1 100644 --- a/back-end/src/main/java/com/techie/backend/global/security/SecurityConfig.java +++ b/back-end/src/main/java/com/techie/backend/global/security/SecurityConfig.java @@ -44,7 +44,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { PathConfig.PERMIT_ALL_PATHS.forEach(path -> auth.requestMatchers(path).permitAll()); PathConfig.AUTHENTICATED_PATHS.forEach(path -> auth.requestMatchers(path).authenticated()); PathConfig.ADMIN_ONLY_PATHS.forEach(path -> auth.requestMatchers(path).hasRole("ADMIN")); - auth.anyRequest().authenticated(); + auth.anyRequest().permitAll(); }) .addFilterBefore(new JWTFilter(jwtUtil), LoginFilter.class) .addFilterAt(new LoginFilter(authenticationManager(authenticationConfiguration), jwtUtil), UsernamePasswordAuthenticationFilter.class) diff --git a/front-end/.husky/pre-commit b/front-end/.husky/pre-commit new file mode 100644 index 00000000..1a1f1f06 --- /dev/null +++ b/front-end/.husky/pre-commit @@ -0,0 +1 @@ +yarn lint-staged \ No newline at end of file diff --git a/front-end/src/app/favicon.ico b/front-end/src/app/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..718d6fea4835ec2d246af9800eddb7ffb276240c GIT binary patch literal 25931 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 LrJugUA?W`A8`#=m literal 0 HcmV?d00001 diff --git a/front-end/src/app/globals.css b/front-end/src/app/globals.css new file mode 100644 index 00000000..e3734be1 --- /dev/null +++ b/front-end/src/app/globals.css @@ -0,0 +1,42 @@ +:root { + --background: #ffffff; + --foreground: #171717; +} + +@media (prefers-color-scheme: dark) { + :root { + --background: #0a0a0a; + --foreground: #ededed; + } +} + +html, +body { + max-width: 100vw; + overflow-x: hidden; +} + +body { + color: var(--foreground); + background: var(--background); + font-family: Arial, Helvetica, sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +* { + box-sizing: border-box; + padding: 0; + margin: 0; +} + +a { + color: inherit; + text-decoration: none; +} + +@media (prefers-color-scheme: dark) { + html { + color-scheme: dark; + } +} diff --git a/front-end/src/components/Header.tsx b/front-end/src/components/Header.tsx index 4ce66cae..3044a6bf 100644 --- a/front-end/src/components/Header.tsx +++ b/front-end/src/components/Header.tsx @@ -10,7 +10,7 @@ import { clearUserInfo } from '@/redux/reducer'; const Header = () => { const [isLoggedIn, setIsLoggedIn] = useState(false); const [searchQuery, setSearchQuery] = useState(''); - const [selectedCategory, setSelectedCategory] = useState('ALL'); + const [selectedCategory, setSelectedCategory] = useState('LANG'); const router = useRouter(); const dispatch = useDispatch(); @@ -64,9 +64,6 @@ const Header = () => {
  • 성장 멘토 🌱
  • -
  • - 커뮤니티 💬 -
  • diff --git a/front-end/src/components/axios/apiClient.tsx b/front-end/src/components/axios/apiClient.tsx index 3cca3c42..637c1d2f 100644 --- a/front-end/src/components/axios/apiClient.tsx +++ b/front-end/src/components/axios/apiClient.tsx @@ -2,7 +2,7 @@ import axios from 'axios'; import Cookies from 'js-cookie'; const apiClient = axios.create({ - baseURL: 'http://localhost:8080/api', + baseURL: `${process.env.NEXT_PUBLIC_API_BASE_URL}/api`, }); // 요청 인터셉터: Authorization 헤더에 토큰 추가 diff --git a/front-end/src/components/memo/ConfirmModal.tsx b/front-end/src/components/memo/ConfirmModal.tsx new file mode 100644 index 00000000..679d0df2 --- /dev/null +++ b/front-end/src/components/memo/ConfirmModal.tsx @@ -0,0 +1,27 @@ +import React from 'react'; + +interface ConfirmModalProps { + message: string; + onConfirm: () => void; + onCancel: () => void; +} + +const ConfirmModal: React.FC = ({ message, onConfirm, onCancel }) => { + return ( +
    +
    +

    {message}

    +
    + + +
    +
    +
    + ); +}; + +export default ConfirmModal; diff --git a/front-end/src/components/memo/MemoForm.tsx b/front-end/src/components/memo/MemoForm.tsx new file mode 100644 index 00000000..fd569c07 --- /dev/null +++ b/front-end/src/components/memo/MemoForm.tsx @@ -0,0 +1,49 @@ +import React, { useState } from 'react'; + +interface MemoFormProps { + initialTitle?: string; // 제목 초기값 + initialContent?: string; // 내용 초기값 + initialTime?: string; // 메모 시간 초기값 + onSave: (title: string, content: string, noteTime: string) => void; + onCancel: () => void; +} + +const MemoForm: React.FC = ({ + initialTitle = '', + initialContent = '', + initialTime = '00:00', + onSave, + onCancel, +}) => { + const [title, setTitle] = useState(initialTitle); + const [content, setContent] = useState(initialContent); + + return ( +
    +
    {initialTime}
    + setTitle(e.target.value)} + className="memo_title_input" + /> + +
    + + +
    +
    + ); +}; + +export default MemoForm; diff --git a/front-end/src/components/memo/MemoList.tsx b/front-end/src/components/memo/MemoList.tsx new file mode 100644 index 00000000..e2be0621 --- /dev/null +++ b/front-end/src/components/memo/MemoList.tsx @@ -0,0 +1,54 @@ +import React from 'react'; +import { Memo } from '@/types/memo'; +import { useInfiniteScroll } from '@/hooks/memo/useInfiniteScroll'; + +interface MemoListProps { + memos: Memo[]; + hasMoreMemos: boolean; + loadMoreMemos: () => void; + onEditMemo: (memo: Memo) => void; + onDeleteMemo: (memoId: string) => void; + onTimeClick: (time: string) => void; +} + +const MemoList: React.FC = ({ + memos, + hasMoreMemos, + loadMoreMemos, + onEditMemo, + onDeleteMemo, + onTimeClick, +}) => { + const { lastElementRef } = useInfiniteScroll({ + hasMore: hasMoreMemos, + loading: false, + onLoadMore: loadMoreMemos, + }); + + return ( +
    + {memos.length > 0 ? ( + memos.map((memo, index) => { + const isLastMemo = index === memos.length - 1; + return ( +
    + onTimeClick(memo.noteTime)}> + {memo.noteTime || '시간 없음'} + +

    {memo.title || ''}

    +

    {memo.content || '내용 없음'}

    +
    + + +
    +
    + ); + }) + ) : ( +

    메모가 없습니다. 메모를 추가해보세요!

    + )} +
    + ); +}; + +export default MemoList; diff --git a/front-end/src/components/mypage/MyMemoSection.tsx b/front-end/src/components/mypage/MyMemoSection.tsx index 49023395..4ecdc20a 100644 --- a/front-end/src/components/mypage/MyMemoSection.tsx +++ b/front-end/src/components/mypage/MyMemoSection.tsx @@ -1,5 +1,3 @@ -'use client'; - import React, { useEffect, useState, useRef, useCallback } from 'react'; import Link from 'next/link'; import { getAllMemos } from '@/app/api/memoAPI'; @@ -52,6 +50,9 @@ const MyMemoSection: React.FC = () => { fetchMemos(currentPage); }, [currentPage, fetchMemos]); + const truncateText = (text: string, maxLength: number) => + text.length > maxLength ? `${text.slice(0, maxLength)}...` : text; + return (

    내 메모 모음

    @@ -68,8 +69,8 @@ const MyMemoSection: React.FC = () => { ref={isLastMemo ? lastMemoRef : null} > -

    {memo.title || '제목 없음'}

    -

    {memo.content}

    +

    {memo.title || ''}

    +

    {truncateText(memo.content || '', 100)}

    {memo.noteTime}
    diff --git a/front-end/src/components/playlist/CategoryPlaylist.tsx b/front-end/src/components/playlist/CategoryPlaylist.tsx index c4922d56..86e6dee8 100644 --- a/front-end/src/components/playlist/CategoryPlaylist.tsx +++ b/front-end/src/components/playlist/CategoryPlaylist.tsx @@ -1,37 +1,26 @@ 'use client'; -import React, { useEffect, useState, useRef, useCallback } from 'react'; +import React, { useEffect, useState, useCallback } from 'react'; import Image from 'next/image'; import Link from 'next/link'; import { useRouter, useSearchParams } from 'next/navigation'; -import { Video } from '@/types/video'; +import Cookies from 'js-cookie'; import vidListData from '@/data/vidListData'; +import { Video } from '@/types/video'; +import { PlayLists } from '@/types/playlist'; import { fetchVideosByCategory } from '@/app/api/videoAPI'; -import '@/styles/pages/playlist/playlist.scss'; -import Cookies from 'js-cookie'; import { addVideo, getVideo, saveVideo, deletepPlaylist } from '@/app/api/playlistApi'; -import { PlayLists } from '@/types/playlist'; +import useInfiniteScroll from '@/hooks/playlist/useInfiniteScroll'; +import { formatDuration } from '@/utils/playlist/formatDuration'; + +import '@/styles/pages/playlist/playlist.scss'; interface CategoryPlaylistProps { category: string; } -const formatDuration = (duration: string | undefined): string => { - if (!duration) return '0:00'; // duration이 없을 경우 0:00으로 표시 - const match = duration.match(/PT(\d+H)?(\d+M)?(\d+S)?/); - if (!match) return '0:00'; - - const hours = parseInt(match[1]?.replace('H', '') || '0', 10); - const minutes = parseInt(match[2]?.replace('M', '') || '0', 10); - const seconds = parseInt(match[3]?.replace('S', '') || '0', 10); - - const totalMinutes = hours * 60 + minutes; - return `${totalMinutes}:${seconds.toString().padStart(2, '0')}`; -}; - const CategoryPlaylist: React.FC = ({ category: initialCategory }) => { const [videos, setVideos] = useState([]); - const [loading, setLoading] = useState(true); const [error, setError] = useState(''); const [category, setCategory] = useState(initialCategory); const [page, setPage] = useState(0); @@ -40,18 +29,24 @@ const CategoryPlaylist: React.FC = ({ category: initialCa const [playlistNmae, setPlayListName] = useState(''); const [isOpen, setIsOpen] = useState(null); const [showModal, setShowModal] = useState(false); - const observer = useRef(null); const router = useRouter(); const searchParams = useSearchParams(); const query = searchParams.get('query') || ''; const [playlists, setPlaylists] = useState(undefined); + const [loadingVideos, setLoadingVideos] = useState(true); + const [loadingPlaylists, setLoadingPlaylists] = useState(true); + // 비디오 로딩 함수 const loadVideos = useCallback( - async (currentPage: number) => { - setLoading(true); + async (currentPage: number, currentCategory: string, currentQuery: string) => { + setLoadingVideos(true); setError(''); try { - const data = await fetchVideosByCategory({ category, query, page: currentPage }); + const data = await fetchVideosByCategory({ + category: currentCategory, + query: currentQuery, + page: currentPage, + }); setHasMore(!data.last); @@ -65,24 +60,26 @@ const CategoryPlaylist: React.FC = ({ category: initialCa console.error('Error fetching videos:', err); setError('비디오를 불러오는 중 문제가 발생했습니다.'); } finally { - setLoading(false); + setLoadingVideos(false); } }, - [category, query], + [], ); + // 카테고리나 검색어가 변경되면 비디오를 새로 로드 useEffect(() => { setPage(0); setVideos([]); setHasMore(true); - loadVideos(0); + loadVideos(0, category, query); }, [category, query, loadVideos]); + // 페이지 번호가 변경되면 추가 비디오 로드 useEffect(() => { if (page > 0) { - loadVideos(page); + loadVideos(page, category, query); } - }, [page, loadVideos]); + }, [page, category, query, loadVideos]); const handleCategoryClick = (newCategory: string) => { if (newCategory === category) return; @@ -91,31 +88,17 @@ const CategoryPlaylist: React.FC = ({ category: initialCa router.push(`/playlists/${newCategory}`); }; - const lastVideoElementRef = useCallback( - (node: HTMLLIElement | null) => { - if (loading || !hasMore) return; - if (observer.current) observer.current.disconnect(); - - observer.current = new IntersectionObserver( - (entries) => { - if (entries[0].isIntersecting) { - setPage((prevPage) => prevPage + 1); - } - }, - { threshold: 0.1 }, - ); - - if (node) observer.current.observe(node); - }, - [loading, hasMore], - ); - - useEffect(() => { - return () => { - if (observer.current) observer.current.disconnect(); - }; + // 무한 스크롤 기능 + const onLoadMore = useCallback(() => { + setPage((prevPage) => prevPage + 1); }, []); + const { lastElementRef } = useInfiniteScroll({ + hasMore, + loading: loadingVideos, + onLoadMore, + }); + const openModal = () => { setShowModal(true); }; @@ -153,10 +136,6 @@ const CategoryPlaylist: React.FC = ({ category: initialCa } }; - useEffect(() => { - console.log(selectVideo); - }, [selectVideo]); - const token = Cookies.get('token'); const toggleBottomBar = (index: number) => { @@ -166,19 +145,21 @@ const CategoryPlaylist: React.FC = ({ category: initialCa // 재생목록 렌더링 useEffect(() => { const fetchData = async () => { + setLoadingPlaylists(true); try { const data = await getVideo(token); - console.log(data); setPlaylists(data); } catch (error) { console.error(error); // 오류 메시지를 상태에 저장 } finally { - setLoading(false); // 로딩 상태를 false로 설정 + setLoadingPlaylists(false); } }; if (token) { fetchData(); // 데이터 가져오기 호출 + } else { + setLoadingPlaylists(false); // 토큰이 없으면 로딩 상태를 false로 설정 } }, [token]); @@ -200,6 +181,7 @@ const CategoryPlaylist: React.FC = ({ category: initialCa closeModal(); setSelectVideo(''); }; + // 재생목록 삭제 const onClickDelete = async (playlistId: string) => { const token = Cookies.get('token'); @@ -222,6 +204,7 @@ const CategoryPlaylist: React.FC = ({ category: initialCa closeModal(); setSelectVideo(''); }; + return (
      @@ -240,8 +223,10 @@ const CategoryPlaylist: React.FC = ({ category: initialCa
    - {loading && videos.length === 0 &&

    로딩 중...

    } {error &&

    {error}

    } + {!error && videos.length === 0 && !loadingVideos &&

    검색 결과가 없습니다.

    } + + {/* 비디오 리스트 */}
      {videos.map((video, index) => { const isLastVideo = index === videos.length - 1; @@ -249,7 +234,7 @@ const CategoryPlaylist: React.FC = ({ category: initialCa
    • = ({ category: initialCa + )} -
      • { @@ -289,8 +273,8 @@ const CategoryPlaylist: React.FC = ({ category: initialCa ); })}
      - {loading && page > 0 &&

      추가 로딩 중...

      } - {!loading && !error && videos.length === 0 &&

      로딩 중...

      } + + {loadingVideos &&

      로딩 중...

      }
    {/* 모달 */} @@ -303,7 +287,7 @@ const CategoryPlaylist: React.FC = ({ category: initialCa > {/* 오버레이 추가 */}
    -
    e.stopPropagation()}> +
    e.stopPropagation()}> 사용자 재생목록 = ({ category: initialCa />
    - {playlists ? ( + {loadingPlaylists ? ( +

    재생목록을 불러오는 중입니다...

    + ) : playlists ? ( playlists.playlists.map((playlist) => (
    { } }; - useEffect(() => { - console.log(selectedVideoIds); - }, [selectedVideoIds]); + useEffect(() => {}, [selectedVideoIds]); // 비디오를 재생목록에 저장 const handleSaveVideo = async () => { @@ -103,7 +101,6 @@ const TeacherPlaylist = () => { if (!token) return; try { const data = await getVideo(token); - console.log(data); setPlaylists(data); } catch (error) { console.error(error); // 오류 메시지를 상태에 저장 @@ -231,7 +228,7 @@ const TeacherPlaylist = () => {
    {/* 오버레이 추가 */}
    -
    e.stopPropagation()}> +
    e.stopPropagation()}> void; +} + +export const useInfiniteScroll = ({ hasMore, loading, onLoadMore }: UseInfiniteScrollOptions) => { + const observer = useRef(null); + + const lastElementRef = useCallback( + (node: HTMLElement | null) => { + if (loading) return; + if (observer.current) observer.current.disconnect(); + + observer.current = new IntersectionObserver((entries) => { + if (entries[0].isIntersecting && hasMore) { + onLoadMore(); + } + }); + + if (node) observer.current.observe(node); + }, + [loading, hasMore, onLoadMore], + ); + + useEffect(() => { + return () => { + if (observer.current) observer.current.disconnect(); + }; + }, []); + + return { lastElementRef }; +}; diff --git a/front-end/src/hooks/memo/useMemos.ts b/front-end/src/hooks/memo/useMemos.ts new file mode 100644 index 00000000..65fcfe49 --- /dev/null +++ b/front-end/src/hooks/memo/useMemos.ts @@ -0,0 +1,92 @@ +import { useState, useEffect, useCallback } from 'react'; +import { Memo } from '@/types/memo'; +import { saveMemo, updateMemo, deleteMemo, getMemosByVideo } from '@/app/api/memoAPI'; + +interface UseMemosProps { + videoId: string; +} + +export const useMemos = ({ videoId }: UseMemosProps) => { + const [memos, setMemos] = useState([]); + const [currentPage, setCurrentPage] = useState(0); + const [hasMoreMemos, setHasMoreMemos] = useState(true); + + const fetchMemosForVideo = useCallback( + async (page: number) => { + try { + const response = await getMemosByVideo(videoId, page); + setMemos((prevMemos) => + page === 0 ? response.data.content : [...prevMemos, ...response.data.content], + ); + + const isEmptyContent = response.data.empty || response.data.content.length === 0; + setHasMoreMemos(!isEmptyContent && !response.data.last); + } catch (error) { + console.error('Failed to fetch memos for video:', error); + } + }, + [videoId], + ); + + useEffect(() => { + fetchMemosForVideo(currentPage); + }, [currentPage, fetchMemosForVideo]); + + const addMemo = async (title: string, content: string, noteTime: string) => { + try { + const payload = { + title, + content, + noteTime, + videoId, + }; + await saveMemo(payload); + // 메모 새로고침 + setCurrentPage(0); + fetchMemosForVideo(0); + } catch (error) { + console.error('Failed to save memo:', error); + } + }; + + const updateMemoById = async ( + memoId: string, + title: string, + content: string, + noteTime: string, + ) => { + try { + const payload = { + title, + content, + noteTime, + }; + await updateMemo(memoId, payload); + // 메모 새로고침 + setCurrentPage(0); + fetchMemosForVideo(0); + } catch (error) { + console.error('Failed to update memo:', error); + } + }; + + const deleteMemoById = async (memoId: string) => { + try { + await deleteMemo(memoId); + // 메모 새로고침 + setCurrentPage(0); + fetchMemosForVideo(0); + } catch (error) { + console.error('Failed to delete memo:', error); + } + }; + + return { + memos, + hasMoreMemos, + setCurrentPage, + addMemo, + updateMemoById, + deleteMemoById, + }; +}; diff --git a/front-end/src/hooks/memo/useYouTubePlayer.ts b/front-end/src/hooks/memo/useYouTubePlayer.ts new file mode 100644 index 00000000..2e34f9fe --- /dev/null +++ b/front-end/src/hooks/memo/useYouTubePlayer.ts @@ -0,0 +1,81 @@ +import { useState, useEffect, useRef, useCallback } from 'react'; +import { formatTime } from '@/utils/memo/timeUtils'; + +interface UseYouTubePlayerProps { + videoId: string; +} + +export const useYouTubePlayer = ({ videoId }: UseYouTubePlayerProps) => { + const [isYouTubeAPIReady, setIsYouTubeAPIReady] = useState(false); + const [memoTime, setMemoTime] = useState(null); + const playerRef = useRef(null); + + // YouTube API 로드 + useEffect(() => { + const loadYouTubeAPI = () => { + if (!window.YT) { + const script = document.createElement('script'); + script.src = 'https://www.youtube.com/iframe_api'; + script.async = true; + document.body.appendChild(script); + + window.onYouTubeIframeAPIReady = () => { + setIsYouTubeAPIReady(true); + }; + } else { + setIsYouTubeAPIReady(true); + } + }; + + loadYouTubeAPI(); + }, []); + + const handlePlayerStateChange = useCallback((event: YT.PlayerStateChangeEvent) => { + if (event.data === YT.PlayerState.PAUSED && playerRef.current) { + const currentTime = playerRef.current.getCurrentTime + ? Math.floor(playerRef.current.getCurrentTime()) + : 0; + const formattedTime = formatTime(currentTime); + + setMemoTime(formattedTime); + } + }, []); + + // YouTube 플레이어 초기화 + useEffect(() => { + if (isYouTubeAPIReady && videoId && !playerRef.current) { + try { + playerRef.current = new window.YT.Player('youtube-player', { + videoId, + events: { + onStateChange: handlePlayerStateChange, + }, + }); + } catch (error) { + console.error('Failed to initialize YouTube Player:', error); + } + } + }, [isYouTubeAPIReady, videoId, handlePlayerStateChange]); + + const handleAddMemo = () => { + if (playerRef.current?.getCurrentTime) { + const currentTime = Math.floor(playerRef.current.getCurrentTime()); + const formattedTime = formatTime(currentTime); + + setMemoTime(formattedTime); + } else { + console.error('Player is not initialized. Cannot add memo.'); + } + }; + + const seekToTime = (time: string) => { + if (playerRef.current?.seekTo) { + const [minutes, seconds] = time.split(':').map(Number); + playerRef.current.seekTo(minutes * 60 + seconds, true); + } else { + console.error('Player is not initialized.'); + } + }; + + return { memoTime, handleAddMemo, seekToTime }; +}; diff --git a/front-end/src/hooks/playlist/useInfiniteScroll.ts b/front-end/src/hooks/playlist/useInfiniteScroll.ts new file mode 100644 index 00000000..f7d63268 --- /dev/null +++ b/front-end/src/hooks/playlist/useInfiniteScroll.ts @@ -0,0 +1,37 @@ +import { useEffect, useRef, useCallback } from 'react'; + +interface UseInfiniteScrollOptions { + hasMore: boolean; + loading: boolean; + onLoadMore: () => void; +} + +function useInfiniteScroll({ hasMore, loading, onLoadMore }: UseInfiniteScrollOptions) { + const observer = useRef(null); + + const lastElementRef = useCallback( + (node: HTMLElement | null) => { + if (loading) return; + if (observer.current) observer.current.disconnect(); + + observer.current = new IntersectionObserver((entries) => { + if (entries[0].isIntersecting && hasMore) { + onLoadMore(); + } + }); + + if (node) observer.current.observe(node); + }, + [loading, hasMore, onLoadMore], + ); + + useEffect(() => { + return () => { + if (observer.current) observer.current.disconnect(); + }; + }, []); + + return { lastElementRef }; +} + +export default useInfiniteScroll; diff --git a/front-end/src/styles/pages/playlist/playlist.scss b/front-end/src/styles/pages/playlist/playlist.scss index cec5537b..27d96692 100644 --- a/front-end/src/styles/pages/playlist/playlist.scss +++ b/front-end/src/styles/pages/playlist/playlist.scss @@ -22,7 +22,7 @@ width: 30%; height: auto; - .modal-content { + .modal_content { display: flex; flex-direction: column; align-items: center; @@ -237,6 +237,12 @@ padding: 2rem 0; } + .memo_title { + font-size: 1.7rem; + font-weight: bold; + margin-bottom: 1rem; + } + .memo_notice { color: #444; margin-top: 1rem; @@ -266,12 +272,25 @@ margin-right: 1rem; font-size: 1.4rem; } + .memo_title_input { + padding: 1rem; + border-radius: 1rem; + border: 1px solid #ccc; + margin-bottom: 1rem; + width: 30rem; + } .memo_input { width: 100%; resize: none; padding: 1rem; border-radius: 1rem; border: 1px solid #ccc; + margin-bottom: 0.5rem; + height: 10rem; + + &::placeholder { + color: #ccc; + } } .save_memo_title { font-size: 2rem; @@ -312,7 +331,7 @@ width: 100%; height: 100vh; - .modal-content { + .modal_content { position: absolute; top: 50%; left: 50%; @@ -327,7 +346,7 @@ color: #5a9bdb; font-size: 2rem; font-weight: bold; - overflow: scroll; + overflow: auto; max-height: 70vh; overflow-x: auto; .playlist_content_container { @@ -361,6 +380,8 @@ .modal_actions { margin-top: 3rem; + display: flex; + justify-content: center; button { width: 8rem; diff --git a/front-end/src/utils/memo/timeUtils.ts b/front-end/src/utils/memo/timeUtils.ts new file mode 100644 index 00000000..696c5cf7 --- /dev/null +++ b/front-end/src/utils/memo/timeUtils.ts @@ -0,0 +1,10 @@ +export const formatTime = (seconds: number): string => { + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + const secs = seconds % 60; + + if (hours > 0) { + return `${hours}:${String(minutes).padStart(2, '0')}:${String(secs).padStart(2, '0')}`; + } + return `${minutes}:${String(secs).padStart(2, '0')}`; +}; diff --git a/front-end/src/utils/playlist/formatDuration.ts b/front-end/src/utils/playlist/formatDuration.ts new file mode 100644 index 00000000..1c861366 --- /dev/null +++ b/front-end/src/utils/playlist/formatDuration.ts @@ -0,0 +1,17 @@ +export const formatDuration = (duration: string | undefined): string => { + if (!duration) return '0:00'; // duration 값이 없을 경우 "0:00" 반환 + const match = duration.match(/PT(\d+H)?(\d+M)?(\d+S)?/); + if (!match) return '0:00'; + + const hours = parseInt(match[1]?.replace('H', '') || '0', 10); + const minutes = parseInt(match[2]?.replace('M', '') || '0', 10); + const seconds = parseInt(match[3]?.replace('S', '') || '0', 10); + + if (hours > 0) { + // 시간이 1 이상인 경우 H:MM:SS 포맷으로 반환 + return `${hours}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`; + } + + // 그렇지 않으면 MM:SS 포맷으로 반환 + return `${minutes}:${seconds.toString().padStart(2, '0')}`; +}; From 46aba397883a3294694a646cce75a8856c028c86 Mon Sep 17 00:00:00 2001 From: kimhyein <70068498+henny1105@users.noreply.github.com> Date: Tue, 3 Dec 2024 15:20:59 +0900 Subject: [PATCH 02/45] =?UTF-8?q?[FE]=20[#176]=20=EB=A9=94=EB=AA=A8=20?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=A7=80=20=EC=88=98=EC=A0=95=20=20(#179)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Initial commit * feat: 개발환경 구축 * refactor: 디렉토리 구조 및 회원가입 관련 서비스 개선 디렉토리 구조를 올바르게 개선하였으며, ModelMapper, Swagger, QueryDSL 설정을 위한 Config 클래스를 분리하여 모듈화를 진행함. feat: Exception 처리 추가 및 User 엔티티 수정 handleUserAlreadyExistsException, handleInvalidEmailFormatException 예외 처리 추가. User 엔티티에 username 필드를 추가하고 기존 nickname을 대체하는 방식으로 Security 설정을 수정함. feat: 회원가입 서비스 구현 및 UserResponse 클래스 추가 회원가입 로직을 위한 컨트롤러, 서비스, 서비스 구현체, 리포지토리, 리포지토리 커스텀, 리포지토리 구현체를 추가하였으며, UserResponse DTO를 추가하여 응답 처리 로직을 개선함. chore: ModelMapper 설정 추가 및 DTO 필드 제외 기능 구현 ModelMapper 설정을 추가하여 DTO에서 특정 필드 변수를 제외할 수 있는 기능을 구현함. * refactor: Category entity 삭제 후 Video entity Category enum으로 대체 * refactor: 잘못된 브랜치 병합 되돌리기 및 재구축 * feat: PR 템플릿 추가 * [1.0.0] 1차 배포 진행 * chore: gpt_history 삭제 (#171) * [FE] 8080 포트 주소 변경 (#172) * [BE] 배포 환경에 맞게 시큐리티 수정 (#173) * fix: 배포 환경에 맞게 시큐리티 수정 * chore: memo 경로 수정 * chore: 구 시큐리티 클래스 , application.yaml 파일 삭제 * feat: 메모 기능 카테고리 리스트 수정 --------- Co-authored-by: Donguk Kim <129242580+atoonatoo@users.noreply.github.com> Co-authored-by: Jiho Co-authored-by: Jiho Kim <83708500+Jiho001@users.noreply.github.com> --- .../playlists/[category]/[videoId]/page.tsx | 423 ++++-------------- .../[category]/[videoId]/page.tsx | 423 ++++-------------- .../components/playlist/CategoryPlaylist.tsx | 4 +- .../components/playlist/TeacherPlaylist.tsx | 2 +- 4 files changed, 162 insertions(+), 690 deletions(-) diff --git a/front-end/src/app/playlists/[category]/[videoId]/page.tsx b/front-end/src/app/playlists/[category]/[videoId]/page.tsx index a8e74518..6502ab70 100644 --- a/front-end/src/app/playlists/[category]/[videoId]/page.tsx +++ b/front-end/src/app/playlists/[category]/[videoId]/page.tsx @@ -1,263 +1,40 @@ 'use client'; -import React, { useEffect, useState, useRef, useCallback } from 'react'; +import React, { useState, useEffect } from 'react'; import { useParams } from 'next/navigation'; +import { useYouTubePlayer } from '@/hooks/memo/useYouTubePlayer'; +import { useMemos } from '@/hooks/memo/useMemos'; +import MemoList from '@/components/memo/MemoList'; +import MemoForm from '@/components/memo/MemoForm'; +import ConfirmModal from '@/components/memo/ConfirmModal'; import { fetchVideoDetails } from '@/app/api/videoAPIDetail'; -import { saveMemo, updateMemo, deleteMemo, getMemosByVideo } from '@/app/api/memoAPI'; import '@/styles/pages/playlist/playlist.scss'; -interface VideoDetails { - title: string; - channelTitle: string; -} - -interface Memo { - id?: string; - noteTime: string; - content: string; - videoId: string; -} - -// YouTube API를 동적으로 로드 -const loadYouTubeAPI = (onReady: () => void) => { - if (!window.YT) { - const script = document.createElement('script'); - script.src = 'https://www.youtube.com/iframe_api'; - script.async = true; - document.body.appendChild(script); - - window.onYouTubeIframeAPIReady = () => { - onReady(); - }; - } else { - onReady(); - } -}; - const VideoPlayerPage: React.FC = () => { const { videoId } = useParams(); - const [videoDetails, setVideoDetails] = useState(null); - const [isYouTubeAPIReady, setIsYouTubeAPIReady] = useState(false); - const [memoText, setMemoText] = useState(''); - const [memos, setMemos] = useState([]); - const [memoTime, setMemoTime] = useState(null); - const [editingMemoId, setEditingMemoId] = useState(null); - const [isAddingMemo, setIsAddingMemo] = useState(false); - const [editedMemoContent, setEditedMemoContent] = useState(''); - const [editedMemoTime, setEditedMemoTime] = useState(null); - const [currentPage, setCurrentPage] = useState(0); - const [hasMoreMemos, setHasMoreMemos] = useState(true); - const observer = useRef(null); - const [memoToDelete, setMemoToDelete] = useState(null); - const playerRef = useRef(null); - - // YouTube API 로드 - useEffect(() => { - loadYouTubeAPI(() => setIsYouTubeAPIReady(true)); - }, []); - - // YouTube 플레이어 상태 변경 핸들러 - const handlePlayerStateChange = useCallback((event: YT.PlayerStateChangeEvent) => { - if (event.data === YT.PlayerState.PAUSED && playerRef.current) { - const currentTime = playerRef.current.getCurrentTime - ? Math.floor(playerRef.current.getCurrentTime()) - : 0; - const formattedTime = formatTime(currentTime); - - setMemoTime(formattedTime); - } - }, []); - - // YouTube 플레이어 초기화 - useEffect(() => { - if (isYouTubeAPIReady && videoId && !playerRef.current) { - try { - playerRef.current = new window.YT.Player('youtube-player', { - videoId: videoId as string, - events: { - onStateChange: handlePlayerStateChange, - }, - }); - } catch (error) { - console.error('Failed to initialize YouTube Player:', error); - } - } - }, [isYouTubeAPIReady, videoId, handlePlayerStateChange]); - - // 시간을 포맷팅 - const formatTime = (seconds: number): string => { - const date = new Date(0); - date.setSeconds(seconds); - return date.toISOString().substr(14, 5); - }; - - // 메모 초기화 - const resetMemo = () => { - setMemoText(''); - setIsAddingMemo(false); - setMemoTime(null); - }; - - // 메모 추가 - const handleAddMemo = () => { - if (playerRef.current?.getCurrentTime) { - const currentTime = Math.floor(playerRef.current.getCurrentTime()); - const formattedTime = formatTime(currentTime); - - setMemoTime(formattedTime); - setIsAddingMemo(true); - } else { - console.error('Player is not initialized. Cannot add memo.'); - } - }; - - // 메모 저장 - const handleSaveMemo = async () => { - if (!memoText.trim() || !memoTime) { - console.warn('Memo text or time is missing:', { memoText, memoTime }); - return; - } - - // videoId를 string으로 변환 - const normalizedVideoId = Array.isArray(videoId) ? videoId[0] : videoId; - - try { - const payload = { - title: videoDetails?.title || '', - content: memoText, - noteTime: memoTime || '00:00', - videoId: normalizedVideoId, - }; - - await saveMemo(payload); - - const updatedResponse = await getMemosByVideo(normalizedVideoId, 0); - setMemos(updatedResponse.data.content); - setCurrentPage(0); - setHasMoreMemos(!updatedResponse.data.last); - } catch (error) { - console.error('Failed to save memo:', error); - } - - resetMemo(); - }; - - // 메모 수정 시작 - const handleEditMemo = (memo: Memo) => { - setEditingMemoId(memo.id || null); - setEditedMemoContent(memo.content); - setEditedMemoTime(memo.noteTime || '00:00'); - }; - - // 메모 수정 취소 - const handleCancelEdit = () => { - setEditingMemoId(null); - setEditedMemoContent(''); - setEditedMemoTime(null); - }; - - // 메모 수정 저장 - const handleSaveEditedMemo = async (memoId: string) => { - if (!editedMemoContent.trim() || !editedMemoTime) { - console.warn('Edited memo text or time is missing:', { - editedMemoContent, - editedMemoTime, - }); - return; - } - - try { - const payload = { - noteTime: editedMemoTime || '00:00', - content: editedMemoContent, - }; - - await updateMemo(memoId, payload); + const normalizedVideoId = Array.isArray(videoId) ? videoId[0] : videoId; + const [videoDetails, setVideoDetails] = useState<{ title: string } | null>(null); + const { memoTime, handleAddMemo, seekToTime } = useYouTubePlayer({ videoId: normalizedVideoId }); - // videoId를 string으로 변환 - const normalizedVideoId = Array.isArray(videoId) ? videoId[0] : videoId; - - const updatedResponse = await getMemosByVideo(normalizedVideoId, 0); - setMemos(updatedResponse.data.content); - setCurrentPage(0); - setHasMoreMemos(!updatedResponse.data.last); - } catch (error) { - console.error('Failed to update memo:', error); - } - - handleCancelEdit(); - }; - - // 메모 삭제 - const handleDeleteMemo = async (memoId: string) => { - if (!memoId) { - console.warn('Memo ID is missing for deletion'); - return; - } - - try { - await deleteMemo(memoId); - - // videoId를 string으로 변환 - const normalizedVideoId = Array.isArray(videoId) ? videoId[0] : videoId; - - const updatedResponse = await getMemosByVideo(normalizedVideoId, 0); - setMemos(updatedResponse.data.content); - setCurrentPage(0); - setHasMoreMemos(!updatedResponse.data.last); - } catch (error) { - console.error('Failed to delete memo:', error); - } - }; - - // 특정 시간대로 이동 - const handleTimeClick = (time: string | undefined) => { - if (!time) { - console.error('Time is missing for memo'); - return; - } - - if (playerRef.current?.seekTo) { - const [minutes, seconds] = time.split(':').map(Number); - playerRef.current.seekTo(minutes * 60 + seconds, true); - } else { - console.error('Player is not initialized.'); - } - }; - - // 메모 리스트 가져오기 - const fetchMemosForVideo = useCallback( - async (page: number) => { - try { - // videoId를 string으로 변환 - const normalizedVideoId = Array.isArray(videoId) ? videoId[0] : videoId; - - const response = await getMemosByVideo(normalizedVideoId, page); - - setMemos((prevMemos) => - page === 0 ? response.data.content : [...prevMemos, ...response.data.content], - ); - - const isEmptyContent = response.data.empty || response.data.content.length === 0; - setHasMoreMemos(!isEmptyContent && !response.data.last); - } catch (error) { - console.error('Failed to fetch memos for video:', error); - } - }, - [videoId], + const { memos, hasMoreMemos, setCurrentPage, addMemo, updateMemoById, deleteMemoById } = useMemos( + { videoId: normalizedVideoId }, ); - // 메모 페이지네이션 - useEffect(() => { - fetchMemosForVideo(currentPage); - }, [currentPage, fetchMemosForVideo]); + const [isAddingMemo, setIsAddingMemo] = useState(false); + const [editingMemo, setEditingMemo] = useState<{ + id: string; + title: string; + content: string; + noteTime: string; + } | null>(null); + const [memoToDelete, setMemoToDelete] = useState(null); // 동영상 정보 가져오기 useEffect(() => { const fetchVideoDetailsAsync = async () => { - if (videoId) { + if (normalizedVideoId) { try { - const details = await fetchVideoDetails(videoId as string); + const details = await fetchVideoDetails(normalizedVideoId); setVideoDetails(details); } catch (error) { console.error('Failed to fetch video details:', error); @@ -266,23 +43,26 @@ const VideoPlayerPage: React.FC = () => { }; fetchVideoDetailsAsync(); - }, [videoId]); + }, [normalizedVideoId]); - // 무한 스크롤 처리 - const lastMemoElementRef = useCallback( - (node: HTMLDivElement | null) => { - if (observer.current) observer.current.disconnect(); + // 메모 저장 + const handleSaveMemo = (title: string, content: string, noteTime: string) => { + addMemo(title, content, noteTime); + setIsAddingMemo(false); + }; - observer.current = new IntersectionObserver((entries) => { - if (entries[0].isIntersecting && hasMoreMemos) { - setCurrentPage((prevPage) => prevPage + 1); - } - }); + // 메모 수정 저장 + const handleSaveEditedMemo = (title: string, content: string, noteTime: string) => { + if (editingMemo) { + updateMemoById(editingMemo.id, title, content, noteTime); + setEditingMemo(null); + } + }; - if (node) observer.current.observe(node); - }, - [hasMoreMemos], - ); + // 메모 더 로드 + const loadMoreMemos = () => { + setCurrentPage((prevPage) => prevPage + 1); + }; return (
    @@ -294,75 +74,42 @@ const VideoPlayerPage: React.FC = () => {

    메모 목록

    메모를 클릭하여 수정하거나 삭제할 수 있습니다.✏️

    내 전체 메모는 마이페이지에서 확인이 가능합니다.🍀

    -
    - {memos.length > 0 ? ( - memos.map((memo, index) => { - const isLastMemo = memos.length === index + 1; - return ( -
    - { - handleTimeClick(memo.noteTime); - }} - > - {memo.noteTime || '시간 없음'} - - {editingMemoId === memo.id ? ( - // 편집 중인 메모 -
    - -
    - - -
    -
    - ) : ( - // 일반 메모 - <> -

    {memo.content || '내용 없음'}

    -
    - - -
    - - )} -
    - ); - }) - ) : ( -

    메모가 없습니다. 메모를 추가해보세요!

    - )} -
    + {/* 메모 리스트 */} + setEditingMemo(memo)} + onDeleteMemo={(memoId) => setMemoToDelete(memoId)} + onTimeClick={seekToTime} + /> + + {/* 메모 추가 또는 수정 */} {isAddingMemo ? ( -
    -
    {memoTime}
    - -
    - - -
    -
    + setIsAddingMemo(false)} + /> + ) : editingMemo ? ( + setEditingMemo(null)} + /> ) : ( - )} @@ -370,26 +117,16 @@ const VideoPlayerPage: React.FC = () => {
    + {/* 삭제 확인 모달 */} {memoToDelete && ( -
    -
    -

    정말 삭제하시겠습니까?

    -
    - - -
    -
    -
    + { + deleteMemoById(memoToDelete); + setMemoToDelete(null); + }} + onCancel={() => setMemoToDelete(null)} + /> )}
    ); diff --git a/front-end/src/app/teacher-lists/[category]/[videoId]/page.tsx b/front-end/src/app/teacher-lists/[category]/[videoId]/page.tsx index a8e74518..6502ab70 100644 --- a/front-end/src/app/teacher-lists/[category]/[videoId]/page.tsx +++ b/front-end/src/app/teacher-lists/[category]/[videoId]/page.tsx @@ -1,263 +1,40 @@ 'use client'; -import React, { useEffect, useState, useRef, useCallback } from 'react'; +import React, { useState, useEffect } from 'react'; import { useParams } from 'next/navigation'; +import { useYouTubePlayer } from '@/hooks/memo/useYouTubePlayer'; +import { useMemos } from '@/hooks/memo/useMemos'; +import MemoList from '@/components/memo/MemoList'; +import MemoForm from '@/components/memo/MemoForm'; +import ConfirmModal from '@/components/memo/ConfirmModal'; import { fetchVideoDetails } from '@/app/api/videoAPIDetail'; -import { saveMemo, updateMemo, deleteMemo, getMemosByVideo } from '@/app/api/memoAPI'; import '@/styles/pages/playlist/playlist.scss'; -interface VideoDetails { - title: string; - channelTitle: string; -} - -interface Memo { - id?: string; - noteTime: string; - content: string; - videoId: string; -} - -// YouTube API를 동적으로 로드 -const loadYouTubeAPI = (onReady: () => void) => { - if (!window.YT) { - const script = document.createElement('script'); - script.src = 'https://www.youtube.com/iframe_api'; - script.async = true; - document.body.appendChild(script); - - window.onYouTubeIframeAPIReady = () => { - onReady(); - }; - } else { - onReady(); - } -}; - const VideoPlayerPage: React.FC = () => { const { videoId } = useParams(); - const [videoDetails, setVideoDetails] = useState(null); - const [isYouTubeAPIReady, setIsYouTubeAPIReady] = useState(false); - const [memoText, setMemoText] = useState(''); - const [memos, setMemos] = useState([]); - const [memoTime, setMemoTime] = useState(null); - const [editingMemoId, setEditingMemoId] = useState(null); - const [isAddingMemo, setIsAddingMemo] = useState(false); - const [editedMemoContent, setEditedMemoContent] = useState(''); - const [editedMemoTime, setEditedMemoTime] = useState(null); - const [currentPage, setCurrentPage] = useState(0); - const [hasMoreMemos, setHasMoreMemos] = useState(true); - const observer = useRef(null); - const [memoToDelete, setMemoToDelete] = useState(null); - const playerRef = useRef(null); - - // YouTube API 로드 - useEffect(() => { - loadYouTubeAPI(() => setIsYouTubeAPIReady(true)); - }, []); - - // YouTube 플레이어 상태 변경 핸들러 - const handlePlayerStateChange = useCallback((event: YT.PlayerStateChangeEvent) => { - if (event.data === YT.PlayerState.PAUSED && playerRef.current) { - const currentTime = playerRef.current.getCurrentTime - ? Math.floor(playerRef.current.getCurrentTime()) - : 0; - const formattedTime = formatTime(currentTime); - - setMemoTime(formattedTime); - } - }, []); - - // YouTube 플레이어 초기화 - useEffect(() => { - if (isYouTubeAPIReady && videoId && !playerRef.current) { - try { - playerRef.current = new window.YT.Player('youtube-player', { - videoId: videoId as string, - events: { - onStateChange: handlePlayerStateChange, - }, - }); - } catch (error) { - console.error('Failed to initialize YouTube Player:', error); - } - } - }, [isYouTubeAPIReady, videoId, handlePlayerStateChange]); - - // 시간을 포맷팅 - const formatTime = (seconds: number): string => { - const date = new Date(0); - date.setSeconds(seconds); - return date.toISOString().substr(14, 5); - }; - - // 메모 초기화 - const resetMemo = () => { - setMemoText(''); - setIsAddingMemo(false); - setMemoTime(null); - }; - - // 메모 추가 - const handleAddMemo = () => { - if (playerRef.current?.getCurrentTime) { - const currentTime = Math.floor(playerRef.current.getCurrentTime()); - const formattedTime = formatTime(currentTime); - - setMemoTime(formattedTime); - setIsAddingMemo(true); - } else { - console.error('Player is not initialized. Cannot add memo.'); - } - }; - - // 메모 저장 - const handleSaveMemo = async () => { - if (!memoText.trim() || !memoTime) { - console.warn('Memo text or time is missing:', { memoText, memoTime }); - return; - } - - // videoId를 string으로 변환 - const normalizedVideoId = Array.isArray(videoId) ? videoId[0] : videoId; - - try { - const payload = { - title: videoDetails?.title || '', - content: memoText, - noteTime: memoTime || '00:00', - videoId: normalizedVideoId, - }; - - await saveMemo(payload); - - const updatedResponse = await getMemosByVideo(normalizedVideoId, 0); - setMemos(updatedResponse.data.content); - setCurrentPage(0); - setHasMoreMemos(!updatedResponse.data.last); - } catch (error) { - console.error('Failed to save memo:', error); - } - - resetMemo(); - }; - - // 메모 수정 시작 - const handleEditMemo = (memo: Memo) => { - setEditingMemoId(memo.id || null); - setEditedMemoContent(memo.content); - setEditedMemoTime(memo.noteTime || '00:00'); - }; - - // 메모 수정 취소 - const handleCancelEdit = () => { - setEditingMemoId(null); - setEditedMemoContent(''); - setEditedMemoTime(null); - }; - - // 메모 수정 저장 - const handleSaveEditedMemo = async (memoId: string) => { - if (!editedMemoContent.trim() || !editedMemoTime) { - console.warn('Edited memo text or time is missing:', { - editedMemoContent, - editedMemoTime, - }); - return; - } - - try { - const payload = { - noteTime: editedMemoTime || '00:00', - content: editedMemoContent, - }; - - await updateMemo(memoId, payload); + const normalizedVideoId = Array.isArray(videoId) ? videoId[0] : videoId; + const [videoDetails, setVideoDetails] = useState<{ title: string } | null>(null); + const { memoTime, handleAddMemo, seekToTime } = useYouTubePlayer({ videoId: normalizedVideoId }); - // videoId를 string으로 변환 - const normalizedVideoId = Array.isArray(videoId) ? videoId[0] : videoId; - - const updatedResponse = await getMemosByVideo(normalizedVideoId, 0); - setMemos(updatedResponse.data.content); - setCurrentPage(0); - setHasMoreMemos(!updatedResponse.data.last); - } catch (error) { - console.error('Failed to update memo:', error); - } - - handleCancelEdit(); - }; - - // 메모 삭제 - const handleDeleteMemo = async (memoId: string) => { - if (!memoId) { - console.warn('Memo ID is missing for deletion'); - return; - } - - try { - await deleteMemo(memoId); - - // videoId를 string으로 변환 - const normalizedVideoId = Array.isArray(videoId) ? videoId[0] : videoId; - - const updatedResponse = await getMemosByVideo(normalizedVideoId, 0); - setMemos(updatedResponse.data.content); - setCurrentPage(0); - setHasMoreMemos(!updatedResponse.data.last); - } catch (error) { - console.error('Failed to delete memo:', error); - } - }; - - // 특정 시간대로 이동 - const handleTimeClick = (time: string | undefined) => { - if (!time) { - console.error('Time is missing for memo'); - return; - } - - if (playerRef.current?.seekTo) { - const [minutes, seconds] = time.split(':').map(Number); - playerRef.current.seekTo(minutes * 60 + seconds, true); - } else { - console.error('Player is not initialized.'); - } - }; - - // 메모 리스트 가져오기 - const fetchMemosForVideo = useCallback( - async (page: number) => { - try { - // videoId를 string으로 변환 - const normalizedVideoId = Array.isArray(videoId) ? videoId[0] : videoId; - - const response = await getMemosByVideo(normalizedVideoId, page); - - setMemos((prevMemos) => - page === 0 ? response.data.content : [...prevMemos, ...response.data.content], - ); - - const isEmptyContent = response.data.empty || response.data.content.length === 0; - setHasMoreMemos(!isEmptyContent && !response.data.last); - } catch (error) { - console.error('Failed to fetch memos for video:', error); - } - }, - [videoId], + const { memos, hasMoreMemos, setCurrentPage, addMemo, updateMemoById, deleteMemoById } = useMemos( + { videoId: normalizedVideoId }, ); - // 메모 페이지네이션 - useEffect(() => { - fetchMemosForVideo(currentPage); - }, [currentPage, fetchMemosForVideo]); + const [isAddingMemo, setIsAddingMemo] = useState(false); + const [editingMemo, setEditingMemo] = useState<{ + id: string; + title: string; + content: string; + noteTime: string; + } | null>(null); + const [memoToDelete, setMemoToDelete] = useState(null); // 동영상 정보 가져오기 useEffect(() => { const fetchVideoDetailsAsync = async () => { - if (videoId) { + if (normalizedVideoId) { try { - const details = await fetchVideoDetails(videoId as string); + const details = await fetchVideoDetails(normalizedVideoId); setVideoDetails(details); } catch (error) { console.error('Failed to fetch video details:', error); @@ -266,23 +43,26 @@ const VideoPlayerPage: React.FC = () => { }; fetchVideoDetailsAsync(); - }, [videoId]); + }, [normalizedVideoId]); - // 무한 스크롤 처리 - const lastMemoElementRef = useCallback( - (node: HTMLDivElement | null) => { - if (observer.current) observer.current.disconnect(); + // 메모 저장 + const handleSaveMemo = (title: string, content: string, noteTime: string) => { + addMemo(title, content, noteTime); + setIsAddingMemo(false); + }; - observer.current = new IntersectionObserver((entries) => { - if (entries[0].isIntersecting && hasMoreMemos) { - setCurrentPage((prevPage) => prevPage + 1); - } - }); + // 메모 수정 저장 + const handleSaveEditedMemo = (title: string, content: string, noteTime: string) => { + if (editingMemo) { + updateMemoById(editingMemo.id, title, content, noteTime); + setEditingMemo(null); + } + }; - if (node) observer.current.observe(node); - }, - [hasMoreMemos], - ); + // 메모 더 로드 + const loadMoreMemos = () => { + setCurrentPage((prevPage) => prevPage + 1); + }; return (
    @@ -294,75 +74,42 @@ const VideoPlayerPage: React.FC = () => {

    메모 목록

    메모를 클릭하여 수정하거나 삭제할 수 있습니다.✏️

    내 전체 메모는 마이페이지에서 확인이 가능합니다.🍀

    -
    - {memos.length > 0 ? ( - memos.map((memo, index) => { - const isLastMemo = memos.length === index + 1; - return ( -
    - { - handleTimeClick(memo.noteTime); - }} - > - {memo.noteTime || '시간 없음'} - - {editingMemoId === memo.id ? ( - // 편집 중인 메모 -
    - -
    - - -
    -
    - ) : ( - // 일반 메모 - <> -

    {memo.content || '내용 없음'}

    -
    - - -
    - - )} -
    - ); - }) - ) : ( -

    메모가 없습니다. 메모를 추가해보세요!

    - )} -
    + {/* 메모 리스트 */} + setEditingMemo(memo)} + onDeleteMemo={(memoId) => setMemoToDelete(memoId)} + onTimeClick={seekToTime} + /> + + {/* 메모 추가 또는 수정 */} {isAddingMemo ? ( -
    -
    {memoTime}
    - -
    - - -
    -
    + setIsAddingMemo(false)} + /> + ) : editingMemo ? ( + setEditingMemo(null)} + /> ) : ( - )} @@ -370,26 +117,16 @@ const VideoPlayerPage: React.FC = () => {
    + {/* 삭제 확인 모달 */} {memoToDelete && ( -
    -
    -

    정말 삭제하시겠습니까?

    -
    - - -
    -
    -
    + { + deleteMemoById(memoToDelete); + setMemoToDelete(null); + }} + onCancel={() => setMemoToDelete(null)} + /> )}
    ); diff --git a/front-end/src/components/playlist/CategoryPlaylist.tsx b/front-end/src/components/playlist/CategoryPlaylist.tsx index 86e6dee8..22a2b8e5 100644 --- a/front-end/src/components/playlist/CategoryPlaylist.tsx +++ b/front-end/src/components/playlist/CategoryPlaylist.tsx @@ -225,7 +225,7 @@ const CategoryPlaylist: React.FC = ({ category: initialCa
    {error &&

    {error}

    } {!error && videos.length === 0 && !loadingVideos &&

    검색 결과가 없습니다.

    } - + {loadingVideos &&

    로딩 중...

    } {/* 비디오 리스트 */}
      {videos.map((video, index) => { @@ -273,8 +273,6 @@ const CategoryPlaylist: React.FC = ({ category: initialCa ); })}
    - - {loadingVideos &&

    로딩 중...

    }
    {/* 모달 */} diff --git a/front-end/src/components/playlist/TeacherPlaylist.tsx b/front-end/src/components/playlist/TeacherPlaylist.tsx index 76dcbc64..6c72b0f2 100644 --- a/front-end/src/components/playlist/TeacherPlaylist.tsx +++ b/front-end/src/components/playlist/TeacherPlaylist.tsx @@ -179,7 +179,7 @@ const TeacherPlaylist = () => {
      {allQuery.isLoading || instQuery.isLoading ? ( -

      로딩 중입니다...

      +

      로딩 중...

      ) : videos.length > 0 ? ( videos.map((video, index) => (
    • From f31d3fe40cf6d27e753e81f568ab88040dddd84e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=98=81?= <163282490+hyeok9912@users.noreply.github.com> Date: Tue, 3 Dec 2024 16:39:04 +0900 Subject: [PATCH 03/45] =?UTF-8?q?[FE][#176]=201=EC=B0=A8=20=EB=B0=B0?= =?UTF-8?q?=ED=8F=AC=20=EC=9D=B4=ED=9B=84=20QA=20=EB=B0=98=EC=98=81=20?= =?UTF-8?q?=ED=94=84=EB=A1=A0=ED=8A=B8=EC=97=94=EB=93=9C=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=EC=82=AC=ED=95=AD=20(#181)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refector:마이페이지 scss 수정 * refector:console.log 삭제 * refector:GPT 입력란 보더값 추가 / 챗봇 짤림현상 수정 / 챗봇 위치 수정 * refector:로그인 오류시 반환 텍스트 수정 * refector:재생목록 생성시 글자 수 제한 --- .vscode/launch.json | 14 ++++++++ front-end/src/app/@isLoginChatbot/page.tsx | 6 ++-- front-end/src/app/api/playlistApi.ts | 1 - front-end/src/app/login/page.tsx | 3 +- .../playlistId/[playlistId]/page.tsx | 1 - .../src/components/mypage/MyVideoSection.tsx | 35 ++++++++++--------- .../components/playlist/CategoryPlaylist.tsx | 19 +++++++--- .../components/playlist/TeacherPlaylist.tsx | 19 +++++++--- .../src/styles/pages/chatbot/chatbot.scss | 8 ++--- .../pages/my-video-list/my-video-list.scss | 17 +++++---- front-end/src/styles/pages/mypage/mypage.scss | 19 +++++++--- 11 files changed, 92 insertions(+), 50 deletions(-) create mode 100644 .vscode/launch.json diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 00000000..73f4ff31 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,14 @@ +{ + "configurations": [ + { + "type": "java", + "name": "Spring Boot-BackendApplication", + "request": "launch", + "cwd": "${workspaceFolder}", + "mainClass": "com.techie.backend.BackendApplication", + "projectName": "back-end", + "args": "", + "envFile": "${workspaceFolder}/.env" + } + ] +} diff --git a/front-end/src/app/@isLoginChatbot/page.tsx b/front-end/src/app/@isLoginChatbot/page.tsx index d62456ce..d3f4a9e8 100644 --- a/front-end/src/app/@isLoginChatbot/page.tsx +++ b/front-end/src/app/@isLoginChatbot/page.tsx @@ -83,8 +83,6 @@ const Chatbot = () => { const apiResponse = await fetchChatBot({ request: textarea, token: token }); typeResponse(apiResponse.response); setTextarea(''); - console.log(apiResponse.response); - console.log('토큰 가져오기 성공', token); } catch (error) { console.error('함수요청 오류', error); } finally { @@ -121,7 +119,7 @@ const Chatbot = () => {
      💬
      e.stopPropagation()}>

      {loading ? '응답을 받아오는 중입니다...' : gptResponse}

      @@ -135,7 +133,7 @@ const Chatbot = () => { onKeyDown={keyDownEnter} >
      diff --git a/front-end/src/app/api/playlistApi.ts b/front-end/src/app/api/playlistApi.ts index bbe12167..5f8b6578 100644 --- a/front-end/src/app/api/playlistApi.ts +++ b/front-end/src/app/api/playlistApi.ts @@ -20,7 +20,6 @@ export const saveVideo = async ( }, }, ); - console.log('영상저장 성공'); return response.data; } catch (error) { console.error('Unexpected error:', error); diff --git a/front-end/src/app/login/page.tsx b/front-end/src/app/login/page.tsx index ef304223..f5cb66e5 100644 --- a/front-end/src/app/login/page.tsx +++ b/front-end/src/app/login/page.tsx @@ -60,8 +60,7 @@ const Login = () => { console.error('Failed to log in, unexpected response status'); } } catch (error) { - console.error('Error during login request:', error); - setError('로그인 중 오류가 발생했습니다.'); + handleLoginError(error); } }; diff --git a/front-end/src/app/my-video-list/playlistId/[playlistId]/page.tsx b/front-end/src/app/my-video-list/playlistId/[playlistId]/page.tsx index 660f0446..46d031c3 100644 --- a/front-end/src/app/my-video-list/playlistId/[playlistId]/page.tsx +++ b/front-end/src/app/my-video-list/playlistId/[playlistId]/page.tsx @@ -20,7 +20,6 @@ const MyVideoList = () => { const fetchVideo = useCallback(async () => { try { const response = await detailPlaylist(playlistId, token); - console.log(response); setPlaylist(response); } catch (error) { console.error(error); diff --git a/front-end/src/components/mypage/MyVideoSection.tsx b/front-end/src/components/mypage/MyVideoSection.tsx index 55f96bc3..2377e028 100644 --- a/front-end/src/components/mypage/MyVideoSection.tsx +++ b/front-end/src/components/mypage/MyVideoSection.tsx @@ -43,22 +43,25 @@ const MyVideoSection: React.FC = () => { }, [fetchPlaylists, detailPlaylistData]); return ( -
      - {isLoading &&

      Loading...

      } - {playlists.playlists.length === 0 && !isLoading &&

      저장된 재생목록이 없습니다.

      } - {playlists.playlists.map((playlist) => ( -
      { - fetchDetailPlaylist(playlist.playlistId); - router.push(`my-video-list/playlistId/${playlist.playlistId}`); - }} - > -

      {playlist.playlistName}

      -

      {playlist.videoCount}

      -
      - ))} +
      +

      내 재생목록 모음

      +
      + {isLoading &&

      Loading...

      } + {playlists.playlists.length === 0 && !isLoading &&

      저장된 재생목록이 없습니다.

      } + {playlists.playlists.map((playlist) => ( +
      { + fetchDetailPlaylist(playlist.playlistId); + router.push(`my-video-list/playlistId/${playlist.playlistId}`); + }} + > +

      {playlist.playlistName}

      +

      {playlist.videoCount} 개의 저장된 영상

      +
      + ))} +
      ); }; diff --git a/front-end/src/components/playlist/CategoryPlaylist.tsx b/front-end/src/components/playlist/CategoryPlaylist.tsx index 22a2b8e5..7e38eb2c 100644 --- a/front-end/src/components/playlist/CategoryPlaylist.tsx +++ b/front-end/src/components/playlist/CategoryPlaylist.tsx @@ -35,6 +35,7 @@ const CategoryPlaylist: React.FC = ({ category: initialCa const [playlists, setPlaylists] = useState(undefined); const [loadingVideos, setLoadingVideos] = useState(true); const [loadingPlaylists, setLoadingPlaylists] = useState(true); + const maxLength = 15; // 비디오 로딩 함수 const loadVideos = useCallback( @@ -106,6 +107,7 @@ const CategoryPlaylist: React.FC = ({ category: initialCa const closeModal = () => { setShowModal(false); setPlayListName(''); + setSelectVideo(''); }; const handleSaveVideo = async () => { @@ -176,10 +178,9 @@ const CategoryPlaylist: React.FC = ({ category: initialCa await addVideo(playlistNmae, selectVideo, playlistId, token); alert('재생목록에 영상이 추가되었습니다'); } catch (error) { - console.log(error); + console.error(error); } closeModal(); - setSelectVideo(''); }; // 재생목록 삭제 @@ -199,10 +200,18 @@ const CategoryPlaylist: React.FC = ({ category: initialCa ); alert('재생목록이 삭제 되었습니다'); } catch (error) { - console.log(error); + console.error(error); } closeModal(); - setSelectVideo(''); + }; + + const onChangePlaylistName = (e: React.ChangeEvent) => { + const { value } = e.target; + if (value.length <= maxLength) { + setPlayListName(value); + } else { + alert('재생목록의 이름은 15자 이내로 작성하여야합니다'); + } }; return ( @@ -290,7 +299,7 @@ const CategoryPlaylist: React.FC = ({ category: initialCa setPlayListName(e.target.value)} + onChange={onChangePlaylistName} placeholder="재생목록 이름 입력" onClick={(e) => e.stopPropagation()} /> diff --git a/front-end/src/components/playlist/TeacherPlaylist.tsx b/front-end/src/components/playlist/TeacherPlaylist.tsx index 6c72b0f2..0fc20511 100644 --- a/front-end/src/components/playlist/TeacherPlaylist.tsx +++ b/front-end/src/components/playlist/TeacherPlaylist.tsx @@ -26,6 +26,7 @@ const TeacherPlaylist = () => { const [showModal, setShowModal] = useState(false); // 모달 열림 상태 const [selectedVideoIds, setSelectedVideoIds] = useState(''); // 선택된 비디오 ID const [playlists, setPlaylists] = useState(undefined); // 재생목록 상태 + const maxLength = 15; // 선택된 강사가 변경될 때 URL 업데이트 const handleTeacherSelect = (inst: (typeof instructorData)[number]) => { @@ -56,6 +57,7 @@ const TeacherPlaylist = () => { const closeModal = () => { setShowModal(false); // 모달 닫기 setPlayListName(''); // 입력 필드 초기화 + setSelectedVideoIds(''); }; const toggleBottomBar = (index: number) => { @@ -122,10 +124,9 @@ const TeacherPlaylist = () => { await addVideo(playlistName, selectedVideoIds, playlistId, token); alert('재생목록에 영상이 추가되었습니다'); } catch (error) { - console.log(error); + console.error(error); } closeModal(); - setSelectedVideoIds(''); }; // 재생목록 삭제 @@ -146,10 +147,18 @@ const TeacherPlaylist = () => { ); alert('재생목록이 삭제 되었습니다'); } catch (error) { - console.log(error); + console.error(error); } closeModal(); - setSelectedVideoIds(''); + }; + + const onChangePlaylistName = (e: React.ChangeEvent) => { + const { value } = e.target; + if (value.length <= maxLength) { + setPlayListName(value); + } else { + alert('재생목록의 이름은 15자 이내로 작성하여야합니다'); + } }; const videos = selected.name === 'ALL' ? allQuery.data || [] : instQuery.data || []; @@ -232,7 +241,7 @@ const TeacherPlaylist = () => { setPlayListName(e.target.value)} + onChange={onChangePlaylistName} placeholder="재생목록 이름 입력" onClick={(e) => e.stopPropagation()} /> diff --git a/front-end/src/styles/pages/chatbot/chatbot.scss b/front-end/src/styles/pages/chatbot/chatbot.scss index 7113be90..b42f0b5f 100644 --- a/front-end/src/styles/pages/chatbot/chatbot.scss +++ b/front-end/src/styles/pages/chatbot/chatbot.scss @@ -16,7 +16,7 @@ } .chatbot-content_login { - width: 40vw; + width: 50rem; display: flex; flex-direction: row; position: fixed; @@ -36,7 +36,7 @@ textarea { width: 50%; - border: none; + border: 1px solid #c9c9c9; border-radius: 10px; min-width: 300px; min-height: 300px; @@ -44,7 +44,6 @@ padding: 10px; scrollbar-width: thin; scrollbar-color: var(--btn-color) #f1f1f1; - border: none; outline: none; color: var(--btn-color); :focus { @@ -100,14 +99,13 @@ div { background-color: var(--btn-color); - width: 100%; display: flex; justify-content: center; padding: 10px; border-radius: 10px; color: white; font-weight: 700; - min-width: 350px; + width: 38rem; .logout-link { color: rgb(255, 235, 59); } diff --git a/front-end/src/styles/pages/my-video-list/my-video-list.scss b/front-end/src/styles/pages/my-video-list/my-video-list.scss index b213816e..77cc4fda 100644 --- a/front-end/src/styles/pages/my-video-list/my-video-list.scss +++ b/front-end/src/styles/pages/my-video-list/my-video-list.scss @@ -17,18 +17,21 @@ font-size: 1.5rem; } .video-item { + border: 1px solid #ccc; width: 15vw; - height: 20vh; + min-height: 15rem; justify-content: center; align-items: center; background-color: var(--bg-color); - border-radius: 2rem; - padding: 15px; + border-radius: 1rem; cursor: pointer; box-sizing: border-box; - color: black; - transition: 1s; + transition: all 0.3s; margin-top: 10px; + color: #333; + font-size: 1.5rem; + font-weight: bold; + padding: 1rem 2rem; &:hover { transform: translateY(-10px); } @@ -37,5 +40,7 @@ .my-video-list-header { font-size: 4rem; - font-weight: 600; + font-weight: bold; + margin-top: 5rem; + margin-bottom: 3rem; } diff --git a/front-end/src/styles/pages/mypage/mypage.scss b/front-end/src/styles/pages/mypage/mypage.scss index 9d21caba..5a3a2a9c 100644 --- a/front-end/src/styles/pages/mypage/mypage.scss +++ b/front-end/src/styles/pages/mypage/mypage.scss @@ -133,29 +133,38 @@ font-size: 1.4rem; } } + .video-title { + font-size: 2rem; + font-weight: bold; + } .playlist-container { display: flex; - gap: 2rem; flex-wrap: wrap; overflow-y: auto; .playlist-item { + border: 1px solid #ccc; display: flex; flex-direction: column; width: 15vw; - align-items: center; background-color: var(--bg-color); color: black; - border-radius: 15px; + border-radius: 1rem; cursor: pointer; - transition: 1s; + transition: all 0.3s; gap: 1rem; - margin-top: 10px; + margin: 2rem 1rem 0; + padding: 1rem 2rem; + min-height: 15rem; .playlist-name { font-size: 1.8rem; } &:hover { transform: translateY(-10px); } + h4 { + font-weight: bold; + margin-bottom: 1rem; + } } a { display: block; From a9827c6f3072e7b75d60f1ce56543fe795928e14 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=98=81?= <163282490+hyeok9912@users.noreply.github.com> Date: Wed, 4 Dec 2024 14:19:20 +0900 Subject: [PATCH 04/45] =?UTF-8?q?[FE]=20[#183][#184]=EC=9E=AC=EC=83=9D?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=20=EB=82=B4=20=EA=B0=9C=EB=B3=84=20=EC=98=81?= =?UTF-8?q?=EC=83=81=20=EC=82=AD=EC=A0=9C=20=EA=B8=B0=EB=8A=A5=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20/=20=EB=B9=88=20=EC=9E=AC=EC=83=9D=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=20=EC=83=9D=EC=84=B1=20=EB=B6=88=EA=B0=80=20/=20?= =?UTF-8?q?=EC=82=AC=EC=9A=A9=EC=9E=90=EA=B2=BD=ED=97=98=20=EC=B8=A1?= =?UTF-8?q?=EB=A9=B4=20alert=20=EC=B0=BD=20=EC=82=AD=EC=A0=9C=20(#182)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feature: 재생목록 내 영상 삭제기능 추가 * chore: scss 수정 * refector: 빈 재생목록 생성불가 / 오타 수정 / alert 창 삭제 --- front-end/src/app/api/playlistApi.ts | 32 ++++++++++++++++++- .../playlistId/[playlistId]/page.tsx | 24 ++++++++++++-- .../components/playlist/CategoryPlaylist.tsx | 24 +++++++------- .../components/playlist/TeacherPlaylist.tsx | 16 +++++----- .../pages/my-video-list/my-video-list.scss | 23 ++++++++++++- 5 files changed, 96 insertions(+), 23 deletions(-) diff --git a/front-end/src/app/api/playlistApi.ts b/front-end/src/app/api/playlistApi.ts index 5f8b6578..7b88af53 100644 --- a/front-end/src/app/api/playlistApi.ts +++ b/front-end/src/app/api/playlistApi.ts @@ -44,7 +44,7 @@ export const getVideo = async (token: string | undefined) => { } }; -// 재생 목록 수정하기 +// 재생 목록에 영상 추가 export const addVideo = async ( playlistName: string, @@ -75,6 +75,36 @@ export const addVideo = async ( } }; +// 재생 목록에 영상 삭제 + +export const deleteVideos = async ( + videoId: string, + playlistId: string, + token: string | undefined, +) => { + try { + const response = await axios.put( + `${baseUrl}/api/playlists/${playlistId}`, + { + playlistName: '', + addVideoIds: [], + removeVideoIds: [videoId], + }, + { + headers: { + Authorization: `Bearer ${token}`, + }, + }, + ); + return response.data; + } catch (error: unknown) { + if (axios.isAxiosError(error)) { + throw error; + } + throw new Error('api 요청 오류'); + } +}; + // 재생목록 삭제 export const deletepPlaylist = async (playlistId: string, token: string | undefined) => { diff --git a/front-end/src/app/my-video-list/playlistId/[playlistId]/page.tsx b/front-end/src/app/my-video-list/playlistId/[playlistId]/page.tsx index 46d031c3..1d457bda 100644 --- a/front-end/src/app/my-video-list/playlistId/[playlistId]/page.tsx +++ b/front-end/src/app/my-video-list/playlistId/[playlistId]/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import { detailPlaylist } from '@/app/api/playlistApi'; +import { detailPlaylist, deleteVideos } from '@/app/api/playlistApi'; import '@/styles/pages/my-video-list/my-video-list.scss'; import { useParams } from 'next/navigation'; import { useCallback, useEffect, useState } from 'react'; @@ -17,6 +17,17 @@ const MyVideoList = () => { const [playlist, setPlaylist] = useState(null); const token = Cookies.get('token'); + + const deleteVideo = async (videoId: string) => { + try { + await deleteVideos(videoId, playlistId as string, token); + // 비디오 삭제 후 재생목록을 다시 불러옵니다. + fetchVideo(); + } catch (error) { + console.error('비디오 삭제 중 오류 발생:', error); + } + }; + const fetchVideo = useCallback(async () => { try { const response = await detailPlaylist(playlistId, token); @@ -28,7 +39,7 @@ const MyVideoList = () => { useEffect(() => { fetchVideo(); - }, [fetchVideo]); // 의존성 배열에 playlistId와 userId 추가 + }, [fetchVideo]); return (
      @@ -43,6 +54,15 @@ const MyVideoList = () => { key={video.videoId} >
      + {video.title}
      diff --git a/front-end/src/components/playlist/CategoryPlaylist.tsx b/front-end/src/components/playlist/CategoryPlaylist.tsx index 7e38eb2c..e00b7202 100644 --- a/front-end/src/components/playlist/CategoryPlaylist.tsx +++ b/front-end/src/components/playlist/CategoryPlaylist.tsx @@ -26,7 +26,7 @@ const CategoryPlaylist: React.FC = ({ category: initialCa const [page, setPage] = useState(0); const [hasMore, setHasMore] = useState(true); const [selectVideo, setSelectVideo] = useState(''); - const [playlistNmae, setPlayListName] = useState(''); + const [playlistName, setPlayListName] = useState(''); const [isOpen, setIsOpen] = useState(null); const [showModal, setShowModal] = useState(false); const router = useRouter(); @@ -116,18 +116,20 @@ const CategoryPlaylist: React.FC = ({ category: initialCa alert('선택된 영상이 없습니다.'); return; } + if (!playlistName.trim()) { + alert('재생목록 이름을 입력해 주세요.'); + return; + } try { - await saveVideo(selectVideo, playlistNmae, token); - alert('영상이 재생목록에 저장되었습니다.'); - closeModal(); + await saveVideo(selectVideo, playlistName, token); const data = await getVideo(token); - setPlaylists(data); // playlists 상태 업데이트 + setPlaylists(data); + setPlayListName(''); } catch (error) { console.error('Error saving video:', error); alert('영상 저장에 실패했습니다.'); } - setSelectVideo(''); }; const handleVideoSelect = (videoId: string) => { @@ -175,7 +177,7 @@ const CategoryPlaylist: React.FC = ({ category: initialCa if (!token) return; try { - await addVideo(playlistNmae, selectVideo, playlistId, token); + await addVideo(playlistName, selectVideo, playlistId, token); alert('재생목록에 영상이 추가되었습니다'); } catch (error) { console.error(error); @@ -198,11 +200,9 @@ const CategoryPlaylist: React.FC = ({ category: initialCa } : undefined, ); - alert('재생목록이 삭제 되었습니다'); } catch (error) { console.error(error); } - closeModal(); }; const onChangePlaylistName = (e: React.ChangeEvent) => { @@ -298,10 +298,12 @@ const CategoryPlaylist: React.FC = ({ category: initialCa 사용자 재생목록 e.stopPropagation()} + onClick={(e) => { + e.stopPropagation(); + }} />
      diff --git a/front-end/src/components/playlist/TeacherPlaylist.tsx b/front-end/src/components/playlist/TeacherPlaylist.tsx index 0fc20511..30fdcaef 100644 --- a/front-end/src/components/playlist/TeacherPlaylist.tsx +++ b/front-end/src/components/playlist/TeacherPlaylist.tsx @@ -75,25 +75,26 @@ const TeacherPlaylist = () => { useEffect(() => {}, [selectedVideoIds]); - // 비디오를 재생목록에 저장 const handleSaveVideo = async () => { const token = Cookies.get('token'); if (selectedVideoIds.length === 0) { alert('선택된 영상이 없습니다.'); return; } + if (!playlistName.trim()) { + alert('재생목록 이름을 입력해 주세요.'); + return; + } try { - await saveVideo(selectedVideoIds, playlistName, token); // 선택된 비디오 ID와 재생목록 이름 전송 - alert('영상이 재생목록에 저장되었습니다.'); - closeModal(); // 저장 후 모달 닫기 + await saveVideo(selectedVideoIds, playlistName, token); const data = await getVideo(token); - setPlaylists(data); // playlists 상태 업데이트 + setPlaylists(data); + setPlayListName(''); } catch (error) { console.error('Error saving video:', error); alert('영상 저장에 실패했습니다.'); } - setSelectedVideoIds(''); }; // 페이지 로드 시 재생목록 데이터 가져오기 @@ -145,11 +146,9 @@ const TeacherPlaylist = () => { } : undefined, ); - alert('재생목록이 삭제 되었습니다'); } catch (error) { console.error(error); } - closeModal(); }; const onChangePlaylistName = (e: React.ChangeEvent) => { @@ -238,6 +237,7 @@ const TeacherPlaylist = () => { {/* 오버레이 추가 */}
      e.stopPropagation()}> + 사용자 재생목록 Date: Wed, 4 Dec 2024 15:29:32 +0900 Subject: [PATCH 05/45] =?UTF-8?q?feat:=20=EB=A7=88=EC=9D=B4=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20=EC=9E=AC=EC=83=9D=EB=AA=A9=EB=A1=9D=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?/=20=EC=9E=AC=EC=83=9D=EB=AA=A9=EB=A1=9D=20=EC=82=AD=EC=A0=9C?= =?UTF-8?q?=EC=8B=9C=20=EA=B2=BD=EA=B3=A0=EC=B0=BD=20=EC=83=9D=EC=84=B1=20?= =?UTF-8?q?/=20chore:=20=EC=9E=AC=EC=83=9D=EB=AA=A9=EB=A1=9D=20css=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/mypage/MyVideoSection.tsx | 29 ++++++++++++++++++- .../components/playlist/CategoryPlaylist.tsx | 4 +++ .../components/playlist/TeacherPlaylist.tsx | 4 +++ .../pages/my-video-list/my-video-list.scss | 5 ++-- front-end/src/styles/pages/mypage/mypage.scss | 20 +++++++++++++ .../src/styles/pages/playlist/playlist.scss | 12 ++++++++ 6 files changed, 71 insertions(+), 3 deletions(-) diff --git a/front-end/src/components/mypage/MyVideoSection.tsx b/front-end/src/components/mypage/MyVideoSection.tsx index 2377e028..ecf95381 100644 --- a/front-end/src/components/mypage/MyVideoSection.tsx +++ b/front-end/src/components/mypage/MyVideoSection.tsx @@ -3,7 +3,7 @@ import React, { useState, useCallback, useEffect } from 'react'; import { DetailPlayList, PlayLists } from '@/types/playlist'; // 인터페이스 임포트 import { useRouter } from 'next/navigation'; -import { getVideo, detailPlaylist } from '@/app/api/playlistApi'; +import { getVideo, detailPlaylist, deletepPlaylist } from '@/app/api/playlistApi'; import Cookies from 'js-cookie'; const MyVideoSection: React.FC = () => { @@ -42,6 +42,24 @@ const MyVideoSection: React.FC = () => { fetchPlaylists(); // 컴포넌트가 마운트될 때 재생목록을 가져옴 }, [fetchPlaylists, detailPlaylistData]); + const onClickDeleteBtn = async (playlistId: string) => { + const confirmDelete = confirm('재생목록을 삭제하시겠습니까?'); + setIsLoading(true); + if (!confirmDelete) { + setIsLoading(false); + return; + } + const token = Cookies.get('token'); + try { + await deletepPlaylist(playlistId, token); + fetchPlaylists(); + } catch (error) { + console.error(error); + } finally { + setIsLoading(false); + } + }; + return (

      내 재생목록 모음

      @@ -57,6 +75,15 @@ const MyVideoSection: React.FC = () => { router.push(`my-video-list/playlistId/${playlist.playlistId}`); }} > +

      {playlist.playlistName}

      {playlist.videoCount} 개의 저장된 영상

      diff --git a/front-end/src/components/playlist/CategoryPlaylist.tsx b/front-end/src/components/playlist/CategoryPlaylist.tsx index e00b7202..64c38d0d 100644 --- a/front-end/src/components/playlist/CategoryPlaylist.tsx +++ b/front-end/src/components/playlist/CategoryPlaylist.tsx @@ -187,6 +187,10 @@ const CategoryPlaylist: React.FC = ({ category: initialCa // 재생목록 삭제 const onClickDelete = async (playlistId: string) => { + const ConfirmDelete = confirm('재생목록을 삭제하시겠습니까?'); + if (!ConfirmDelete) { + return; + } const token = Cookies.get('token'); if (!token) return; try { diff --git a/front-end/src/components/playlist/TeacherPlaylist.tsx b/front-end/src/components/playlist/TeacherPlaylist.tsx index 30fdcaef..57b66821 100644 --- a/front-end/src/components/playlist/TeacherPlaylist.tsx +++ b/front-end/src/components/playlist/TeacherPlaylist.tsx @@ -134,6 +134,10 @@ const TeacherPlaylist = () => { const onClickDelete = async (playlistId: string) => { const token = Cookies.get('token'); if (!token) return; + const ConfirmDelete = confirm(`재생목록을 삭제하시겠습니까?`); + if (!ConfirmDelete) { + return; + } try { await deletepPlaylist(playlistId, token); diff --git a/front-end/src/styles/pages/my-video-list/my-video-list.scss b/front-end/src/styles/pages/my-video-list/my-video-list.scss index fbb1ab7b..a8221691 100644 --- a/front-end/src/styles/pages/my-video-list/my-video-list.scss +++ b/front-end/src/styles/pages/my-video-list/my-video-list.scss @@ -44,18 +44,19 @@ flex-direction: column; } .deleteBtn { - position: relative; + position: absolute; opacity: 0; color: black; font-weight: bold; font-size: 1.2rem; justify-content: center; align-items: center; - margin-left: 18rem; visibility: hidden; transition: opacity 0.3s ease, visibility 0.5s ease; + top: 0.5rem; + right: 1rem; } } diff --git a/front-end/src/styles/pages/mypage/mypage.scss b/front-end/src/styles/pages/mypage/mypage.scss index 5a3a2a9c..561c2123 100644 --- a/front-end/src/styles/pages/mypage/mypage.scss +++ b/front-end/src/styles/pages/mypage/mypage.scss @@ -155,16 +155,36 @@ margin: 2rem 1rem 0; padding: 1rem 2rem; min-height: 15rem; + position: relative; .playlist-name { font-size: 1.8rem; } &:hover { transform: translateY(-10px); + .delete-btn { + opacity: 1; + visibility: visible; + } } h4 { font-weight: bold; margin-bottom: 1rem; } + .delete-btn { + position: absolute; + top: 0.5rem; + right: 1rem; + opacity: 0; + font-size: 1.2rem; + background: none; + min-width: 0; + color: black; + font-weight: bold; + visibility: hidden; + transition: + opacity 0.3s ease, + visibility 0.3s ease; + } } a { display: block; diff --git a/front-end/src/styles/pages/playlist/playlist.scss b/front-end/src/styles/pages/playlist/playlist.scss index 27d96692..c2e939bc 100644 --- a/front-end/src/styles/pages/playlist/playlist.scss +++ b/front-end/src/styles/pages/playlist/playlist.scss @@ -357,17 +357,29 @@ align-items: center; text-align: center; gap: 1.5rem; + border-bottom: 1px solid #aeabab; + margin-bottom: 1rem; justify-content: space-between; input { width: 2rem; height: 2rem; cursor: pointer; } + &:hover .deleteBtn { + visibility: visible; + opacity: 1; + } + .deleteBtn { background-color: red; text-align: center; font-size: 1rem; font-weight: 600; + opacity: 0; + visibility: hidden; + transition: + opacity 0.3s ease, + visibility 0.3s ease; } } } From 28fb470d1177db91c97d5c71ea23754d3a1a9815 Mon Sep 17 00:00:00 2001 From: kimhyein <70068498+henny1105@users.noreply.github.com> Date: Thu, 5 Dec 2024 20:42:49 +0900 Subject: [PATCH 06/45] =?UTF-8?q?[FE]=20[#188]=20console.error=20=EC=9C=A0?= =?UTF-8?q?=ED=8B=B8=EB=A6=AC=ED=8B=B0=20=ED=95=A8=EC=88=98=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC=20(#189)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- front-end/src/app/@isLoginChatbot/page.tsx | 3 +- front-end/src/app/api/playlistApi.ts | 3 +- front-end/src/app/api/teacherAPI.ts | 5 +-- front-end/src/app/api/videoAPI.ts | 6 ++-- front-end/src/app/api/videoAPIDetail.ts | 3 +- front-end/src/app/api/youtubeAPI.ts | 3 +- front-end/src/app/login/page.tsx | 7 ++-- .../playlistId/[playlistId]/page.tsx | 5 +-- front-end/src/app/page.tsx | 3 +- .../playlists/[category]/[videoId]/page.tsx | 31 ++++++++++------ front-end/src/app/signup/page.tsx | 4 +-- .../[category]/[videoId]/page.tsx | 31 ++++++++++------ .../src/components/mypage/MyMemoSection.tsx | 3 +- .../src/components/mypage/MyVideoSection.tsx | 7 ++-- .../src/components/mypage/UserInfoSection.tsx | 9 ++--- .../components/playlist/CategoryPlaylist.tsx | 16 +++++---- .../components/playlist/TeacherPlaylist.tsx | 9 ++--- front-end/src/hooks/memo/useMemos.ts | 35 +++++++++++++++---- front-end/src/hooks/memo/useYouTubePlayer.ts | 7 ++-- front-end/src/styles/pages/mypage/mypage.scss | 8 ++++- front-end/src/utils/logger.ts | 5 +++ 21 files changed, 137 insertions(+), 66 deletions(-) create mode 100644 front-end/src/utils/logger.ts diff --git a/front-end/src/app/@isLoginChatbot/page.tsx b/front-end/src/app/@isLoginChatbot/page.tsx index d3f4a9e8..8dd7a82e 100644 --- a/front-end/src/app/@isLoginChatbot/page.tsx +++ b/front-end/src/app/@isLoginChatbot/page.tsx @@ -3,6 +3,7 @@ import { useEffect, useState } from 'react'; import '@/styles/pages/chatbot/chatbot.scss'; import fetchChatBot from '../api/chatBotApi'; import Cookies from 'js-cookie'; +import { devConsoleError } from '@/utils/logger'; const Chatbot = () => { const [position, setPosition] = useState({ x: 1850, y: 1000 }); @@ -84,7 +85,7 @@ const Chatbot = () => { typeResponse(apiResponse.response); setTextarea(''); } catch (error) { - console.error('함수요청 오류', error); + devConsoleError('함수요청 오류', error); } finally { setLoading(false); } diff --git a/front-end/src/app/api/playlistApi.ts b/front-end/src/app/api/playlistApi.ts index 7b88af53..7ee16258 100644 --- a/front-end/src/app/api/playlistApi.ts +++ b/front-end/src/app/api/playlistApi.ts @@ -1,5 +1,6 @@ import axios from 'axios'; const baseUrl = process.env.NEXT_PUBLIC_API_BASE_URL; +import { devConsoleError } from '@/utils/logger'; // 재생 목록 생성 export const saveVideo = async ( videoId: string, @@ -22,7 +23,7 @@ export const saveVideo = async ( ); return response.data; } catch (error) { - console.error('Unexpected error:', error); + devConsoleError('Unexpected error:', error); } }; diff --git a/front-end/src/app/api/teacherAPI.ts b/front-end/src/app/api/teacherAPI.ts index 727b8844..d9e304d5 100644 --- a/front-end/src/app/api/teacherAPI.ts +++ b/front-end/src/app/api/teacherAPI.ts @@ -1,6 +1,7 @@ import axios from 'axios'; import instructorData from '@/data/instructorData'; import { Video, PlaylistResponse } from '@/types/video'; +import { devConsoleError } from '@/utils/logger'; const API_KEY = process.env.NEXT_PUBLIC_YOUTUBE_API_KEY; const BASE_URL = 'https://www.googleapis.com/youtube/v3'; @@ -18,7 +19,7 @@ export const getUploadsId = async (channelId: string): Promise => return res.data.items?.[0]?.contentDetails?.relatedPlaylists?.uploads || null; } catch (err) { - console.error('Error fetching uploads ID:', err); + devConsoleError('Error fetching uploads ID:', err); return null; } }; @@ -46,7 +47,7 @@ export const getVideosFromPlaylist = async (playlistId: string): Promise { channelTitle: item.snippet.channelTitle, }; } catch (error) { - console.error('Failed to fetch video details:', error); + devConsoleError('Failed to fetch video details:', error); return null; } }; diff --git a/front-end/src/app/api/youtubeAPI.ts b/front-end/src/app/api/youtubeAPI.ts index a0cb6a0c..d0856eff 100644 --- a/front-end/src/app/api/youtubeAPI.ts +++ b/front-end/src/app/api/youtubeAPI.ts @@ -1,5 +1,6 @@ import axios from 'axios'; +import { devConsoleError } from '@/utils/logger'; const YOUTUBE_API_KEY = process.env.NEXT_PUBLIC_YOUTUBE_API_KEY; const PLAYLIST_ID = 'PL5uS0BZeM8zXcxj2pLybOFj8xMFGIjvfo'; @@ -10,7 +11,7 @@ export const fetchPlaylistVideos = async () => { ); return response.data.items; } catch (error) { - console.error('Error fetching playlist videos', error); + devConsoleError('Error fetching playlist videos', error); return []; } }; diff --git a/front-end/src/app/login/page.tsx b/front-end/src/app/login/page.tsx index f5cb66e5..79657480 100644 --- a/front-end/src/app/login/page.tsx +++ b/front-end/src/app/login/page.tsx @@ -9,6 +9,7 @@ import Cookies from 'js-cookie'; import { loginUser } from '@/app/api/loginUserApi'; import '@/styles/pages/login/login.scss'; import { setUserInfo } from '@/redux/reducer'; +import { devConsoleError } from '@/utils/logger'; const Login = () => { const [formData, setFormData] = useState({ email: '', password: '' }); @@ -54,10 +55,10 @@ const Login = () => { const decodedJWT = decodeJWT(token); dispatch(setUserInfo(decodedJWT)); } else { - console.error('Token is undefined in the response headers'); + devConsoleError('Token is undefined in the response headers'); } } else { - console.error('Failed to log in, unexpected response status'); + devConsoleError('Failed to log in, unexpected response status'); } } catch (error) { handleLoginError(error); @@ -89,7 +90,7 @@ const Login = () => { } else { setError('로그인 중 오류가 발생했습니다.'); } - console.error('로그인 오류:', axiosError); + devConsoleError('로그인 오류:', axiosError); }; return ( diff --git a/front-end/src/app/my-video-list/playlistId/[playlistId]/page.tsx b/front-end/src/app/my-video-list/playlistId/[playlistId]/page.tsx index 1d457bda..019853ed 100644 --- a/front-end/src/app/my-video-list/playlistId/[playlistId]/page.tsx +++ b/front-end/src/app/my-video-list/playlistId/[playlistId]/page.tsx @@ -7,6 +7,7 @@ import { useCallback, useEffect, useState } from 'react'; import { DetailPlayList } from '@/types/playlist'; import Link from 'next/link'; import Cookies from 'js-cookie'; +import { devConsoleError } from '@/utils/logger'; interface Params { playlistId?: string; } @@ -24,7 +25,7 @@ const MyVideoList = () => { // 비디오 삭제 후 재생목록을 다시 불러옵니다. fetchVideo(); } catch (error) { - console.error('비디오 삭제 중 오류 발생:', error); + devConsoleError('비디오 삭제 중 오류 발생:', error); } }; @@ -33,7 +34,7 @@ const MyVideoList = () => { const response = await detailPlaylist(playlistId, token); setPlaylist(response); } catch (error) { - console.error(error); + devConsoleError(error); } }, [playlistId, token]); // 의존성 배열에 playlistId와 token 추가 diff --git a/front-end/src/app/page.tsx b/front-end/src/app/page.tsx index 46c26297..575363b8 100644 --- a/front-end/src/app/page.tsx +++ b/front-end/src/app/page.tsx @@ -11,6 +11,7 @@ import studentData from '@/data/studentData'; import vidListData from '@/data/vidListData'; import instructorData from '@/data/instructorData'; import { Video } from '@/types/video'; +import { devConsoleError } from '@/utils/logger'; export default function Home() { const [videos, setVideos] = useState([]); @@ -24,7 +25,7 @@ export default function Home() { setVideos(latestVideos); } catch (error) { - console.error('Failed to fetch videos:', error); + devConsoleError('Failed to fetch videos:', error); } }; diff --git a/front-end/src/app/playlists/[category]/[videoId]/page.tsx b/front-end/src/app/playlists/[category]/[videoId]/page.tsx index 6502ab70..2d5ac3c6 100644 --- a/front-end/src/app/playlists/[category]/[videoId]/page.tsx +++ b/front-end/src/app/playlists/[category]/[videoId]/page.tsx @@ -2,6 +2,8 @@ import React, { useState, useEffect } from 'react'; import { useParams } from 'next/navigation'; +import { useSelector } from 'react-redux'; +import { RootState } from '@/redux/store'; import { useYouTubePlayer } from '@/hooks/memo/useYouTubePlayer'; import { useMemos } from '@/hooks/memo/useMemos'; import MemoList from '@/components/memo/MemoList'; @@ -9,15 +11,19 @@ import MemoForm from '@/components/memo/MemoForm'; import ConfirmModal from '@/components/memo/ConfirmModal'; import { fetchVideoDetails } from '@/app/api/videoAPIDetail'; import '@/styles/pages/playlist/playlist.scss'; +import { devConsoleError } from '@/utils/logger'; const VideoPlayerPage: React.FC = () => { const { videoId } = useParams(); const normalizedVideoId = Array.isArray(videoId) ? videoId[0] : videoId; const [videoDetails, setVideoDetails] = useState<{ title: string } | null>(null); - const { memoTime, handleAddMemo, seekToTime } = useYouTubePlayer({ videoId: normalizedVideoId }); + const isLoggedIn = useSelector((state: RootState) => state.user.userInfo !== null); // 로그인 상태 확인 + + // Pass isLoggedIn to useMemos + const { memoTime, handleAddMemo, seekToTime } = useYouTubePlayer({ videoId: normalizedVideoId }); const { memos, hasMoreMemos, setCurrentPage, addMemo, updateMemoById, deleteMemoById } = useMemos( - { videoId: normalizedVideoId }, + { videoId: normalizedVideoId, isLoggedIn }, ); const [isAddingMemo, setIsAddingMemo] = useState(false); @@ -37,7 +43,7 @@ const VideoPlayerPage: React.FC = () => { const details = await fetchVideoDetails(normalizedVideoId); setVideoDetails(details); } catch (error) { - console.error('Failed to fetch video details:', error); + devConsoleError('Failed to fetch video details:', error); } } }; @@ -64,6 +70,17 @@ const VideoPlayerPage: React.FC = () => { setCurrentPage((prevPage) => prevPage + 1); }; + // 메모 추가 클릭 시 + const handleAddMemoClick = () => { + if (!isLoggedIn) { + alert('로그인 후 시도하세요.'); + return; + } + + handleAddMemo(); + setIsAddingMemo(true); + }; + return (
      @@ -103,13 +120,7 @@ const VideoPlayerPage: React.FC = () => { onCancel={() => setEditingMemo(null)} /> ) : ( - )} diff --git a/front-end/src/app/signup/page.tsx b/front-end/src/app/signup/page.tsx index e00e23a3..5cb06ccd 100644 --- a/front-end/src/app/signup/page.tsx +++ b/front-end/src/app/signup/page.tsx @@ -3,7 +3,7 @@ import React, { useState } from 'react'; import { useRouter } from 'next/navigation'; import { AxiosError } from 'axios'; - +import { devConsoleError } from '@/utils/logger'; import { fetchRegisterUser } from '@/app/api/registerUserApi'; import '@/styles/pages/register/register.scss'; @@ -67,7 +67,7 @@ const Signup = () => { } else { setError('회원가입 중 오류가 발생했습니다.'); } - console.error('회원가입 오류:', error); + devConsoleError('회원가입 오류:', error); }; return ( diff --git a/front-end/src/app/teacher-lists/[category]/[videoId]/page.tsx b/front-end/src/app/teacher-lists/[category]/[videoId]/page.tsx index 6502ab70..2d5ac3c6 100644 --- a/front-end/src/app/teacher-lists/[category]/[videoId]/page.tsx +++ b/front-end/src/app/teacher-lists/[category]/[videoId]/page.tsx @@ -2,6 +2,8 @@ import React, { useState, useEffect } from 'react'; import { useParams } from 'next/navigation'; +import { useSelector } from 'react-redux'; +import { RootState } from '@/redux/store'; import { useYouTubePlayer } from '@/hooks/memo/useYouTubePlayer'; import { useMemos } from '@/hooks/memo/useMemos'; import MemoList from '@/components/memo/MemoList'; @@ -9,15 +11,19 @@ import MemoForm from '@/components/memo/MemoForm'; import ConfirmModal from '@/components/memo/ConfirmModal'; import { fetchVideoDetails } from '@/app/api/videoAPIDetail'; import '@/styles/pages/playlist/playlist.scss'; +import { devConsoleError } from '@/utils/logger'; const VideoPlayerPage: React.FC = () => { const { videoId } = useParams(); const normalizedVideoId = Array.isArray(videoId) ? videoId[0] : videoId; const [videoDetails, setVideoDetails] = useState<{ title: string } | null>(null); - const { memoTime, handleAddMemo, seekToTime } = useYouTubePlayer({ videoId: normalizedVideoId }); + const isLoggedIn = useSelector((state: RootState) => state.user.userInfo !== null); // 로그인 상태 확인 + + // Pass isLoggedIn to useMemos + const { memoTime, handleAddMemo, seekToTime } = useYouTubePlayer({ videoId: normalizedVideoId }); const { memos, hasMoreMemos, setCurrentPage, addMemo, updateMemoById, deleteMemoById } = useMemos( - { videoId: normalizedVideoId }, + { videoId: normalizedVideoId, isLoggedIn }, ); const [isAddingMemo, setIsAddingMemo] = useState(false); @@ -37,7 +43,7 @@ const VideoPlayerPage: React.FC = () => { const details = await fetchVideoDetails(normalizedVideoId); setVideoDetails(details); } catch (error) { - console.error('Failed to fetch video details:', error); + devConsoleError('Failed to fetch video details:', error); } } }; @@ -64,6 +70,17 @@ const VideoPlayerPage: React.FC = () => { setCurrentPage((prevPage) => prevPage + 1); }; + // 메모 추가 클릭 시 + const handleAddMemoClick = () => { + if (!isLoggedIn) { + alert('로그인 후 시도하세요.'); + return; + } + + handleAddMemo(); + setIsAddingMemo(true); + }; + return (
      @@ -103,13 +120,7 @@ const VideoPlayerPage: React.FC = () => { onCancel={() => setEditingMemo(null)} /> ) : ( - )} diff --git a/front-end/src/components/mypage/MyMemoSection.tsx b/front-end/src/components/mypage/MyMemoSection.tsx index 4ecdc20a..82759bb3 100644 --- a/front-end/src/components/mypage/MyMemoSection.tsx +++ b/front-end/src/components/mypage/MyMemoSection.tsx @@ -2,6 +2,7 @@ import React, { useEffect, useState, useRef, useCallback } from 'react'; import Link from 'next/link'; import { getAllMemos } from '@/app/api/memoAPI'; import { Memo } from '@/types/memo'; +import { devConsoleError } from '@/utils/logger'; const MyMemoSection: React.FC = () => { const [memos, setMemos] = useState([]); @@ -25,7 +26,7 @@ const MyMemoSection: React.FC = () => { setHasMoreMemos(!response.data.last); } catch (error) { - console.error('Failed to fetch memos:', error); + devConsoleError('Failed to fetch memos:', error); } finally { setIsLoading(false); } diff --git a/front-end/src/components/mypage/MyVideoSection.tsx b/front-end/src/components/mypage/MyVideoSection.tsx index ecf95381..96619a1a 100644 --- a/front-end/src/components/mypage/MyVideoSection.tsx +++ b/front-end/src/components/mypage/MyVideoSection.tsx @@ -5,6 +5,7 @@ import { DetailPlayList, PlayLists } from '@/types/playlist'; // 인터페이스 import { useRouter } from 'next/navigation'; import { getVideo, detailPlaylist, deletepPlaylist } from '@/app/api/playlistApi'; import Cookies from 'js-cookie'; +import { devConsoleError } from '@/utils/logger'; const MyVideoSection: React.FC = () => { const [playlists, setPlaylists] = useState({ playlists: [] }); @@ -19,7 +20,7 @@ const MyVideoSection: React.FC = () => { const response = await getVideo(token); setPlaylists(response); // PlayLists 형태로 응답을 설정 } catch (error) { - console.error(error); + devConsoleError(error); } finally { setIsLoading(false); } @@ -32,7 +33,7 @@ const MyVideoSection: React.FC = () => { const response = await detailPlaylist(playlistId, token); setDetailPlaylistData(response); // 상세 재생목록 데이터를 설정 } catch (error) { - console.error(error); + devConsoleError(error); } finally { setIsLoading(false); } @@ -54,7 +55,7 @@ const MyVideoSection: React.FC = () => { await deletepPlaylist(playlistId, token); fetchPlaylists(); } catch (error) { - console.error(error); + devConsoleError(error); } finally { setIsLoading(false); } diff --git a/front-end/src/components/mypage/UserInfoSection.tsx b/front-end/src/components/mypage/UserInfoSection.tsx index 041ca739..a4354991 100644 --- a/front-end/src/components/mypage/UserInfoSection.tsx +++ b/front-end/src/components/mypage/UserInfoSection.tsx @@ -7,6 +7,7 @@ import { getUserInfo, updateUserNickname, updateUserPassword, deleteUser } from import { RootState } from '@/redux/store'; import { setUserInfo, clearUserInfo } from '@/redux/reducer'; import Cookies from 'js-cookie'; +import { devConsoleError } from '@/utils/logger'; const UserInfoSection: React.FC = () => { const dispatch = useDispatch(); @@ -26,7 +27,7 @@ const UserInfoSection: React.FC = () => { dispatch(setUserInfo(userData)); setNickname(userData.nickname); } catch (error) { - console.error('Error fetching user info:', error); + devConsoleError('Error fetching user info:', error); } }; @@ -40,7 +41,7 @@ const UserInfoSection: React.FC = () => { dispatch(setUserInfo({ ...userInfo, nickname })); alert('닉네임이 성공적으로 수정되었습니다.'); } catch (error: unknown) { - console.error('Error during nickname update:', error); + devConsoleError('Error during nickname update:', error); alert('닉네임 수정에 실패했습니다.'); } }; @@ -57,7 +58,7 @@ const UserInfoSection: React.FC = () => { setCurrentPassword(''); setNewPassword(''); } catch (error: unknown) { - console.error('Error during password update:', error); + devConsoleError('Error during password update:', error); alert('비밀번호 수정에 실패했습니다.'); } }; @@ -76,7 +77,7 @@ const UserInfoSection: React.FC = () => { window.dispatchEvent(new Event('loginStatusChanged')); router.push('/'); } catch (error: unknown) { - console.error('Error during account deletion:', error); + devConsoleError('Error during account deletion:', error); alert('회원 탈퇴에 실패했습니다.'); } }; diff --git a/front-end/src/components/playlist/CategoryPlaylist.tsx b/front-end/src/components/playlist/CategoryPlaylist.tsx index 64c38d0d..1f4d3119 100644 --- a/front-end/src/components/playlist/CategoryPlaylist.tsx +++ b/front-end/src/components/playlist/CategoryPlaylist.tsx @@ -12,6 +12,7 @@ import { fetchVideosByCategory } from '@/app/api/videoAPI'; import { addVideo, getVideo, saveVideo, deletepPlaylist } from '@/app/api/playlistApi'; import useInfiniteScroll from '@/hooks/playlist/useInfiniteScroll'; import { formatDuration } from '@/utils/playlist/formatDuration'; +import { devConsoleError } from '@/utils/logger'; import '@/styles/pages/playlist/playlist.scss'; @@ -58,7 +59,7 @@ const CategoryPlaylist: React.FC = ({ category: initialCa ), ]); } catch (err) { - console.error('Error fetching videos:', err); + devConsoleError('Error fetching videos:', err); setError('비디오를 불러오는 중 문제가 발생했습니다.'); } finally { setLoadingVideos(false); @@ -127,7 +128,7 @@ const CategoryPlaylist: React.FC = ({ category: initialCa setPlaylists(data); setPlayListName(''); } catch (error) { - console.error('Error saving video:', error); + devConsoleError('Error saving video:', error); alert('영상 저장에 실패했습니다.'); } }; @@ -154,7 +155,7 @@ const CategoryPlaylist: React.FC = ({ category: initialCa const data = await getVideo(token); setPlaylists(data); } catch (error) { - console.error(error); // 오류 메시지를 상태에 저장 + devConsoleError(error); // 오류 메시지를 상태에 저장 } finally { setLoadingPlaylists(false); } @@ -180,7 +181,7 @@ const CategoryPlaylist: React.FC = ({ category: initialCa await addVideo(playlistName, selectVideo, playlistId, token); alert('재생목록에 영상이 추가되었습니다'); } catch (error) { - console.error(error); + devConsoleError(error); } closeModal(); }; @@ -205,7 +206,7 @@ const CategoryPlaylist: React.FC = ({ category: initialCa : undefined, ); } catch (error) { - console.error(error); + devConsoleError(error); } }; @@ -238,8 +239,7 @@ const CategoryPlaylist: React.FC = ({ category: initialCa
      {error &&

      {error}

      } {!error && videos.length === 0 && !loadingVideos &&

      검색 결과가 없습니다.

      } - {loadingVideos &&

      로딩 중...

      } - {/* 비디오 리스트 */} + {loadingVideos && page === 0 &&

      로딩 중...

      } {/* 첫 페이지 로딩 중 */}
        {videos.map((video, index) => { const isLastVideo = index === videos.length - 1; @@ -286,8 +286,10 @@ const CategoryPlaylist: React.FC = ({ category: initialCa ); })}
      + {loadingVideos && page > 0 &&

      추가 로딩 중...

      } {/* 추가 로딩 중 메시지 */}
      + {/* 모달 */} {showModal && (
      { const router = useRouter(); @@ -92,7 +93,7 @@ const TeacherPlaylist = () => { setPlaylists(data); setPlayListName(''); } catch (error) { - console.error('Error saving video:', error); + devConsoleError('Error saving video:', error); alert('영상 저장에 실패했습니다.'); } }; @@ -106,7 +107,7 @@ const TeacherPlaylist = () => { const data = await getVideo(token); setPlaylists(data); } catch (error) { - console.error(error); // 오류 메시지를 상태에 저장 + devConsoleError(error); // 오류 메시지를 상태에 저장 } }; @@ -125,7 +126,7 @@ const TeacherPlaylist = () => { await addVideo(playlistName, selectedVideoIds, playlistId, token); alert('재생목록에 영상이 추가되었습니다'); } catch (error) { - console.error(error); + devConsoleError(error); } closeModal(); }; @@ -151,7 +152,7 @@ const TeacherPlaylist = () => { : undefined, ); } catch (error) { - console.error(error); + devConsoleError(error); } }; diff --git a/front-end/src/hooks/memo/useMemos.ts b/front-end/src/hooks/memo/useMemos.ts index 65fcfe49..bdacf933 100644 --- a/front-end/src/hooks/memo/useMemos.ts +++ b/front-end/src/hooks/memo/useMemos.ts @@ -1,18 +1,26 @@ import { useState, useEffect, useCallback } from 'react'; import { Memo } from '@/types/memo'; import { saveMemo, updateMemo, deleteMemo, getMemosByVideo } from '@/app/api/memoAPI'; +import { devConsoleError } from '@/utils/logger'; interface UseMemosProps { videoId: string; + isLoggedIn: boolean; } -export const useMemos = ({ videoId }: UseMemosProps) => { +export const useMemos = ({ videoId, isLoggedIn }: UseMemosProps) => { const [memos, setMemos] = useState([]); const [currentPage, setCurrentPage] = useState(0); const [hasMoreMemos, setHasMoreMemos] = useState(true); const fetchMemosForVideo = useCallback( async (page: number) => { + if (!isLoggedIn) { + setMemos([]); + setHasMoreMemos(false); + return; + } + try { const response = await getMemosByVideo(videoId, page); setMemos((prevMemos) => @@ -22,10 +30,10 @@ export const useMemos = ({ videoId }: UseMemosProps) => { const isEmptyContent = response.data.empty || response.data.content.length === 0; setHasMoreMemos(!isEmptyContent && !response.data.last); } catch (error) { - console.error('Failed to fetch memos for video:', error); + devConsoleError('Failed to fetch memos for video:', error); } }, - [videoId], + [videoId, isLoggedIn], ); useEffect(() => { @@ -33,6 +41,11 @@ export const useMemos = ({ videoId }: UseMemosProps) => { }, [currentPage, fetchMemosForVideo]); const addMemo = async (title: string, content: string, noteTime: string) => { + if (!isLoggedIn) { + console.warn('로그인하지 않은 상태에서는 메모를 추가할 수 없습니다.'); + return; // Do not proceed if not logged in + } + try { const payload = { title, @@ -45,7 +58,7 @@ export const useMemos = ({ videoId }: UseMemosProps) => { setCurrentPage(0); fetchMemosForVideo(0); } catch (error) { - console.error('Failed to save memo:', error); + devConsoleError('Failed to save memo:', error); } }; @@ -55,6 +68,11 @@ export const useMemos = ({ videoId }: UseMemosProps) => { content: string, noteTime: string, ) => { + if (!isLoggedIn) { + console.warn('로그인하지 않은 상태에서는 메모를 수정할 수 없습니다.'); + return; + } + try { const payload = { title, @@ -66,18 +84,23 @@ export const useMemos = ({ videoId }: UseMemosProps) => { setCurrentPage(0); fetchMemosForVideo(0); } catch (error) { - console.error('Failed to update memo:', error); + devConsoleError('Failed to update memo:', error); } }; const deleteMemoById = async (memoId: string) => { + if (!isLoggedIn) { + console.warn('로그인하지 않은 상태에서는 메모를 삭제할 수 없습니다.'); + return; + } + try { await deleteMemo(memoId); // 메모 새로고침 setCurrentPage(0); fetchMemosForVideo(0); } catch (error) { - console.error('Failed to delete memo:', error); + devConsoleError('Failed to delete memo:', error); } }; diff --git a/front-end/src/hooks/memo/useYouTubePlayer.ts b/front-end/src/hooks/memo/useYouTubePlayer.ts index 2e34f9fe..b62b3c75 100644 --- a/front-end/src/hooks/memo/useYouTubePlayer.ts +++ b/front-end/src/hooks/memo/useYouTubePlayer.ts @@ -1,5 +1,6 @@ import { useState, useEffect, useRef, useCallback } from 'react'; import { formatTime } from '@/utils/memo/timeUtils'; +import { devConsoleError } from '@/utils/logger'; interface UseYouTubePlayerProps { videoId: string; @@ -52,7 +53,7 @@ export const useYouTubePlayer = ({ videoId }: UseYouTubePlayerProps) => { }, }); } catch (error) { - console.error('Failed to initialize YouTube Player:', error); + devConsoleError('Failed to initialize YouTube Player:', error); } } }, [isYouTubeAPIReady, videoId, handlePlayerStateChange]); @@ -64,7 +65,7 @@ export const useYouTubePlayer = ({ videoId }: UseYouTubePlayerProps) => { setMemoTime(formattedTime); } else { - console.error('Player is not initialized. Cannot add memo.'); + devConsoleError('Player is not initialized. Cannot add memo.'); } }; @@ -73,7 +74,7 @@ export const useYouTubePlayer = ({ videoId }: UseYouTubePlayerProps) => { const [minutes, seconds] = time.split(':').map(Number); playerRef.current.seekTo(minutes * 60 + seconds, true); } else { - console.error('Player is not initialized.'); + devConsoleError('Player is not initialized.'); } }; diff --git a/front-end/src/styles/pages/mypage/mypage.scss b/front-end/src/styles/pages/mypage/mypage.scss index 561c2123..66bd38ee 100644 --- a/front-end/src/styles/pages/mypage/mypage.scss +++ b/front-end/src/styles/pages/mypage/mypage.scss @@ -110,6 +110,10 @@ justify-content: space-between; transition: all 0.3s; + span { + color: var(--sub-btn-color); + } + &:hover { transform: translateY(-10px); } @@ -131,6 +135,7 @@ p { color: #333; font-size: 1.4rem; + margin-bottom: 1rem; } } .video-title { @@ -157,7 +162,8 @@ min-height: 15rem; position: relative; .playlist-name { - font-size: 1.8rem; + font-size: 1.4rem; + color: #333; } &:hover { transform: translateY(-10px); diff --git a/front-end/src/utils/logger.ts b/front-end/src/utils/logger.ts new file mode 100644 index 00000000..41277e8d --- /dev/null +++ b/front-end/src/utils/logger.ts @@ -0,0 +1,5 @@ +export const devConsoleError = (message: string, error: unknown = ''): void => { + if (process.env.NODE_ENV === 'development') { + console.error(message, error); + } +}; From 55fb7dd6b96a3d3dd458a8416555fd785401f61b Mon Sep 17 00:00:00 2001 From: hyeok9912 Date: Thu, 5 Dec 2024 21:11:40 +0900 Subject: [PATCH 07/45] =?UTF-8?q?fix:cross-spawn=20=EB=B2=84=EC=A0=84=20?= =?UTF-8?q?=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- front-end/package.json | 1 + front-end/yarn.lock | 29 +++++++++++------------------ 2 files changed, 12 insertions(+), 18 deletions(-) diff --git a/front-end/package.json b/front-end/package.json index c8f28885..bdb11da4 100644 --- a/front-end/package.json +++ b/front-end/package.json @@ -14,6 +14,7 @@ "@reduxjs/toolkit": "^2.3.0", "@tanstack/react-query": "^5.60.5", "axios": "^1.7.7", + "cross-spawn": "^7.0.6", "dotenv": "^16.4.5", "js-cookie": "^3.0.5", "jsonwebtoken": "^9.0.2", diff --git a/front-end/yarn.lock b/front-end/yarn.lock index eb01a19d..d6e8cd99 100644 --- a/front-end/yarn.lock +++ b/front-end/yarn.lock @@ -684,6 +684,15 @@ cross-spawn@^7.0.0, cross-spawn@^7.0.2, cross-spawn@^7.0.3: shebang-command "^2.0.0" which "^2.0.1" +cross-spawn@^7.0.6: + version "7.0.6" + resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.6.tgz#8a58fe78f00dcd70c370451759dfbfaf03e8ee9f" + integrity sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA== + dependencies: + path-key "^3.1.0" + shebang-command "^2.0.0" + which "^2.0.1" + csstype@^3.0.2: version "3.1.3" resolved "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz" @@ -2699,16 +2708,7 @@ string-argv@~0.3.2: resolved "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz" integrity sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q== -"string-width-cjs@npm:string-width@^4.2.0": - version "4.2.3" - resolved "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz" - integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== - dependencies: - emoji-regex "^8.0.0" - is-fullwidth-code-point "^3.0.0" - strip-ansi "^6.0.1" - -string-width@^4.1.0: +"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0: version "4.2.3" resolved "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -2797,14 +2797,7 @@ string.prototype.trimstart@^1.0.8: define-properties "^1.2.1" es-object-atoms "^1.0.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1": - version "6.0.1" - resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz" - integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== - dependencies: - ansi-regex "^5.0.1" - -strip-ansi@^6.0.0, strip-ansi@^6.0.1: +"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== From e0a33d4de9ebd52eee0ca011eb2851affbc93a61 Mon Sep 17 00:00:00 2001 From: kimhyein <70068498+henny1105@users.noreply.github.com> Date: Thu, 5 Dec 2024 22:34:30 +0900 Subject: [PATCH 08/45] =?UTF-8?q?[FE]=20=ED=83=80=EC=9E=85=20=EC=97=90?= =?UTF-8?q?=EB=9F=AC=20=EC=98=A4=EB=A5=98=20=EC=88=98=EC=A0=95=20(#193)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../playlistId/[playlistId]/page.tsx | 6 +++--- .../app/playlists/[category]/[videoId]/page.tsx | 10 ++++++++-- .../teacher-lists/[category]/[videoId]/page.tsx | 10 ++++++++-- .../src/components/mypage/MyVideoSection.tsx | 6 +++--- .../src/components/playlist/CategoryPlaylist.tsx | 12 +++++++----- .../src/components/playlist/TeacherPlaylist.tsx | 16 +++++++++++----- front-end/src/utils/logger.ts | 6 +++++- 7 files changed, 45 insertions(+), 21 deletions(-) diff --git a/front-end/src/app/my-video-list/playlistId/[playlistId]/page.tsx b/front-end/src/app/my-video-list/playlistId/[playlistId]/page.tsx index 019853ed..82a229e3 100644 --- a/front-end/src/app/my-video-list/playlistId/[playlistId]/page.tsx +++ b/front-end/src/app/my-video-list/playlistId/[playlistId]/page.tsx @@ -8,6 +8,7 @@ import { DetailPlayList } from '@/types/playlist'; import Link from 'next/link'; import Cookies from 'js-cookie'; import { devConsoleError } from '@/utils/logger'; + interface Params { playlistId?: string; } @@ -28,15 +29,14 @@ const MyVideoList = () => { devConsoleError('비디오 삭제 중 오류 발생:', error); } }; - const fetchVideo = useCallback(async () => { try { const response = await detailPlaylist(playlistId, token); setPlaylist(response); } catch (error) { - devConsoleError(error); + devConsoleError('Failed to fetch playlist details', error); } - }, [playlistId, token]); // 의존성 배열에 playlistId와 token 추가 + }, [playlistId, token]); useEffect(() => { fetchVideo(); diff --git a/front-end/src/app/playlists/[category]/[videoId]/page.tsx b/front-end/src/app/playlists/[category]/[videoId]/page.tsx index 2d5ac3c6..8ba8e94d 100644 --- a/front-end/src/app/playlists/[category]/[videoId]/page.tsx +++ b/front-end/src/app/playlists/[category]/[videoId]/page.tsx @@ -20,7 +20,6 @@ const VideoPlayerPage: React.FC = () => { const isLoggedIn = useSelector((state: RootState) => state.user.userInfo !== null); // 로그인 상태 확인 - // Pass isLoggedIn to useMemos const { memoTime, handleAddMemo, seekToTime } = useYouTubePlayer({ videoId: normalizedVideoId }); const { memos, hasMoreMemos, setCurrentPage, addMemo, updateMemoById, deleteMemoById } = useMemos( { videoId: normalizedVideoId, isLoggedIn }, @@ -97,7 +96,14 @@ const VideoPlayerPage: React.FC = () => { memos={memos} hasMoreMemos={hasMoreMemos} loadMoreMemos={loadMoreMemos} - onEditMemo={(memo) => setEditingMemo(memo)} + onEditMemo={(memo) => + setEditingMemo({ + id: memo.id, + title: memo.title || '', // undefined 처리 + content: memo.content, + noteTime: memo.noteTime, + }) + } onDeleteMemo={(memoId) => setMemoToDelete(memoId)} onTimeClick={seekToTime} /> diff --git a/front-end/src/app/teacher-lists/[category]/[videoId]/page.tsx b/front-end/src/app/teacher-lists/[category]/[videoId]/page.tsx index 2d5ac3c6..8ba8e94d 100644 --- a/front-end/src/app/teacher-lists/[category]/[videoId]/page.tsx +++ b/front-end/src/app/teacher-lists/[category]/[videoId]/page.tsx @@ -20,7 +20,6 @@ const VideoPlayerPage: React.FC = () => { const isLoggedIn = useSelector((state: RootState) => state.user.userInfo !== null); // 로그인 상태 확인 - // Pass isLoggedIn to useMemos const { memoTime, handleAddMemo, seekToTime } = useYouTubePlayer({ videoId: normalizedVideoId }); const { memos, hasMoreMemos, setCurrentPage, addMemo, updateMemoById, deleteMemoById } = useMemos( { videoId: normalizedVideoId, isLoggedIn }, @@ -97,7 +96,14 @@ const VideoPlayerPage: React.FC = () => { memos={memos} hasMoreMemos={hasMoreMemos} loadMoreMemos={loadMoreMemos} - onEditMemo={(memo) => setEditingMemo(memo)} + onEditMemo={(memo) => + setEditingMemo({ + id: memo.id, + title: memo.title || '', // undefined 처리 + content: memo.content, + noteTime: memo.noteTime, + }) + } onDeleteMemo={(memoId) => setMemoToDelete(memoId)} onTimeClick={seekToTime} /> diff --git a/front-end/src/components/mypage/MyVideoSection.tsx b/front-end/src/components/mypage/MyVideoSection.tsx index 96619a1a..ee767873 100644 --- a/front-end/src/components/mypage/MyVideoSection.tsx +++ b/front-end/src/components/mypage/MyVideoSection.tsx @@ -20,7 +20,7 @@ const MyVideoSection: React.FC = () => { const response = await getVideo(token); setPlaylists(response); // PlayLists 형태로 응답을 설정 } catch (error) { - devConsoleError(error); + devConsoleError('Failed to fetch playlists', error); } finally { setIsLoading(false); } @@ -33,7 +33,7 @@ const MyVideoSection: React.FC = () => { const response = await detailPlaylist(playlistId, token); setDetailPlaylistData(response); // 상세 재생목록 데이터를 설정 } catch (error) { - devConsoleError(error); + devConsoleError('Failed to fetch playlist details', error); } finally { setIsLoading(false); } @@ -55,7 +55,7 @@ const MyVideoSection: React.FC = () => { await deletepPlaylist(playlistId, token); fetchPlaylists(); } catch (error) { - devConsoleError(error); + devConsoleError('Failed to delete playlist', error); } finally { setIsLoading(false); } diff --git a/front-end/src/components/playlist/CategoryPlaylist.tsx b/front-end/src/components/playlist/CategoryPlaylist.tsx index 1f4d3119..4b83d324 100644 --- a/front-end/src/components/playlist/CategoryPlaylist.tsx +++ b/front-end/src/components/playlist/CategoryPlaylist.tsx @@ -155,7 +155,7 @@ const CategoryPlaylist: React.FC = ({ category: initialCa const data = await getVideo(token); setPlaylists(data); } catch (error) { - devConsoleError(error); // 오류 메시지를 상태에 저장 + devConsoleError('Failed to fetch playlists', error); // 명확한 메시지와 에러 전달 } finally { setLoadingPlaylists(false); } @@ -181,19 +181,21 @@ const CategoryPlaylist: React.FC = ({ category: initialCa await addVideo(playlistName, selectVideo, playlistId, token); alert('재생목록에 영상이 추가되었습니다'); } catch (error) { - devConsoleError(error); + devConsoleError('Failed to add video to playlist', error); // 명확한 메시지와 에러 전달 } closeModal(); }; // 재생목록 삭제 const onClickDelete = async (playlistId: string) => { - const ConfirmDelete = confirm('재생목록을 삭제하시겠습니까?'); - if (!ConfirmDelete) { + const confirmDelete = confirm('재생목록을 삭제하시겠습니까?'); // 변수 이름 수정 + if (!confirmDelete) { return; } + const token = Cookies.get('token'); if (!token) return; + try { await deletepPlaylist(playlistId, token); setPlaylists((prevPlaylists: PlayLists | undefined) => @@ -206,7 +208,7 @@ const CategoryPlaylist: React.FC = ({ category: initialCa : undefined, ); } catch (error) { - devConsoleError(error); + devConsoleError('Failed to delete playlist', error); // 에러 메시지와 에러 전달 } }; diff --git a/front-end/src/components/playlist/TeacherPlaylist.tsx b/front-end/src/components/playlist/TeacherPlaylist.tsx index 2e6de7e7..666bf753 100644 --- a/front-end/src/components/playlist/TeacherPlaylist.tsx +++ b/front-end/src/components/playlist/TeacherPlaylist.tsx @@ -107,7 +107,7 @@ const TeacherPlaylist = () => { const data = await getVideo(token); setPlaylists(data); } catch (error) { - devConsoleError(error); // 오류 메시지를 상태에 저장 + devConsoleError('Failed to fetch playlists', error); } }; @@ -117,6 +117,11 @@ const TeacherPlaylist = () => { // 재생목록에 영상 추가 const onClickCheckBox = async (playlistId: string) => { const token = Cookies.get('token'); + if (!token) { + alert('로그인이 필요합니다.'); + return; + } + if (selectedVideoIds.length === 0) { alert('선택된 영상이 없습니다.'); return; @@ -126,7 +131,7 @@ const TeacherPlaylist = () => { await addVideo(playlistName, selectedVideoIds, playlistId, token); alert('재생목록에 영상이 추가되었습니다'); } catch (error) { - devConsoleError(error); + devConsoleError('Failed to add videos to playlist', error); } closeModal(); }; @@ -135,8 +140,9 @@ const TeacherPlaylist = () => { const onClickDelete = async (playlistId: string) => { const token = Cookies.get('token'); if (!token) return; - const ConfirmDelete = confirm(`재생목록을 삭제하시겠습니까?`); - if (!ConfirmDelete) { + + const confirmDelete = confirm(`재생목록을 삭제하시겠습니까?`); + if (!confirmDelete) { return; } @@ -152,7 +158,7 @@ const TeacherPlaylist = () => { : undefined, ); } catch (error) { - devConsoleError(error); + devConsoleError('Failed to delete playlist', error); // 명확한 메시지와 에러 전달 } }; diff --git a/front-end/src/utils/logger.ts b/front-end/src/utils/logger.ts index 41277e8d..fd4e9de5 100644 --- a/front-end/src/utils/logger.ts +++ b/front-end/src/utils/logger.ts @@ -1,5 +1,9 @@ export const devConsoleError = (message: string, error: unknown = ''): void => { if (process.env.NODE_ENV === 'development') { - console.error(message, error); + if (error instanceof Error) { + console.error(`${message}: ${error.message}`, error.stack); + } else { + console.error(`${message}:`, error); + } } }; From e77d5b0284de276105ca83db75bea81d48f33b2e Mon Sep 17 00:00:00 2001 From: henny Date: Thu, 5 Dec 2024 22:42:12 +0900 Subject: [PATCH 09/45] =?UTF-8?q?chore:=20eslint=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/app/my-video-list/playlistId/[playlistId]/page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/front-end/src/app/my-video-list/playlistId/[playlistId]/page.tsx b/front-end/src/app/my-video-list/playlistId/[playlistId]/page.tsx index 0ca9d404..82a229e3 100644 --- a/front-end/src/app/my-video-list/playlistId/[playlistId]/page.tsx +++ b/front-end/src/app/my-video-list/playlistId/[playlistId]/page.tsx @@ -76,4 +76,4 @@ const MyVideoList = () => { ); }; -export default MyVideoList; \ No newline at end of file +export default MyVideoList; From 2f5128624e16a5fe393a6bc7eb01ab9833a03301 Mon Sep 17 00:00:00 2001 From: Jiho Kim <83708500+Jiho001@users.noreply.github.com> Date: Mon, 9 Dec 2024 14:19:06 +0900 Subject: [PATCH 10/45] =?UTF-8?q?[BE]=20AWS=20EC2=20HealthCheck=EC=9A=A9?= =?UTF-8?q?=20=EC=BB=A8=ED=8A=B8=EB=A1=A4=EB=9F=AC=20=EC=83=9D=EC=84=B1=20?= =?UTF-8?q?(#198)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/techie/backend/global/AwsHealthCheck.java | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 back-end/src/main/java/com/techie/backend/global/AwsHealthCheck.java diff --git a/back-end/src/main/java/com/techie/backend/global/AwsHealthCheck.java b/back-end/src/main/java/com/techie/backend/global/AwsHealthCheck.java new file mode 100644 index 00000000..ec174592 --- /dev/null +++ b/back-end/src/main/java/com/techie/backend/global/AwsHealthCheck.java @@ -0,0 +1,13 @@ +package com.techie.backend.global; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class AwsHealthCheck { + @GetMapping("/healthCheck") + public ResponseEntity healthCheck() { + return ResponseEntity.ok().build(); + } +} From ea3bbc75cab088d9bb67172237348d9d8c7e7c4e Mon Sep 17 00:00:00 2001 From: hyeok9912 Date: Thu, 12 Dec 2024 12:52:19 +0900 Subject: [PATCH 11/45] =?UTF-8?q?refector:=20=EC=98=81=EC=83=81=EC=9D=B4?= =?UTF-8?q?=20=EC=A1=B4=EC=9E=AC=ED=95=A0=EB=95=8C=20=EC=97=90=EB=9F=AC=20?= =?UTF-8?q?=ED=95=B8=EB=93=A4=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- front-end/src/app/api/playlistApi.ts | 5 +---- front-end/src/components/playlist/CategoryPlaylist.tsx | 10 +++++++++- front-end/src/components/playlist/TeacherPlaylist.tsx | 9 ++++++++- front-end/src/styles/pages/mypage/mypage.scss | 2 +- 4 files changed, 19 insertions(+), 7 deletions(-) diff --git a/front-end/src/app/api/playlistApi.ts b/front-end/src/app/api/playlistApi.ts index 7ee16258..40ff0908 100644 --- a/front-end/src/app/api/playlistApi.ts +++ b/front-end/src/app/api/playlistApi.ts @@ -69,10 +69,7 @@ export const addVideo = async ( ); return response.data; } catch (error: unknown) { - if (axios.isAxiosError(error)) { - throw error; - } - throw new Error('api 요청 오류'); + throw error; } }; diff --git a/front-end/src/components/playlist/CategoryPlaylist.tsx b/front-end/src/components/playlist/CategoryPlaylist.tsx index 4b83d324..c0721831 100644 --- a/front-end/src/components/playlist/CategoryPlaylist.tsx +++ b/front-end/src/components/playlist/CategoryPlaylist.tsx @@ -15,6 +15,7 @@ import { formatDuration } from '@/utils/playlist/formatDuration'; import { devConsoleError } from '@/utils/logger'; import '@/styles/pages/playlist/playlist.scss'; +import axios from 'axios'; interface CategoryPlaylistProps { category: string; @@ -181,8 +182,15 @@ const CategoryPlaylist: React.FC = ({ category: initialCa await addVideo(playlistName, selectVideo, playlistId, token); alert('재생목록에 영상이 추가되었습니다'); } catch (error) { - devConsoleError('Failed to add video to playlist', error); // 명확한 메시지와 에러 전달 + if (axios.isAxiosError(error) && error.response) { + if (error.response.status === 500) { + alert('해당 영상은 재생목록 내에 존재하는 영상입니다'); + } else { + devConsoleError('Failed to update video in playlist', error); + } + } } + closeModal(); }; diff --git a/front-end/src/components/playlist/TeacherPlaylist.tsx b/front-end/src/components/playlist/TeacherPlaylist.tsx index 666bf753..5e993ae2 100644 --- a/front-end/src/components/playlist/TeacherPlaylist.tsx +++ b/front-end/src/components/playlist/TeacherPlaylist.tsx @@ -13,6 +13,7 @@ import Cookies from 'js-cookie'; import instructorData from '@/data/instructorData'; import { PlayLists } from '@/types/playlist'; import { devConsoleError } from '@/utils/logger'; +import axios from 'axios'; const TeacherPlaylist = () => { const router = useRouter(); @@ -131,7 +132,13 @@ const TeacherPlaylist = () => { await addVideo(playlistName, selectedVideoIds, playlistId, token); alert('재생목록에 영상이 추가되었습니다'); } catch (error) { - devConsoleError('Failed to add videos to playlist', error); + if (axios.isAxiosError(error) && error.response) { + if (error.response.status === 500) { + alert('해당 영상은 재생목록 내에 존재하는 영상입니다'); + } else { + devConsoleError('Failed to update video in playlist', error); + } + } } closeModal(); }; diff --git a/front-end/src/styles/pages/mypage/mypage.scss b/front-end/src/styles/pages/mypage/mypage.scss index 66bd38ee..1da90d08 100644 --- a/front-end/src/styles/pages/mypage/mypage.scss +++ b/front-end/src/styles/pages/mypage/mypage.scss @@ -162,7 +162,7 @@ min-height: 15rem; position: relative; .playlist-name { - font-size: 1.4rem; + font-size: 2rem; color: #333; } &:hover { From cd3b9a4debeb578dcf4fc06b3b47fd4f17f60f10 Mon Sep 17 00:00:00 2001 From: kimhyein <70068498+henny1105@users.noreply.github.com> Date: Thu, 12 Dec 2024 12:54:57 +0900 Subject: [PATCH 12/45] =?UTF-8?q?[FE]=20[#195]=20=EB=A9=94=EC=9D=B8=20?= =?UTF-8?q?=EB=B0=98=EC=9D=91=ED=98=95=20=EC=9E=91=EC=97=85=20=20(#200)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 메인 반응형 진행중 * feat: 메인 반응형 진행중 --- front-end/src/app/page.tsx | 25 ++- .../styles/common/abstracts/_responsive.scss | 8 + front-end/src/styles/common/layout/_grid.scss | 4 + .../src/styles/common/layout/_header.scss | 4 + front-end/src/styles/pages/home/home.scss | 187 ++++++++++++++++++ 5 files changed, 226 insertions(+), 2 deletions(-) diff --git a/front-end/src/app/page.tsx b/front-end/src/app/page.tsx index 575363b8..33fb2edb 100644 --- a/front-end/src/app/page.tsx +++ b/front-end/src/app/page.tsx @@ -147,12 +147,26 @@ export default function Home() {

      최신 트렌드와 함께 배우세요.

      {videos.map((video, index) => ( @@ -200,7 +214,6 @@ export default function Home() {

      성장한 수강생들의 진솔한 후기✍️

      함께 이룬 성장을 자랑스럽게 소개합니다.

      {studentData.map((student, index) => ( diff --git a/front-end/src/styles/common/abstracts/_responsive.scss b/front-end/src/styles/common/abstracts/_responsive.scss index 5cdf2d4f..33bb1024 100644 --- a/front-end/src/styles/common/abstracts/_responsive.scss +++ b/front-end/src/styles/common/abstracts/_responsive.scss @@ -1,6 +1,7 @@ /*반응형 화면 크기*/ $pc: 1921px; $pc_1800: 1800px; +$pc_1680: 1680px; $pc_1550: 1550px; $pc_1440: 1440px; $pc_1280: 1280px; @@ -32,6 +33,13 @@ $mo_350: 350px; } } +/* 1680px 이상일때 */ +@mixin pc_1680 { + @media (max-width: $pc_1680) { + @content; + } +} + /* 1550px 이상일때 */ @mixin pc_1550 { @media (max-width: $pc_1550) { diff --git a/front-end/src/styles/common/layout/_grid.scss b/front-end/src/styles/common/layout/_grid.scss index 88783661..e107018a 100644 --- a/front-end/src/styles/common/layout/_grid.scss +++ b/front-end/src/styles/common/layout/_grid.scss @@ -1,6 +1,10 @@ .inner { max-width: 144rem; margin: 0 auto; + + @include pc_1680 { + padding: 0 2rem; + } } .section_title { font-size: 4rem; diff --git a/front-end/src/styles/common/layout/_header.scss b/front-end/src/styles/common/layout/_header.scss index c92e52e9..9d9d3554 100644 --- a/front-end/src/styles/common/layout/_header.scss +++ b/front-end/src/styles/common/layout/_header.scss @@ -5,6 +5,10 @@ display: flex; justify-content: space-between; padding: 1.5rem 0; + + @include pc_1680 { + padding: 1.5rem 2rem; + } } .header_menu { display: flex; diff --git a/front-end/src/styles/pages/home/home.scss b/front-end/src/styles/pages/home/home.scss index 0ace20c3..cc12751b 100644 --- a/front-end/src/styles/pages/home/home.scss +++ b/front-end/src/styles/pages/home/home.scss @@ -1,9 +1,22 @@ +// abstracts +@import '../../common/abstracts/mixins'; +@import '../../common/abstracts/responsive'; + .section { h2 { font-size: 3.5rem; font-weight: 700; text-align: center; letter-spacing: -0.2rem; + + @include mo { + font-size: 3rem; + } + + @include mo_500 { + font-size: 2.3rem; + } + } .sub_title { font-size: 1.7rem; @@ -11,6 +24,10 @@ text-align: center; margin-bottom: 4rem; color: gray; + + @include mo { + font-size: 1.5rem; + } } .swiper-pagination-bullet-active { @@ -29,6 +46,10 @@ &:after { font-size: 1.6rem; } + + @include mo { + display: none; + } } &.sec01 { @@ -37,6 +58,10 @@ height: 40rem; background: var(--gradient-bg); + @include mo { + height: 25rem; + } + .inner { height: 100%; } @@ -84,6 +109,17 @@ top: 44%; transform: translateY(-50%); max-width: 60rem; + + @include pc_1550 { + left: 10rem; + } + + @include mo_989 { + width: 100%; + left: 50%; + max-width: none; + transform: translate(-50%, -50%); + } } .right_cover { @@ -95,6 +131,18 @@ display: flex; justify-content: center; align-items: center; + + @include pc_1440 { + width: 60rem; + } + + @include pc_1280 { + width: 40rem; + } + + @include mo_989 { + display: none; + } } .tag { @@ -109,6 +157,10 @@ align-items: center; justify-content: center; font-weight: 700; + + @include mo { + font-size: 1.4rem; + } } } @@ -118,10 +170,26 @@ font-size: 3.2rem; letter-spacing: -0.2rem; line-height: 1.5; + + @include pc_1280 { + font-size: 3rem; + } + + @include mo_989 { + font-size: 2.5rem; + } + + @include mo { + font-size: 2rem; + } } p { font-size: 1.6rem; + + @include mo { + font-size: 1.5rem; + } } } } @@ -129,6 +197,10 @@ &.sec02 { .inner { padding: 7rem 0; + + @include mo { + padding: 4rem 0; + } } .dev_list { display: flex; @@ -136,6 +208,26 @@ margin-bottom: 5rem; gap: 2rem; + @include pc_1550 { + gap: 1rem; + } + + @include pc_1280 { + display: grid; + grid-template-columns: repeat(8, 1fr); + padding: 0 2rem; + } + + @include mo_989 { + grid-template-columns: repeat(6, 1fr); + } + + @include mo { + grid-template-columns: repeat(4, 1fr); + margin-bottom: 0; + } + + li { &.active { background: var(--bg-color); @@ -148,15 +240,39 @@ padding: 1rem 3rem; border-radius: 1rem; transition: all 0.5s; + + @include pc_1280 { + padding: 1rem 2rem; + } + + @include mo { + padding: 1rem; + } + &:hover { background: var(--bg-color); } img { margin: 0 auto; + + @include pc_1550 { + width: 4rem; + height: 4rem; + } + @include mo { + width: 3rem; + height: 3rem; + } } span { font-weight: 500; + @include pc_1280 { + white-space: nowrap; + } + @include mo { + font-size: 1.4rem; + } } } } @@ -185,12 +301,20 @@ .inner { padding: 10rem 5rem; max-width: 168rem; + + @include mo { + padding: 5rem 2rem; + } } h2 { margin-bottom: 0; } .mySwiper { padding: 7rem 0; + + @include mo { + padding: 2rem 0; + } } .swiper-slide { .vid_desc { @@ -242,11 +366,34 @@ &.sec04 { .inner { padding: 15rem 0; + + @include pc_1440 { + padding: 10rem 2rem; + } + + @include mo { + padding: 5rem 2rem; + } } .instructor_list { display: flex; gap: 2rem; + @include pc_1280 { + display: grid; + grid-template-columns: repeat(3, 1fr); + } + + @include mo { + display: grid; + grid-template-columns: repeat(2, 1fr); + } + + @include mo_500 { + display: grid; + grid-template-columns: repeat(1, 1fr); + } + li { border: 0.1rem solid #ccc; border-radius: 1rem; @@ -255,6 +402,10 @@ &:hover { transform: scale(1.05); + + @include mo { + transform: scale(1); + } } a { @@ -264,11 +415,22 @@ gap: 2rem; padding: 3rem 1.5rem; + @include mo_500 { + padding: 2rem 1rem; + align-items: baseline; + gap: 1rem; + } + img { border-radius: 50%; border: 0.1rem solid #ccc; width: 15rem; height: 15rem; + + @include mo_500 { + width: 8rem; + height: 8rem; + } } } @@ -278,9 +440,16 @@ font-size: 1.8rem; font-weight: 700; margin-bottom: 0.5rem; + + @include mo { + font-size: 1.6rem; + } } p { line-height: 1.5; + @include mo { + font-size: 1.4rem; + } } } } @@ -291,6 +460,10 @@ background: var(--bg-color); padding: 15rem 0; + @include mo { + padding: 5rem 0; + } + .personaSwiper { width: 100%; } @@ -310,6 +483,12 @@ align-items: center; background: #fff; + @include mo { + width: 20rem; + height: auto; + flex-direction: column; + } + .img_box { border-radius: 50%; min-width: 18rem; @@ -317,6 +496,10 @@ background: linear-gradient(120deg, #6a83ff77, #6ab5ff75); position: relative; + @include mo { + display: none; + } + .memoji { position: absolute; top: 50%; @@ -340,6 +523,10 @@ font-weight: 400; line-height: 1.5; letter-spacing: -0.12rem; + + @include mo { + font-size: 1.4rem; + } } .student_details { text-align: right; From 343e6cb935a8b6d0a1a2637e6288ec0d4093a7df Mon Sep 17 00:00:00 2001 From: hyeok9912 Date: Thu, 12 Dec 2024 12:57:13 +0900 Subject: [PATCH 13/45] =?UTF-8?q?chore:eslint=20=EC=98=A4=EB=A5=98=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- front-end/src/app/api/playlistApi.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/front-end/src/app/api/playlistApi.ts b/front-end/src/app/api/playlistApi.ts index 40ff0908..7ee16258 100644 --- a/front-end/src/app/api/playlistApi.ts +++ b/front-end/src/app/api/playlistApi.ts @@ -69,7 +69,10 @@ export const addVideo = async ( ); return response.data; } catch (error: unknown) { - throw error; + if (axios.isAxiosError(error)) { + throw error; + } + throw new Error('api 요청 오류'); } }; From 3e4613bdbf7dcb728cb57d12c115984f4900ebcc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=98=81?= <163282490+hyeok9912@users.noreply.github.com> Date: Thu, 9 Jan 2025 20:07:29 +0900 Subject: [PATCH 14/45] =?UTF-8?q?feat:=20metadata=20=EC=84=A4=EC=A0=95=20(?= =?UTF-8?q?#205)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- front-end/src/app/favicon.ico | Bin 25931 -> 15406 bytes front-end/src/app/layout.tsx | 16 ++++++++++++++-- front-end/src/components/Header.tsx | 1 + 3 files changed, 15 insertions(+), 2 deletions(-) diff --git a/front-end/src/app/favicon.ico b/front-end/src/app/favicon.ico index 718d6fea4835ec2d246af9800eddb7ffb276240c..11ebad3ec497e66a1cd9e7635ccfa3e3bad596d0 100644 GIT binary patch literal 15406 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 K*#rIVf&T{>t^n5n literal 25931 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 LrJugUA?W`A8`#=m diff --git a/front-end/src/app/layout.tsx b/front-end/src/app/layout.tsx index 38e0c3b2..3329717f 100644 --- a/front-end/src/app/layout.tsx +++ b/front-end/src/app/layout.tsx @@ -1,5 +1,3 @@ -'use client'; - import React from 'react'; import '@/styles/common/common.scss'; import Header from '@/components/Header'; @@ -11,6 +9,20 @@ import '@/styles/pages/chatbot/chatbot.scss'; import ChatbotLayout from '@/components/ChatbotLayout'; import ClientProvider from '@/components/ClientProvider'; import { ReactQueryProvider } from '@/providers/ReactQueryProvider'; +import { Metadata } from 'next'; + +export const metadata: Metadata = { + title: 'Techie', + description: '테키와 함께 유튜브 강의를 보며 메모하고, GPT와 함께 학습을 더 효율적으로 해보세요!', + openGraph: { + title: 'Techie', + description: + '테키와 함께 유튜브 강의를 보며 메모하고, GPT와 함께 학습을 더 효율적으로 해보세요!', + }, + icons: { + icon: '/favicon.ico', + }, +}; export default function RootLayout({ children, diff --git a/front-end/src/components/Header.tsx b/front-end/src/components/Header.tsx index 3044a6bf..e4a4cc8a 100644 --- a/front-end/src/components/Header.tsx +++ b/front-end/src/components/Header.tsx @@ -1,3 +1,4 @@ +'use client'; import React, { useEffect, useState } from 'react'; import Link from 'next/link'; import { FiSearch } from 'react-icons/fi'; From be73e4ebd9f111921005a8a693f9eee20ea10a3f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=98=81?= <163282490+hyeok9912@users.noreply.github.com> Date: Thu, 9 Jan 2025 20:07:43 +0900 Subject: [PATCH 15/45] =?UTF-8?q?[FE][#195]=20=EB=A1=9C=EA=B7=B8=EC=9D=B8?= =?UTF-8?q?=20/=20=ED=9A=8C=EC=9B=90=EA=B0=80=EC=9E=85=20/=20=EC=B1=97?= =?UTF-8?q?=EB=B4=87=20=EB=B0=98=EC=9D=91=ED=98=95=20(#204)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 로그인 / 회원가입 / 챗봇 반응형 진행중 * chore:eslint 오류 수정정 --- front-end/src/app/@isLoginChatbot/page.tsx | 13 +- front-end/src/app/@isLogoutChatbot/page.tsx | 15 +- front-end/src/app/signup/page.tsx | 2 +- .../styles/common/abstracts/_responsive.scss | 19 ++- .../src/styles/pages/chatbot/chatbot.scss | 161 ++++++++++++++++-- front-end/src/styles/pages/login/login.scss | 51 ++++++ .../src/styles/pages/register/register.scss | 72 +++++++- 7 files changed, 308 insertions(+), 25 deletions(-) diff --git a/front-end/src/app/@isLoginChatbot/page.tsx b/front-end/src/app/@isLoginChatbot/page.tsx index 8dd7a82e..ddce1866 100644 --- a/front-end/src/app/@isLoginChatbot/page.tsx +++ b/front-end/src/app/@isLoginChatbot/page.tsx @@ -23,9 +23,18 @@ const Chatbot = () => { const handleResize = () => { const { innerHeight, innerWidth } = window; - if (innerWidth > 0) { + if (innerWidth > 767) { setPosition({ x: innerWidth - xOffset, y: innerHeight - yOffset }); } + if (innerWidth < 767) { + setPosition({ x: innerWidth - xOffset + 20, y: innerHeight - yOffset + 10 }); + } + if (innerWidth < 500) { + setPosition({ x: innerWidth - xOffset + 40, y: innerHeight - yOffset + 20 }); + } + if (innerWidth < 330) { + setPosition({ x: innerWidth - xOffset + 60, y: innerHeight - yOffset + 25 }); + } }; // 초기 호출 @@ -120,7 +129,7 @@ const Chatbot = () => {
      💬
      e.stopPropagation()}>

      {loading ? '응답을 받아오는 중입니다...' : gptResponse}

      diff --git a/front-end/src/app/@isLogoutChatbot/page.tsx b/front-end/src/app/@isLogoutChatbot/page.tsx index 29604690..88b5daad 100644 --- a/front-end/src/app/@isLogoutChatbot/page.tsx +++ b/front-end/src/app/@isLogoutChatbot/page.tsx @@ -14,13 +14,22 @@ const LogoutChatBot = () => { useEffect(() => { const yOffset = 70; // 하단에서의 오프셋 - const xOffset = 90; + const xOffset = 100; const handleResize = () => { const { innerHeight, innerWidth } = window; - if (innerWidth > 0) { + if (innerWidth > 767) { setPosition({ x: innerWidth - xOffset, y: innerHeight - yOffset }); } + if (innerWidth < 767) { + setPosition({ x: innerWidth - xOffset + 20, y: innerHeight - yOffset + 10 }); + } + if (innerWidth < 500) { + setPosition({ x: innerWidth - xOffset + 40, y: innerHeight - yOffset + 20 }); + } + if (innerWidth < 330) { + setPosition({ x: innerWidth - xOffset + 60, y: innerHeight - yOffset + 25 }); + } }; // 초기 호출 @@ -74,7 +83,7 @@ const LogoutChatBot = () => {
      💬
      e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()}> GPT 기능은   diff --git a/front-end/src/app/signup/page.tsx b/front-end/src/app/signup/page.tsx index 5cb06ccd..11d9bfee 100644 --- a/front-end/src/app/signup/page.tsx +++ b/front-end/src/app/signup/page.tsx @@ -81,7 +81,7 @@ const Signup = () => { type="text" name="nickname" className="input" - placeholder="Techie Project 에서 사용하실 닉네임을 입력해주세요" + placeholder="Techie Project 에서 사용하실 닉네임을 입력해주세요." onChange={handleChange} value={formData.nickname} required diff --git a/front-end/src/styles/common/abstracts/_responsive.scss b/front-end/src/styles/common/abstracts/_responsive.scss index 33bb1024..94a202ec 100644 --- a/front-end/src/styles/common/abstracts/_responsive.scss +++ b/front-end/src/styles/common/abstracts/_responsive.scss @@ -10,7 +10,7 @@ $mo_989: 989px; $mo_600: 600px; $mo: 767px; $mo_500: 500px; -$mo_350: 350px; +$mo_330: 330px; /* pc 사이즈만 */ @mixin only_pc { @@ -53,7 +53,7 @@ $mo_350: 350px; @content; } } - +// pc /* 1280px 이상일때 */ @mixin pc_1280 { @media (max-width: $pc_1280) { @@ -75,16 +75,17 @@ $mo_350: 350px; } } -/* 600px 이상일때 */ -@mixin mo_600 { - @media (max-width: $mo_600) { +// 태블릿 +/* 767px 이상일때 */ +@mixin mo { + @media (max-width: $mo) { @content; } } -/* 767px 이상일때 */ -@mixin mo { - @media (max-width: $mo) { +/* 600px 이상일때 */ +@mixin mo_600 { + @media (max-width: $mo_600) { @content; } } @@ -96,6 +97,8 @@ $mo_350: 350px; } } +// 모바일 + /* 330px 이상일때 */ @mixin mo_330 { @media (max-width: $mo_330) { diff --git a/front-end/src/styles/pages/chatbot/chatbot.scss b/front-end/src/styles/pages/chatbot/chatbot.scss index b42f0b5f..ed34852c 100644 --- a/front-end/src/styles/pages/chatbot/chatbot.scss +++ b/front-end/src/styles/pages/chatbot/chatbot.scss @@ -1,6 +1,9 @@ +@import '../../common/abstracts/responsive'; +@import '../../common/abstracts/mixins'; + .chatbot { - width: 50px; - height: 50px; + width: 5rem; + height: 5rem; display: flex; justify-content: center; align-items: center; @@ -9,17 +12,38 @@ user-select: none; z-index: 1000; cursor: pointer; + @include mo { + width: 4.5rem; + height: 4.5rem; + } + @include mo_500 { + width: 4rem; + height: 4rem; + } + @include mo_330 { + width: 3.5rem; + height: 3.5rem; + } } .icon { font-size: 1.5rem; + @include mo { + font-size: 1.3rem; + } + @include mo_500 { + font-size: 1.1rem; + } + @include mo_330 { + font-size: 1rem; + } } .chatbot-content_login { width: 50rem; + height: 30rem; display: flex; flex-direction: row; - position: fixed; opacity: 0; transform: scale(0) translateY(-10px); transform-origin: bottom right; @@ -27,7 +51,24 @@ opacity 0.3s ease, transform 0.3s ease; gap: 10px; - min-width: 620px; + + @include mo { + width: 40rem; + font-size: 1.2rem; + height: 23rem; + right: 0; + bottom: 0; + } + @include mo_500 { + width: 30rem; + font-size: 1rem; + height: 20rem; + } + @include mo_330 { + width: 23rem; + font-size: 0.8rem; + height: 13rem; + } &.isOpen { opacity: 1; @@ -38,14 +79,21 @@ width: 50%; border: 1px solid #c9c9c9; border-radius: 10px; - min-width: 300px; - min-height: 300px; resize: none; padding: 10px; scrollbar-width: thin; scrollbar-color: var(--btn-color) #f1f1f1; outline: none; color: var(--btn-color); + @include mo { + font-size: 1.2rem; + } + @include mo_500 { + font-size: 1rem; + } + @include mo_330 { + font-size: 0.8rem; + } :focus { outline: none; } @@ -58,8 +106,7 @@ padding: 10px; overflow-y: auto; overflow-x: hidden; - max-height: 300px; - min-height: 300px; + max-height: 30rem; user-select: text; scrollbar-width: thin; scrollbar-color: var(--btn-color) #f1f1f1; @@ -67,6 +114,15 @@ p { color: white; cursor: text; + @include mo { + font-size: 1.2rem; + } + @include mo_500 { + font-size: 1rem; + } + @include mo_330 { + font-size: 0.8rem; + } } } @@ -75,15 +131,76 @@ top: 85%; right: 2%; cursor: pointer; - width: 30px; - height: 30px; + width: 3rem; + height: 3rem; background-color: var(--btn-color); border-radius: 50%; + text-align: center; + line-height: 3rem; color: white; + font-size: 1.5rem; + @include mo { + width: 2.8rem; + height: 2.8rem; + line-height: 2.8rem; + } + @include mo_500 { + width: 2.5rem; + height: 2.5rem; + line-height: 2.5rem; + font-size: 1rem; + } + @include mo_330 { + width: 2rem; + height: 2rem; + line-height: 2rem; + font-size: 0.8rem; + top: 80%; + } + p { + @include mo { + font-size: 1.2rem; + } + @include mo_500 { + font-size: 1rem; + } + @include mo_330 { + font-size: 0.8rem; + } + } } } /// 로그아웃 +.chatbot { + width: 5rem; + height: 5rem; + @include mo { + width: 4.5rem; + height: 4.5rem; + } + @include mo_500 { + width: 4rem; + height: 4rem; + } + @include mo_330 { + width: 3.5rem; + height: 3.5rem; + } + .icon { + font-size: 1.5rem; + @include mo { + font-size: 1.3rem; + } + @include mo_500 { + font-size: 1.1rem; + } + @include mo_330 { + font-size: 1rem; + } + } +} + .chatbot-content_logout { width: 25vw; display: flex; @@ -101,13 +218,37 @@ background-color: var(--btn-color); display: flex; justify-content: center; + align-items: center; padding: 10px; border-radius: 10px; color: white; font-weight: 700; width: 38rem; + margin-left: auto; + margin-top: auto; + @include mo { + width: 30rem; + font-size: 1.2rem; + } + @include mo_500 { + width: 25rem; + font-size: 1rem; + } + @include mo_330 { + width: 20rem; + font-size: 0.8rem; + } .logout-link { color: rgb(255, 235, 59); + @include mo { + font-size: 1.2rem; + } + @include mo_500 { + font-size: 1rem; + } + @include mo_330 { + font-size: 0.8rem; + } } } diff --git a/front-end/src/styles/pages/login/login.scss b/front-end/src/styles/pages/login/login.scss index 33e364fd..ae966dbd 100644 --- a/front-end/src/styles/pages/login/login.scss +++ b/front-end/src/styles/pages/login/login.scss @@ -1,3 +1,6 @@ +@import '../../common/abstracts/mixins'; +@import '../../common/abstracts/responsive'; + .login-container { padding: 10rem 0; @@ -6,13 +9,40 @@ font-weight: 700; margin-bottom: 3.2rem; text-align: center; + @include mo { + font-size: 3.2rem; + } + @include mo_500 { + font-size: 3rem; + } + @include mo_330 { + font-size: 2.8rem; + } } .input-wrapper { max-width: 50rem; margin: 0 auto; + @include mo { + max-width: 40rem; + } + @include mo_500 { + max-width: 27rem; + } + @include mo_330 { + max-width: 18rem; + } strong { font-size: 1.7rem; font-weight: 700; + @include mo { + font-size: 1.5rem; + } + @include mo_500 { + font-size: 1.2rem; + } + @include mo_330 { + font-size: 1.1rem; + } } input:focus { outline: none; @@ -25,6 +55,15 @@ width: 100%; height: 4.5rem; padding: 0 1rem; + @include mo { + height: 3.8rem; + } + @include mo_500 { + height: 3rem; + } + @include mo_330 { + height: 2.5rem; + } } .email-wrapper { margin-bottom: 2rem; @@ -48,6 +87,18 @@ width: 100%; height: 4.5rem; margin-bottom: 3rem; + @include mo { + height: 3.8rem; + font-size: 1.3rem; + } + @include mo_500 { + height: 3rem; + font-size: 1.2rem; + } + @include mo_330 { + height: 2.5rem; + font-size: 1.1rem; + } } } } diff --git a/front-end/src/styles/pages/register/register.scss b/front-end/src/styles/pages/register/register.scss index 346bfdba..640419d7 100644 --- a/front-end/src/styles/pages/register/register.scss +++ b/front-end/src/styles/pages/register/register.scss @@ -4,7 +4,8 @@ $error-color: rgb(255, 0, 0); $valid-color: rgb(35, 171, 35); $sub-btn-color: var(--sub-btn-color); $bg-color: var(--bg-color); - +@import '../../common/abstracts/mixins'; +@import '../../common/abstracts/responsive'; .register-container { padding: 10rem 0; display: flex; @@ -14,16 +15,43 @@ $bg-color: var(--bg-color); .register-wrapper { max-width: 50rem; margin: 0 auto; + @include mo { + max-width: 40rem; + } + @include mo_500 { + max-width: 27rem; + } + @include mo_330 { + max-width: 18rem; + } strong { font-size: 1.7rem; font-weight: 700; + @include mo { + font-size: 1.5rem; + } + @include mo_500 { + font-size: 1.2rem; + } + @include mo_330 { + font-size: 1.1rem; + } } .error-message { color: $error-color; margin-bottom: 1rem; text-align: center; font-size: 1.3rem; + @include mo { + font-size: 1.2rem; + } + @include mo_500 { + font-size: 1rem; + } + @include mo_330 { + font-size: 0.8rem; + } } span { @@ -32,6 +60,15 @@ $bg-color: var(--bg-color); margin: 1%; color: $border-color; display: block; + @include mo { + font-size: 10px; + } + @include mo_500 { + font-size: 8px; + } + @include mo_330 { + font-size: 6px; + } } input { @@ -41,6 +78,18 @@ $bg-color: var(--bg-color); height: 4.5rem; padding: 0 1rem; margin-bottom: 1rem; + @include mo { + height: 3.8rem; + font-size: 1.5rem; + } + @include mo_500 { + height: 3rem; + font-size: 1rem; + } + @include mo_330 { + height: 2.5rem; + font-size: 0.75rem; + } &:focus { outline: none; @@ -58,6 +107,15 @@ $bg-color: var(--bg-color); & > h2 { font-size: 4rem; font-weight: 700; + @include mo { + font-size: 3.2rem; + } + @include mo_500 { + font-size: 3rem; + } + @include mo_330 { + font-size: 2.8rem; + } } button { @@ -69,5 +127,17 @@ $bg-color: var(--bg-color); width: 100%; height: 4.5rem; margin-bottom: 3rem; + @include mo { + height: 3.8rem; + font-size: 1.3rem; + } + @include mo_500 { + height: 3rem; + font-size: 1.2rem; + } + @include mo_330 { + height: 2.5rem; + font-size: 1.1rem; + } } } From e3cc17fff697a4b4ee832916814b81d1ddb0aac2 Mon Sep 17 00:00:00 2001 From: kimhyein <70068498+henny1105@users.noreply.github.com> Date: Tue, 14 Jan 2025 20:42:37 +0900 Subject: [PATCH 16/45] =?UTF-8?q?[FE]=20[#195]=20=EB=A9=94=EC=9D=B8=20/=20?= =?UTF-8?q?=EC=84=9C=EB=B8=8C=20=EB=B0=98=EC=9D=91=ED=98=95=20=EC=9E=91?= =?UTF-8?q?=EC=97=85=20(#207)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 메인 반응형 진행중 * feat: 메인 반응형 진행중 * feat: 강의탐색 반응형 * 메모 반응형 완료 * feat: 메모 반응형 완료 * chore: eslint 오류 수정 * feat: 성장멘토 반응형 완료 * feat: header 메뉴바 반응형 * chore: eslint 오류 수정 * chore: 오류 일부 수정 --- FETCH_HEAD | 0 front-end/.env | 4 + .../assets/images/main/icon_close_menu.png | Bin 0 -> 731 bytes .../public/assets/images/main/icon_menu.png | Bin 0 -> 435 bytes front-end/src/app/page.tsx | 6 +- front-end/src/components/Header.tsx | 82 +++++++++++++- .../components/playlist/CategoryPlaylist.tsx | 30 ++--- .../components/playlist/TeacherPlaylist.tsx | 30 ++--- front-end/src/styles/common/layout/_grid.scss | 10 ++ .../src/styles/common/layout/_header.scss | 103 ++++++++++++++++++ front-end/src/styles/pages/home/home.scss | 2 - front-end/src/styles/pages/mypage/mypage.scss | 36 ++++++ .../src/styles/pages/playlist/playlist.scss | 100 ++++++++++++++++- 13 files changed, 370 insertions(+), 33 deletions(-) create mode 100644 FETCH_HEAD create mode 100644 front-end/.env create mode 100644 front-end/public/assets/images/main/icon_close_menu.png create mode 100644 front-end/public/assets/images/main/icon_menu.png diff --git a/FETCH_HEAD b/FETCH_HEAD new file mode 100644 index 00000000..e69de29b diff --git a/front-end/.env b/front-end/.env new file mode 100644 index 00000000..908f1d13 --- /dev/null +++ b/front-end/.env @@ -0,0 +1,4 @@ +NEXT_PUBLIC_API_BASE_URL=http://localhost:8080 +NEXT_PUBLIC_REGISTER_USER=api/users/register +NEXT_PUBLIC_YOUTUBE_API_KEY=AIzaSyCvUYwoHL7TEB_VC5_YN3dUujxEgItvs58 +NEXT_PUBLIC_PLAYLIST_ID=PL5uS0BZeM8zXcxj2pLybOFj8xMFGIjvfo \ No newline at end of file diff --git a/front-end/public/assets/images/main/icon_close_menu.png b/front-end/public/assets/images/main/icon_close_menu.png new file mode 100644 index 0000000000000000000000000000000000000000..b1918778bdd805b6267af7e43070f7dd3bbd1cb2 GIT binary patch literal 731 zcmV<10wn#3P)2Ue zs-%D}DU&YmfGC3yN)>)%JH_x7fPZFoXJ+r+BW>kb>~n03-Md~0yZ}A|XTWdZ7BA7jqVP zj<866LikE>vtW_O2r=>#;0oYp#6nLIV&x~m4}eWz6uE=-2r2R-U_(3Z0prLW?|@+q zn?#1h%Z~}pkJuqT!av}BNt;Ac-uW@`Vb&k=7$KXy@Dw4tyzmgAn7ptYp}4$Ajj-MH zi4v$LFDylS?Md)IHX(mFK z)7#D?SQuS*7QuqNs2;(Ryr>qzqP!>`!Lqz47QutO$R5FyyvP>8qr7Mx!Lz(*9N{g~ z@2i_7zgxqmA-lv!xCPz-e2?70y1Yn<@D0MPV||equ@U}4xNg@cc@Y!g65#Wk1zzRF zq6kL-YfQg=*1Noz72yI{)uLCxSK#_#@h{+A5RN84{gd6D{)FXHhk72X`jj=E$dIGdCcF0-`CN#J+ zhGhoxf642H-S^(IlbIcmBuSDaN%~zMxC6#ORalIHA<%28_zv)1K%sT6pl2*Zg`zZPdJT-Zf3t&S2)!w|A z0Ow5=-z~iO*(U797L~FY+M(P7ky$N5t_#;896aWJ!gVnsoDVjl2Mj3pczi(kZXrpM dBuSDq*Eclri?b)ugt!0z002ovPDHLkV1oRWw@?58 literal 0 HcmV?d00001 diff --git a/front-end/src/app/page.tsx b/front-end/src/app/page.tsx index 33fb2edb..277439cd 100644 --- a/front-end/src/app/page.tsx +++ b/front-end/src/app/page.tsx @@ -151,7 +151,11 @@ export default function Home() { navigation={true} loop={true} grabCursor={true} - modules={[Navigation]} + modules={[Navigation, Autoplay]} + autoplay={{ + delay: 2500, + disableOnInteraction: false, + }} className="mySwiper" breakpoints={{ 1280: { diff --git a/front-end/src/components/Header.tsx b/front-end/src/components/Header.tsx index e4a4cc8a..372f3e71 100644 --- a/front-end/src/components/Header.tsx +++ b/front-end/src/components/Header.tsx @@ -5,13 +5,14 @@ import { FiSearch } from 'react-icons/fi'; import { useRouter } from 'next/navigation'; import Cookies from 'js-cookie'; import { useDispatch } from 'react-redux'; - +import Image from 'next/image'; import { clearUserInfo } from '@/redux/reducer'; const Header = () => { const [isLoggedIn, setIsLoggedIn] = useState(false); const [searchQuery, setSearchQuery] = useState(''); const [selectedCategory, setSelectedCategory] = useState('LANG'); + const [isMenuOpen, setIsMenuOpen] = useState(false); const router = useRouter(); const dispatch = useDispatch(); @@ -51,6 +52,10 @@ const Header = () => { } }; + const toggleMenu = () => { + setIsMenuOpen((prev) => !prev); // 모바일 메뉴 상태 토 + }; + return (
    + +
    +
    + icon_menu +
    +
    + close_menu +
      +
    • + 강의 탐색 🔍 +
    • +
    • + 성장 멘토 🌱 +
    • +
    +
    + + + setSearchQuery(e.target.value)} + onKeyDown={handleKeyDown} + /> + +
    +
    +
      + {isLoggedIn ? ( + <> +
    • + +
    • +
    • + 마이페이지 +
    • + + ) : ( + <> +
    • + 로그인 +
    • +
    • + 회원가입 +
    • + + )} +
    +
    +
    +
    ); diff --git a/front-end/src/components/playlist/CategoryPlaylist.tsx b/front-end/src/components/playlist/CategoryPlaylist.tsx index c0721831..c60f7a1c 100644 --- a/front-end/src/components/playlist/CategoryPlaylist.tsx +++ b/front-end/src/components/playlist/CategoryPlaylist.tsx @@ -231,20 +231,22 @@ const CategoryPlaylist: React.FC = ({ category: initialCa return (
    -
      - {vidListData.map((tab) => ( -
    • - -
    • - ))} -
    +
    +
      + {vidListData.map((tab) => ( +
    • + +
    • + ))} +
    +
    {error &&

    {error}

    } diff --git a/front-end/src/components/playlist/TeacherPlaylist.tsx b/front-end/src/components/playlist/TeacherPlaylist.tsx index 5e993ae2..5b92eaab 100644 --- a/front-end/src/components/playlist/TeacherPlaylist.tsx +++ b/front-end/src/components/playlist/TeacherPlaylist.tsx @@ -185,20 +185,22 @@ const TeacherPlaylist = () => { return (
    {/* 강사 리스트 */} -
      - {instructorData.map((inst) => ( -
    • - -
    • - ))} -
    +
    +
      + {instructorData.map((inst) => ( +
    • + +
    • + ))} +
    +
    {/* 동영상 리스트 */}
    diff --git a/front-end/src/styles/common/layout/_grid.scss b/front-end/src/styles/common/layout/_grid.scss index e107018a..eb4418c7 100644 --- a/front-end/src/styles/common/layout/_grid.scss +++ b/front-end/src/styles/common/layout/_grid.scss @@ -1,3 +1,7 @@ +// abstracts +@import '../../common/abstracts/mixins'; +@import '../../common/abstracts/responsive'; + .inner { max-width: 144rem; margin: 0 auto; @@ -6,9 +10,15 @@ padding: 0 2rem; } } + .section_title { font-size: 4rem; font-weight: 700; margin-bottom: 3.2rem; text-align: center; + + @include mo { + font-size: 3rem; + margin-bottom: 2rem; + } } diff --git a/front-end/src/styles/common/layout/_header.scss b/front-end/src/styles/common/layout/_header.scss index 9d9d3554..5414e90a 100644 --- a/front-end/src/styles/common/layout/_header.scss +++ b/front-end/src/styles/common/layout/_header.scss @@ -9,6 +9,10 @@ @include pc_1680 { padding: 1.5rem 2rem; } + + @include mo_989 { + align-items: center; + } } .header_menu { display: flex; @@ -24,6 +28,9 @@ display: flex; align-items: center; gap: 2.7rem; + @include mo_989 { + display: none; + } .menu_item { a { font-size: 1.55rem; @@ -35,6 +42,9 @@ display: flex; align-items: center; gap: 2.7rem; + @include mo_989 { + display: none; + } .auth_list { display: flex; align-items: center; @@ -59,6 +69,15 @@ position: relative; display: flex; gap: 1rem; + + @include pc_1280 { + width: 40%; + } + + @include mo_989 { + display: none; + } + select { border-radius: 1rem; border: 1px solid #ccc; @@ -79,3 +98,87 @@ } } } + +.header_menu_mo { + .menu_icon { + display: none; + + @include mo_989 { + display: block; + } + } + .header_menu_mo_list { + position: absolute; + width: 100%; + height: 100%; + top: 0; + right: 0; + background: #fff; + z-index: 9999; + padding: 5rem 2rem; + overflow: hidden; + transform: translateX(100%); + opacity: 0; + transition: + transform 0.5s ease, + opacity 0.5s ease; + + &.active { + transform: translateX(0); + opacity: 1; + } + } + .close_button { + position: absolute; + width: 1.6rem; + height: 1.6rem; + top: 28px; + right: 24px; + } + .menu_list_mo { + .menu_item { + border-bottom: 1px solid #ccc; + + a { + font-size: 1.6rem; + padding: 1rem 0; + display: block; + } + } + } + .search_box_mo { + margin-top: 5rem; + position: relative; + + select { + display: flex; + width: 100%; + border-radius: 0.5rem; + border: 1px solid #ccc; + padding: 1rem 0.5rem; + margin-bottom: 1rem; + position: relative; + } + + input { + border: 1px solid #ccc; + width: 100%; + border-radius: 0.5rem; + padding: 1rem 0.5rem; + } + + button { + position: absolute; + top: 6.8rem; + right: 12px; + } + } + + .auth_box_mo { + .auth_list { + display: flex; + gap: 1rem; + margin-top: 5rem; + } + } +} diff --git a/front-end/src/styles/pages/home/home.scss b/front-end/src/styles/pages/home/home.scss index cc12751b..6ebb115b 100644 --- a/front-end/src/styles/pages/home/home.scss +++ b/front-end/src/styles/pages/home/home.scss @@ -16,7 +16,6 @@ @include mo_500 { font-size: 2.3rem; } - } .sub_title { font-size: 1.7rem; @@ -227,7 +226,6 @@ margin-bottom: 0; } - li { &.active { background: var(--bg-color); diff --git a/front-end/src/styles/pages/mypage/mypage.scss b/front-end/src/styles/pages/mypage/mypage.scss index 1da90d08..3763815c 100644 --- a/front-end/src/styles/pages/mypage/mypage.scss +++ b/front-end/src/styles/pages/mypage/mypage.scss @@ -1,9 +1,21 @@ +// abstracts +@import '../../common/abstracts/mixins'; +@import '../../common/abstracts/responsive'; + .mypage_container { padding: 10rem 0; + @include mo { + padding: 5rem 0; + } + .user_info_section { max-width: 50rem; margin: 0 auto; + + @include mo { + max-width: none; + } } strong { @@ -71,9 +83,21 @@ display: flex; gap: 5rem; + @include mo { + flex-direction: column; + } + .left_cont { width: 20%; + @include pc_1280 { + width: 30%; + } + + @include mo { + width: 100%; + } + .title { font-size: 1.8rem; font-weight: 700; @@ -84,6 +108,14 @@ .right_cont { width: 80%; + @include pc_1280 { + width: 70%; + } + + @include mo { + width: 100%; + } + .memo_section { h2 { font-size: 2rem; @@ -220,6 +252,10 @@ border-bottom: 1px solid #ddd; cursor: pointer; + @include mo { + padding: 1rem; + } + &.active { background: #f9f9f9; font-weight: 700; diff --git a/front-end/src/styles/pages/playlist/playlist.scss b/front-end/src/styles/pages/playlist/playlist.scss index c2e939bc..1999c692 100644 --- a/front-end/src/styles/pages/playlist/playlist.scss +++ b/front-end/src/styles/pages/playlist/playlist.scss @@ -1,3 +1,7 @@ +// abstracts +@import '../../common/abstracts/mixins'; +@import '../../common/abstracts/responsive'; + .playlists_container { .overlay { position: fixed; @@ -54,14 +58,38 @@ .inner { // padding: 10rem 0; } + .dev_list_cont { + width: 100%; + @include pc_1280 { + overflow-x: scroll; + } + } .dev_list { display: flex; justify-content: center; text-align: center; - padding: 4rem 0; + padding: 4rem 2rem; border-bottom: 1px solid #ccc; gap: 1rem; + @include pc_1280 { + width: 128rem; + } + + &.teacher { + width: 100%; + + @include mo_989 { + width: 100rem; + } + } + + @include mo { + justify-content: flex-start; + padding: 2rem; + width: 85rem; + } + &.teacher { li { span { @@ -85,6 +113,10 @@ transition: all 0.3s; cursor: pointer; + @include mo { + width: auto; + } + &:hover { background: var(--bg-color); } @@ -101,8 +133,18 @@ cursor: pointer; gap: 0.5rem; + @include mo { + border: 1px solid #ccc; + border-radius: 1rem; + padding: 0.5rem 1rem; + } + img { margin: 0 auto; + + @include mo { + display: none; + } } span { display: block; @@ -119,10 +161,26 @@ padding: 5rem 0; margin-bottom: 2rem; + @include pc_1280 { + grid-template-columns: repeat(4, 1fr); + } + + @include mo_989 { + grid-template-columns: repeat(3, 1fr); + } + + @include mo { + grid-template-columns: repeat(2, 1fr); + } + .video_item { &:hover { img { transform: translateY(-10px); + + @include mo { + transform: translateY(0px); + } } } img { @@ -130,19 +188,35 @@ margin-bottom: 1rem; border: 1px solid #ccc; transition: all 0.3s; + + @include mo { + width: 100%; + } } .title { font-size: 1.5rem; font-weight: 500; line-height: 1.5; margin-bottom: 1rem; + + @include mo { + font-size: 1.4rem; + } } .channel_title { font-size: 1.3rem; + + @include mo { + font-size: 1.2rem; + } } .date { color: #444; font-size: 1.25rem; + + @include mo { + font-size: 1.2rem; + } } .duration { color: var(--sub-btn-color); @@ -162,6 +236,7 @@ margin-left: 80%; cursor: pointer; transition: 0.5s ease; + &:hover { transform: rotateZ(-45deg); } @@ -200,6 +275,11 @@ } .video_player_container { padding: 5rem; + + @include pc_1280 { + padding: 5rem 2rem; + } + .inner { padding: 5rem 0; } @@ -209,20 +289,38 @@ font-weight: 600; margin-bottom: 2rem; letter-spacing: -1.2px; + + @include mo { + font-size: 2rem; + } } } .video_frame_and_memo { display: flex; // gap: 2rem; + @include pc_1280 { + flex-direction: column; + gap: 2rem; + } + iframe { width: 100%; height: 60rem; + + @include mo { + height: 30vh; + } } .memo_container { width: 80%; padding: 0 5rem; + @include pc_1280 { + width: 100%; + padding: 0; + } + .memo_list { height: 30rem; overflow-y: scroll; From b535c40fb16ea37ed46ee15262db452a9e40d8d5 Mon Sep 17 00:00:00 2001 From: Jiho Kim <83708500+Jiho001@users.noreply.github.com> Date: Thu, 23 Jan 2025 19:15:28 +0900 Subject: [PATCH 17/45] =?UTF-8?q?[BE]=20[#210]=20=EC=BB=A4=EB=AE=A4?= =?UTF-8?q?=EB=8B=88=ED=8B=B0=20=EA=B2=8C=EC=8B=9C=EA=B8=80=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20(#212)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 게시글 엔티티 클래스 작성 * feat: 게시글 DTO 작성 * chore: Remove .idea files from tracking * feat: 게시글 서비스 인터페이스 작성 * chore: Remove .idea directory from tracking * chore: @NotBlank 추가 * build: validation 의존성 추가 * chore: Post 엔티티에 작성시간 추가 * feat: Post에 @Builder 생성자 추가 * feat: createPost 서비스 메소드 완료 * feat: 게시글 등록 핸들러 완료 * feat:게시물 작성 createPost 완료 * feat: 나의 게시물 보기 getMyPost 완료 * feat: 나의 게시물 수정/삭제 완료 * chore: Post 응답 DTO에 유저 정보 추가 * feat: 유저 ID로 유저를 찾는 서비스 메소드 추가 * feat: 유저 ID로 게시글 찾기 완료 * feat: 카테고리별 게시글 찾기 완료 * feat: 게시물 검색 완료 * feat: 복수 삭제로 변경 --- .idea/.gitignore | 8 -- .idea/compiler.xml | 24 ---- .idea/dataSources.xml | 19 --- .idea/gradle.xml | 17 --- .idea/inspectionProfiles/Project_Default.xml | 6 - .idea/jarRepositories.xml | 20 --- .idea/misc.xml | 7 - .idea/modules.xml | 8 -- .idea/techie.iml | 9 -- .idea/uiDesigner.xml | 124 ------------------ .idea/vcs.xml | 7 - back-end/build.gradle | 4 + .../board/controller/PostController.java | 75 +++++++++++ .../com/techie/backend/board/domain/Post.java | 53 ++++++++ .../backend/board/domain/PostCategory.java | 6 + .../techie/backend/board/dto/PostRequest.java | 25 ++++ .../backend/board/dto/PostResponse.java | 26 ++++ .../board/repository/PostRepository.java | 34 +++++ .../backend/board/service/PostService.java | 23 ++++ .../board/service/PostServiceImpl.java | 105 +++++++++++++++ .../exception/GlobalExceptionHandler.java | 2 +- .../backend/global/mapper/PostMapper.java | 29 ++++ .../backend/memo/service/MemoServiceImpl.java | 2 +- .../backend/user/service/UserService.java | 1 + .../backend/user/service/UserServiceImpl.java | 5 + 25 files changed, 388 insertions(+), 251 deletions(-) delete mode 100644 .idea/.gitignore delete mode 100644 .idea/compiler.xml delete mode 100644 .idea/dataSources.xml delete mode 100644 .idea/gradle.xml delete mode 100644 .idea/inspectionProfiles/Project_Default.xml delete mode 100644 .idea/jarRepositories.xml delete mode 100644 .idea/misc.xml delete mode 100644 .idea/modules.xml delete mode 100644 .idea/techie.iml delete mode 100644 .idea/uiDesigner.xml delete mode 100644 .idea/vcs.xml create mode 100644 back-end/src/main/java/com/techie/backend/board/controller/PostController.java create mode 100644 back-end/src/main/java/com/techie/backend/board/domain/Post.java create mode 100644 back-end/src/main/java/com/techie/backend/board/domain/PostCategory.java create mode 100644 back-end/src/main/java/com/techie/backend/board/dto/PostRequest.java create mode 100644 back-end/src/main/java/com/techie/backend/board/dto/PostResponse.java create mode 100644 back-end/src/main/java/com/techie/backend/board/repository/PostRepository.java create mode 100644 back-end/src/main/java/com/techie/backend/board/service/PostService.java create mode 100644 back-end/src/main/java/com/techie/backend/board/service/PostServiceImpl.java create mode 100644 back-end/src/main/java/com/techie/backend/global/mapper/PostMapper.java diff --git a/.idea/.gitignore b/.idea/.gitignore deleted file mode 100644 index c3f502a1..00000000 --- a/.idea/.gitignore +++ /dev/null @@ -1,8 +0,0 @@ -# 디폴트 무시된 파일 -/shelf/ -/workspace.xml -# 에디터 기반 HTTP 클라이언트 요청 -/httpRequests/ -# Datasource local storage ignored files -/dataSources/ -/dataSources.local.xml diff --git a/.idea/compiler.xml b/.idea/compiler.xml deleted file mode 100644 index 72b0b0c2..00000000 --- a/.idea/compiler.xml +++ /dev/null @@ -1,24 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/.idea/dataSources.xml b/.idea/dataSources.xml deleted file mode 100644 index 63e4ea35..00000000 --- a/.idea/dataSources.xml +++ /dev/null @@ -1,19 +0,0 @@ - - - - - mysql.8 - true - true - $PROJECT_DIR$/back-end/src/main/resources/application.yaml - com.mysql.cj.jdbc.Driver - jdbc:mysql://localhost:3306/techie - - - - - - $ProjectFileDir$ - - - \ No newline at end of file diff --git a/.idea/gradle.xml b/.idea/gradle.xml deleted file mode 100644 index 77e1b764..00000000 --- a/.idea/gradle.xml +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - - \ No newline at end of file diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml deleted file mode 100644 index 03d9549e..00000000 --- a/.idea/inspectionProfiles/Project_Default.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - \ No newline at end of file diff --git a/.idea/jarRepositories.xml b/.idea/jarRepositories.xml deleted file mode 100644 index fdc392fe..00000000 --- a/.idea/jarRepositories.xml +++ /dev/null @@ -1,20 +0,0 @@ - - - - - - - - - - - \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml deleted file mode 100644 index ade1ecd1..00000000 --- a/.idea/misc.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml deleted file mode 100644 index cff8c3c7..00000000 --- a/.idea/modules.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/.idea/techie.iml b/.idea/techie.iml deleted file mode 100644 index d6ebd480..00000000 --- a/.idea/techie.iml +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - \ No newline at end of file diff --git a/.idea/uiDesigner.xml b/.idea/uiDesigner.xml deleted file mode 100644 index 2b63946d..00000000 --- a/.idea/uiDesigner.xml +++ /dev/null @@ -1,124 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml deleted file mode 100644 index 9927b2ae..00000000 --- a/.idea/vcs.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - \ No newline at end of file diff --git a/back-end/build.gradle b/back-end/build.gradle index b1f68881..8f17304d 100644 --- a/back-end/build.gradle +++ b/back-end/build.gradle @@ -14,6 +14,9 @@ java { } configurations { + all { + exclude group: 'commons-logging', module: 'commons-logging' + } compileOnly { extendsFrom annotationProcessor } @@ -30,6 +33,7 @@ dependencies { implementation group: 'org.modelmapper', name: 'modelmapper', version: '3.1.1' // modelmapper implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.0.2' implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta' + implementation 'org.springframework.boot:spring-boot-starter-validation' annotationProcessor "com.querydsl:querydsl-apt:${dependencyManagement.importedProperties['querydsl.version']}:jakarta" annotationProcessor "jakarta.annotation:jakarta.annotation-api" annotationProcessor "jakarta.persistence:jakarta.persistence-api" diff --git a/back-end/src/main/java/com/techie/backend/board/controller/PostController.java b/back-end/src/main/java/com/techie/backend/board/controller/PostController.java new file mode 100644 index 00000000..e77301e4 --- /dev/null +++ b/back-end/src/main/java/com/techie/backend/board/controller/PostController.java @@ -0,0 +1,75 @@ +package com.techie.backend.board.controller; + +import com.techie.backend.board.domain.PostCategory; +import com.techie.backend.board.dto.PostRequest; +import com.techie.backend.board.dto.PostResponse; +import com.techie.backend.board.service.PostServiceImpl; +import com.techie.backend.global.security.UserDetailsCustom; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/post") +@Slf4j +public class PostController { + + private final PostServiceImpl postServiceImpl; + + @PostMapping + public ResponseEntity createPost(@RequestBody PostRequest postRequest, + @AuthenticationPrincipal UserDetailsCustom userDetails) { + PostResponse postResponse = postServiceImpl.createPost(postRequest, userDetails); + return ResponseEntity.ok(postResponse); + } + + @GetMapping("/my") + public ResponseEntity> getMyPosts(@AuthenticationPrincipal UserDetailsCustom userDetails, + Pageable pageable) { + Page myPosts = postServiceImpl.getMyPosts(userDetails, pageable); + return ResponseEntity.ok(myPosts); + } + + @GetMapping("/{id}") + public ResponseEntity> getYourPosts(@PathVariable Long id, + Pageable pageable) { + Page yourPosts = postServiceImpl.getYourPosts(id, pageable); + return ResponseEntity.ok(yourPosts); + } + + @GetMapping("category/{category}") + public ResponseEntity> getPostsByCategory(@PathVariable PostCategory category, + Pageable pageable) { + Page posts = postServiceImpl.getAllPosts(category, pageable); + return ResponseEntity.ok(posts); + } + + @GetMapping + public ResponseEntity> searchPost(@RequestParam PostCategory category, + @RequestParam String query, + Pageable pageable) { + Page posts = postServiceImpl.searchPost(category, query, pageable); + return ResponseEntity.ok(posts); + } + + @PutMapping("/{id}") + public ResponseEntity updatePost(@RequestBody PostRequest.Update updateRequest, + @PathVariable Long id, + @AuthenticationPrincipal UserDetailsCustom userDetails) { + PostResponse postResponse = postServiceImpl.updatePost(id, updateRequest, userDetails); + return ResponseEntity.ok(postResponse); + } + + @DeleteMapping + public ResponseEntity deletePost(@RequestBody PostRequest.Delete delRequest, + @AuthenticationPrincipal UserDetailsCustom userDetails) { + postServiceImpl.deletePost(delRequest, userDetails); + return ResponseEntity.noContent().build(); + } +} diff --git a/back-end/src/main/java/com/techie/backend/board/domain/Post.java b/back-end/src/main/java/com/techie/backend/board/domain/Post.java new file mode 100644 index 00000000..7da6c64b --- /dev/null +++ b/back-end/src/main/java/com/techie/backend/board/domain/Post.java @@ -0,0 +1,53 @@ +package com.techie.backend.board.domain; + +import com.techie.backend.global.BaseTime; +import com.techie.backend.user.domain.User; +import jakarta.persistence.*; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Table(name = "posts") +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Post extends BaseTime { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private long id; + + @Column @NotBlank(message = "제목은 비어있을 수 없습니다.") + private String title; + + @Column(columnDefinition="TEXT") + @NotBlank(message = "내용은 비어있을 수 없습니다.") + private String content; + + @JoinColumn(name = "user_id", nullable = false) + @ManyToOne(fetch = FetchType.LAZY) + private User user; + + @Column @NotNull + @Enumerated(EnumType.STRING) + private PostCategory category; + + @Builder + public Post(String title, String content, User user, PostCategory category) { + this.title = title; + this.content = content; + this.user = user; + this.category = category; + } + + public void changeTitle(String title) { + this.title = title; + } + + public void changeContent(String content) { + this.content = content; + } +} diff --git a/back-end/src/main/java/com/techie/backend/board/domain/PostCategory.java b/back-end/src/main/java/com/techie/backend/board/domain/PostCategory.java new file mode 100644 index 00000000..204880c9 --- /dev/null +++ b/back-end/src/main/java/com/techie/backend/board/domain/PostCategory.java @@ -0,0 +1,6 @@ +package com.techie.backend.board.domain; + +public enum PostCategory { + QNA, + FREE +} diff --git a/back-end/src/main/java/com/techie/backend/board/dto/PostRequest.java b/back-end/src/main/java/com/techie/backend/board/dto/PostRequest.java new file mode 100644 index 00000000..bdc36077 --- /dev/null +++ b/back-end/src/main/java/com/techie/backend/board/dto/PostRequest.java @@ -0,0 +1,25 @@ +package com.techie.backend.board.dto; + +import com.techie.backend.board.domain.PostCategory; +import lombok.Data; + +import java.util.ArrayList; +import java.util.List; + +@Data +public class PostRequest { + private String title; + private String content; + private PostCategory category; + + @Data + public static class Update { + private String title; + private String content; + } + + @Data + public static class Delete { + private List postIds = new ArrayList<>(); + } +} diff --git a/back-end/src/main/java/com/techie/backend/board/dto/PostResponse.java b/back-end/src/main/java/com/techie/backend/board/dto/PostResponse.java new file mode 100644 index 00000000..a683ae21 --- /dev/null +++ b/back-end/src/main/java/com/techie/backend/board/dto/PostResponse.java @@ -0,0 +1,26 @@ +package com.techie.backend.board.dto; + +import com.techie.backend.board.domain.Post; +import com.techie.backend.board.domain.PostCategory; +import lombok.Data; + +import java.time.LocalDateTime; + +@Data +public class PostResponse { + private String title; + private String content; + private PostCategory category; + private LocalDateTime writtenAt; + private String email; + private String nickname; + + public PostResponse(Post post) { + this.title = post.getTitle(); + this.content = post.getContent(); + this.category = post.getCategory(); + this.writtenAt = post.getUpdatedAt(); + this.email = post.getUser().getEmail(); + this.nickname = post.getUser().getNickname(); + } +} diff --git a/back-end/src/main/java/com/techie/backend/board/repository/PostRepository.java b/back-end/src/main/java/com/techie/backend/board/repository/PostRepository.java new file mode 100644 index 00000000..3393f8e5 --- /dev/null +++ b/back-end/src/main/java/com/techie/backend/board/repository/PostRepository.java @@ -0,0 +1,34 @@ +package com.techie.backend.board.repository; + +import com.techie.backend.board.domain.Post; +import com.techie.backend.board.domain.PostCategory; +import com.techie.backend.user.domain.User; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; + + +public interface PostRepository extends JpaRepository { + Page searchAllByUser(Pageable pageable, User user); + Page searchAllByCategory(Pageable pageable, PostCategory category); + + @Query(""" + SELECT p FROM Post p + WHERE p.category = :category + AND (p.title LIKE %:query% OR p.content LIKE %:query%) + """) + Page searchByQuery(Pageable pageable, PostCategory category, @Param("query") String query); + + @Modifying(clearAutomatically = true) + @Query(""" + DELETE FROM Post p + WHERE p.id IN :postIds + AND p.user.id = :userId + """) + void deleteAllByIds(@Param("postIds") List postIds, @Param("userId") Long userId); +} diff --git a/back-end/src/main/java/com/techie/backend/board/service/PostService.java b/back-end/src/main/java/com/techie/backend/board/service/PostService.java new file mode 100644 index 00000000..fa098f6f --- /dev/null +++ b/back-end/src/main/java/com/techie/backend/board/service/PostService.java @@ -0,0 +1,23 @@ +package com.techie.backend.board.service; + +import com.techie.backend.board.domain.PostCategory; +import com.techie.backend.board.dto.PostRequest; +import com.techie.backend.board.dto.PostResponse; +import com.techie.backend.global.security.UserDetailsCustom; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +public interface PostService { + // CREATE + public PostResponse createPost(PostRequest postRequest, UserDetailsCustom userDetails); + // READ + public Page getMyPosts(UserDetailsCustom userDetails, Pageable pageable); + public Page getYourPosts(Long userId, Pageable pageable); + Page getAllPosts(PostCategory category, Pageable pageable); + + // UPDATE + public PostResponse updatePost(Long id, PostRequest.Update updateRequest, UserDetailsCustom userDetails); + + // DELETE + public void deletePost(PostRequest.Delete delRequest, UserDetailsCustom userDetails); +} diff --git a/back-end/src/main/java/com/techie/backend/board/service/PostServiceImpl.java b/back-end/src/main/java/com/techie/backend/board/service/PostServiceImpl.java new file mode 100644 index 00000000..deb87f9b --- /dev/null +++ b/back-end/src/main/java/com/techie/backend/board/service/PostServiceImpl.java @@ -0,0 +1,105 @@ +package com.techie.backend.board.service; + +import com.techie.backend.board.domain.Post; +import com.techie.backend.board.domain.PostCategory; +import com.techie.backend.board.dto.PostRequest; +import com.techie.backend.board.dto.PostResponse; +import com.techie.backend.board.repository.PostRepository; +import com.techie.backend.global.exception.user.NoChangesException; +import com.techie.backend.global.mapper.PostMapper; +import com.techie.backend.global.security.UserDetailsCustom; +import com.techie.backend.user.domain.User; +import com.techie.backend.user.service.UserService; +import jakarta.persistence.EntityManager; +import jakarta.persistence.EntityNotFoundException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@RequiredArgsConstructor +@Transactional +@Slf4j +public class PostServiceImpl implements PostService { + private final UserService userService; + private final PostRepository postRepository; + private final PostMapper postMapper; + private final EntityManager em; + + @Override + public PostResponse createPost(PostRequest postRequest, UserDetailsCustom userDetails) { + User user = userService.getUserFromSecurityContext(userDetails); + Post post = postMapper.toEntity(postRequest, user); + Post createdPost = postRepository.save(post); + return postMapper.toDto(createdPost); + } + + @Override + public Page getMyPosts(UserDetailsCustom userDetails, Pageable pageable) { + User user = userService.getUserFromSecurityContext(userDetails); + return getPosts(user, pageable); + } + + @Override + public Page getYourPosts(Long userId, Pageable pageable) { + User user = userService.getUserById(userId); + return getPosts(user, pageable); + } + + public Page getPosts(User user, Pageable pageable) { + Page posts = postRepository.searchAllByUser(pageable, user); + if(posts.isEmpty()) { + throw new EntityNotFoundException("해당 사용자의 게시물이 없습니다."); + } + return posts.map(postMapper::toDto); + } + + @Override + public Page getAllPosts(PostCategory category, Pageable pageable) { + // 게시글이 없다면 빈 페이지 반환 + return postRepository.searchAllByCategory(pageable, category).map(postMapper::toDto); + } + + public Page searchPost(PostCategory postCategory, String query, Pageable pageable) { + // 게시글이 없다면 빈 페이지 반환 + return postRepository.searchByQuery(pageable, postCategory, query).map(postMapper::toDto); + } + + @Override + public PostResponse updatePost(Long id, PostRequest.Update updateRequest, UserDetailsCustom userDetails) { + Post post = postRepository.findById(id).orElseThrow(() + -> new EntityNotFoundException("게시글이 없습니다.")); + User user = userService.getUserFromSecurityContext(userDetails); + + validateOwner(user, post); + + postMapper.updateDto(updateRequest, post); + em.flush(); // 수정 사항 바로 반영 + + return postMapper.toDto(post); + } + + + @Override + @Transactional + public void deletePost(PostRequest.Delete delRequest, UserDetailsCustom userDetails) { + List postIds = delRequest.getPostIds(); + if(postIds == null || postIds.isEmpty()) { + throw new NoChangesException(); + } + User user = userService.getUserFromSecurityContext(userDetails); + postRepository.deleteAllByIds(postIds, user.getId()); + } + + private void validateOwner(User user, Post post) { + if(!user.getId().equals(post.getUser().getId())) { + throw new AccessDeniedException("수정/삭제할 권한이 없습니다."); + } + } +} diff --git a/back-end/src/main/java/com/techie/backend/global/exception/GlobalExceptionHandler.java b/back-end/src/main/java/com/techie/backend/global/exception/GlobalExceptionHandler.java index 95c7d1a9..c413e49a 100644 --- a/back-end/src/main/java/com/techie/backend/global/exception/GlobalExceptionHandler.java +++ b/back-end/src/main/java/com/techie/backend/global/exception/GlobalExceptionHandler.java @@ -12,10 +12,10 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.security.access.AccessDeniedException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; -import java.nio.file.AccessDeniedException; import java.util.HashMap; import java.util.Map; diff --git a/back-end/src/main/java/com/techie/backend/global/mapper/PostMapper.java b/back-end/src/main/java/com/techie/backend/global/mapper/PostMapper.java new file mode 100644 index 00000000..80df5231 --- /dev/null +++ b/back-end/src/main/java/com/techie/backend/global/mapper/PostMapper.java @@ -0,0 +1,29 @@ +package com.techie.backend.global.mapper; + +import com.techie.backend.board.domain.Post; +import com.techie.backend.board.dto.PostRequest; +import com.techie.backend.board.dto.PostResponse; +import com.techie.backend.user.domain.User; +import org.springframework.stereotype.Component; + +@Component +public class PostMapper { + public Post toEntity(PostRequest postRequest, User user) { + return Post.builder() + .title(postRequest.getTitle()) + .content(postRequest.getContent()) + .category(postRequest.getCategory()) + .user(user) + .build(); + } + + public PostResponse toDto(Post post) { + return new PostResponse(post); + } + + public void updateDto(PostRequest.Update updateRequest, Post post) { + post.changeTitle(updateRequest.getTitle()); + post.changeContent(updateRequest.getContent()); + } + +} diff --git a/back-end/src/main/java/com/techie/backend/memo/service/MemoServiceImpl.java b/back-end/src/main/java/com/techie/backend/memo/service/MemoServiceImpl.java index 03bf2cba..1209bc79 100644 --- a/back-end/src/main/java/com/techie/backend/memo/service/MemoServiceImpl.java +++ b/back-end/src/main/java/com/techie/backend/memo/service/MemoServiceImpl.java @@ -13,7 +13,7 @@ import com.techie.backend.video.repository.VideoRepository; import com.techie.backend.video.service.VideoService; import jakarta.persistence.EntityNotFoundException; -import jakarta.transaction.Transactional; +import org.springframework.transaction.annotation.Transactional; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.modelmapper.ModelMapper; diff --git a/back-end/src/main/java/com/techie/backend/user/service/UserService.java b/back-end/src/main/java/com/techie/backend/user/service/UserService.java index 5efc5276..8bb5fe9d 100644 --- a/back-end/src/main/java/com/techie/backend/user/service/UserService.java +++ b/back-end/src/main/java/com/techie/backend/user/service/UserService.java @@ -11,4 +11,5 @@ public interface UserService { Boolean updateUser(UserDetailsCustom userDetails, UserRequest.Update request); User getUserFromSecurityContext(UserDetailsCustom userDetailsCustom); Boolean deleteUser(UserDetailsCustom userDetails, UserRequest.Delete request); + User getUserById(Long id); } \ No newline at end of file diff --git a/back-end/src/main/java/com/techie/backend/user/service/UserServiceImpl.java b/back-end/src/main/java/com/techie/backend/user/service/UserServiceImpl.java index 07499200..cccc7cd6 100644 --- a/back-end/src/main/java/com/techie/backend/user/service/UserServiceImpl.java +++ b/back-end/src/main/java/com/techie/backend/user/service/UserServiceImpl.java @@ -125,4 +125,9 @@ public Boolean deleteUser(UserDetailsCustom userDetails, UserRequest.Delete requ userRepository.delete(user); return true; } + + @Override + public User getUserById(Long id) { + return userRepository.findById(id).orElseThrow(UserNotFoundException::new); + } } \ No newline at end of file From 49b431cbfc119c082662bcae6f93d32bd5feec76 Mon Sep 17 00:00:00 2001 From: Donguk Kim <129242580+atoonatoo@users.noreply.github.com> Date: Sat, 25 Jan 2025 20:05:56 +0900 Subject: [PATCH 18/45] =?UTF-8?q?[BE]=20=20[#208]=20=EC=BB=A4=EB=AE=A4?= =?UTF-8?q?=EB=8B=88=ED=8B=B0=20=EB=8C=93=EA=B8=80=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?(#213)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: Entity 추가 * feat: Comment DTO 추가 * feat: repository 추가 * feat: comment service, controller 추가 * feat: comment create 메서드 추가 * feat: comment get, put 메서드 추가 * feat: comment delete 메서드 추가 * feat: comment 메서드 테스트 후 수정 * chore: comment update 업데이트 날짜 추가 --- .../comment/controller/CommentController.java | 53 +++++++++++ .../backend/comment/domain/Comment.java | 43 +++++++++ .../backend/comment/dto/CommentRequest.java | 13 +++ .../backend/comment/dto/CommentResponse.java | 22 +++++ .../comment/repository/CommentRepository.java | 14 +++ .../repository/CommentRepositoryCustom.java | 10 ++ .../repository/CommentRepositoryImpl.java | 30 ++++++ .../comment/service/CommentService.java | 18 ++++ .../comment/service/CommentServiceImpl.java | 95 +++++++++++++++++++ .../backend/global/mapper/CommentMapper.java | 24 +++++ 10 files changed, 322 insertions(+) create mode 100644 back-end/src/main/java/com/techie/backend/comment/controller/CommentController.java create mode 100644 back-end/src/main/java/com/techie/backend/comment/domain/Comment.java create mode 100644 back-end/src/main/java/com/techie/backend/comment/dto/CommentRequest.java create mode 100644 back-end/src/main/java/com/techie/backend/comment/dto/CommentResponse.java create mode 100644 back-end/src/main/java/com/techie/backend/comment/repository/CommentRepository.java create mode 100644 back-end/src/main/java/com/techie/backend/comment/repository/CommentRepositoryCustom.java create mode 100644 back-end/src/main/java/com/techie/backend/comment/repository/CommentRepositoryImpl.java create mode 100644 back-end/src/main/java/com/techie/backend/comment/service/CommentService.java create mode 100644 back-end/src/main/java/com/techie/backend/comment/service/CommentServiceImpl.java create mode 100644 back-end/src/main/java/com/techie/backend/global/mapper/CommentMapper.java diff --git a/back-end/src/main/java/com/techie/backend/comment/controller/CommentController.java b/back-end/src/main/java/com/techie/backend/comment/controller/CommentController.java new file mode 100644 index 00000000..a06940d7 --- /dev/null +++ b/back-end/src/main/java/com/techie/backend/comment/controller/CommentController.java @@ -0,0 +1,53 @@ +package com.techie.backend.comment.controller; + +import com.techie.backend.board.repository.PostRepository; +import com.techie.backend.comment.dto.CommentRequest; +import com.techie.backend.comment.dto.CommentResponse; +import com.techie.backend.comment.repository.CommentRepository; +import com.techie.backend.comment.service.CommentService; +import com.techie.backend.global.security.UserDetailsCustom; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/api/post/{postId}/comments") +@RequiredArgsConstructor +public class CommentController { + private final CommentService commentService; + + @PostMapping + public ResponseEntity createComment(@PathVariable Long postId, + @RequestBody CommentRequest commentRequest, + @AuthenticationPrincipal UserDetailsCustom userDetails) { + CommentResponse commentResponse = commentService.createComment(postId, commentRequest, userDetails); + return ResponseEntity.ok(commentResponse); + } + + @GetMapping + public ResponseEntity> getComments(@PathVariable Long postId) { + List commentResponses = commentService.getCommentsByPostId(postId); + return ResponseEntity.ok(commentResponses); + } + + @PutMapping("/{commentId}") + public ResponseEntity updateComment(@PathVariable Long postId, + @PathVariable Long commentId, + @RequestBody CommentRequest.Update updateRequest, + @AuthenticationPrincipal UserDetailsCustom userDetailsCustom) { + commentService.updateComment(postId, commentId, updateRequest, userDetailsCustom); + return ResponseEntity.ok().build(); + } + + @DeleteMapping("/{commentId}") + public ResponseEntity deleteComment(@PathVariable Long postId, + @PathVariable Long commentId, + @AuthenticationPrincipal UserDetailsCustom userDetailsCustom) { + + commentService.deleteComment(postId, commentId, userDetailsCustom); + return ResponseEntity.noContent().build(); + } +} \ No newline at end of file diff --git a/back-end/src/main/java/com/techie/backend/comment/domain/Comment.java b/back-end/src/main/java/com/techie/backend/comment/domain/Comment.java new file mode 100644 index 00000000..e7fa4252 --- /dev/null +++ b/back-end/src/main/java/com/techie/backend/comment/domain/Comment.java @@ -0,0 +1,43 @@ +package com.techie.backend.comment.domain; + +import com.techie.backend.board.domain.Post; +import com.techie.backend.global.BaseTime; +import com.techie.backend.user.domain.User; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "comment") +public class Comment extends BaseTime { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private String content; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "post_id", nullable = false) + private Post post; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Builder + public Comment(String content, Post post, User user) { + this.content = content; + this.post = post; + this.user = user; + } + + public void updateContent(String content) { + this.content = content; + } +} \ No newline at end of file diff --git a/back-end/src/main/java/com/techie/backend/comment/dto/CommentRequest.java b/back-end/src/main/java/com/techie/backend/comment/dto/CommentRequest.java new file mode 100644 index 00000000..1449136d --- /dev/null +++ b/back-end/src/main/java/com/techie/backend/comment/dto/CommentRequest.java @@ -0,0 +1,13 @@ +package com.techie.backend.comment.dto; + +import lombok.Data; + +@Data +public class CommentRequest { + private String content; + + @Data + public static class Update { + private String content; + } +} \ No newline at end of file diff --git a/back-end/src/main/java/com/techie/backend/comment/dto/CommentResponse.java b/back-end/src/main/java/com/techie/backend/comment/dto/CommentResponse.java new file mode 100644 index 00000000..ca490cfc --- /dev/null +++ b/back-end/src/main/java/com/techie/backend/comment/dto/CommentResponse.java @@ -0,0 +1,22 @@ + +package com.techie.backend.comment.dto; + +import com.techie.backend.comment.domain.Comment; +import lombok.Data; + +import java.time.LocalDateTime; + +@Data +public class CommentResponse { + private String content; + private String writerNickname; + private LocalDateTime createDate; + private LocalDateTime updatedDate; + + public CommentResponse(Comment comment) { + this.content = comment.getContent(); + this.writerNickname = comment.getUser().getNickname(); + this.createDate = comment.getCreatedAt(); + this.updatedDate = comment.getUpdatedAt(); + } +} \ No newline at end of file diff --git a/back-end/src/main/java/com/techie/backend/comment/repository/CommentRepository.java b/back-end/src/main/java/com/techie/backend/comment/repository/CommentRepository.java new file mode 100644 index 00000000..af678822 --- /dev/null +++ b/back-end/src/main/java/com/techie/backend/comment/repository/CommentRepository.java @@ -0,0 +1,14 @@ +package com.techie.backend.comment.repository; + +import com.techie.backend.board.domain.Post; +import com.techie.backend.comment.domain.Comment; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface CommentRepository extends JpaRepository, CommentRepositoryCustom { + List findByPost(Post post); + + Optional findByIdAndPostId(Long commentId, Long postId); +} \ No newline at end of file diff --git a/back-end/src/main/java/com/techie/backend/comment/repository/CommentRepositoryCustom.java b/back-end/src/main/java/com/techie/backend/comment/repository/CommentRepositoryCustom.java new file mode 100644 index 00000000..52cb2670 --- /dev/null +++ b/back-end/src/main/java/com/techie/backend/comment/repository/CommentRepositoryCustom.java @@ -0,0 +1,10 @@ +package com.techie.backend.comment.repository; + +import com.techie.backend.comment.domain.Comment; + +import java.util.Optional; + +public interface CommentRepositoryCustom { + Optional findCommentByPostIdAndCommentId(Long postId, Long commentId); + +} diff --git a/back-end/src/main/java/com/techie/backend/comment/repository/CommentRepositoryImpl.java b/back-end/src/main/java/com/techie/backend/comment/repository/CommentRepositoryImpl.java new file mode 100644 index 00000000..6c6c47aa --- /dev/null +++ b/back-end/src/main/java/com/techie/backend/comment/repository/CommentRepositoryImpl.java @@ -0,0 +1,30 @@ +package com.techie.backend.comment.repository; + +import com.querydsl.jpa.impl.JPAQueryFactory; +import com.techie.backend.board.domain.QPost; +import com.techie.backend.comment.domain.Comment; +import com.techie.backend.comment.domain.QComment; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +@RequiredArgsConstructor +public class CommentRepositoryImpl implements CommentRepositoryCustom { + private final JPAQueryFactory queryFactory; + + @Override + public Optional findCommentByPostIdAndCommentId(Long postId, Long commentId) { + QComment comment = QComment.comment; + QPost post = QPost.post; + + Comment result = queryFactory + .selectFrom(comment) + .where(comment.id.eq(commentId) + .and(comment.post.id.eq(postId))) + .fetchOne(); + + return Optional.ofNullable(result); + } +} diff --git a/back-end/src/main/java/com/techie/backend/comment/service/CommentService.java b/back-end/src/main/java/com/techie/backend/comment/service/CommentService.java new file mode 100644 index 00000000..8ddf9ab0 --- /dev/null +++ b/back-end/src/main/java/com/techie/backend/comment/service/CommentService.java @@ -0,0 +1,18 @@ +package com.techie.backend.comment.service; + + +import com.techie.backend.comment.dto.CommentRequest; +import com.techie.backend.comment.dto.CommentResponse; +import com.techie.backend.global.security.UserDetailsCustom; + +import java.util.List; + +public interface CommentService { + CommentResponse createComment(Long postId, CommentRequest commentRequest, UserDetailsCustom userDetails); + + List getCommentsByPostId(Long postId); + + void updateComment(Long postId, Long commentId, CommentRequest.Update updateRequest, UserDetailsCustom userDetailsCustom); + + void deleteComment(Long postId, Long commentId, UserDetailsCustom userDetailsCustom); +} diff --git a/back-end/src/main/java/com/techie/backend/comment/service/CommentServiceImpl.java b/back-end/src/main/java/com/techie/backend/comment/service/CommentServiceImpl.java new file mode 100644 index 00000000..26c0ebe4 --- /dev/null +++ b/back-end/src/main/java/com/techie/backend/comment/service/CommentServiceImpl.java @@ -0,0 +1,95 @@ +package com.techie.backend.comment.service; + +import com.techie.backend.board.domain.Post; +import com.techie.backend.board.repository.PostRepository; +import com.techie.backend.comment.domain.Comment; +import com.techie.backend.comment.dto.CommentRequest; +import com.techie.backend.comment.dto.CommentResponse; +import com.techie.backend.comment.repository.CommentRepository; +import com.techie.backend.global.mapper.CommentMapper; +import com.techie.backend.global.security.UserDetailsCustom; +import com.techie.backend.user.domain.User; +import com.techie.backend.user.service.UserService; +import jakarta.persistence.EntityNotFoundException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.stream.Collectors; + +@Service +@Transactional +@RequiredArgsConstructor +@Slf4j +public class CommentServiceImpl implements CommentService { + private final CommentRepository commentRepository; + private final PostRepository postRepository; + private final UserService userService; + private final CommentMapper commentMapper; + + @Override + public CommentResponse createComment(Long postId, + CommentRequest commentRequest, + UserDetailsCustom userDetails) { + + User user = userService.getUserFromSecurityContext(userDetails); + + Post post = postRepository.findById(postId) + .orElseThrow(() -> new EntityNotFoundException("게시글을 찾을 수 없습니다.")); + + Comment comment = commentMapper.toEntity(commentRequest, post, user); + Comment createComment = commentRepository.save(comment); + + return commentMapper.toDto(createComment); + } + + @Override + public List getCommentsByPostId(Long postId) { + + Post post = postRepository.findById(postId) + .orElseThrow(() -> new EntityNotFoundException("게시글을 찾을 수 없습니다.")); + + List comments = commentRepository.findByPost(post); + + return comments.stream() + .map(commentMapper::toDto) + .collect(Collectors.toList()); + } + + @Override + public void updateComment(Long postId, + Long commentId, + CommentRequest.Update updateRequest, + UserDetailsCustom userDetailsCustom) { + + Comment comment = commentRepository.findCommentByPostIdAndCommentId(postId, commentId) + .orElseThrow(() -> new EntityNotFoundException("해당 게시물에 댓글이 존재하지 않습니다.")); + + User user = userService.getUserFromSecurityContext(userDetailsCustom); + + if (!comment.getUser().getId().equals(user.getId())) { + throw new SecurityException("댓글 작성자가 아닙니다."); + } + + comment.updateContent(updateRequest.getContent()); + } + + + @Override + public void deleteComment(Long postId, + Long commentId, + UserDetailsCustom userDetailsCustom) { + + Comment comment = commentRepository.findById(commentId) + .orElseThrow(() -> new EntityNotFoundException("댓글을 찾을 수 없습니다.")); + User user = userService.getUserFromSecurityContext(userDetailsCustom); + + if (!comment.getUser().getId().equals(user.getId())) { + throw new SecurityException("댓글 작성자가 아닙니다."); + } + commentRepository.delete(comment); + } + +} \ No newline at end of file diff --git a/back-end/src/main/java/com/techie/backend/global/mapper/CommentMapper.java b/back-end/src/main/java/com/techie/backend/global/mapper/CommentMapper.java new file mode 100644 index 00000000..1985c4ed --- /dev/null +++ b/back-end/src/main/java/com/techie/backend/global/mapper/CommentMapper.java @@ -0,0 +1,24 @@ +package com.techie.backend.global.mapper; + +import com.techie.backend.board.domain.Post; +import com.techie.backend.comment.domain.Comment; +import com.techie.backend.comment.dto.CommentRequest; +import com.techie.backend.comment.dto.CommentResponse; +import com.techie.backend.user.domain.User; +import org.springframework.stereotype.Component; + +@Component +public class CommentMapper { + + public Comment toEntity(CommentRequest commentRequest, Post post, User user) { + return Comment.builder() + .content(commentRequest.getContent()) + .post(post) + .user(user) + .build(); + } + + public CommentResponse toDto(Comment comment) { + return new CommentResponse(comment); + } +} From f8f5dc66cdac1f4ec83b5b21aa1ed488cceea678 Mon Sep 17 00:00:00 2001 From: Donguk Kim <129242580+atoonatoo@users.noreply.github.com> Date: Mon, 27 Jan 2025 22:47:00 +0900 Subject: [PATCH 19/45] =?UTF-8?q?[BE]=20[#214]=20Service=20=EC=9D=B8?= =?UTF-8?q?=ED=84=B0=ED=8E=98=EC=9D=B4=EC=8A=A4=20=EC=A0=9C=EA=B1=B0=20?= =?UTF-8?q?=EB=B0=8F=20Service=20=ED=81=B4=EB=9E=98=EC=8A=A4=20=EA=B5=AC?= =?UTF-8?q?=EC=A1=B0=EB=A1=9C=20=ED=86=B5=ED=95=A9=20(#215)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * user service interface 삭제, service impl를 단독 class로 수정 * refactor: playlist, comment, gpt service interface 삭제후 service impl class 단일 클래스로 수정 --- .../comment/controller/CommentController.java | 2 - .../comment/service/CommentService.java | 84 +++++++- .../comment/service/CommentServiceImpl.java | 95 --------- .../backend/gpt/service/GptService.java | 98 ++++++++- .../backend/gpt/service/GptServiceImpl.java | 104 --------- .../playlist/service/PlaylistService.java | 193 ++++++++++++++++- .../playlist/service/PlaylistServiceImpl.java | 199 ------------------ .../backend/user/service/UserService.java | 127 ++++++++++- .../backend/user/service/UserServiceImpl.java | 133 ------------ 9 files changed, 478 insertions(+), 557 deletions(-) delete mode 100644 back-end/src/main/java/com/techie/backend/comment/service/CommentServiceImpl.java delete mode 100644 back-end/src/main/java/com/techie/backend/gpt/service/GptServiceImpl.java delete mode 100644 back-end/src/main/java/com/techie/backend/playlist/service/PlaylistServiceImpl.java delete mode 100644 back-end/src/main/java/com/techie/backend/user/service/UserServiceImpl.java diff --git a/back-end/src/main/java/com/techie/backend/comment/controller/CommentController.java b/back-end/src/main/java/com/techie/backend/comment/controller/CommentController.java index a06940d7..c6e72500 100644 --- a/back-end/src/main/java/com/techie/backend/comment/controller/CommentController.java +++ b/back-end/src/main/java/com/techie/backend/comment/controller/CommentController.java @@ -1,9 +1,7 @@ package com.techie.backend.comment.controller; -import com.techie.backend.board.repository.PostRepository; import com.techie.backend.comment.dto.CommentRequest; import com.techie.backend.comment.dto.CommentResponse; -import com.techie.backend.comment.repository.CommentRepository; import com.techie.backend.comment.service.CommentService; import com.techie.backend.global.security.UserDetailsCustom; import lombok.RequiredArgsConstructor; diff --git a/back-end/src/main/java/com/techie/backend/comment/service/CommentService.java b/back-end/src/main/java/com/techie/backend/comment/service/CommentService.java index 8ddf9ab0..5c724dc2 100644 --- a/back-end/src/main/java/com/techie/backend/comment/service/CommentService.java +++ b/back-end/src/main/java/com/techie/backend/comment/service/CommentService.java @@ -1,18 +1,88 @@ package com.techie.backend.comment.service; - +import com.techie.backend.board.domain.Post; +import com.techie.backend.board.repository.PostRepository; import com.techie.backend.comment.dto.CommentRequest; import com.techie.backend.comment.dto.CommentResponse; +import com.techie.backend.comment.repository.CommentRepository; +import com.techie.backend.global.mapper.CommentMapper; import com.techie.backend.global.security.UserDetailsCustom; +import com.techie.backend.user.domain.User; +import com.techie.backend.user.service.UserService; +import jakarta.persistence.EntityNotFoundException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import java.util.List; +import java.util.stream.Collectors; + +@Service +@Transactional +@RequiredArgsConstructor +@Slf4j +public class CommentService { + private final CommentRepository commentRepository; + private final PostRepository postRepository; + private final UserService userService; + private final CommentMapper commentMapper; + + public CommentResponse createComment(Long postId, + CommentRequest commentRequest, + UserDetailsCustom userDetails) { + + User user = userService.getUserFromSecurityContext(userDetails); + + Post post = postRepository.findById(postId) + .orElseThrow(() -> new EntityNotFoundException("게시글을 찾을 수 없습니다.")); + + com.techie.backend.comment.domain.Comment comment = commentMapper.toEntity(commentRequest, post, user); + com.techie.backend.comment.domain.Comment createComment = commentRepository.save(comment); + + return commentMapper.toDto(createComment); + } + + public List getCommentsByPostId(Long postId) { + + Post post = postRepository.findById(postId) + .orElseThrow(() -> new EntityNotFoundException("게시글을 찾을 수 없습니다.")); + + List comments = commentRepository.findByPost(post); + + return comments.stream() + .map(commentMapper::toDto) + .collect(Collectors.toList()); + } + + public void updateComment(Long postId, + Long commentId, + CommentRequest.Update updateRequest, + UserDetailsCustom userDetailsCustom) { + + com.techie.backend.comment.domain.Comment comment = commentRepository.findCommentByPostIdAndCommentId(postId, commentId) + .orElseThrow(() -> new EntityNotFoundException("해당 게시물에 댓글이 존재하지 않습니다.")); + + User user = userService.getUserFromSecurityContext(userDetailsCustom); + + if (!comment.getUser().getId().equals(user.getId())) { + throw new SecurityException("댓글 작성자가 아닙니다."); + } -public interface CommentService { - CommentResponse createComment(Long postId, CommentRequest commentRequest, UserDetailsCustom userDetails); + comment.updateContent(updateRequest.getContent()); + } - List getCommentsByPostId(Long postId); + public void deleteComment(Long postId, + Long commentId, + UserDetailsCustom userDetailsCustom) { - void updateComment(Long postId, Long commentId, CommentRequest.Update updateRequest, UserDetailsCustom userDetailsCustom); + com.techie.backend.comment.domain.Comment comment = commentRepository.findById(commentId) + .orElseThrow(() -> new EntityNotFoundException("댓글을 찾을 수 없습니다.")); + User user = userService.getUserFromSecurityContext(userDetailsCustom); - void deleteComment(Long postId, Long commentId, UserDetailsCustom userDetailsCustom); -} + if (!comment.getUser().getId().equals(user.getId())) { + throw new SecurityException("댓글 작성자가 아닙니다."); + } + commentRepository.delete(comment); + } +} \ No newline at end of file diff --git a/back-end/src/main/java/com/techie/backend/comment/service/CommentServiceImpl.java b/back-end/src/main/java/com/techie/backend/comment/service/CommentServiceImpl.java deleted file mode 100644 index 26c0ebe4..00000000 --- a/back-end/src/main/java/com/techie/backend/comment/service/CommentServiceImpl.java +++ /dev/null @@ -1,95 +0,0 @@ -package com.techie.backend.comment.service; - -import com.techie.backend.board.domain.Post; -import com.techie.backend.board.repository.PostRepository; -import com.techie.backend.comment.domain.Comment; -import com.techie.backend.comment.dto.CommentRequest; -import com.techie.backend.comment.dto.CommentResponse; -import com.techie.backend.comment.repository.CommentRepository; -import com.techie.backend.global.mapper.CommentMapper; -import com.techie.backend.global.security.UserDetailsCustom; -import com.techie.backend.user.domain.User; -import com.techie.backend.user.service.UserService; -import jakarta.persistence.EntityNotFoundException; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.util.List; -import java.util.stream.Collectors; - -@Service -@Transactional -@RequiredArgsConstructor -@Slf4j -public class CommentServiceImpl implements CommentService { - private final CommentRepository commentRepository; - private final PostRepository postRepository; - private final UserService userService; - private final CommentMapper commentMapper; - - @Override - public CommentResponse createComment(Long postId, - CommentRequest commentRequest, - UserDetailsCustom userDetails) { - - User user = userService.getUserFromSecurityContext(userDetails); - - Post post = postRepository.findById(postId) - .orElseThrow(() -> new EntityNotFoundException("게시글을 찾을 수 없습니다.")); - - Comment comment = commentMapper.toEntity(commentRequest, post, user); - Comment createComment = commentRepository.save(comment); - - return commentMapper.toDto(createComment); - } - - @Override - public List getCommentsByPostId(Long postId) { - - Post post = postRepository.findById(postId) - .orElseThrow(() -> new EntityNotFoundException("게시글을 찾을 수 없습니다.")); - - List comments = commentRepository.findByPost(post); - - return comments.stream() - .map(commentMapper::toDto) - .collect(Collectors.toList()); - } - - @Override - public void updateComment(Long postId, - Long commentId, - CommentRequest.Update updateRequest, - UserDetailsCustom userDetailsCustom) { - - Comment comment = commentRepository.findCommentByPostIdAndCommentId(postId, commentId) - .orElseThrow(() -> new EntityNotFoundException("해당 게시물에 댓글이 존재하지 않습니다.")); - - User user = userService.getUserFromSecurityContext(userDetailsCustom); - - if (!comment.getUser().getId().equals(user.getId())) { - throw new SecurityException("댓글 작성자가 아닙니다."); - } - - comment.updateContent(updateRequest.getContent()); - } - - - @Override - public void deleteComment(Long postId, - Long commentId, - UserDetailsCustom userDetailsCustom) { - - Comment comment = commentRepository.findById(commentId) - .orElseThrow(() -> new EntityNotFoundException("댓글을 찾을 수 없습니다.")); - User user = userService.getUserFromSecurityContext(userDetailsCustom); - - if (!comment.getUser().getId().equals(user.getId())) { - throw new SecurityException("댓글 작성자가 아닙니다."); - } - commentRepository.delete(comment); - } - -} \ No newline at end of file diff --git a/back-end/src/main/java/com/techie/backend/gpt/service/GptService.java b/back-end/src/main/java/com/techie/backend/gpt/service/GptService.java index b5d09da4..4fe8cf86 100644 --- a/back-end/src/main/java/com/techie/backend/gpt/service/GptService.java +++ b/back-end/src/main/java/com/techie/backend/gpt/service/GptService.java @@ -1,11 +1,103 @@ package com.techie.backend.gpt.service; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.techie.backend.global.exception.gpt.GptRequestException; +import com.techie.backend.global.exception.gpt.InvalidGptRequestException; import com.techie.backend.global.security.UserDetailsCustom; +import com.techie.backend.gpt.domain.Gpt; import com.techie.backend.gpt.dto.GptRequest; import com.techie.backend.gpt.dto.GptResponse; +import com.techie.backend.gpt.repository.GptRepository; +import com.techie.backend.user.domain.User; +import com.techie.backend.user.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import org.apache.hc.client5.http.classic.methods.HttpPost; +import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; +import org.apache.hc.client5.http.impl.classic.HttpClients; +import org.apache.hc.client5.http.protocol.HttpClientContext; +import org.apache.hc.core5.http.ClassicHttpResponse; +import org.apache.hc.core5.http.ContentType; +import org.apache.hc.core5.http.HttpHost; +import org.apache.hc.core5.http.io.entity.StringEntity; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; -public interface GptService { - ResponseEntity questionAndAnswer(GptRequest gptRequest, UserDetailsCustom userDetails); +import java.io.IOException; +import java.net.URISyntaxException; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.List; +import java.util.Map; -} +@Service +@RequiredArgsConstructor +public class GptService { + + private final ObjectMapper objectMapper; + private final GptRepository gptRepository; + private final UserRepository userRepository; + + @Value("${openai.api.key}") + private String API_KEY; + @Value("${openai.api.url}") + private String API_URL; + + public ResponseEntity questionAndAnswer(GptRequest gptRequest, UserDetailsCustom userDetails) { + String request = gptRequest.getRequest(); + + if (request == null || request.isEmpty()) { + throw new InvalidGptRequestException(); + } + + try (CloseableHttpClient httpClient = HttpClients.createDefault()) { + HttpPost httpPost = new HttpPost(API_URL); + httpPost.setHeader("Authorization", "Bearer " + API_KEY); + httpPost.setHeader("Content-Type", "application/json"); + + String apiHost = new URL(API_URL).getHost(); + HttpHost targetHost = HttpHost.create("https://" + apiHost); + + Map body = new HashMap<>(); + body.put("model", "gpt-3.5-turbo"); + body.put("messages", List.of(Map.of("role", "user", "content", request))); + + StringEntity entity = new StringEntity(objectMapper.writeValueAsString(body), ContentType.APPLICATION_JSON); + httpPost.setEntity(entity); + + HttpClientContext context = HttpClientContext.create(); + + try (ClassicHttpResponse response = httpClient.executeOpen(targetHost, httpPost, context)) { + int statusCode = response.getCode(); + if (statusCode != HttpStatus.OK.value()) { + throw new GptRequestException(); + } + + String responseBody = new String(response.getEntity().getContent().readAllBytes(), StandardCharsets.UTF_8); + + JsonNode rootNode = objectMapper.readTree(responseBody); + String gptContent = rootNode.path("choices").get(0).path("message").path("content").asText(); + User user = userRepository.findByEmail(userDetails.getUsername()); + + Gpt gpt = new Gpt(); + gpt.updateRequest(request); + gpt.updateResponse(gptContent); + gpt.assignUser(user); + gptRepository.save(gpt); + + GptResponse gptResponse = new GptResponse(); + gptResponse.setRequest(request); + gptResponse.setResponse(gptContent); + + return new ResponseEntity<>(gptResponse, HttpStatus.OK); + } + } catch (IOException e) { + throw new GptRequestException(); + } catch (URISyntaxException e) { + throw new RuntimeException(e); + } + } +} \ No newline at end of file diff --git a/back-end/src/main/java/com/techie/backend/gpt/service/GptServiceImpl.java b/back-end/src/main/java/com/techie/backend/gpt/service/GptServiceImpl.java deleted file mode 100644 index 079abc8f..00000000 --- a/back-end/src/main/java/com/techie/backend/gpt/service/GptServiceImpl.java +++ /dev/null @@ -1,104 +0,0 @@ -package com.techie.backend.gpt.service; - -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.techie.backend.global.exception.gpt.GptRequestException; -import com.techie.backend.global.exception.gpt.InvalidGptRequestException; -import com.techie.backend.global.security.UserDetailsCustom; -import com.techie.backend.gpt.domain.Gpt; -import com.techie.backend.gpt.dto.GptRequest; -import com.techie.backend.gpt.dto.GptResponse; -import com.techie.backend.gpt.repository.GptRepository; -import com.techie.backend.user.domain.User; -import com.techie.backend.user.repository.UserRepository; -import lombok.RequiredArgsConstructor; -import org.apache.hc.client5.http.classic.methods.HttpPost; -import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; -import org.apache.hc.client5.http.impl.classic.HttpClients; -import org.apache.hc.client5.http.protocol.HttpClientContext; -import org.apache.hc.core5.http.ClassicHttpResponse; -import org.apache.hc.core5.http.ContentType; -import org.apache.hc.core5.http.HttpHost; -import org.apache.hc.core5.http.io.entity.StringEntity; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -import org.springframework.stereotype.Service; - -import java.io.IOException; -import java.net.URISyntaxException; -import java.net.URL; -import java.nio.charset.StandardCharsets; -import java.util.HashMap; -import java.util.List; -import java.util.Map; - -@Service -@RequiredArgsConstructor -public class GptServiceImpl implements GptService { - - private final ObjectMapper objectMapper; - private final GptRepository gptRepository; - private final UserRepository userRepository; - - @Value("${openai.api.key}") - private String API_KEY; - @Value("${openai.api.url}") - private String API_URL; - - @Override - public ResponseEntity questionAndAnswer(GptRequest gptRequest, UserDetailsCustom userDetails) { - String request = gptRequest.getRequest(); - - if (request == null || request.isEmpty()) { - throw new InvalidGptRequestException(); - } - - try (CloseableHttpClient httpClient = HttpClients.createDefault()) { - HttpPost httpPost = new HttpPost(API_URL); - httpPost.setHeader("Authorization", "Bearer " + API_KEY); - httpPost.setHeader("Content-Type", "application/json"); - - String apiHost = new URL(API_URL).getHost(); - HttpHost targetHost = HttpHost.create("https://" + apiHost); - - Map body = new HashMap<>(); - body.put("model", "gpt-3.5-turbo"); - body.put("messages", List.of(Map.of("role", "user", "content", request))); - - StringEntity entity = new StringEntity(objectMapper.writeValueAsString(body), ContentType.APPLICATION_JSON); - httpPost.setEntity(entity); - - HttpClientContext context = HttpClientContext.create(); - - try (ClassicHttpResponse response = httpClient.executeOpen(targetHost, httpPost, context)) { - int statusCode = response.getCode(); - if (statusCode != HttpStatus.OK.value()) { - throw new GptRequestException(); - } - - String responseBody = new String(response.getEntity().getContent().readAllBytes(), StandardCharsets.UTF_8); - - JsonNode rootNode = objectMapper.readTree(responseBody); - String gptContent = rootNode.path("choices").get(0).path("message").path("content").asText(); - User user = userRepository.findByEmail(userDetails.getUsername()); - - Gpt gpt = new Gpt(); - gpt.updateRequest(request); - gpt.updateResponse(gptContent); - gpt.assignUser(user); - gptRepository.save(gpt); - - GptResponse gptResponse = new GptResponse(); - gptResponse.setRequest(request); - gptResponse.setResponse(gptContent); - - return new ResponseEntity<>(gptResponse, HttpStatus.OK); - } - } catch (IOException e) { - throw new GptRequestException(); - } catch (URISyntaxException e) { - throw new RuntimeException(e); - } - } -} \ No newline at end of file diff --git a/back-end/src/main/java/com/techie/backend/playlist/service/PlaylistService.java b/back-end/src/main/java/com/techie/backend/playlist/service/PlaylistService.java index b0953a41..5498c848 100644 --- a/back-end/src/main/java/com/techie/backend/playlist/service/PlaylistService.java +++ b/back-end/src/main/java/com/techie/backend/playlist/service/PlaylistService.java @@ -1,15 +1,194 @@ - package com.techie.backend.playlist.service; + +import com.techie.backend.global.exception.playlist.PlaylistNotFoundException; +import com.techie.backend.global.exception.playlist.VideoNotFoundException; import com.techie.backend.global.security.UserDetailsCustom; +import com.techie.backend.playlist.domain.Playlist; import com.techie.backend.playlist.dto.PlaylistRequest; import com.techie.backend.playlist.dto.PlaylistResponse; +import com.techie.backend.playlist.repository.PlaylistRepository; +import com.techie.backend.playlist_video.domain.PlaylistVideo; +import com.techie.backend.playlist_video.repository.PlaylistVideoRepository; +import com.techie.backend.user.domain.User; +import com.techie.backend.user.service.UserService; +import com.techie.backend.video.domain.Video; +import com.techie.backend.video.dto.VideoResponse; +import com.techie.backend.video.repository.VideoRepository; +import com.techie.backend.video.service.VideoService; +import lombok.RequiredArgsConstructor; +import org.modelmapper.ModelMapper; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class PlaylistService { + private final PlaylistRepository playlistRepository; + private final PlaylistVideoRepository playlistVideoRepository; + private final VideoRepository videoRepository; + private final UserService userService; + private final VideoService videoService; + private final ModelMapper modelMapper; + + @Transactional + public Boolean createPlaylist(UserDetailsCustom userDetails, PlaylistRequest.CreatePlaylist request) { + User user = userService.getUserFromSecurityContext(userDetails); + + Playlist playlist = Playlist.builder() + .name(request.getPlaylistName()) + .user(user) + .build(); + playlistRepository.save(playlist); + + if (request.getVideoId() == null || request.getVideoId().isEmpty()) { + return true; + } + + String videoId = request.getVideoId(); + String videoUri = videoService.buildVideoUri(videoId); + String videoResponseJson = videoService.getYoutubeResponse(videoUri).getBody(); + List videoResponses = videoService.convertJsonToVideoDTOWithoutPaging(videoResponseJson); + + if (videoResponses.isEmpty()) { + return false; + } + + VideoResponse videoResponse = videoResponses.get(0); + Video video = videoRepository.findByVideoId(videoId) + .orElseGet(() -> { + Video newVideo = Video.builder() + .videoId(videoId) + .title(videoResponse.getTitle()) + .build(); + return videoRepository.save(newVideo); + }); + + playlist.addVideo(video); + playlistRepository.save(playlist); + return true; + } + + public PlaylistResponse.Overview getPlaylists(UserDetailsCustom userDetails) { + User user = userService.getUserFromSecurityContext(userDetails); + List playlists = playlistRepository.findAllByUser(user); + + if (playlists.isEmpty()) { + return new PlaylistResponse.Overview(List.of()); + } + + List playlistSummaries = playlists.stream() + .map(playlist -> { + List
    ); diff --git a/front-end/src/app/api/playlistApi.ts b/front-end/src/app/api/playlistApi.ts index 7ee16258..e6ea6cc9 100644 --- a/front-end/src/app/api/playlistApi.ts +++ b/front-end/src/app/api/playlistApi.ts @@ -108,7 +108,7 @@ export const deleteVideos = async ( // 재생목록 삭제 -export const deletepPlaylist = async (playlistId: string, token: string | undefined) => { +export const deletePlaylist = async (playlistId: string, token: string | undefined) => { try { const response = await axios.delete(`${baseUrl}/api/playlists/${playlistId}`, { headers: { diff --git a/front-end/src/app/gpt-histories/page.tsx b/front-end/src/app/gpt-histories/page.tsx deleted file mode 100644 index 5ee4a3e3..00000000 --- a/front-end/src/app/gpt-histories/page.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import React from 'react'; - -const Gpthistories = () => { - return ( -
    -

    gpt 기록

    -
    - ); -}; - -export default Gpthistories; diff --git a/front-end/src/app/login/page.tsx b/front-end/src/app/login/page.tsx index 79657480..dfbefb7f 100644 --- a/front-end/src/app/login/page.tsx +++ b/front-end/src/app/login/page.tsx @@ -1,21 +1,24 @@ 'use client'; import React, { useState } from 'react'; -import { useDispatch } from 'react-redux'; -import { useRouter } from 'next/navigation'; + import { AxiosError } from 'axios'; -import Cookies from 'js-cookie'; -import { loginUser } from '@/app/api/loginUserApi'; import '@/styles/pages/login/login.scss'; -import { setUserInfo } from '@/redux/reducer'; + import { devConsoleError } from '@/utils/logger'; +import { performLogin, decodeJWT } from '@/components/authservice/authservice'; +import { useDispatch } from 'react-redux'; +import { useRouter } from 'next/navigation'; +import { setUserInfo } from '@/redux/reducer'; const Login = () => { const [formData, setFormData] = useState({ email: '', password: '' }); - const [error, setError] = useState(''); const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(''); + const router = useRouter(); + const dispatch = useDispatch(); // 입력값 변경 시 formData 업데이트 @@ -31,57 +34,19 @@ const Login = () => { setIsLoading(true); try { - await performLogin(); + const token = await performLogin(formData); + window.dispatchEvent(new Event('loginStatusChanged')); + router.push('/'); // 로그인 성공 시 메인 페이지로 이동 + + // JWT 디코딩 + const decodedJWT = decodeJWT(token); + dispatch(setUserInfo(decodedJWT)); } catch (loginError) { handleLoginError(loginError); } finally { setIsLoading(false); } }; - - // 로그인 요청 전송 함수 - const performLogin = async () => { - try { - const res = await loginUser(formData); - if (res.status === 200) { - const token = res.headers['authorization']?.split(' ')[1]; - if (token) { - // 쿠키에 토큰을 저장 - Cookies.set('token', token, { expires: 1, path: '/' }); - window.dispatchEvent(new Event('loginStatusChanged')); - router.push('/'); // 로그인 성공 시 메인 페이지로 이동 - - // JWT 디코딩 - const decodedJWT = decodeJWT(token); - dispatch(setUserInfo(decodedJWT)); - } else { - devConsoleError('Token is undefined in the response headers'); - } - } else { - devConsoleError('Failed to log in, unexpected response status'); - } - } catch (error) { - handleLoginError(error); - } - }; - - // JWT 디코딩 함수 - const decodeJWT = (token: string) => { - const base64Payload = token.split('.')[1]; - const base64 = base64Payload.replace(/-/g, '+').replace(/_/g, '/'); - return JSON.parse( - decodeURIComponent( - window - .atob(base64) - .split('') - .map(function (c) { - return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2); - }) - .join(''), - ), - ); - }; - // 로그인 오류 처리 함수 const handleLoginError = (error: unknown) => { const axiosError = error as AxiosError; diff --git a/front-end/src/app/memo/page.tsx b/front-end/src/app/memo/page.tsx deleted file mode 100644 index 6c25dff0..00000000 --- a/front-end/src/app/memo/page.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import React from 'react'; - -const Memo = () => { - return ( -
    -

    메모

    -
    - ); -}; - -export default Memo; diff --git a/front-end/src/app/posts/[id]/edit/page.tsx b/front-end/src/app/posts/[id]/edit/page.tsx index 749dfd2d..746357a3 100644 --- a/front-end/src/app/posts/[id]/edit/page.tsx +++ b/front-end/src/app/posts/[id]/edit/page.tsx @@ -26,7 +26,7 @@ export default function PostEdit({ params }: { params: { id: string } }) { }; fetchPost(); - }, [params.id]); + }, [params.id, router]); const handleUpdate = async () => { if (!post.title.trim() || !post.content.trim()) { diff --git a/front-end/src/app/posts/page.tsx b/front-end/src/app/posts/page.tsx index 249d6fd0..6821f90e 100644 --- a/front-end/src/app/posts/page.tsx +++ b/front-end/src/app/posts/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState, useEffect } from 'react'; +import { useState, useEffect, useCallback } from 'react'; import { useRouter } from 'next/navigation'; import { useSelector } from 'react-redux'; import { RootState } from '@/redux/store'; @@ -24,7 +24,7 @@ export default function PostList() { const [currentPage, setCurrentPage] = useState(0); const [totalPages, setTotalPages] = useState(1); - const loadPosts = async () => { + const loadPosts = useCallback(async () => { try { const data = await fetchPosts(category, searchQuery, currentPage); setPosts(data.content); @@ -32,11 +32,11 @@ export default function PostList() { } catch (error) { console.error('게시글 로딩 실패:', error); } - }; + }, [category, searchQuery, currentPage]); useEffect(() => { loadPosts(); - }, [category, searchQuery, currentPage]); + }, [loadPosts]); const handleSearch = () => { setSearchQuery(query); diff --git a/front-end/src/app/search-histories/page.tsx b/front-end/src/app/search-histories/page.tsx deleted file mode 100644 index 9e6d3e00..00000000 --- a/front-end/src/app/search-histories/page.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import React from 'react'; - -const Searchhistories = () => { - return ( -
    -

    검색기록

    -
    - ); -}; - -export default Searchhistories; diff --git a/front-end/src/app/signup/page.tsx b/front-end/src/app/signup/page.tsx index 11d9bfee..f27fd3c4 100644 --- a/front-end/src/app/signup/page.tsx +++ b/front-end/src/app/signup/page.tsx @@ -4,7 +4,8 @@ import React, { useState } from 'react'; import { useRouter } from 'next/navigation'; import { AxiosError } from 'axios'; import { devConsoleError } from '@/utils/logger'; -import { fetchRegisterUser } from '@/app/api/registerUserApi'; + +import { registerUser } from '@/components/authservice/registerservice'; import '@/styles/pages/register/register.scss'; const Signup = () => { @@ -43,19 +44,6 @@ const Signup = () => { } }; - // 회원가입 요청 전송 함수 - const registerUser = async (userData: { - nickname: string; - email: string; - password: string; - confirmPassword: string; - }) => { - const response = await fetchRegisterUser(userData); - if (response.status === 200 || response.status === 201) { - alert('회원가입 완료'); - } - }; - // 회원가입 오류 처리 함수 const handleSignupError = (error: unknown) => { if (error instanceof AxiosError && error.response) { diff --git a/front-end/src/app/watch-histories/page.tsx b/front-end/src/app/watch-histories/page.tsx deleted file mode 100644 index e1c645d1..00000000 --- a/front-end/src/app/watch-histories/page.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import React from 'react'; - -const Watchhistory = () => { - return ( -
    -

    시청기록

    -
    - ); -}; - -export default Watchhistory; diff --git a/front-end/src/components/authservice/authservice.tsx b/front-end/src/components/authservice/authservice.tsx new file mode 100644 index 00000000..c7aa7fb7 --- /dev/null +++ b/front-end/src/components/authservice/authservice.tsx @@ -0,0 +1,39 @@ +import { loginUser } from '@/app/api/loginUserApi'; +import Cookies from 'js-cookie'; + +import { devConsoleError } from '@/utils/logger'; + +// 로그인 요청 전송 함수 +export const performLogin = async (formData: { email: string; password: string }) => { + const res = await loginUser(formData); + if (res.status === 200) { + const token = res.headers['authorization']?.split(' ')[1]; + if (token) { + // 쿠키에 토큰을 저장 + Cookies.set('token', token, { expires: 1, path: '/' }); + return token; + } else { + devConsoleError('Token is undefined in the response headers'); + } + } else { + devConsoleError('Failed to log in, unexpected response status'); + } + throw Error; +}; + +// JWT 디코딩 함수 +export const decodeJWT = (token: string) => { + const base64Payload = token.split('.')[1]; + const base64 = base64Payload.replace(/-/g, '+').replace(/_/g, '/'); + return JSON.parse( + decodeURIComponent( + window + .atob(base64) + .split('') + .map(function (c) { + return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2); + }) + .join(''), + ), + ); +}; diff --git a/front-end/src/components/authservice/registerservice.tsx b/front-end/src/components/authservice/registerservice.tsx new file mode 100644 index 00000000..992a7143 --- /dev/null +++ b/front-end/src/components/authservice/registerservice.tsx @@ -0,0 +1,13 @@ +import { fetchRegisterUser } from '@/app/api/registerUserApi'; +// 회원가입 요청 전송 함수 +export const registerUser = async (userData: { + nickname: string; + email: string; + password: string; + confirmPassword: string; +}) => { + const response = await fetchRegisterUser(userData); + if (response.status === 200 || response.status === 201) { + alert('회원가입 완료'); + } +}; diff --git a/front-end/src/components/chatbot/chatLog.tsx b/front-end/src/components/chatbot/chatLog.tsx new file mode 100644 index 00000000..b3dcb5a0 --- /dev/null +++ b/front-end/src/components/chatbot/chatLog.tsx @@ -0,0 +1,81 @@ +import { useState } from 'react'; +import { devConsoleError } from '@/utils/logger'; +import fetchChatBot from '../../app/api/chatBotApi'; +import '@/styles/pages/chatbot/chatbot.scss'; +import Cookies from 'js-cookie'; +const Chatlog = () => { + const [gptlog, setGptlog] = useState<{ user: string; bot: string }[]>([]); + const [textarea, setTextarea] = useState(''); + const [loading, setLoading] = useState(false); + + // api 요청 함수 + const handleSubmit = async () => { + const token = Cookies.get('token'); + if (!textarea) return; + if (!token) return; + try { + setLoading(true); + + // 새 요청시 설정된 문구 이후에 응답값을 받아오면 그 값으로 대체 + const newLogEntry = { user: textarea, bot: '' }; + setGptlog((prev) => [...prev, newLogEntry]); + const apiResponse = await fetchChatBot({ request: textarea, token: token }); + + setGptlog((prev) => + prev.map((log, index) => + index === prev.length - 1 ? { ...log, bot: apiResponse.response } : log, + ), + ); + setTextarea(''); + } catch (error) { + devConsoleError('함수요청 오류', error); + } finally { + setLoading(false); + } + }; + + // 키보드 이벤트 'Enter' + const keyDownEnter = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey) { + handleSubmit(); + e.preventDefault(); + } + }; + // textarea 입력 이벤트 + const handleTextArea = (e: React.ChangeEvent) => { + setTextarea(e.target.value); + }; + + return ( + <> +
    e.stopPropagation()}> +
    + {gptlog.map((log, index) => ( +
    + {log.user} + Techie + + {loading && index === gptlog.length - 1 + ? 'Techie 가 답변을 준비중이에요!..' + : log.bot} + +
    + ))} +
    +
    + + + + ); +}; + +export default Chatlog; diff --git a/front-end/src/components/playlist/CategoryPlaylist.tsx b/front-end/src/components/playlist/CategoryPlaylist.tsx index 56411eed..34316672 100644 --- a/front-end/src/components/playlist/CategoryPlaylist.tsx +++ b/front-end/src/components/playlist/CategoryPlaylist.tsx @@ -1,21 +1,21 @@ +// components/CategoryPlaylist.tsx 'use client'; import React, { useEffect, useState, useCallback } from 'react'; -import Image from 'next/image'; -import Link from 'next/link'; import { useRouter, useSearchParams } from 'next/navigation'; -import Cookies from 'js-cookie'; -import vidListData from '@/data/vidListData'; + import { Video } from '@/types/video'; import { PlayLists } from '@/types/playlist'; import { fetchVideosByCategory } from '@/app/api/videoAPI'; -import { addVideo, getVideo, saveVideo, deletepPlaylist } from '@/app/api/playlistApi'; import useInfiniteScroll from '@/hooks/playlist/useInfiniteScroll'; -import { formatDuration } from '@/utils/playlist/formatDuration'; +import VideoList from './VideoList'; +import Modal from './Modal'; +import CategoryTabs from './CategoryTabs'; import { devConsoleError } from '@/utils/logger'; +import Cookies from 'js-cookie'; import '@/styles/pages/playlist/playlist.scss'; -import axios from 'axios'; +import { deletePlaylist } from '@/app/api/playlistApi'; interface CategoryPlaylistProps { category: string; @@ -27,32 +27,53 @@ const CategoryPlaylist: React.FC = ({ category: initialCa const [category, setCategory] = useState(initialCategory); const [page, setPage] = useState(0); const [hasMore, setHasMore] = useState(true); - const [selectVideo, setSelectVideo] = useState(''); - const [playlistName, setPlayListName] = useState(''); - const [isOpen, setIsOpen] = useState(null); const [showModal, setShowModal] = useState(false); + const [playlists, setPlaylists] = useState(undefined); + const [loadingVideos, setLoadingVideos] = useState(true); + + const [selectVideo, setSelectVideo] = useState(null); const router = useRouter(); const searchParams = useSearchParams(); const query = searchParams.get('query') || ''; - const [playlists, setPlaylists] = useState(undefined); - const [loadingVideos, setLoadingVideos] = useState(true); - const [loadingPlaylists, setLoadingPlaylists] = useState(true); - const maxLength = 15; + const token = Cookies.get('token'); + const openModal = (videoId: string) => { + setSelectVideo(videoId); + setShowModal(true); + }; + + // 재생목록 삭제 함수 + const onClickDelete = async (playlistId: string) => { + const confirmDelete = confirm('재생목록을 삭제하시겠습니까?'); + if (!confirmDelete) { + return; + } + + if (!token) return; + + try { + await deletePlaylist(playlistId, token); + setPlaylists((prevPlaylists: PlayLists | undefined) => + prevPlaylists + ? { + playlists: prevPlaylists.playlists.filter( + (playlist) => playlist.playlistId !== playlistId, + ), + } + : undefined, + ); + } catch (error) { + devConsoleError('Failed to delete playlist', error); + } + }; // 비디오 로딩 함수 const loadVideos = useCallback( - async (currentPage: number, currentCategory: string, currentQuery: string) => { + async (currentPage: number) => { setLoadingVideos(true); setError(''); try { - const data = await fetchVideosByCategory({ - category: currentCategory, - query: currentQuery, - page: currentPage, - }); - + const data = await fetchVideosByCategory({ category, query, page: currentPage }); setHasMore(!data.last); - setVideos((prevVideos) => [ ...prevVideos, ...data.content.filter( @@ -66,31 +87,23 @@ const CategoryPlaylist: React.FC = ({ category: initialCa setLoadingVideos(false); } }, - [], + [category, query], ); - // 카테고리나 검색어가 변경되면 비디오를 새로 로드 useEffect(() => { setPage(0); setVideos([]); setHasMore(true); - loadVideos(0, category, query); - }, [category, query, loadVideos]); + loadVideos(0); + }, [category, loadVideos]); // 페이지 번호가 변경되면 추가 비디오 로드 useEffect(() => { if (page > 0) { - loadVideos(page, category, query); + loadVideos(page); } }, [page, category, query, loadVideos]); - const handleCategoryClick = (newCategory: string) => { - if (newCategory === category) return; - - setCategory(newCategory); - router.push(`/playlists/${newCategory}`); - }; - // 무한 스크롤 기능 const onLoadMore = useCallback(() => { setPage((prevPage) => prevPage + 1); @@ -102,270 +115,31 @@ const CategoryPlaylist: React.FC = ({ category: initialCa onLoadMore, }); - const openModal = () => { - setShowModal(true); - }; - - const closeModal = () => { - setShowModal(false); - setPlayListName(''); - setSelectVideo(''); - }; - - const handleSaveVideo = async () => { - const token = Cookies.get('token'); - if (selectVideo.length === 0) { - alert('선택된 영상이 없습니다.'); - return; - } - if (!playlistName.trim()) { - alert('재생목록 이름을 입력해 주세요.'); - return; - } - - try { - await saveVideo(selectVideo, playlistName, token); - const data = await getVideo(token); - setPlaylists(data); - setPlayListName(''); - } catch (error) { - devConsoleError('Error saving video:', error); - alert('영상 저장에 실패했습니다.'); - } - }; - - const handleVideoSelect = (videoId: string) => { - if (selectVideo.includes(videoId)) { - setSelectVideo(selectVideo.replace(videoId, '')); - } else { - setSelectVideo(videoId); - } - }; - - const token = Cookies.get('token'); - - const toggleBottomBar = (index: number) => { - setIsOpen(isOpen === index ? null : index); - }; - - // 재생목록 렌더링 - useEffect(() => { - const fetchData = async () => { - setLoadingPlaylists(true); - try { - const data = await getVideo(token); - setPlaylists(data); - } catch (error) { - devConsoleError('Failed to fetch playlists', error); // 명확한 메시지와 에러 전달 - } finally { - setLoadingPlaylists(false); - } - }; - - if (token) { - fetchData(); // 데이터 가져오기 호출 - } else { - setLoadingPlaylists(false); // 토큰이 없으면 로딩 상태를 false로 설정 - } - }, [token]); - - // put api로 수정해야하는 부분 - const onClickCheckBox = async (playlistId: string) => { - const token = Cookies.get('token'); - if (selectVideo.length === 0) { - alert('선택된 영상이 없습니다.'); - return; - } - - if (!token) return; - try { - await addVideo(playlistName, selectVideo, playlistId, token); - alert('재생목록에 영상이 추가되었습니다'); - } catch (error) { - if (axios.isAxiosError(error) && error.response) { - if (error.response.status === 500) { - alert('해당 영상은 재생목록 내에 존재하는 영상입니다'); - } else { - devConsoleError('Failed to update video in playlist', error); - } - } - } - - closeModal(); - }; - - // 재생목록 삭제 - const onClickDelete = async (playlistId: string) => { - const confirmDelete = confirm('재생목록을 삭제하시겠습니까?'); // 변수 이름 수정 - if (!confirmDelete) { - return; - } - - const token = Cookies.get('token'); - if (!token) return; - - try { - await deletepPlaylist(playlistId, token); - setPlaylists((prevPlaylists: PlayLists | undefined) => - prevPlaylists - ? { - playlists: prevPlaylists.playlists.filter( - (playlist) => playlist.playlistId !== playlistId, - ), - } - : undefined, - ); - } catch (error) { - devConsoleError('Failed to delete playlist', error); // 에러 메시지와 에러 전달 - } - }; - - const onChangePlaylistName = (e: React.ChangeEvent) => { - const { value } = e.target; - if (value.length <= maxLength) { - setPlayListName(value); - } else { - alert('재생목록의 이름은 15자 이내로 작성하여야합니다'); + const handleCategoryClick = (newCategory: string) => { + if (newCategory !== category) { + setCategory(newCategory); + router.push(`/playlists/${newCategory}`); } }; return (
    -
    -
      - {vidListData.map((tab) => ( -
    • - -
    • - ))} -
    -
    -
    -
    - {error &&

    {error}

    } - {!error && videos.length === 0 && !loadingVideos &&

    검색 결과가 없습니다.

    } - {loadingVideos && page === 0 &&

    로딩 중...

    } {/* 첫 페이지 로딩 중 */} -
      - {videos.map((video, index) => { - const isLastVideo = index === videos.length - 1; - return ( -
    • - - {video.title} -

      {formatDuration(video.duration ?? '')}

      -

      {video.title}

      -

      {video.channelTitle}

      -

      {new Date(video.publishedAt).toLocaleDateString()}

      - - {token && ( - - )} -
        -
      • { - openModal(); - toggleBottomBar(index); - }} - > - 재생목록에 저장 -
      • -
      -
    • - ); - })} -
    - {loadingVideos && page > 0 &&

    추가 로딩 중...

    } {/* 추가 로딩 중 메시지 */} -
    -
    - - {/* 모달 */} + + {showModal && ( -
    { - closeModal(); - }} - > - {/* 오버레이 추가 */} -
    -
    e.stopPropagation()}> - - 사용자 재생목록 - { - e.stopPropagation(); - }} - /> - -
    - {loadingPlaylists ? ( -

    재생목록을 불러오는 중입니다...

    - ) : playlists ? ( - playlists.playlists.map((playlist) => ( -
    - { - onClickCheckBox(playlist.playlistId); - }} - /> -

    {playlist.playlistName}

    - -
    - )) - ) : ( -
    재생목록이 없습니다.
    - )} -
    -
    -
    -
    + setShowModal(false)} + selectVideo={selectVideo} + onClickDelete={onClickDelete} + /> )}
    ); diff --git a/front-end/src/components/playlist/CategoryTabs.tsx b/front-end/src/components/playlist/CategoryTabs.tsx new file mode 100644 index 00000000..ba74ffee --- /dev/null +++ b/front-end/src/components/playlist/CategoryTabs.tsx @@ -0,0 +1,32 @@ +// components/CategoryTabs.tsx +import React from 'react'; +import Image from 'next/image'; +import vidListData from '@/data/vidListData'; + +interface CategoryTabsProps { + category: string; + onCategoryClick: (newCategory: string) => void; +} + +const CategoryTabs: React.FC = ({ category, onCategoryClick }) => { + return ( +
    +
      + {vidListData.map((tab) => ( +
    • + +
    • + ))} +
    +
    + ); +}; + +export default CategoryTabs; diff --git a/front-end/src/components/playlist/Modal.tsx b/front-end/src/components/playlist/Modal.tsx new file mode 100644 index 00000000..bb83c76b --- /dev/null +++ b/front-end/src/components/playlist/Modal.tsx @@ -0,0 +1,156 @@ +import React, { useEffect, useState } from 'react'; +import { PlayLists } from '@/types/playlist'; +import Cookies from 'js-cookie'; +import { addVideo, getVideo, saveVideo } from '@/app/api/playlistApi'; +import { devConsoleError } from '@/utils/logger'; +import axios from 'axios'; + +interface ModalProps { + playlists: PlayLists | undefined; + setPlaylists: (playlists: PlayLists | undefined) => void; + onClose: () => void; + selectVideo: string | null; + onClickDelete: (playlistId: string) => void; +} + +const Modal: React.FC = ({ + playlists, + setPlaylists, + onClose, + selectVideo, + onClickDelete, +}) => { + const [playlistName, setPlayListName] = useState(''); + const [loading, setLoading] = useState(false); + const [loadingPlaylists, setLoadingPlaylists] = useState(true); + const maxLength = 15; + const token = Cookies.get('token'); + + const onChangePlaylistName = (e: React.ChangeEvent) => { + const { value } = e.target; + if (value.length <= maxLength) { + setPlayListName(value); + } else { + alert('재생목록의 이름은 15자 이내로 작성하여야합니다'); + } + }; + + const handleSaveVideo = async () => { + if (!selectVideo) { + alert('선택된 영상이 없습니다.'); + return; + } + if (!playlistName.trim()) { + alert('재생목록 이름을 입력해 주세요.'); + return; + } + + setLoading(true); + + try { + await saveVideo(selectVideo, playlistName, token); + } catch (error) { + console.error('Error saving video:', error); + alert('영상 저장에 실패했습니다.'); + } finally { + setLoading(false); + onClose(); + } + }; + + const onClickCheckBox = async (playlistId: string) => { + const token = Cookies.get('token'); + if (!selectVideo) { + alert('선택된 영상이 없습니다.'); + return; + } + + if (!token) return; + try { + await addVideo(playlistName, selectVideo, playlistId, token); + alert('재생목록에 영상이 추가되었습니다'); + } catch (error) { + if (axios.isAxiosError(error) && error.response) { + if (error.response.status === 500) { + alert('해당 영상은 재생목록 내에 존재하는 영상입니다'); + } else { + devConsoleError('Failed to update video in playlist', error); + } + } + } + + onClose(); + }; + + useEffect(() => { + const fetchData = async () => { + setLoadingPlaylists(true); + try { + const data = await getVideo(token); + setPlaylists(data); + } catch (error) { + devConsoleError('Failed to fetch playlists', error); + } finally { + setLoadingPlaylists(false); + } + }; + + if (token) { + fetchData(); + } else { + setLoadingPlaylists(false); + } + }, [token, setPlaylists]); + + return ( +
    +
    e.stopPropagation()}> + 사용자 재생목록 + { + e.stopPropagation(); + }} + /> + +
    + {loadingPlaylists ? ( +

    재생목록을 불러오는 중입니다...

    + ) : playlists ? ( + playlists.playlists.map((playlist) => ( +
    + { + onClickCheckBox(playlist.playlistId); + }} + /> +

    {playlist.playlistName}

    + +
    + )) + ) : ( +
    재생목록이 없습니다.
    + )} +
    + +
    +
    + ); +}; + +export default Modal; diff --git a/front-end/src/components/playlist/VideoItem.tsx b/front-end/src/components/playlist/VideoItem.tsx new file mode 100644 index 00000000..f28840bf --- /dev/null +++ b/front-end/src/components/playlist/VideoItem.tsx @@ -0,0 +1,77 @@ +// components/VideoItem.tsx +import React, { useState } from 'react'; +import Link from 'next/link'; +import Image from 'next/image'; +import { Video } from '@/types/video'; +import { formatDuration } from '@/utils/playlist/formatDuration'; +import Cookies from 'js-cookie'; + +interface VideoItemProps { + video: Video; + isLastVideo: boolean; + lastElementRef: React.RefObject; + index: number; + openModal: (videoId: string) => void; +} + +const VideoItem: React.FC = ({ + video, + isLastVideo, + lastElementRef, + index, + openModal, +}) => { + const token = Cookies.get('token'); + const [selectVideo, setSelectVideo] = useState('init'); + const [isOpen, setIsOpen] = useState(null); + const handleVideoSelect = (videoId: string) => { + if (selectVideo.includes(videoId)) { + setSelectVideo(selectVideo.replace(videoId, '')); + } else { + setSelectVideo(videoId); + } + }; + const toggleBottomBar = (index: number) => { + setIsOpen(isOpen === index ? null : index); + }; + + return ( +
  • + + {video.title} +

    {formatDuration(video.duration ?? '')}

    +

    {video.title}

    +

    {video.channelTitle}

    +

    {new Date(video.publishedAt).toLocaleDateString()}

    + + {token && ( + + )} +
      +
    • { + openModal(video.videoId); + }} + > + 재생목록에 저장 +
    • +
    +
  • + ); +}; + +export default VideoItem; diff --git a/front-end/src/components/playlist/VideoList.tsx b/front-end/src/components/playlist/VideoList.tsx new file mode 100644 index 00000000..b789a062 --- /dev/null +++ b/front-end/src/components/playlist/VideoList.tsx @@ -0,0 +1,45 @@ +// components/VideoList.tsx +import React from 'react'; +import { Video } from '@/types/video'; +import VideoItem from './VideoItem'; + +interface VideoListProps { + videos: Video[]; + loadingVideos: boolean; + error: string; + lastElementRef: React.RefObject; + openModal: (videoId: string) => void; +} + +const VideoList: React.FC = ({ + videos, + loadingVideos, + error, + lastElementRef, + openModal, +}) => { + return ( +
    +
    + {error &&

    {error}

    } + {!error && videos.length === 0 && !loadingVideos &&

    검색 결과가 없습니다.

    } + +
      + {videos.map((video, index) => ( + + ))} +
    + {loadingVideos &&

    로딩 중...

    } +
    +
    + ); +}; + +export default VideoList; From 0586c51c1f52bc33d29bbcb62814a138221aff5d Mon Sep 17 00:00:00 2001 From: hyeok9912 Date: Tue, 8 Apr 2025 14:42:35 +0900 Subject: [PATCH 32/45] =?UTF-8?q?feat:=20=EA=B5=AC=EA=B8=80=20,=20?= =?UTF-8?q?=EB=84=A4=EC=9D=B4=EB=B2=84=20=EC=86=8C=EC=85=9C=20=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=EC=9D=B8=20=EC=9A=94=EC=B2=AD=EC=B2=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../public/assets/images/main/googleIcon.png | Bin 0 -> 20865 bytes front-end/src/app/api/socialLoginApi.ts | 22 +++++++++++++ front-end/src/app/login/page.tsx | 10 +++++- .../{authservice.tsx => AuthLogin.tsx} | 0 .../src/components/axios/socialLogin.tsx | 6 ++++ front-end/src/styles/pages/login/login.scss | 30 ++++++++++++++++++ 6 files changed, 67 insertions(+), 1 deletion(-) create mode 100644 front-end/public/assets/images/main/googleIcon.png create mode 100644 front-end/src/app/api/socialLoginApi.ts rename front-end/src/components/authservice/{authservice.tsx => AuthLogin.tsx} (100%) create mode 100644 front-end/src/components/axios/socialLogin.tsx diff --git a/front-end/public/assets/images/main/googleIcon.png b/front-end/public/assets/images/main/googleIcon.png new file mode 100644 index 0000000000000000000000000000000000000000..494acede0c520f847f75982ca1f671fc6eaa889e GIT binary patch literal 20865 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 G$o~QRsPyvy literal 0 HcmV?d00001 diff --git a/front-end/src/app/api/socialLoginApi.ts b/front-end/src/app/api/socialLoginApi.ts new file mode 100644 index 00000000..d5298769 --- /dev/null +++ b/front-end/src/app/api/socialLoginApi.ts @@ -0,0 +1,22 @@ +import socialLogin from '@/components/axios/socialLogin'; + +const redirect_uri = 'http://localhost:3000'; // 로그인 이후 리다이렉트 될 Url 이후 수정 예정 +// 구글 로그인 +export const loginWithGoogle = async () => { + const googleLoginUrl = `/oauth2/authorization/google?mode=login&redirect_uri=${redirect_uri}`; + try { + window.location.href = `${socialLogin.defaults.baseURL}${googleLoginUrl}`; + } catch (error) { + console.error('구글 로그인 실패', error); + } +}; +// 네이버 로그인 +export const loginWithNaver = async () => { + const naverLoginUrl = `/oauth2/authorization/naver?mode=login&redirect_uri=${redirect_uri}`; + try { + window.location.href = `${socialLogin.defaults.baseURL}${naverLoginUrl}`; + } catch (error) { + console.error('네이버 로그인 실패', error); + } +}; +// 추후 Url 로 받은 토큰 값을 디코딩하는 과정 필요 diff --git a/front-end/src/app/login/page.tsx b/front-end/src/app/login/page.tsx index dfbefb7f..ea9e3ac3 100644 --- a/front-end/src/app/login/page.tsx +++ b/front-end/src/app/login/page.tsx @@ -7,10 +7,11 @@ import { AxiosError } from 'axios'; import '@/styles/pages/login/login.scss'; import { devConsoleError } from '@/utils/logger'; -import { performLogin, decodeJWT } from '@/components/authservice/authservice'; +import { performLogin, decodeJWT } from '@/components/authservice/AuthLogin'; import { useDispatch } from 'react-redux'; import { useRouter } from 'next/navigation'; import { setUserInfo } from '@/redux/reducer'; +import { loginWithGoogle, loginWithNaver } from '../api/socialLoginApi'; const Login = () => { const [formData, setFormData] = useState({ email: '', password: '' }); @@ -85,6 +86,13 @@ const Login = () => {
    + +
    ); }; diff --git a/front-end/src/components/authservice/authservice.tsx b/front-end/src/components/authservice/AuthLogin.tsx similarity index 100% rename from front-end/src/components/authservice/authservice.tsx rename to front-end/src/components/authservice/AuthLogin.tsx diff --git a/front-end/src/components/axios/socialLogin.tsx b/front-end/src/components/axios/socialLogin.tsx new file mode 100644 index 00000000..dec54775 --- /dev/null +++ b/front-end/src/components/axios/socialLogin.tsx @@ -0,0 +1,6 @@ +import axios from 'axios'; + +const socialLogin = axios.create({ + baseURL: `${process.env.NEXT_PUBLIC_API_BASE_URL}`, +}); +export default socialLogin; diff --git a/front-end/src/styles/pages/login/login.scss b/front-end/src/styles/pages/login/login.scss index ae966dbd..fec28bf7 100644 --- a/front-end/src/styles/pages/login/login.scss +++ b/front-end/src/styles/pages/login/login.scss @@ -101,4 +101,34 @@ } } } + .googleLogin { + display: flex; + width: 18%; + height: 6vh; + justify-content: center; + align-items: center; + margin: 0 auto; + background-color: var(--sub-btn-color); + color: white; + font-size: 1.5rem; + font-weight: bold; + border-radius: 2rem; + .googleIcon { + width: 2rem; + margin-right: 2rem; + } + } + .naverLogin { + display: flex; + width: 18%; + height: 6vh; + justify-content: center; + align-items: center; + margin: 1rem auto; + color: white; + background-color: #02bd53; + font-size: 1.5rem; + font-weight: bold; + border-radius: 2rem; + } } From ac1cfbb50bb6555be6b5e09f84496f0a8e50d20d Mon Sep 17 00:00:00 2001 From: hyeok9912 Date: Sat, 12 Apr 2025 16:52:41 +0900 Subject: [PATCH 33/45] =?UTF-8?q?feat=20:=20=EA=B5=AC=EA=B8=80=20,=20?= =?UTF-8?q?=EB=84=A4=EC=9D=B4=EB=B2=84=20=EC=86=8C=EC=85=9C=20=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=EC=9D=B8=20callback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .vscode/settings.json | 5 ++-- front-end/src/app/api/socialLoginApi.ts | 2 +- .../src/app/socialLoginCallback/page.tsx | 30 +++++++++++++++++++ 3 files changed, 34 insertions(+), 3 deletions(-) create mode 100644 front-end/src/app/socialLoginCallback/page.tsx diff --git a/.vscode/settings.json b/.vscode/settings.json index 0ca4d0be..a9e1c131 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,3 +1,4 @@ { - "java.compile.nullAnalysis.mode": "automatic" -} \ No newline at end of file + "java.compile.nullAnalysis.mode": "automatic", + "java.debug.settings.onBuildFailureProceed": true +} diff --git a/front-end/src/app/api/socialLoginApi.ts b/front-end/src/app/api/socialLoginApi.ts index d5298769..68c48ed0 100644 --- a/front-end/src/app/api/socialLoginApi.ts +++ b/front-end/src/app/api/socialLoginApi.ts @@ -1,6 +1,6 @@ import socialLogin from '@/components/axios/socialLogin'; +const redirect_uri = 'http://localhost:3000/socialLoginCallback'; // 로그인 이후 리다이렉트 될 Url 이후 수정 예정 -const redirect_uri = 'http://localhost:3000'; // 로그인 이후 리다이렉트 될 Url 이후 수정 예정 // 구글 로그인 export const loginWithGoogle = async () => { const googleLoginUrl = `/oauth2/authorization/google?mode=login&redirect_uri=${redirect_uri}`; diff --git a/front-end/src/app/socialLoginCallback/page.tsx b/front-end/src/app/socialLoginCallback/page.tsx new file mode 100644 index 00000000..80276f36 --- /dev/null +++ b/front-end/src/app/socialLoginCallback/page.tsx @@ -0,0 +1,30 @@ +'use client'; + +import { useEffect } from 'react'; +import { useSearchParams } from 'next/navigation'; +import { decodeJWT } from '@/components/authservice/AuthLogin'; +import { useDispatch } from 'react-redux'; +import { setUserInfo } from '@/redux/reducer'; +import { useRouter } from 'next/navigation'; +import Cookies from 'js-cookie'; + +const socialcallback = () => { + const searchParams = useSearchParams(); + const dispatch = useDispatch(); + const router = useRouter(); + useEffect(() => { + const token = searchParams.get('access_token'); + console.log(token); + if (token) { + const decodedJWT = decodeJWT(token); + dispatch(setUserInfo(decodedJWT)); + window.dispatchEvent(new Event('loginStatusChanged')); + router.push('/'); + Cookies.set('token', token); + } else { + console.error(); + } + }, [dispatch, searchParams, router]); + return
    로그인 중...
    ; +}; +export default socialcallback; From 344160a1f3efd7e14398ae8665db2d8433c2457b Mon Sep 17 00:00:00 2001 From: hyeok9912 Date: Mon, 14 Apr 2025 14:20:20 +0900 Subject: [PATCH 34/45] =?UTF-8?q?chore=20:=20eslint=20=EC=98=A4=EB=A5=98?= =?UTF-8?q?=20=EC=88=98=EC=A0=95=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- front-end/src/app/login/page.tsx | 3 ++- front-end/src/app/socialLoginCallback/page.tsx | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/front-end/src/app/login/page.tsx b/front-end/src/app/login/page.tsx index ea9e3ac3..e9393b62 100644 --- a/front-end/src/app/login/page.tsx +++ b/front-end/src/app/login/page.tsx @@ -12,6 +12,7 @@ import { useDispatch } from 'react-redux'; import { useRouter } from 'next/navigation'; import { setUserInfo } from '@/redux/reducer'; import { loginWithGoogle, loginWithNaver } from '../api/socialLoginApi'; +import Image from 'next/image'; const Login = () => { const [formData, setFormData] = useState({ email: '', password: '' }); @@ -87,7 +88,7 @@ const Login = () => {