프로젝트 트랙 7차 WIL

white noise·2025년 2월 20일

2024 프로젝트 트랙

목록 보기
6/6

들어가며

이번에는 로그인 기능 이후 거의 모든 걸 구현했다.
내가 맡은 일은 ListPage 제작, KeyWord 테스트 페이지 구현, KeyWord 테스트 결과페이지 구현, 마이페이지 구현을 맡았다. 그러면 이제부터 차근차근 내가 만든 기능들을 나열해보자.

아 그리고 css파일은 첨부하지 않겠다. css파일이 너무 길기 때문에.. 첨부하면 아마 스크롤하다보면 손가락근육이 발달될 것이다

ListPage

이걸 ListPage라고 말하는게 맞는진 모르겠지만 메뉴를 눌렀을 때 페이지들에 연결될 수 있게 만드는 메뉴라고 보면 될 것 같다.

잘 만든 페이지라면 아마 페이지가 달라지기보다는 모달을 사용했을 것 같기는 하지만 다른 사람들이 그렇게 하는지도 잘 모르겠고.. 일단 나는 초보자이기 때문에 독립적인 페이지로 구현을 했다.

import { useNavigate } from 'react-router-dom';
import style from "../components/ListPage.module.css"

const ListPage = () =>{
    const nav = useNavigate();
    return(
        <div className={style.page}>
            <div className={style.upperbox}>
                <img src={import.meta.env.BASE_URL + 'Xmark.svg'} className={style.x} onClick={()=> nav("/")} />
            </div>
            <ul className={style.ul}>
                <li className={style.list} onClick={()=> nav("/keyword")}>KEYWORD <img src={import.meta.env.BASE_URL + 'Frame132.svg'} className={style.img} /></li>
                <hr />
                <li className={style.list} onClick={()=> nav("/fortune")}>FORTUNE <img src={import.meta.env.BASE_URL + 'Frame132.svg'} className={style.img} /></li>
                <hr />
                <li className={style.list} onClick={()=> nav("/mbti")}>MBTI <img src={import.meta.env.BASE_URL + 'Frame132.svg'} className={style.img} /></li>
            </ul>
    </div>
    )
}

export default ListPage;

간단하게 구현을 했다. useNavigate를 이용해서 세 페이지에 연결시켜줬고, 한 칸마다 마우스를 올리면 hovering을 시켜줬다.

이거는 한참 뒤에나 완성한 건데, 앞으로도 많이 사용한 것이기도 하다. 바로 이미지 색을 변환하는 건데, svg파일이면 색을 변경할 수 있다. 다른 파일은 하는법을 모른다 이것만 css파일을 첨부하겠다.

.list:hover img{
    filter: brightness(0) saturate(100%) invert(46%) sepia(31%) saturate(5036%) hue-rotate(343deg) brightness(97%) contrast(101%);
    transition: 0.5s;
}

이미지의 색깔을 바꿀 때는 css 스타일링? 으로는 바뀌지 않았다. 예를 들어 style.img라고 className을 지정해줬을 때, .img로 css스타일을 해줘도 바뀌지 않는다. 그런데 부모요소에서 .list img이런식으로 지정해주면 가능했다. 이유는 모르겠다.. 아시는 분은 댓글 부탁드립니다.

어쨌든 하던말을 계속 해보자면, 이미지태그로 css를 지정할때, filter을 이용해서 색을 바꿔주는데 filter값은 어떻게 아느냐 하면 https://angel-rs.github.io/css-color-filter-generator/ 여기서 색상을 입력하면 알아서 해준다. 여기에서 많은 도움을 받았다.

근데 hovering을 하면서 transition: 0.5s라고 한게 보일텐데, 이상하게 filter를 이용해서 색을 바꿀 때, transition을 사용하면 색이 다 바뀐 이후에는 내가 원하는 색이 나오는데, 그 전 0~0.5초 사이에는 이상한 색이 나온다. 내가 만든 이 페이지에서는 핑크색이 나온다. 왜 그런지는 모른다..

KeywordPage

키워드 페이지는 이번 프로젝트의 메인 기능이다. 그만큼 많은 난관이 있었다. 기능별로 어떤 과정을 거쳤는지 한번 말해보겠다.

모달 구현

모달은 처음 구현하는 거였는데, 모달 자체가 어떤 새로운 라이브러리를 사용하거나 특별한 테크닉이 필요한 줄 알았는데, 그냥 페이지 위에 내가 원하는 창을 새로 만들면 그게 모달이더라..

