기본적으로 부트스트랩 프레임워크를 기본으로 사용하면서 조금씩 필요한 부분을 css 조정을 하여서 제작하였습니다.
프로젝트 구조
- project_name // 소스 코드 디렉토리
- index.html // 리액트 인덱스 html
- img // 정적 이미지 디렉토리
- logo.png // 파비콘용 로고
- src // 소스 코드 디렉토리
- components // 페이지 영역 디렉토리
- Header.js // 헤더 영역 폼
- LiveStream.js // 생방송 표시 폼
- Login.js // 로그인 폼
- MainContents.js // 메인 컨텐츠 폼
- SearchResults.js // 검색 결과 폼
- SideBar.js // 사이드바 폼
- img // 이미지 디렉토리
- alarm.png // 알람에 사용하고자 하는 이미지
- logo.png // 임시로 사용하는 이미지
- style
- App.css
- Header.css
- index.css
- LiveStream.css
- MainContents.css
- SearchResults.css
- SideBar.css
- App.js
- index.js
index.html
<!DOCTYPE html>
<html lang="en" >
<head>
<meta charset="utf-8" />
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" >
<title>PIViewer</title>
<link rel="icon" href="img/logo.png" type ="image/png" >
</head>
<body>
<div id="piview" class="h-100" ></div>
</body>
</html>
index.js
const root = ReactDOM.createRoot(document .getElementById('piview' ));
root.render(
<React.StrictMode >
<App />
</React.StrictMode >
);
App.js
function App () {
const [isLoggedIn, setIsLoggedIn] = useState(false );
const [streramTwitchId, setStreramTwitchId] = useState("" );
const handleLogin = () => {
setIsLoggedIn(true );
};
const handleLogout = () => {
setIsLoggedIn(false );
};
useEffect(() => {
// Spring 서버로 JSON 데이터를 보내는 함수
async function sendDataToSpringServer () {
try {
const response = await fetch('http://localhost:8080/checkLogin' , {
method: 'GET' ,
headers: {
'Content-Type' : 'application/json' , // JSON 데이터라는 것을 명시
},
credentials: 'include' ,
});
if (response.ok) {
const responseData = await response.json(); // Spring 서버에서의 응답 데이터를 JSON으로 파싱
if (responseData === true ){
// 로그인 확인 함수 실행
handleLogin();
}
console.log('Spring 서버 응답 데이터:' , responseData);
} else {
console.error('Spring 서버 응답 에러:' , response.status, response.statusText);
}
} catch (error) {
console.error('오류 발생:' , error);
}
}
sendDataToSpringServer();
}, []);
return (
<Router>
<div className="h-100" >
<header>
<Header isLoggedIn={isLoggedIn} onLogout={handleLogout} />
</header>
<div className="d-flex h-100" >
<div className="d-flex flex-column h-100 bg-body-tertiary sidebar mt-header" >
<div className="border-bottom add-follow-button" >
{isLoggedIn &&
<Link className="list-group-item list-group-item-action py-3 lh-sm text-center" to='/search' >
<strong className="mb-1" >팔로우 추가</strong>
</Link>
}
</div>
<div className="text-center p-3 border-bottom fs-5 fw-semibold" >팔로우 목록</div>
<div className="list-group list-group-flush border-bottom scrollarea" >
{isLoggedIn ? (
<SideBar setStreramTwitchId={setStreramTwitchId}/>
) : (
<div className='text-center' >로그인을 해주세요</div>
)}
</div>
</div>
<div className='w-100 mt-header contents' >
<Routes>
<Route path="/search" element={<SearchResults />} />
<Route path="/" element={<MainContents isLoggedIn={isLoggedIn} onLogin={handleLogin}/>} />
<Route path="/:customUrl" element={<LiveStream streramTwitchId={streramTwitchId} setStreramTwitchId={setStreramTwitchId}/>} />
<Route path="/login" element={<Login isLoggedIn={isLoggedIn} onLogin={handleLogin}/>} />
</Routes>
</div>
</div>
</div>
</Router>
);
}
export default App;
Header.js
function Header({ isLoggedIn, onLogout }) {
const navigate = useNavigate();
const Logout = (event) => {
event.preventDefault();
async function sendDataToSpringServer () {
try {
const response = await fetch('http://localhost:8080/logout' , {
method: 'GET' ,
headers: {
'Content-Type' : 'application/json' , // JSON 데이터라는 것을 명시
},
credentials: 'include'
});
if (response.ok) {
const responseData = await response.json(); // Spring 서버에서의 응답 데이터를 JSON으로 파싱
onLogout();
navigate(`/`);
console.log('Spring 서버 응답 데이터:' , responseData);
} else {
console.error('Spring 서버 응답 에러:' , response.status, response.statusText);
}
} catch (error) {
console.error('오류 발생:' , error);
}
}
sendDataToSpringServer();
}
return ( isLoggedIn ? (
<Navbar className="px-3 py-0 bg-primary bg-opacity-50 navbar header" >
<Container fluid>
<Navbar.Brand>
<Link to="/" className='site-logo h2' >PIVeiwer</Link>
</Navbar.Brand>
<Navbar id="basic-navbar-nav d-flex justify-content-end" >
<Nav className='d-flex align-items-center' >
<NavDropdown title={<Image src={alarm} className='bg-danger rounded-circle' ></Image>} id="basic-nav-dropdown" align="end" >
<div>
<div className="small text-gray-500 text-center" >공지판</div>
</div>
<NavDropdown.Item className='py-3' >
<div>
<div className="small text-gray-500" >Documentation</div>
Usage instructions and reference
</div>
</NavDropdown.Item>
</NavDropdown>
<NavDropdown title={<Image src={logo} className='rounded-circle' ></Image>} id="basic-nav-dropdown" align="end" >
{/* 아직 기능 미구현 */}
<NavDropdown.Item href="#action/3.1" >내정보</NavDropdown.Item>
<NavDropdown.Divider />
<NavDropdown.Item onClick={Logout}>로그 아웃</NavDropdown.Item>
</NavDropdown>
</Nav>
</Navbar>
</Container>
</Navbar>
) : (
<Navbar className="px-3 py-0 bg-primary bg-opacity-50 navbar header" >
<Container fluid>
<Navbar.Brand>
<Link to="/" className='site-logo h2' >PIVeiwer</Link>
</Navbar.Brand>
<Navbar id="basic-navbar-nav d-flex justify-content-end" >
<Nav className='d-flex align-items-center' >
<Button type ="button" href='/login' >login</Button>
</Nav>
</Navbar>
</Container>
</Navbar>
)
);
}
export default Header;
isLoggedIn 이 true 인 경우( 로그인 상태 )
isLoggedin 이 false 인 경우( 로그아웃 상태 ) 혹은 로그아웃 버튼을 눌렀을 경우
SideBar.js
function SideBar({setStreramTwitchId}) {
const [followes, setFollowes] = useState([]);
const navigate = useNavigate();
// Link를 클릭하면 URL 경로와 함께 쿼리를 추가하여 다른 페이지로 이동합니다
const handleLinkClick = (e, custom_url, video_id) => {
e.preventDefault(); // 기본 동작 중지
getLiveStreamTwitchChannelId(custom_url);
const query = '?v=' + video_id;
const newPath = '/' + custom_url + query;
navigate(newPath);
}
async function getFollowChannels () {
try {
const response = await fetch('http://localhost:8080/getFollowChannels' , {
method: 'GET' ,
headers: {
'Content-Type' : 'application/json' ,
},
credentials: 'include' ,
});
if (response.ok) {
const responseData = await response.json();
setFollowes(responseData || []);
console.log('Spring 서버 응답 데이터:' , responseData);
} else {
console.error('Spring 서버 응답 에러:' , response.status, response.statusText);
}
} catch (error) {
console.error('오류 발생:' , error);
}
}
async function getLiveStreamTwitchChannelId(custom_url) {
try {
const response = await fetch('http://localhost:8080/getLiveStreamTwitchChannelId?custumUrl=' + custom_url, {
method: 'GET' ,
headers: {
'Content-Type' : 'application/json' ,
},
credentials: 'include' ,
});
if (response.ok) {
const responseData = await response.text();
setStreramTwitchId(responseData);
console.log('Spring 서버 응답 데이터:' , responseData);
return responseData;
} else {
console.error('Spring 서버 응답 에러:' , response.status, response.statusText);
}
} catch (error) {
console.error('오류 발생' );
}
}
useEffect(() => {
// 초기 로딩 시 한 번 데이터 가져오기
getFollowChannels();
// 3초마다 데이터 업데이트
const intervalId = setInterval(() => {
getFollowChannels();
}, 30000);
return () => {
// 컴포넌트가 언마운트될 때 clearInterval을 사용하여 인터벌 제거
clearInterval(intervalId);
};
}, []);
return (
<div>
<h4 className='m-0' >생방송</h4>
{followes.map((item, index) => (
<div key={index}>
{ item.is_live === "Live" && (
<Link className="list-group-item list-group-item-action py-3 lh-sm" aria-current="true" onClick={(e) => handleLinkClick(e, item.custom_url, item.video_id)}>
<div className='row' >
<div className='sidebar_img' >
<img src={item.thumbnails_url} alt="" className="col sidebar_img" />
</div>
<div className='col mx-2' >
<div className='overflow-text' ><strong >{item.name}</strong></div>
<div><small className='live-text' >{item.is_live}</small></div>
</div>
</div>
</Link>
)}
</div>
))}
<h4 className='m-0' >오프라인</h4>
{followes.map((item, index) => (
<div key={index}>
{ item.is_live === "" && (
<Link className="list-group-item list-group-item-action py-3 lh-sm" aria-current="true" >
<div className='row' >
<div className='sidebar_img' >
<img src={item.thumbnails_url} alt="" className="col sidebar_img" />
</div>
<div className='col mx-2' >
<div className='overflow-text' ><strong >{item.name}</strong></div>
<div><small>{item.is_live}</small></div>
</div>
</div>
</Link>
)}
</div>
))}
</div>
);
}
export default SideBar;
로그아웃 상태
로그인 상태(생방송 중, 비방상태)
SideBar.js에서 팔로우 추가를 입력했을때
Search.Results.js
function SearchResults () {
const [customUrl, setCustomUrl] = useState('' );
const [searchResult, setSearchResult] = useState();
const [show, setShow] = useState(false );
const handleShow = () => {
setShow(true );
};
const handleClose = () => setShow(false );
async function searchChannel(customUrl) {
try {
const response = await fetch('http://localhost:8080/searchChannel?search=' + customUrl, {
method: 'GET' ,
headers: {
'Content-Type' : 'application/json' ,
},
credentials: 'include' ,
});
if (response.ok) {
const responseData = await response.json();
if (responseData.searchResult)
setSearchResult(responseData.liveConfig);
else
setSearchResult(null);
console.log('Spring 서버 응답 데이터:' , responseData);
} else {
console.error('Spring 서버 응답 에러:' , response.status, response.statusText);
}
} catch (error) {
console.error('오류 발생:' , error);
}
}
async function sendFollowData () {
try {
const response = await fetch('http://localhost:8080/follow?customUrl=' + customUrl, {
method: 'GET' ,
headers: {
'Content-Type' : 'application/json' ,
},
credentials: 'include' ,
});
if (response.ok) {
} else {
console.error('Spring 서버 응답 에러:' , response.status, response.statusText);
}
} catch (error) {
console.error('오류 발생:' , error);
}
}
const addFollow = () => {
sendFollowData();
handleClose();
}
return (
<div className='h-100 p-5 w-50' >
<h1>팔로우 추가</h1>
<form onSubmit={(event) => {
event.preventDefault();
searchChannel(customUrl);
}} className='pt-3 py-2' >
<div className="row" >
<div className="row w-100" >
<label htmlFor="firstName" className="form-label" >채널 검색</label>
<div className='col' >
<input type ="text" className="form-control" placeholder="검색할 채널의 커스텀url을 정확히 입력해주세요" value={customUrl} onChange={(e) => setCustomUrl(e.target.value)}/>
</div>
<Button variant="primary" onClick={() => handleShow()} className='col addfollowbutton' >추가하기</Button>
</div>
</div>
</form>
<div className="d-md-flex flex-md-equal w-100" >
<div className="text-bg-dark pt-3 w-100 overflow-hidden" >
<div className="my-3 py-3" >
<h2 className="display-5 text-center" >검색결과</h2>
</div>
<div className="bg-body-tertiary shadow-sm mx-auto my-3 searchResult" >
<div className="text-black searchResultField" id="youtubeResult" >
{searchResult != null ? (
<Link className="list-group-item py-3 px-2 lh-sm searchResultElement" aria-current="true" >
<div className='row' >
<img src={searchResult.thumbnails_url} alt="Thumbnail" className='thumbnailSize' ></img>
<div className="w-100 align-items-center col" >
<div>
<strong className="mb-1" >{searchResult.custom_url}</strong>
</div>
<div className="d-flex justify-content-center align-items-center channel-des-area" >{searchResult.description}</div>
</div>
</div>
</Link>
) : (
<div className="list-group-item py-3 px-2 lh-sm searchResultElement" >
<div className='row' >
<div className="w-100 align-items-center col" >
<div>
<strong className="mb-1" >검색결과가 없습니다.</strong>
</div>
</div>
</div>
</div>
)}
</div>
</div>
</div>
</div>
<Modal show={show} onHide={handleClose} className='disable-drag' >
<Modal.Header closeButton>
<Modal.Title>확인</Modal.Title>
</Modal.Header>
<Modal.Body>
<div className="row" >
<div className="col" >
<div className="mb-3" >
<div>
<div className='row' >
<div>
{searchResult != null ? (
<Link className="list-group-item py-3 px-2 lh-sm searchResultElement" aria-current="true" >
<div className='row' >
<img src={searchResult.thumbnails_url} alt="Thumbnail" className='thumbnailSize' ></img>
<div className="w-100 align-items-center col" >
<div>
<strong className="mb-1" >{searchResult.custom_url}</strong>
</div>
<div className="d-flex justify-content-center align-items-center channel-des-area" >{searchResult.description}</div>
</div>
</div>
</Link>
) : (
<div>검색을 진행한 뒤 진행해 주세요</div>
)
}
</div>
</div>
</div>
</div>
</div>
</div>
</Modal.Body>
<Modal.Footer>
<div className='d-flex align-items-center justify-content-between' >
확인이 끝났다면 추가하기 버튼을 눌러주세요.
<Button variant="primary" onClick={addFollow}>
추가하기
</Button>
</div>
</Modal.Footer>
</Modal>
</div>
);
}
export default SearchResults;
초기화면
검색전
검색후 예) 슈카 코믹스
추가하기 버튼 입력 ( 검색전 )
추가하기 버튼 입력 ( 검색후 )
MainContents.js
function InitForm () {
return (
<div className='col p-5' >
로그인 초기 화면 입니다.
</div>
);
}
function NoticeBoard () {
return (
<div>
<h1>공지판 입니다.</h1>
</div>
);
}
function MainContents({ isLoggedIn, onLogin }) {
return (
isLoggedIn ? (
<InitForm />
) : (
<NoticeBoard />
)
);
}
export default MainContents;
로그인 전
로그인 후
생방송 중인 채널을 클릭시
초기 화면(예) 슈카월드 코믹스)
LiveStream.js
function LiveStream({streramTwitchId, setStreramTwitchId}) {
const { customUrl } = useParams();
const [twitchSearchResult, setTwitchSearchResult] = useState([]);
const [show, setShow] = useState(false );
const location = useLocation();
const searchParams = new URLSearchParams(location.search);
const videoId = searchParams.get('v' );
const handleShow = () => setShow(true );
const handleClose = () => setShow(false );
useEffect(() => {
ytplayerResize();
twitchChatIframeResize();
window.addEventListener("ytplayer" , ytplayerResize);
window.addEventListener("twitchChatIframe" , twitchChatIframeResize);
// 컴포넌트가 언마운트될 때 이벤트 리스너 정리
return () => {
window.removeEventListener("ytplayer" , ytplayerResize);
window.removeEventListener("twitchChatIframe" , twitchChatIframeResize);
};
}, []);
async function setLiveStreamTwitchChannelId(e) {
e.preventDefault();
const twitchChannelId = document.getElementById('twitchId' ).value;
try {
const response = await fetch('http://localhost:8080/setLiveStreamTwitchChannelId?custumUrl=' + customUrl + "&twitchChannelId=" + twitchChannelId, {
method: 'GET' ,
headers: {
'Content-Type' : 'application/json' ,
},
credentials: 'include' ,
});
if (response.ok) {
const responseData = await response.text();
setStreramTwitchId(twitchChannelId);
console.log('Spring 서버 응답 데이터:' , responseData);
return responseData;
} else {
console.error('Spring 서버 응답 에러:' , response.status, response.statusText);
}
} catch (error) {
console.error('오류 발생' );
}
}
async function searchTwitchChannelId(search) {
try {
const response = await fetch('http://localhost:8080/twitchSearchChannel?search=' + search, {
method: 'GET' ,
headers: {
'Content-Type' : 'application/json' ,
},
credentials: 'include' ,
});
if (response.ok) {
const responseData = await response.json();
setTwitchSearchResult(responseData.data || []);
console.log('Spring 서버 응답 데이터:' , responseData);
} else {
console.error('Spring 서버 응답 에러:' , response.status, response.statusText);
}
} catch (error) {
console.error('오류 발생:' , error);
}
}
function ytplayerResize () {
const iframe = document.getElementById("ytplayer" );
// 필요한 경우 헤더와 푸터의 높이를 뺄 수 있습니다
const headerHeight = 100; // 헤더 높이로 대체
const footerHeight = 0; // 푸터 높이로 대체
const sideBarWidth = 250;
const chattingWidth = 350;
iframe.width = window.innerWidth - sideBarWidth - chattingWidth;
iframe.height = window.innerHeight - headerHeight - footerHeight;
}
function twitchChatIframeResize () {
const iframe = document.getElementById("twitchChatIframe" );
// 필요한 경우 헤더와 푸터의 높이를 뺄 수 있습니다
const headerHeight = 100; // 헤더 높이로 대체
// iframe.width = window.innerWidth - sideBarWidth - chattingWidth;
iframe.height = window.innerHeight - headerHeight;
}
return (
<div className='w-100 h-100 row' >
<iframe
id="ytplayer"
title="liveStream"
src={"https://www.youtube.com/embed/" + videoId + "?autoplay=1" }
frameborder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
allowfullscreen
className="col-md-8"
>
</iframe>
{/* <iframe width="560" height="315" src="https://www.youtube.com/embed/DgpEmYsT9hE?si=NJmaumU3EIGVDXzi" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" allowfullscreen></iframe> */}
<div className='col-md-3' >
<div className='streamIdBx' >
<button onClick={() => handleShow()}>Twitch id 찾기</button>
</div>
<iframe
src={"https://www.twitch.tv/embed/" + streramTwitchId +"/chat?parent=localhost" }
title="twitchChatIframe"
width="350"
id="twitchChatIframe"
theme="black"
>
</iframe>
</div>
<Modal show={show} onHide={handleClose} className='disable-drag' >
<Modal.Header closeButton>
<Modal.Title>twitch id 찾기</Modal.Title>
</Modal.Header>
<Modal.Body>
<label>직접 입력하거나 검색해서 찾아주세요</label>
<input type ="text" className="form-control" id="twitchId" placeholder="twitch id" ></input>
<div id="searchResult" ></div>
<div className='pt-4' >
<label>검색</label>
<form onSubmit={(e) => {
e.preventDefault();
searchTwitchChannelId(e.target.elements.searchInput.value);}}>
<input type ="text" className="form-control" placeholder="Search..." name="searchInput" />
</form>
<div>검색 결과</div>
<div className='scrollarea search-result' >
{twitchSearchResult.map((item, index) => (
<Link key={index} className="list-group-item py-3 px-2 lh-sm searchResultElement" aria-current="true" onClick={(e) => {e.preventDefault(); document.getElementById("twitchId" ).value = item.broadcaster_login}}>
<div className='row scrollba' >
<img src={item.thumbnail_url} alt={item.thumbnail_url ? "Thumbnail" : "" } className="thumbnailSize" />
<div className="w-100 align-items-center col" >
<div>
<strong className="mb-1" >{item.display_name}</strong>
</div>
</div>
</div>
</Link>
))}
</div>
</div>
</Modal.Body>
<Modal.Footer>
<Button variant="primary" onClick={setLiveStreamTwitchChannelId}>
추가하기
</Button>
</Modal.Footer>
</Modal>
</div>
);
}
export default LiveStream;
동영상 영역
채팅 영역
트위치 방송 아이디가 없는 경우
트위치 방송 아이디를 입력했는 경우
Twitch Id 찾기 버튼을 누른경우
다이어로그로 표현 ( 트위치 아이디 검색전 )
다이어로그로 표현 ( 트위치 아이디 검색후 )
헤더의 Login 버튼을 클릭 했을 경우
로그인 화면
Login.js
function SignupForm (){
const Signup = () => {
const data = {
username: document.getElementById('username' ).value,
password: document.getElementById('password' ).value,
youtubeChannelId: document.getElementById('youtubeChannelId' ).value,
};
// Spring 서버로 JSON 데이터를 보내는 함수
async function sendDataToSpringServer () {
try {
const response = await fetch('http://localhost:8080/signup' , {
method: 'POST' ,
headers: {
'Content-Type' : 'application/json' , // JSON 데이터라는 것을 명시
},
body: JSON.stringify(data), // JSON 데이터를 문자열로 변환해서 요청 본문에 담음
});
if (response.ok) {
const responseData = await response.json(); // Spring 서버에서의 응답 데이터를 JSON으로 파싱
console.log('Spring 서버 응답 데이터:' , responseData);
} else {
console.error('Spring 서버 응답 에러:' , response.status, response.statusText);
}
} catch (error) {
console.error('오류 발생:' , error);
}
}
sendDataToSpringServer();
}
return (
<div className='form-signin col p-5 mx-auto' >
<div className='text-center h3 p-3' >회원 가입</div>
<EmailForm/>
<PasswordForm/>
<div className="form-floating mt-2" >
<input type ="text" className="form-control" id="youtubeChannelId" placeholder="" ></input>
<label htmlFor="youtubeChannelid" >YoutubeChannelid</label>
</div>
<button onClick={Signup} className="btn btn-primary w-100 py-2 mt-3" >Signin</button>
</div>
);
}
function EmailForm (){
return (
<div className="form-floating" >
<input type ="text" className="form-control email-signin" id="username" placeholder="" ></input>
<label htmlFor="username" >Email address</label>
</div>
);
}
function PasswordForm (){
return (
<div className="form-floating" >
<input type ="password" className="form-control password-signin" id="password" placeholder="" ></input>
<label htmlFor="password" >password</label>
</div>
);
}
function Login({ onLogin }) {
const [isSignUp, setIsSignUp] = useState(true );
const navigate = useNavigate();
const GoSignup = () => {
setIsSignUp(false );
};
const LoginSubmit = () =>{
const data = {
username: document.getElementById('username' ).value,
password: document.getElementById('password' ).value
};
// Spring 서버로 JSON 데이터를 보내는 함수
async function sendDataToSpringServer () {
try {
const response = await fetch('http://localhost:8080/login' , {
method: 'POST' ,
headers: {
'Content-Type' : 'application/json' , // JSON 데이터라는 것을 명시
},
body: JSON.stringify(data), // JSON 데이터를 문자열로 변환해서 요청 본문에 담음
credentials: 'include' ,
});
if (response.ok) {
const responseData = await response.json(); // Spring 서버에서의 응답 데이터를 JSON으로 파싱
// 로그인 확인 함수 실행
onLogin();
navigate(`/`);
console.log('Spring 서버 응답 데이터:' , responseData);
} else {
console.error('Spring 서버 응답 에러:' , response.status, response.statusText);
}
} catch (error) {
console.error('오류 발생:' , error);
}
}
sendDataToSpringServer();
}
return (
isSignUp ? (
<React.StrictMode>
<div className='form-signin col p-5 mx-auto' >
<div className='text-center h3 p-3' >로그인</div>
<EmailForm />
<PasswordForm />
<button onClick={LoginSubmit} className="btn btn-primary w-100 py-2 mt-3" >Login</button>
<button onClick={GoSignup} className="btn btn-primary w-100 py-2 mt-3" >Signin</button>
</div>
</React.StrictMode>
) : (
<React.StrictMode>
<SignupForm />
</React.StrictMode>
)
);
}
export default Login;
Signin 버튼을 누른 경우
회원가입 폼