기본적으로 부트스트랩 프레임워크를 기본으로 사용하면서 조금씩 필요한 부분을 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 버튼을 누른 경우
회원가입 폼