어쨌든 모달은 여닫을 수 있어야 모달이기 때문에 닫는게 중요했다. 내가 원하는 모달은 처음 페이지가 렌더링 됐을 때 모달이 나오는 것이지 닫은 후 다시 열 필요가 없기 때문에 닫는 것만 구현하면 됐다. 그래서 모달을 누르면 모달을 닫을 수 있는 기능을 구현했다. 이따 밑에 또 서술하겠지만, 밑에서는 모달 밖을 누르면 모달이 닫히는 기능을 구현하기도 했다.

모달은 컴포넌트를 따로 만들었는데, 모달 안에 들어가야 할 변수들과 모달이 열렸는지 확인하기 위한 변수(useState)와 모달을 닫는 함수를 넣어서 모달 컴포넌트를 짜봤다.

처음에 {modalOpened && (....)}라는 걸 이용해서 .... 사이에 내가 구현하고 싶은 페이지를 작성했는데, 이 방법은 이 때 체득해서 이후에 프로젝트를 하며 많이 쓴 방법이다. 이건 modalOpened라는 boolean타입의 useState 변수인데, 모달이 열려있다고(변수가 true) 할 때만 모달을 띄워주는 것이다. 이를 통해 useState(true)로 처음은 모달을 띄우고 모달창을 누르면(onClick 이벤트로 modalClose함수가 실행된다.) modalOpenedfalse가 되면서 모달창이 닫히게 된다.

import style from "./Modal.module.css";
const Modal = ({min, size, modalOpened, modalClose}) =>{
    return(
        <> 
            {modalOpened && (
                <div className={style.blur}>
                    <div className={style.box}>
                        <div className={style.modal} onClick={modalClose}>
                            <p className={style.keyword}>KEYWORD</p>
                            <h1 className={style.h1}>키워드를 선택해주세요</h1>
                            <h2 className={style.h2}>하나만 선택할 수 있어요</h2>
                            <br/>
                            <ul className={style.ul}>
                                <li><img src={import.meta.env.BASE_URL + 'clock.svg'}/>총 예상시간 | {min}</li>
                                <li><img src={import.meta.env.BASE_URL + 'list.svg'}/>총 문항 수 | {size}</li>
                            </ul>
                        </div>
                        <p>화면을 클릭해주세요</p>
                    </div>
                </div>
            )}
        </>
    );
}

export default Modal;


그리고 페이지에 여러 요소가 있는데 모달창을 구현하는 방법은 z-index를 사용하는 것이다. z-index가 클수록 위에 배치된다.

페이지 구현

여기는 쓸 내용이 너무 많아서.. 간략하게 요소별로 서술해볼까 한다. 앞에 내용처럼 주저리주저리 쓰다보면 읽다보면 3줄요약이 마려울 것이다.

사이드 바 구현

이 페이지에서 사이드 바의 역할은 질문이 4개가 있는데, 그 질문을 왔다갔다 할 수 있게 하는 기능과, 내가 지금 어느 질문에 있는지 확인할 수 있게 하는 기능이 들어가있다.

그래서 지금이 몇단계 질문인지 확인할 수 있는 변수를 받아주고, 변수를 바꿀 수 있는 함수를 props로 받아서 컴포넌트를 만들었다.

import style from './TestMenu.module.css'

const TestMenu = ({step, setStep}) =>{
    const handleStep = (newStep) =>{
        setStep(newStep)
    }
    const menu_list=[
        {id:1, title:"감정 & 기분"},
        {id:2, title:"날씨"},
        {id:3, title:"상황"},
        {id:4, title:"장르"}
    ];

    return(
        <>
            <div className={style.menu}>
                <div className={style.content}>
                    {menu_list.map((it) =>(
                        step >= it.id &&(
                            <div key={it.id} onClick={() => handleStep(it.id)} className={ it.id === step ? style.focus : style.box}>{it.title}</div>
                        )
                    ))}
                </div>
            </div>
        </>
    )
}

export default TestMenu;

내가 지금 어느 단계에 있는지 알 수 있고, 클릭하면 step을 바꾸어 다른 단계로 이동할 수 있다.

푸터 구현

이 페이지에서 푸터의 기능은 질문이 몇 가지인지, 지금 단계가 어디인지, 다음 단계로 넘어갈 수 있는 기능을 가지고 있다.

