일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | 5 | 6 | 7 |
8 | 9 | 10 | 11 | 12 | 13 | 14 |
15 | 16 | 17 | 18 | 19 | 20 | 21 |
22 | 23 | 24 | 25 | 26 | 27 | 28 |
29 | 30 |
- sever components
- 프로그래머스
- 클로저
- 리액트
- RECOIL
- @tailwind
- sever action
- js
- image component
- 자바스크립트
- revalidatepath
- commit phase
- dynamic pages
- unstable_nostore
- supabase realtime
- revalidatetag
- SSR
- @tailwind components
- client components
- @tailwind utility
- interceptor routes
- server components
- 3진법 뒤집기
- render phase
- CSS
- @tailwind base
- iron-session
- 타입스크립트
- static pages
- createbrowserrouter
- Today
- Total
개발하는 너구리
저스트잇 프로젝트 본문
팀프로젝트 프론트 GibHub 레퍼지토리
https://github.com/wecode-bootcamp-korea/justcode-7-2nd-Justit-front
GitHub - wecode-bootcamp-korea/justcode-7-2nd-Justit-front
Contribute to wecode-bootcamp-korea/justcode-7-2nd-Justit-front development by creating an account on GitHub.
github.com
Project Overview
지난번 프로젝트는 e-commerce를 경험해봤기에, 이번 프로젝트는 다른 분야를 모델링하고 싶었다.
모델링할 사이트를 찾던중 점핏이라는 채용사이트를 발견했고, 사이트 내 직무탐색 페이지의 카테고리&필터 기능을 너무 도전해보고 싶었다. 중복적용되는 필터만해도 카테고리,기술스택,지역,경력,태그로 5가지의 필터인데 이것을 어떻게 구현할지 생각만해도 머리가 띵했지만 도전해보고 싶었다. 원래 어려운것을 하나하나 풀어나가는게 제맛이 아닌가요 으ㅡ으
프로젝트 팀 회의 시간 중 다들 이 의견에 동의했고 점핏을 모델링한 사용자에게 개발자 직무정보,채용정보를 제공하는 사이트를 제작하기로했다.
React를 사용하여 프로젝트 진행하기
- 목데이터가 아닌 백엔드 서버와 통신해 데이터를 fetch 해오는 프로젝트
작업 기간
2022.11.14 ~ 2022.11.25
기술 스택
프론트엔드 4명
- HTML/CSS
- SASS
- JavaScript
- React
백엔드 3명
- Node.js
- MySQL
저스트잇 프로젝트 전체 시연 영상
주요 구현 사항
🐻 표시는 내가 기여한 기능
- 직무탐색 페이지 레이아웃 및 SCSS 스타일링 구현 🐻
- 전체, 서버/백엔드, 프론트엔드, 웹 풀스택, 안드로이드, IOS 총 6개의 카테고리 기능 구현 🐻
- Java, SpringBoot, Node.js, Python, Django, MySQL 등 총 27개의 기술스택 필터 기능 구현 🐻
- 4.5일제, 재택특무, 유연근무제, 시차출근제 등 총 13개의 태그 필터 기능 구현 🐻
- 전체, 서울, 분당, 마포, 인천 등 총 9개의 지역 필터 기능 구현 🐻
- 전체, 신입, 경력1~10년 등 career 필터 기능 구현(해당 년수의 career필터 선택시 경력요구사항 범위 내에 포함된 결과만 렌더링) 🐻
- '필터 더보기' 팝업 창 내에서 기술스택 검색기능 구현 및 검색결과를 필터기능으로 연결 🐻
- 총 5개의 카테고리&필터 각각 useSearchParams를 활용한 쿼리스트링 기능 구현 🐻
- Reset버튼(초기화) 기능 구현 🐻
카테고리 기능 구현
const [btnActive, setBtnActive] = useState([]); //카테고리
let [searchParams, setSearchParams] = useSearchParams(); //쿼리스트링
const toggleActive = e => {
const currentQuery = e.target.dataset.query.toString();
if (btnActive.filter(ele => ele == e.target.value).length > 0) {
setBtnActive(btnActive.filter(el => el != e.target.value));
//filter메소드를 활용해 활성화된 버튼은 재클릭시 비활성으로 변경
removeParams('position', currentQuery);
setSearchParams(searchParams);
} else {
setBtnActive(prev => [...prev, e.target.value]);
//spread를 활용해 여러개의 카테고리 동시 적용가능하게 작성
searchParams.append('position', currentQuery);
setSearchParams(searchParams);
}
};
<section className="cateContainer">
<div className="cateBtn">
<button className={btnActive.length > 0 ? '' : ' active'} onClick={handleTechOff}>
전체</button>
{cateData.map((item, idx) => {
return (
<button type="button" key={item.id} value={idx}
className={
item.className +
(btnActive.filter(el => el == idx).length > 0 ? ' active' : '')}
//카테고리가 클릭이 된다면 클래스네임에 ' active'를 붙여 css를 활용해 색상변경
data-query={item.id}
onClick={toggleActive}>
{item.text} </button>);
})}
</div>
</section>
//SCSS
&.active {
background-color: rgb(0, 221, 109);
color: rgb(255, 255, 255);
border: 1px rgb(0, 221, 109) solid;
font-weight: 700;
}
코드핵심
- 클래스네임을 변경해 해당 카테고리 클릭 시 활성화된 것처럼 표현
- spread를 활용해 여러개의 카테고리 동시적용 가능
- 배열메소드 filter를 이용해 활성화된 카테고리 재 클릭시 비활성으로 변경
기술스택 필터 기능
const [techBtnActive, setTechBtnActive] = useState([]); //기술스택
let [searchParams, setSearchParams] = useSearchParams(); //쿼리스트링
const [filterCount, setFilterCount] = useState(0); //적용필터갯수
const toggleTechActive = e => {
const currentQuery = e.target.dataset.query.toString();
if (techBtnActive.filter(ele => ele == e.target.value).length > 0) {
setTechBtnActive(techBtnActive.filter(el => el != e.target.value));
//배열메소드 filter를 활용한 활성필터 -> 비활성필터 변경
setFilterCount(prev => prev - 1); //필터 비활성시 적용된 필터갯수 -1
removeParams('techStack', currentQuery);
setSearchParams(searchParams);
} else {
setTechBtnActive(prev => [...prev, e.target.value]);
//spread를 활용한 여러개 기술스택 필터 동시적용 가능
setFilterCount(prev => prev + 1);
searchParams.append('techStack', currentQuery);
setSearchParams(searchParams);
}
};
<div className="techContainer">
{mockTech
.filter(el => techResult.some(i => i == el.id))
.map((item, idx) => {
return (
<button
type="button"
key={item.id}
value={item.id}
className={item.className +
(techBtnActive.filter(el => el == item.id).length > 0 ? ' active' : '')}
//클래스네임 변경으로 필터 활성화 시 색상변경
data-query={item.value}
onClick={toggleTechActive}
>
<img src={item.src} width="20px" />
{item.text}
</button>
);
})}
</div>
//SCSS
&.active {
background-color: rgb(0, 221, 109);
color: rgb(255, 255, 255);
border: 1px rgb(0, 221, 109) solid;
font-weight: 500;
}
태그 필터
const scrollRef = useRef();
const [scrollX, setScrollX] = useState(0); //좌우슬라이더의 현재 위치
const [maxScrollX, setMaxScrollX] = useState(); //슬라이더의 이동가능 최대위치
const leftMove = () => {
scrollRef.current.scrollTo({
left: scrollRef.current.scrollLeft - 400,
//왼쪽버튼 클릭마다 scrollLeft값에 마이너스 값을 주어 슬라이드가 왼쪽으로 이동하게끔 구현
behavior: 'smooth',
});
};
const rightMove = () => {
scrollRef.current.scrollTo({
left: scrollRef.current.scrollLeft + 400,
//오른쪽버튼 클릭마다 scrollLeft값에 플러스 값을 주어 슬라이드가 오른쪽으로 이동하게끔 구현
behavior: 'smooth',
});
};
useEffect(() => {
scrollRef.current.addEventListener('scroll', () => {
setScrollX(Math.ceil(scrollRef.current.scrollLeft));
setMaxScrollX(
scrollRef.current.scrollWidth - scrollRef.current.clientWidth
);
});
}); //useEffect Hook을 활용해 좌우이동 버튼을 조건충족시 즉각적으로 사라지게끔 구현
const toggleActive = e => {
const currentQuery = e.target.dataset.query.toString();
if (tagBtnActive.filter(ele => ele == e.target.value).length > 0) {
setTagBtnActive(tagBtnActive.filter(el => el != e.target.value));
//filter메소드를 이용해 활성필터 -> 비활성필터로 변경
removeParams('tag', currentQuery);
setSearchParams(searchParams);
} else {
setTagBtnActive(prev => [...prev, e.target.value]);
searchParams.append('tag', currentQuery);
setSearchParams(searchParams);
}
};
{scrollX != 0 ? ( <button className="prevBtn" onClick={leftMove}></button>) : null}
//scrollX의 값 즉 좌우슬라이드의 위치가 맨 처음이면 왼쪽 이동버튼을 사라지게끔 구현
{scrollX == maxScrollX ? null : (
<button className="nextBtn" onClick={rightMove}></button>)}
//좌우슬라이드의 위치가 maxScrollX라면 즉, 마지막이라면 오른쪽 이동버튼을 사라지게끔 구현
<div className="tagContainer" ref={scrollRef}>
{tagData.map((item, idx) => {
return (
<button
type="button"
key={item.id}
value={idx}
className={
item.className +
(tagBtnActive.filter(el => el == idx).length > 0
? ' active'
: '')
//태그 필터 활성화시 클레스네임 변경으로 활성화 스타일링 구현
}
data-query={item.filter}
onClick={toggleActive}
>
{item.icon}
{item.text}
</button>
);
})}
</div>
//SCSS
&.active {
color: rgb(255, 255, 255);
font-weight: 500;
background-color: rgb(61, 61, 61);
}
지역 필터
const [locaBtnActive, setLocaBtnActive] = useState('전체'); //지역
const [filterCount, setFilterCount] = useState(0); //적용필터갯수
const handleLoca = e => {
const currentQuery = e.target.dataset.query.toString();
setLocaBtnActive(e.target.value);
searchParams.set('location', currentQuery);
setSearchParams(searchParams);
if (e.target.value == '전체') {
locaBtnActive == '전체'
? setFilterCount(prev => prev)
: setFilterCount(prev => prev - 1);
} else {
locaBtnActive == '전체'
? setFilterCount(prev => prev + 1)
: setFilterCount(prev => prev);
} //지역필터에서 '전체'는 필터갯수에 포함이 안되기때문에 제거하기위한 로직
};
const handleLocaTotal = e => {
setLocaBtnActive(e.target.value);
searchParams.delete('location');
setSearchParams(searchParams);
};
<label>
<input
type="radio"
name="location"
value="전체"
checked={locaBtnActive == '전체'}
onChange={handleLocaTotal}>
</input>
전체
</label>
Career 필터
const [careerBox, setCareerBox] = useState(false);
const handleCareerBtn = e => {
setCareerBtnActive(e.target.value);
const currentQuery = e.target.dataset.query.toString();
searchParams.set('career', currentQuery);
setSearchParams(searchParams);
};
const handleCareerTotal = e => {
setCareerBtnActive(e.target.value);
searchParams.delete('career');
setSearchParams(searchParams);
};
const handleCareerReset = () => {
setCareerBtnActive('전체');
searchParams.delete('career');
setSearchParams(searchParams);
};
<label>
<input
type="radio"
name="career"
value="전체"
checked={careerBtnActive == '전체'}
onChange={handleCareerTotal}
/>
전체
</label>
<div className="careerReset">
<button onClick={handleCareerReset}>초기화</button>
</div>
기술스택 검색기능 구현 및 검색결과를 필터기능으로 연결
const [techOnClick, setTechOnClick] = useState(false); //기술스택 필터의 활성,비활성 상태
const [techOnClickId, setTechOnClickId] = useState([]); //활성화된 기술스택 필터의 id값
const [search, setSearch] = useState(''); //검색창에 입력된 검색어 저장
const handleTechOn = e => {
const currentQuery = e.target.dataset.query.toString();
setTechOnClickId(prev => [...prev, e.target.value]);
//spread를 이용해 여러개의 활성화된 기술스택 필터의 id값 저장
setTechBtnActive(prev => [...prev, e.target.value]);
//spread를 이용해 여러개의 기술스택 필터 활성화
setTechOnClick(true);
setSearch('');
setFilterCount(prev => prev + 1);
searchParams.append('techStack', currentQuery);
setSearchParams(searchParams);
};
const handleTechOff = e => {
const currentQuery = e.target.dataset.query.toString();
setTechOnClickId(techOnClickId.filter(el => el != e.target.value));
//비활성시킨 기술스택 필터의 id값 기준으로 filter메소드로 필터 제거
setTechOnClick(techOnClickId.length > 1 ? true : false);
setTechBtnActive(techBtnActive.filter(el => el != e.target.value));
setFilterCount(prev => prev - 1);
removeParams('techStack', currentQuery);
setSearchParams(searchParams);
};
const onChangeSearch = e => {
setSearch(e.target.value);
};
const filterTitle = mockTech.filter(p => {
return p.text.toLocaleLowerCase().includes(search.toLocaleLowerCase());
//search state에 저장된 검색어를 includes메소드 이용해 mockTech데이터에서 해당 기술스택 검색
});
{search ? (
<div className="techSearchResult">
<ul>
{filterTitle.map(mockTech => (
<li
key={mockTech.id}
value={mockTech.id}
onClick={handleTechOn}
data-query={mockTech.text}
>
<img
src={mockTech.src}
alt="필터아이콘"
width="20px"
/>
{mockTech.text}
</li>
))}
</ul>
</div>
) : null}
useSearchParams를 활용한 쿼리스트링 기능
let [searchParams, setSearchParams] = useSearchParams();
const [postData, setPostData] = useState([]); //DB데이터
useEffect(() => {
fetch(`http://localhost:8000/posts?${searchParams}`, {
method: 'GET',
})
.then(res => res.json())
.then(result => setPostData(result.result));
}, [searchParams]);
const removeParams = (key, value) => {
const allParams = searchParams.getAll(key);
//현재 쿼리스트링에 저장된 Params를 배열로 가져온 뒤
allParams.splice(allParams.indexOf(value), 1);
//지우고자하는 params값을 spilce메소드로 배열에서 삭제
searchParams.delete(key);
//params 초기화
allParams.forEach(item => {
searchParams.append(key, item);
}); //반복문을 이용해 지우고 남은 값으로 새로운 Params 세팅
};
const toggleActive = e => {
const currentQuery = e.target.dataset.query.toString();
if (btnActive.filter(ele => ele == e.target.value).length > 0) {
setBtnActive(btnActive.filter(el => el != e.target.value));
removeParams('position', currentQuery);
//만들어놓은 params제거함수를 이용해 해당 쿼리스트링제거
setSearchParams(searchParams);
} else {
setBtnActive(prev => [...prev, e.target.value]);
searchParams.append('position', currentQuery);
setSearchParams(searchParams);
}
};
const toggleTechActive = e => {
const currentQuery = e.target.dataset.query.toString();
if (techBtnActive.filter(ele => ele == e.target.value).length > 0) {
setTechBtnActive(techBtnActive.filter(el => el != e.target.value));
setFilterCount(prev => prev - 1);
removeParams('techStack', currentQuery);
//만들어놓은 params제거함수를 이용해 해당 쿼리스트링제거
setSearchParams(searchParams);
} else {
setTechBtnActive(prev => [...prev, e.target.value]);
setFilterCount(prev => prev + 1);
searchParams.append('techStack', currentQuery);
setSearchParams(searchParams);
}
};
Project Review
이번 프로젝트는 기능구현에 집중할 수 있었던 프로젝트였다. 이 점이 너무 좋았다. 지난번 프로젝트에선 기능구현보단 상세페이지의 디테일에 더 중점을 두었기 때문에 많은 기능구현을 못해봤어서 이번 프로젝트를 진행하면서 구현해야할 많은 기능들에 처음에는 숨이 턱 막혀왔지만 하나하나 풀어나가는 내 자신에 대해 스스로 칭찬도 했다..여러가지의 필터들의 기능을 구현하는데 많은 노력과 시간이 들었지만, 하나하나 필터들의 기능이 정상 작동할때마다 신나서 방방뛰었다..
지난번 프로젝트에서 제대로 경험해보지 못했던 쿼리스트링에 대해서 많은 공부를 할수 있었고, 그 영향일까 이제는 웹서핑을 하다가 상단에 떠있는 URL들이 반가웠다. 아.. 이 사이트는 쿼리스트링을 이렇게 사용하겠구나.. 아니까 보인다랄까?..ㅎ
특히나 기본중에 기본이라고 할수 있는 JavaScript에 대한 공부도 더 심도있게 할수있던 계기였다. 필터들의 기능을 구현하면서 많은 메소드들을 사용했고 이제는 JS에 익숙하다고 착각했던 내 자신에 대한 반성을 했다. JS실력이 너무나도 중요하다! JS를 더더더 공부해야겠다는 뼈저린 경험을 했다.
JS!! JS!! JS!!! 공부하고 또 공부하자!