푸터는 사이드바에 비해 크게 어렵지는 않았다. 동적으로 관리해야 할 것은 단계가 어디인지에 따라 색칠되는 막대기였는데, 반복문을 사용해서 만들었다. 근데 왜 이미지 파일이 두개냐고 하면.. 이 때는 svg파일 색 바꾸는 법을 몰랐다....

푸터는 다음 단계로 넘어가는 버튼이 존재하므로, 사이드바와는 다르게 '다음'단계로만 넘어가면 돼서 props를 보면 handleNext가 들어가있는 걸 알 수 있다.

import style from './TestFooter.module.css';

const TestFooter = ( {step, size, handleNext}) =>{
    const rendering = () => {
        const result = [];
        for(let i=0; i<size; i++){
            i < step ? result.push(<img key={i} src={import.meta.env.BASE_URL + 'Tree.svg'} alt="tree" />) : result.push(<img key={i} src={import.meta.env.BASE_URL + 'TreeEmpty.svg'} alt="emptyTree" />) 
        }
        return result;
    }
    return(
        <> 
            <div className={style.Foo}>
                <div className={style.content}>
                    <div className={style.left}>
                        <img src={import.meta.env.BASE_URL + 'check-circle-broken.svg'} alt="check" />
                         <p>총 문항 | {size}</p>
                         </div>
                    <div className={style.mid}> {rendering()} </div>
                    <button onClick={handleNext} className={style.button}>다음</button>
                </div>
            </div>
        </>
    )
}

export default TestFooter;

키워드 기능 구현

키워드 기능은 질문 단계에 따라 바뀌는 질문의 내용과 답변 키워드들인데, 아까도 말했듯이 나는 초보이기 때문에 이것도 매 질문마다 페이지를 바꿔야하나 싶었다. 하지만 그런게 아니었고, useState를 이용해서 state별로 페이지를 바꿔주면 되는 것이었다.

그렇게 단계에 따라서 페이지를 바꿨는데, 그럼 그 내용은 어떻게 바꾸냐 하면 배열에 요소들을 저장해놓고 지금 단계에 따라 map을 이용해 렌더링?해준다.

const menu_list=[
        {id:1, name: "one", title:"감정 & 기분", message: ["반가워요, 사용자님","지금 감정이나 기분을 골라주세요!","고르고 싶지 않다면 '잘 모르겠어요'를 선택해주세요"], buttons: ["행복", "슬픔", "화남", "피곤", "평온", "설렘", "외로움", "잘 모르겠어요"]},
        {id:2, name: "two", title:"날씨", message: ["오늘의 날씨는 어떤가요?"], buttons: ["봄", "여름", "가을", "겨울","비", "눈", "맑음", "흐림", "잘 모르겠어요"]},
        {id:3, name: "three", title:"상황", message: ["사용자님과 연관되는 상황을 골라주세요"], buttons: ["노동요", "여행", "이별", "산책", "공부", "축하", "응원", "휴식", "샤워", "크리스마스", "연말/연초", "잘 모르겠어요"]},
        {id:4, name: "four", title:"장르", message: ["마지막 질문이에요!", "선호하시는 장르는 무엇인가요?"], buttons: ["발라드", "락", "댄스", "힙합", "인디", "R&B", "잘 모르겠어요"]}
    ];

이런 배열을 만들어서

<div className={style.buttons}>
	{menu_list[step - 1].buttons.map((it) => (
		<button key={it} onClick={() => handleClick(it)} className={clicked === it ? bstyle.act : bstyle.button}>{it}</button>
	))}
</div>

이런식으로 단계마다 다른 버튼들을 나열해줬다.

버튼 클릭효과

버튼이 클릭된 효과는 어떻게 줬냐 하면, clicked라는 변수를 이용해서 클릭 된 버튼의 이름과 일치하는 버튼만 색을 바꿔줬다. 그런데 여기서 문제점이 하나 있다. 아까 사이드바에서 이미 했던 단계로 돌아가는 기능이 있었는데, 이런식으로 한다면 예를들어 세번째 단계에서 '공부' 키워드를 골랐을 때, 첫번째 단계로 돌아가면 키워드에 '공부'가 없어서 아무 키워드도 눌려있지 않다. 나는 이미 고르고 넘어갔는데 기록이 없다는 것이다. 그래서 어차피 백엔드에 모든 키워드들을 넘겨줘야하기 때문에 저장할 수 있는 배열을 만들었다.

const [submitArr, setSubmitArr] = useState({ // 키워드 선택 기록용
        one: "",
        two: "",
        three: "",
        four: ""
    });

const handleClick = (bnt) =>{
        setClicked(bnt);
        setSubmitArr({...submitArr, [menu_list[step - 1].name]: bnt});
    }

이런식으로 단계마다 어떤 키워드를 골랐는지 기록해두고, 그 키워드와 맞는 버튼만 색깔을 바꿔주는 식으로 만들 수 있었다. 여기서도 login 페이지를 만들때 사용했던 방법이 사용됐다.
setSubmitArr({...submitArr, [menu_list[step - 1].name]: bnt});

채팅효과

키워드 페이지의 질문은 채팅느낌으로 진행되는데, 갑자기 띡하고 나오는건 좀 이상한 것 같아서 그냥 gpt랑 대화하는 느낌으로 채팅효과가 있으면 좋을 것 같아서 채팅 효과를 넣어봤다. 그랬더니 디자이너님이 아주 극찬을 해주셔서 감사했는데... 내가 했다고 해야할지.. gpt가 했다고 해야할지.. 이 부분은 내가 이해를 잘 못한 부분이라서 그냥 첨부만 하겠다.

const [displayed, setDisplayed] = useState([]); // 타이핑 애니메이션
const [currentIndex, setCurrentIndex] = useState(0);
const [typedText, setTypedText] = useState('');
const resetTyping = () =>{
	setDisplayed([]);
	setCurrentIndex(0);
	setTypedText('');
}

useEffect(() => {
        const messages = menu_list[step - 1].message;

        if (currentIndex < messages.length) {
            let charIndex = 0;
            const interval = setInterval(() => {
                if (messages[currentIndex] && charIndex < messages[currentIndex].length) {
                    setTypedText(messages[currentIndex].substring(0, charIndex + 1));
                    charIndex++;
                } else {
                    clearInterval(interval);
                    setTimeout(() => {
                        if(messages[currentIndex]){
                            setDisplayed((prev) => [...prev, messages[currentIndex]])
                        }; // 한 줄 완료 후 저장}
                        setCurrentIndex((prev) => prev + 1); // 다음 메시지 진행
                        setTypedText(''); // 다음 메시지를 위해 초기화
                    }, 400); // 문장 간 딜레이
                }
            }, 50); // 글자 타이핑 속도 (50ms)

            return () => clearInterval(interval);
        }
    }, [currentIndex, step]);
.
.
.
.
<div className={style.chatbox}>
	{displayed.map((msg, index) => (
		<div key={index} className={style.chat}>
			<p>{msg}</p>
		</div>
    ))}
	{typedText && <div className={style.chat}> <p>{typedText}</p> </div>}
</div>

다음 페이지로 연결

아까 사용했던 버튼 클릭기록용 겸 백엔드에 전달할 키워드인데, 기록한 키워드들을 4단계에서 다음버튼을 누르면 다음 페이지 nav('/kResult', {state: {submitArr}});로 연결해준다. 이 때 페이지를 이동하면서 백엔드에 전달해줄 키워드들을 같이 다음페이지에 전달해줬다.

const [submitArr, setSubmitArr] = useState({ // 키워드 선택 기록용
        one: "",
        two: "",
        three: "",
        four: ""
    });

const handleStep = (newStep) =>{
        if(newStep < 5){
            setStep(newStep);
            setClicked(submitArr[menu_list[newStep - 1].name])
            resetTyping();
        } else{
            console.log(submitArr);
            nav('/kResult', {state: {submitArr}});
        }
        console.log(step);
    }

KeywordPage 마무리

이런 식으로 KeywordPage의 기능을 마무리하고 전체 코드와 이미지를 첨부하겠다.

import {useState, useEffect} from 'react';
import {useNavigate} from 'react-router-dom'
import Header from '../components/Header.jsx';
import Modal from '../components/Modal';
import TestFooter from '../components/TestFooter';
import TestMenu from '../components/TestMenu'
import style from '../components/KeywordPage.module.css';
import bstyle from '../components/TestButton.module.css';

const KeywordPage = () =>{
    const nav = useNavigate();
    const [modalOpened, setModalOpened] = useState(true); // 모달창
    const [step, setStep] = useState(1); // 현재 페이지
    const [clicked, setClicked] = useState(''); // pressed 효과 만들기
    const [submitArr, setSubmitArr] = useState({ // 키워드 선택 기록용용
        one: "",
        two: "",
        three: "",
        four: ""
    });

    const [displayed, setDisplayed] = useState([]); // 타이핑 애니메이션션
    const [currentIndex, setCurrentIndex] = useState(0);
    const [typedText, setTypedText] = useState('');

    const handleClick = (bnt) =>{
        setClicked(bnt);
        setSubmitArr({...submitArr, [menu_list[step - 1].name]: bnt});
    }
    const modalClose = () => {
        setModalOpened(false);
    }

    const handleStep = (newStep) =>{
        if(newStep < 5){
            setStep(newStep);
            setClicked(submitArr[menu_list[newStep - 1].name])
            resetTyping();
        } else{
            console.log(submitArr);
            nav('/kResult', {state: {submitArr}});
        }
        console.log(step);
    }

    const resetTyping = () =>{
        setDisplayed([]);
        setCurrentIndex(0);
        setTypedText('');
    }

    useEffect(() => {
        const messages = menu_list[step - 1].message;

        if (currentIndex < messages.length) {
            let charIndex = 0;
            const interval = setInterval(() => {
                if (messages[currentIndex] && charIndex < messages[currentIndex].length) {
                    setTypedText(messages[currentIndex].substring(0, charIndex + 1));
                    charIndex++;
                } else {
                    clearInterval(interval);
                    setTimeout(() => {
                        if(messages[currentIndex]){
                            setDisplayed((prev) => [...prev, messages[currentIndex]])
                        }; // 한 줄 완료 후 저장}
                        setCurrentIndex((prev) => prev + 1); // 다음 메시지 진행
                        setTypedText(''); // 다음 메시지를 위해 초기화
                    }, 400); // 문장 간 딜레이
                }
            }, 50); // 글자 타이핑 속도 (50ms)

            return () => clearInterval(interval);
        }
    }, [currentIndex, step]);

    const menu_list=[
        {id:1, name: "one", title:"감정 & 기분", message: ["반가워요, 사용자님","지금 감정이나 기분을 골라주세요!","고르고 싶지 않다면 '잘 모르겠어요'를 선택해주세요"], buttons: ["행복", "슬픔", "화남", "피곤", "평온", "설렘", "외로움", "잘 모르겠어요"]},
        {id:2, name: "two", title:"날씨", message: ["오늘의 날씨는 어떤가요?"], buttons: ["봄", "여름", "가을", "겨울","비", "눈", "맑음", "흐림", "잘 모르겠어요"]},
        {id:3, name: "three", title:"상황", message: ["사용자님과 연관되는 상황을 골라주세요"], buttons: ["노동요", "여행", "이별", "산책", "공부", "축하", "응원", "휴식", "샤워", "크리스마스", "연말/연초", "잘 모르겠어요"]},
        {id:4, name: "four", title:"장르", message: ["마지막 질문이에요!", "선호하시는 장르는 무엇인가요?"], buttons: ["발라드", "락", "댄스", "힙합", "인디", "R&B", "잘 모르겠어요"]}
    ];

    return(
        <>
            <div className={style.back}>
                <Header />
                <TestMenu step={step} setStep={handleStep}/>
                <div className={style.box}>
                    <div className={style.upperbox}>
                        <div className={style.pro}>
                            <img src={import.meta.env.BASE_URL + 'Ellipse26.svg'} />
                        </div>
                        <div>
                            <div className={style.chatbox}>
                                {displayed.map((msg, index) => (
                                    <div key={index} className={style.chat}>
                                        <p>{msg}</p>
                                    </div>
                                ))}
                                {typedText && <div className={style.chat}> <p>{typedText}</p> </div>}
                            </div>
                        </div>
                    </div>
                    <div className={style.bntbox}>
                        <div className={style.buttons}>
                            {menu_list[step - 1].buttons.map((it) => (
                                <button key={it} onClick={() => handleClick(it)} className={clicked === it ? bstyle.act : bstyle.button}>{it}</button>
                            ))}
                        </div>
                    </div>
                </div>

                <Modal modalOpened={modalOpened} modalClose={modalClose} min={2} size={menu_list.length} />

                <TestFooter step={step} size={menu_list.length} handleNext={() => handleStep(clicked===''? step : step + 1)} />
            </div>
        </>
    )
}

export default KeywordPage;

KeyResultPage

키워드 서비스의 결과를 띄우는 페이지이다. 키워드페이지에서 선택한 키워드들을 props로 받았다. 키워드페이지에서 useNavigate를 이용해서 보냈는데, 이걸 받으려면 useLocation을 사용해야한다. 그래서 받은 키워드를 백엔드에 연결시켜서 결과를 받아 페이지를 구현했다.

로딩화면

사실 로딩 시간이 걸릴지는 아직 백엔드와 연결을 많이 안 해봐서 잘 모르겠는데 이런 화면이 디자인됐기도 하고 있으면 좋을 것 같아서 로딩이 별로 안 걸린다고 하면 시간을 늘려볼까 생각중이다.

구현은 모달창과 비슷하게 해서 크게 어렵지 않았다. 가운데 LP판이 돌아가는 애니메이션을 framer-motion을 이용해서 구현했다.

import { motion } from "framer-motion";
import style from './Loading.module.css';

const Loading = () => {
    const variants = {
        first: {rotate: 0},
        animationEnd:{rotateZ: 360},
    };
    
    return(
        <div className={style.back}>
            <div className={style.box}>
                <motion.img
                src={import.meta.env.BASE_URL + 'Lp.svg'} 
                variants={variants}
                initial="first"
                animate="animationEnd"
                transition={{
                    duration: 5,
                }}/>
                <div className={style.wait}>
                    <p className={style.text}>사용자님에게</p>
                    <p className={style.text}>어울리는 노래를 찾고 있어요</p>
                </div>
            </div>
        </div>
    )
}

export default Loading;

곡 추천

첫 곡 추천인데, 백엔드로부터 5곡의 플레이리스트를 받으면 첫 곡을 대표곡으로 한 곡 추천해준다. 이때 lp판을 누르면 다음 화면으로 넘어가게 되며 플레이리스트를 추천해주는 화면으로 전환된다.

이 화면 또한 다음화면과 유기적으로 연결되면 좋겠어서 lp판이 자연스럽게 이동하게 애니메이션을 구현했다. 근데 초보자이슈로 누르면 lp판이 이동하지만 바로 다른 lp판까지 보여서 약간은 아쉬운 감이 없잖아 있다.

플레이리스트 추천

왼쪽에 사이드바에는 플레이리스트가 나열돼있고, 오른쪽은 LP판들이 보인다. 왼쪽의 플레이리스트의 곡을 누르면 유튜브링크가 연결되어 유튜브 페이지를 열어주고, 오른쪽의 LP판을 누르면 임베드된 유튜브 플레이어가 나온다.

왼쪽 사이드바도 LP판과 같이 애니메이션으로 생기는 작업을 했다.

사이드바

사이드바 곡 밑에 줄이 쳐져있는데, 마지막 곡만 줄이 없어서 그걸 구현하는 css는 이 코드이다.

.sub p:not(:last-child) {
    border-right: 1px solid var(--Neutral-50, #777);
}

처음에는 기존 코드에 이걸 추가하는 줄 알았는데, 그게 아니고 이 코드만 넣어야지 마지막 요소에만 스타일이 추가되지 않는다.

노래 저장하기는 미구현상태이다.

LP판

state 관리를 통해서 LP판들이 왼쪽 오른쪽으로 움직이는 애니메이션을 구현했고, 밑에 곡의 정보도 state를 이용해서 바꿔줬다. 애니메이션은 애를 먹었지만 밑에 곡 정보는 여러번 해봐서 그런지 크게 어려움이 있지는 않았다.

다른 사람들은 어떻게 구현할지 모르겠지만, 나는 어려워서 그냥 5개의 LP판을 다 깔아놓고 사이드바에 더 높은 z-index를 줘서 가려지게 만든 다음 LP판들을 이동하게 만들었다.

유튜브 임베드

LP판을 클릭하면 유튜브 플레이어가 나오도록 하고 싶어서 구현했다. 사실 이것도 꽤나 많은 어려움이 있었는데, 어디서는 API를 사용하는 것 같고.. 어디서는 라이브러리를 사용하는 거 같고.. 어디서는 iframe을 사용하는 거 같고.. 그래서 많이 찾아보고 나는 iframe으로 구현했다.

iframe은 유튜브에서 공유하기를 눌렀을 때 퍼가기 창에 있다.

iframe의 내용을 다 복붙하고 안에 src를 바꿔줘야 다른 유튜브 링크도 연결해서 사용할 수 있는데, 일반 유튜브 링크랑 임베드시 필요한 링크랑 달라서 그걸 조정해주는 코드를 작성해서 다른 유튜브 링크도 연결할 수 있게 만들어줬다.

그리고 유튜브 플레이어라는 모달창이기 때문에 모달창 바깥을 클릭하면 모달이 사라지게 하고 싶어서 LP판을 클릭했을 때 블러를 넣어주는 오버레이를 하나 깔아주고 그 위에 유튜브 모달을 넣어주는 방식으로 만들어봤다. 그래서 나름 쉽게 오버레이를 클릭하면 모달이 닫히게 만들었다.

추가로 유튜브플레이어에서 전체화면이 안되길래 찾아봤더니 iframe에서 allow= ... allowfullscreen="true"를 해줘야 전체화면이 가능해진다.

const [videoId, setVideoId] = useState("");
    const extractId = (url) =>{
        const match = url.match(/(?:youtube\.com\/(?:.*v=|.*\/)|youtu\.be\/)([^"&?/ ]{11})/);
        return match ? match[1] : null;
    }
    const clickLp = () => {
        setClicked(true);
        const id = extractId(playList[currentMusic].youtube_url);
        setVideoId(id);
    }
    
.
.
.

{clicked?<div className={clicked ? styles.youtubeBack : ""} onClick={() => {setClicked(false);}}><iframe className={styles.youtube} src={`https://www.youtube.com/embed/${videoId}`} title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen="true"></iframe></div>:""}

KeyResultPage 마무리

이렇게 플레이리스트 추천 페이지까지 마무리했다.
사실 이 페이지는 css가 중요한 페이지인데.. css가 360줄이 넘어가서 첨부하지 않겠다.. 왜 이렇게 긴지는 내가 코드를 잘 못쓰는건지.. 원래 그런 페이지인지.. 모르겠다. 둘 다 일수도..

import {useState, useEffect} from 'react';
import {useLocation , useNavigate} from 'react-router-dom';
import { listData } from '../components/Auth';
import Header from '../components/Header';
import Loading from '../components/Loading';
import styles from '../components/KeyResultPage.module.css';

const KeyResultPage = () => {
    const nav = useNavigate();
    const location = useLocation();
    const keywords = location.state?.submitArr;
    const [loading, setLoading] = useState(true);
    const [playList, setPlayList] = useState([
        {title: "자니", artist: "프라이머리", date: "2024.01.01", youtube_url: "https://youtu.be/sQxrSj6g-3o?si=dVwMAuylTDXnKA8U"},
        {title: "여행", artist: "볼빨간사춘기", date: "2024.01.01", youtube_url: "https://youtu.be/xRbPAVnqtcs?si=pKSZNWZq2EgwHcFG"},
        {title: "HAPPY", artist: "DAY6", date: "2024.01.01", youtube_url: "https://youtu.be/sQxrSj6g-3o?si=dVwMAuylTDXnKA8U"},
        {title: "어제의 너, 오늘의 나", artist: "도경수", date: "2024.01.01", youtube_url: "https://youtu.be/sQxrSj6g-3o?si=dVwMAuylTDXnKA8U"},
        {title: "눈이 오잖아", artist: "이무진", date: "2024.01.01", youtube_url: "https://youtu.be/sQxrSj6g-3o?si=dVwMAuylTDXnKA8U"},
    ]);
    const [animate, setAnimate] = useState(false);
    const [currentMusic, setCurrentMusic] = useState(0);
    
    useEffect(() =>{
        const fetchData = async () => {
            try{
                const data = await listData({keywords, setLoading});
                setPlayList(data);
            } catch (error){
                console.log("플레이리스트 로드 오류: ", error);
            } finally{
                setLoading(false);
                console.log(playList);
            }
            
        }
        
        fetchData();
    }, []);

    const startAnimate = () => {
        setAnimate(true);
    }
    const nextMusic = () => {
        setCurrentMusic((prev) => prev + 1);
    };
    const prevMusic = () => {
        setCurrentMusic((prev) => prev - 1);
    };

    const [clicked, setClicked] = useState(false);
    const [videoId, setVideoId] = useState("");
    const extractId = (url) =>{
        const match = url.match(/(?:youtube\.com\/(?:.*v=|.*\/)|youtu\.be\/)([^"&?/ ]{11})/);
        return match ? match[1] : null;
    }
    const clickLp = () => {
        setClicked(true);
        const id = extractId(playList[currentMusic].youtube_url);
        setVideoId(id);
    }

    return(
        <>  
            <Header />
            <div className={styles.back}>
                {loading?<Loading />:null}

                <div className={`${styles.container} ${animate ? styles.slide : ""}`}>
                    {clicked?<div className={clicked ? styles.youtubeBack : ""} onClick={() => {setClicked(false);}}><iframe className={styles.youtube} src={`https://www.youtube.com/embed/${videoId}`} title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen="true"></iframe></div>:""}
                    <div className={styles.lpWrapper}>
                        {animate?<h1 className={styles.today}></h1>:<h1 className={styles.today}>오늘 하루 사용자님의 노래는</h1>}
                        <div className={styles.lp} onClick={startAnimate} style={{transform: `translateX(${(currentMusic * (-550)) - 50}px)`, transition: "transform 1.5s ease-in-out",}}>
                            <img src={import.meta.env.BASE_URL + 'Lp.svg'} className={styles.lpImg} onClick={animate?clickLp:undefined}/>
                            <img src={import.meta.env.BASE_URL + 'Lp.svg'} className={styles.lpImg} style={{visibility: animate ? "visible" : "hidden", }} onClick={clickLp} />
                            <img src={import.meta.env.BASE_URL + 'Lp.svg'} className={styles.lpImg} onClick={clickLp} />
                            <img src={import.meta.env.BASE_URL + 'Lp.svg'} className={styles.lpImg} onClick={clickLp} />
                            <img src={import.meta.env.BASE_URL + 'Lp.svg'} className={styles.lpImg} onClick={clickLp} />
                        </div>
                        <ul>
                            <li>{playList[currentMusic].title}</li>
                            <li className={styles.sub}>
                                <p>{playList[currentMusic].artist}</p>
                                <p>{playList[currentMusic].date}</p>
                            </li>
                        </ul>
                    </div>

                    {animate && (
                        <>
                        <button className={`${currentMusic === 0 ? "":styles.prevButton}`} onClick={prevMusic}><img src={import.meta.env.BASE_URL + 'chevron-whiteLeft.svg'}/></button>
                        <button className={`${currentMusic === playList.length - 1 ? "":styles.nextButton}`} onClick={nextMusic}><img src={import.meta.env.BASE_URL + 'chevron-white.svg'}/></button>
                        </>
                    )}
                    <div className={styles.sideBar}>
                        <div className={styles.inner}>
                            <p className={styles.genre}>ROCK</p>
                            <h2 className={styles.for}>사용자님을 위한</h2>
                            <h1 className={styles.list}>PLAYLIST</h1>
                            <ul>
                                {playList.map((song, index) => (
                                    <li key={index} className={styles.songBox} onClick={() => {window.open(song.youtube_url)}}>
                                        <div>
                                            <p className={styles.songArtist}>{song.artist}</p>
                                            <p className={styles.songTitle}>{song.title}</p>
                                        </div>
                                        <img src={import.meta.env.BASE_URL + 'chevron-right.svg'} />
                                    </li>
                                ))}
                            </ul>
                        </div>
                        <div className={styles.sideBottom}>
                                <button className={styles.re} onClick={() => {nav('/keyword')}}>다시하기 <img src={import.meta.env.BASE_URL + 'refresh.svg'} /></button>
                                <button className={styles.save}>노래 저장하기 <img src={import.meta.env.BASE_URL + 'chevron-black.svg'} /></button>
                            </div>
                    </div>
                </div>
            </div>
        </>
    )
}

export default KeyResultPage;

마무리 소감

이렇게 프로젝트를 얼레벌레 마무리하게 되었는데, 그래도 목표했던 메인 기능만큼은 완성한 것 같아서 나름 뿌듯하다.

솔직히 얼레벌레 한 거라서 결과물이 나왔어도 나에게 큰 도움이 안 될 줄 알았는데 gpt가 도와준 것도 많아서 나에게 꽤나 많은 도움이 된 것 같다. 처음에 로그인 페이지랑 리스트 페이지 만들 때만 해도 이것저것 알아가며 시간이 많이 소요됐는데, 후반부 쯤 가니까 이미 비슷한 기능을 만들어봐서 시간이 훨씬 덜 소요됐다. 그래봤자 1시간 걸릴거 50분으로 줄은 격이긴 하다

profile
Hello World

0개의 댓글