feat: 라이브 수업 구현

This commit is contained in:
jhynsoo 2024-08-05 11:42:40 +09:00
parent e3667f1b73
commit ba6945de05
34 changed files with 515 additions and 461 deletions

View File

@ -8,6 +8,8 @@
"name": "edufocus", "name": "edufocus",
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"@livekit/components-react": "^2.4.3",
"@livekit/components-styles": "^1.0.12",
"@stomp/stompjs": "^7.0.0", "@stomp/stompjs": "^7.0.0",
"@tanstack/react-query": "^5.49.2", "@tanstack/react-query": "^5.49.2",
"axios": "^1.7.2", "axios": "^1.7.2",
@ -905,6 +907,31 @@
"node": "^12.22.0 || ^14.17.0 || >=16.0.0" "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
} }
}, },
"node_modules/@floating-ui/core": {
"version": "1.6.5",
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.6.5.tgz",
"integrity": "sha512-8GrTWmoFhm5BsMZOTHeGD2/0FLKLQQHvO/ZmQga4tKempYRLz8aqJGqXVuQgisnMObq2YZ2SgkwctN1LOOxcqA==",
"license": "MIT",
"dependencies": {
"@floating-ui/utils": "^0.2.5"
}
},
"node_modules/@floating-ui/dom": {
"version": "1.6.8",
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.6.8.tgz",
"integrity": "sha512-kx62rP19VZ767Q653wsP1XZCGIirkE09E0QUGNYTM/ttbbQHqcGPdSfWFxUyyNLc/W6aoJRBajOSXhP6GXjC0Q==",
"license": "MIT",
"dependencies": {
"@floating-ui/core": "^1.6.0",
"@floating-ui/utils": "^0.2.5"
}
},
"node_modules/@floating-ui/utils": {
"version": "0.2.5",
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.5.tgz",
"integrity": "sha512-sTcG+QZ6fdEUObICavU+aB3Mp8HY4n14wYHdxK4fXjPmv3PXZZeY5RaguJmGyeH/CJQhX3fqKUtS4qc1LoHwhQ==",
"license": "MIT"
},
"node_modules/@humanwhocodes/config-array": { "node_modules/@humanwhocodes/config-array": {
"version": "0.11.14", "version": "0.11.14",
"resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.14.tgz", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.14.tgz",
@ -1009,6 +1036,55 @@
"@jridgewell/sourcemap-codec": "^1.4.14" "@jridgewell/sourcemap-codec": "^1.4.14"
} }
}, },
"node_modules/@livekit/components-core": {
"version": "0.11.2",
"resolved": "https://registry.npmjs.org/@livekit/components-core/-/components-core-0.11.2.tgz",
"integrity": "sha512-rXQ1OvyGe9gY8BCpH5FTr4Il17/sS/ecJQbG3PoOXAkQVl5JP965eqUPyKXZTdxNKlVLef00AygrO2pPArwOTA==",
"license": "Apache-2.0",
"dependencies": {
"@floating-ui/dom": "1.6.8",
"loglevel": "1.9.1",
"rxjs": "7.8.1"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@livekit/protocol": "^1.16.0",
"livekit-client": "^2.4.0",
"tslib": "^2.6.2"
}
},
"node_modules/@livekit/components-react": {
"version": "2.4.3",
"resolved": "https://registry.npmjs.org/@livekit/components-react/-/components-react-2.4.3.tgz",
"integrity": "sha512-XhCvwFvNjhBJcoQHIY4Hk6MBp7mM9q0n0i7sN/xK3fB1DSjkxIkpc7lh/+Pjqdu6F6OJT3MjwNFYnftqy6kcmw==",
"license": "Apache-2.0",
"dependencies": {
"@livekit/components-core": "0.11.2",
"clsx": "2.1.1",
"usehooks-ts": "3.1.0"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@livekit/protocol": "^1.16.0",
"livekit-client": "^2.4.0",
"react": ">=18",
"react-dom": ">=18",
"tslib": "^2.6.2"
}
},
"node_modules/@livekit/components-styles": {
"version": "1.0.12",
"resolved": "https://registry.npmjs.org/@livekit/components-styles/-/components-styles-1.0.12.tgz",
"integrity": "sha512-Hsxkfq240w0tMPtkQTHQotpkYfIY4lhP2pzegvOIIV/nYxj8LeRYypUjxJpFw3s6jQcV/WQS7oCYmFQdy98Jtw==",
"license": "Apache-2.0",
"engines": {
"node": ">=18"
}
},
"node_modules/@livekit/protocol": { "node_modules/@livekit/protocol": {
"version": "1.19.1", "version": "1.19.1",
"resolved": "https://registry.npmjs.org/@livekit/protocol/-/protocol-1.19.1.tgz", "resolved": "https://registry.npmjs.org/@livekit/protocol/-/protocol-1.19.1.tgz",
@ -2420,6 +2496,15 @@
"node": "*" "node": "*"
} }
}, },
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/color-convert": { "node_modules/color-convert": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@ -4324,6 +4409,12 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/lodash.debounce": {
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz",
"integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==",
"license": "MIT"
},
"node_modules/lodash.merge": { "node_modules/lodash.merge": {
"version": "4.6.2", "version": "4.6.2",
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
@ -5244,7 +5335,6 @@
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz",
"integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==",
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true,
"dependencies": { "dependencies": {
"tslib": "^2.1.0" "tslib": "^2.1.0"
} }
@ -5873,6 +5963,21 @@
"react": "^16.8.0 || ^17.0.0 || ^18.0.0" "react": "^16.8.0 || ^17.0.0 || ^18.0.0"
} }
}, },
"node_modules/usehooks-ts": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/usehooks-ts/-/usehooks-ts-3.1.0.tgz",
"integrity": "sha512-bBIa7yUyPhE1BCc0GmR96VU/15l/9gP1Ch5mYdLcFBaFGQsdmXkvjV0TtOqW1yUd6VjIwDunm+flSciCQXujiw==",
"license": "MIT",
"dependencies": {
"lodash.debounce": "^4.0.8"
},
"engines": {
"node": ">=16.15.0"
},
"peerDependencies": {
"react": "^16.8.0 || ^17 || ^18"
}
},
"node_modules/vite": { "node_modules/vite": {
"version": "5.3.3", "version": "5.3.3",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.3.3.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-5.3.3.tgz",

View File

@ -13,6 +13,8 @@
"test:run": "vitest run" "test:run": "vitest run"
}, },
"dependencies": { "dependencies": {
"@livekit/components-react": "^2.4.3",
"@livekit/components-styles": "^1.0.12",
"@stomp/stompjs": "^7.0.0", "@stomp/stompjs": "^7.0.0",
"@tanstack/react-query": "^5.49.2", "@tanstack/react-query": "^5.49.2",
"axios": "^1.7.2", "axios": "^1.7.2",

View File

@ -1,42 +1,112 @@
import styles from './ChatRoom.module.css'; import styles from './ChatRoom.module.css';
import { useParams } from 'react-router-dom';
import SendIcon from '/src/assets/icons/send.svg?react'; import SendIcon from '/src/assets/icons/send.svg?react';
import useChatRoom from '../../hooks/chat/useChatRoom'; import { cloneElement, useEffect, useRef } from 'react';
import { ChatEntry, useChat, useMaybeLayoutContext } from '@livekit/components-react';
export default function ChatRoom() { export default function ChatRoom({ ...props }) {
const { roomId } = useParams(); const chatInputRef = useRef(null);
const { messages, handleSubmit, inputRef, chatListRef } = useChatRoom(roomId); const ulRef = useRef(null);
const { send, chatMessages, isSending } = useChat();
const layoutContext = useMaybeLayoutContext();
const lastReadMsgAt = useRef(0);
async function handleChatSubmit(event) {
event.preventDefault();
if (chatInputRef.current && chatInputRef.current.value.trim() !== '') {
if (send) {
await send(chatInputRef.current.value);
chatInputRef.current.value = '';
chatInputRef.current.focus();
}
}
}
useEffect(() => {
if (ulRef) {
ulRef.current?.scrollTo({ top: ulRef.current.scrollHeight });
}
}, [ulRef, chatMessages]);
useEffect(() => {
if (!layoutContext || chatMessages.length === 0) {
return;
}
if (
layoutContext.widget.state?.showChat &&
chatMessages.length > 0 &&
lastReadMsgAt.current !== chatMessages[chatMessages.length - 1]?.timestamp
) {
lastReadMsgAt.current = chatMessages[chatMessages.length - 1]?.timestamp;
return;
}
const unreadMessageCount = chatMessages.filter(
(msg) => !lastReadMsgAt.current || msg.timestamp > lastReadMsgAt.current
).length;
const { widget } = layoutContext;
if (unreadMessageCount > 0 && widget.state?.unreadMessages !== unreadMessageCount) {
widget.dispatch?.({ msg: 'unread_msg', count: unreadMessageCount });
}
}, [chatMessages, layoutContext, layoutContext.widget]);
return ( return (
<section className={styles.room}> <div
{...props}
className="lk-chat"
>
<h2 className={styles.title}>채팅</h2> <h2 className={styles.title}>채팅</h2>
<ol
className={styles.messageList} <ul
ref={chatListRef} className="lk-list lk-chat-messages"
ref={ulRef}
> >
{messages.map?.((message) => ( {props.children
<li ? chatMessages.map((msg, idx) =>
key={message.id} cloneElement(props.children, {
className={message.isMine ? styles.my : styles.your} entry: msg,
> key: msg.id ?? idx,
<span className={styles.name}>{message.name}</span> })
<span className={styles.bubble}>{message.text}</span> )
</li> : chatMessages.map((msg, idx, allMsg) => {
))} const hideName = idx >= 1 && allMsg[idx - 1].from === msg.from;
</ol> const hideTimestamp = idx >= 1 && msg.timestamp - allMsg[idx - 1].timestamp < 60_000;
return (
<ChatEntry
key={msg.id ?? idx}
hideName={hideName}
hideTimestamp={hideName === false ? false : hideTimestamp}
entry={msg}
/>
);
})}
</ul>
<form <form
action="POST" className="lk-chat-form"
onSubmit={handleSubmit} onSubmit={handleChatSubmit}
className={styles.form}
> >
<input <input
className="lk-form-control lk-chat-form-input"
disabled={isSending}
ref={chatInputRef}
type="text" type="text"
ref={inputRef} placeholder="메시지"
onInput={(ev) => ev.stopPropagation()}
onKeyDown={(ev) => ev.stopPropagation()}
onKeyUp={(ev) => ev.stopPropagation()}
/> />
<button type="submit"> <button
type="submit"
className={`lk-button lk-chat-form-button ${styles.button}`}
disabled={isSending}
>
<SendIcon /> <SendIcon />
</button> </button>
</form> </form>
</section> </div>
); );
} }

View File

@ -3,7 +3,7 @@
flex-direction: column; flex-direction: column;
justify-content: space-between; justify-content: space-between;
position: relative; position: relative;
width: 100%; width: 400px;
height: 100%; height: 100%;
overflow-y: hidden; overflow-y: hidden;
} }
@ -20,8 +20,9 @@
align-items: start; align-items: start;
gap: 12px; gap: 12px;
list-style: none; list-style: none;
max-height: 100%;
margin: 0; margin: 0;
padding: 0 16px; padding: 0 8px 8px;
white-space: nowrap; white-space: nowrap;
overflow-x: hidden; overflow-x: hidden;
overflow-y: auto; overflow-y: auto;
@ -91,8 +92,8 @@
align-items: center; align-items: center;
gap: 8px; gap: 8px;
margin: 16px; margin: 16px;
background-color: var(--background); background-color: black;
border: 1px solid var(--border-color); border: 1px solid var(--border-color-tertiary);
border-radius: 12px; border-radius: 12px;
box-shadow: var(--shadow); box-shadow: var(--shadow);
overflow: hidden; overflow: hidden;
@ -103,7 +104,7 @@
width: 100%; width: 100%;
border: none; border: none;
border-radius: 12px; border-radius: 12px;
color: var(--text-color); /* color: var(--text-color); */
font-size: 16px; font-size: 16px;
line-height: 1.4; line-height: 1.4;
font-weight: 400; font-weight: 400;
@ -124,7 +125,11 @@
line-height: 1.4; line-height: 1.4;
font-weight: 500; font-weight: 500;
cursor: pointer; cursor: pointer;
background-color: var(--background); background-color: black;
stroke: var(--text-color); stroke: white;
} }
} }
.button {
stroke: white;
}

View File

@ -1,15 +1,11 @@
import styles from './InfoEditForm.module.css'; import styles from './InfoEditForm.module.css';
import { useState } from 'react'; import { useState } from 'react';
export default function InfoEditForm({ onSubmit }) { export default function InfoEditForm() {
const [username, setUsername] = useState(''); const [username, setUsername] = useState('');
const [useremail, setUseremail] = useState(''); const [useremail, setUseremail] = useState('');
return ( return (
<form <form className={styles.infoEditForm}>
onSubmit={(e) => onSubmit(e, username, useremail)}
className={styles.infoEditForm}
>
<p className={styles.textHeading}>이름 변경</p> <p className={styles.textHeading}>이름 변경</p>
<div className={styles.inputBox}> <div className={styles.inputBox}>
<label <label

View File

@ -1,22 +0,0 @@
import { useEffect, useRef } from 'react';
export default function LiveAudio({ track }) {
const audioRef = useRef(null);
useEffect(() => {
if (audioRef.current) {
track.attach(audioRef.current);
}
return () => {
track.detach();
};
}, [track]);
return (
<audio
ref={audioRef}
id={track.sid}
/>
);
}

View File

@ -1 +0,0 @@
export { default as LiveAudio } from './LiveAudio';

View File

@ -1,46 +1,99 @@
import styles from './LiveRoom.module.css'; import { isEqualTrackRef, isTrackReference } from '@livekit/components-core';
import {
CarouselLayout,
ConnectionStateToast,
ControlBar,
FocusLayout,
FocusLayoutContainer,
GridLayout,
LayoutContextProvider,
ParticipantTile,
RoomAudioRenderer,
useCreateLayoutContext,
usePinnedTracks,
useTracks,
} from '@livekit/components-react';
import { RoomEvent, Track } from 'livekit-client';
import { useEffect, useRef } from 'react';
import ChatRoom from '../ChatRoom/ChatRoom'; import ChatRoom from '../ChatRoom/ChatRoom';
import { LiveAudio } from '../LiveAudio';
import { LiveVideo } from '../LiveVideo';
import LoadingIndicator from '../LoadingIndicator.jsx/LoadingIndicator';
export default function LiveRoom({ room, localTrack, remoteTracks, leaveRoom, mainTrack }) { export default function LiveRoom() {
return ( const lastAutoFocusedScreenShareTrack = useRef(null);
<>
<main> const tracks = useTracks(
{room ? ( [
<> { source: Track.Source.Camera, withPlaceholder: true },
<div className={styles.videoWrapper}> { source: Track.Source.ScreenShare, withPlaceholder: false },
{localTrack && ( ],
<LiveVideo { updateOnlyOn: [RoomEvent.ActiveSpeakersChanged], onlySubscribed: false }
track={localTrack} );
identity="나" const layoutContext = useCreateLayoutContext();
/>
)} const screenShareTracks = tracks
{remoteTracks.map((track) => .filter(isTrackReference)
track.trackPublication.kind === 'video' ? ( .filter((track) => track.publication.source === Track.Source.ScreenShare);
<LiveVideo
key={track.trackPublication.trackSid} const focusTrack = usePinnedTracks(layoutContext)?.[0];
track={track.trackPublication.videoTrack} const carouselTracks = tracks.filter((track) => !isEqualTrackRef(track, focusTrack));
/>
) : ( useEffect(() => {
<LiveAudio if (
key={track.trackPublication.trackSid} screenShareTracks.some((track) => track.publication.isSubscribed) &&
track={track.trackPublication.audioTrack} lastAutoFocusedScreenShareTrack.current === null
/> ) {
layoutContext.pin.dispatch?.({ msg: 'set_pin', trackReference: screenShareTracks[0] });
lastAutoFocusedScreenShareTrack.current = screenShareTracks[0];
} else if (
lastAutoFocusedScreenShareTrack.current &&
!screenShareTracks.some(
(track) => track.publication.trackSid === lastAutoFocusedScreenShareTrack.current?.publication?.trackSid
) )
)} ) {
layoutContext.pin.dispatch?.({ msg: 'clear_pin' });
lastAutoFocusedScreenShareTrack.current = null;
}
if (focusTrack && !isTrackReference(focusTrack)) {
const updatedFocusTrack = tracks.find(
(tr) => tr.participant.identity === focusTrack.participant.identity && tr.source === focusTrack.source
);
if (updatedFocusTrack !== focusTrack && isTrackReference(updatedFocusTrack)) {
layoutContext.pin.dispatch?.({ msg: 'set_pin', trackReference: updatedFocusTrack });
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
// eslint-disable-next-line react-hooks/exhaustive-deps
screenShareTracks.map((ref) => `${ref.publication.trackSid}_${ref.publication.isSubscribed}`).join(),
focusTrack?.publication?.trackSid,
tracks,
]);
return (
<div className="lk-video-conference">
<LayoutContextProvider value={layoutContext}>
<div className="lk-video-conference-inner">
{!focusTrack ? (
<div className="lk-grid-layout-wrapper">
<GridLayout tracks={tracks}>
<ParticipantTile />
</GridLayout>
</div> </div>
{mainTrack && <LiveVideo track={mainTrack} />}
</>
) : ( ) : (
<LoadingIndicator fill /> <div className="lk-focus-layout-wrapper">
<FocusLayoutContainer>
<CarouselLayout tracks={carouselTracks}>
<ParticipantTile />
</CarouselLayout>
{focusTrack && <FocusLayout trackRef={focusTrack} />}
</FocusLayoutContainer>
</div>
)} )}
</main> <ControlBar controls={{ chat: false, leave: false }} />
<aside> </div>
<ChatRoom /> <ChatRoom />
<button onClick={leaveRoom}>나가기</button> </LayoutContextProvider>
</aside> <RoomAudioRenderer />
</> <ConnectionStateToast />
</div>
); );
} }

View File

@ -1,11 +1,17 @@
.main {
display: flex;
flex-direction: column;
}
.videoWrapper { .videoWrapper {
flex-grow: 0; flex: 0 0 auto;
display: flex; display: flex;
overflow-x: auto; overflow-x: auto;
height: 80px; height: 80px;
gap: 10px; gap: 10px;
padding: 10px; padding: 10px;
border-bottom: 1px solid var(--border-color); border-bottom: 1px solid var(--border-color);
box-sizing: border-box;
& > audio { & > audio {
display: none; display: none;
@ -21,7 +27,41 @@
} }
} }
.myVideo { .mainContent {
border-radius: 12px; display: flex;
border: 2px solid var(--border-color); justify-content: center;
align-items: center;
width: 100%;
height: 100%;
box-sizing: border-box;
& > video {
width: 100%;
height: calc(100vh - 208px);
object-fit: contain;
box-sizing: border-box;
}
}
.controlBar {
display: flex;
justify-content: space-between;
align-items: center;
height: 80px;
border-top: 1px solid var(--border-color);
box-sizing: border-box;
& > button {
background-color: var(--background-color-secondary);
color: var(--text-color-primary);
border: none;
border-radius: 4px;
padding: 8px 16px;
cursor: pointer;
transition: background-color 0.2s;
&:hover {
background-color: var(--background-color-tertiary);
}
}
} }

View File

@ -1,24 +0,0 @@
import styles from './LiveVideo.module.css';
import { useEffect, useRef } from 'react';
export default function LiveVideo({ track }) {
const videoRef = useRef(null);
useEffect(() => {
if (videoRef.current) {
track.attach(videoRef.current);
}
return () => {
track.detach();
};
}, [track]);
return (
<video
ref={videoRef}
id={track?.sid}
className={styles.video}
/>
);
}

View File

@ -1,36 +0,0 @@
.wrapper {
width: fit-content;
height: 100%;
position: relative;
border-radius: 10px;
overflow: hidden;
& > p {
z-index: 99;
position: absolute;
bottom: 0;
left: 0;
width: 100%;
margin: 0;
padding: 0;
text-align: center;
opacity: 0;
background-color: var(--whiteOpacity600);
font-size: 12px;
line-height: 1.4;
color: var(--text-color);
font-weight: bold;
transition: opacity 0.2s;
}
&:hover > p {
opacity: 1;
}
}
.video {
max-width: 100%;
max-height: 100%;
object-fit: cover;
border-radius: 8px;
}

View File

@ -1 +0,0 @@
export { default as LiveVideo } from './LiveVideo';

View File

@ -1,9 +1,10 @@
import styles from './LoadingIndicator.module.css'; import styles from './LoadingIndicator.module.css';
export default function LoadingIndicator({ fill = false }) { export default function LoadingIndicator({ fill = false, label }) {
return fill ? ( return fill ? (
<div className={styles.wrapper}> <div className={styles.wrapper}>
<div className={styles.indicator} /> <div className={styles.indicator} />
{label && <div className={styles.label}>{label}</div>}
</div> </div>
) : ( ) : (
<div className={styles.indicator} /> <div className={styles.indicator} />

View File

@ -4,10 +4,26 @@
} }
} }
@keyframes wave {
0% {
rotate: -5deg;
}
50% {
rotate: 5deg;
}
100% {
rotate: -5deg;
}
}
.wrapper { .wrapper {
display: flex; display: flex;
flex-direction: column;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
gap: 10px;
width: 100%; width: 100%;
height: 100%; height: 100%;
margin: 0 auto; margin: 0 auto;
@ -22,3 +38,11 @@
box-sizing: border-box; box-sizing: border-box;
animation: spin 2s cubic-bezier(0.645, 0.045, 0.355, 1) infinite; animation: spin 2s cubic-bezier(0.645, 0.045, 0.355, 1) infinite;
} }
.label {
font-size: 16px;
line-height: 1.4;
font-weight: 400;
color: var(--text-color);
animation: wave 1.5s ease infinite;
}

View File

@ -1,13 +1,13 @@
import { useState, useRef } from 'react'; import { useState, useRef } from 'react';
import styles from './PasswordChangeForm.module.css'; import styles from './PasswordChangeForm.module.css';
export default function PasswordChangeForm({ onSubmit, onPwError = false }) { export default function PasswordChangeForm() {
// TODO: onPwError( )
const [errorConfirmMessage, setErrorConfirmMessage] = useState(false); const [errorConfirmMessage, setErrorConfirmMessage] = useState(false);
const [errorSameMessage, setErrorSameMessage] = useState(false); const [errorSameMessage, setErrorSameMessage] = useState(false);
const currentPasswordRef = useRef(''); const currentPasswordRef = useRef('');
const newPasswordRef = useRef(''); const newPasswordRef = useRef('');
const confirmPasswordRef = useRef(''); const confirmPasswordRef = useRef('');
const userPassword = '1234';
const handleSubmit = (e) => { const handleSubmit = (e) => {
e.preventDefault(); e.preventDefault();
@ -15,15 +15,13 @@ export default function PasswordChangeForm({ onSubmit, onPwError = false }) {
const newPassword = newPasswordRef.current.value; const newPassword = newPasswordRef.current.value;
const confirmPassword = confirmPasswordRef.current.value; const confirmPassword = confirmPasswordRef.current.value;
if (currentPassword === userPassword) {
setErrorSameMessage(false);
} else {
setErrorSameMessage(true);
}
if (newPassword === confirmPassword) { if (newPassword === confirmPassword) {
setErrorConfirmMessage(false); setErrorConfirmMessage(false);
onSubmit(currentPassword, newPassword, confirmPassword);
if (onPwError) {
setErrorSameMessage(true);
} else {
setErrorSameMessage(false);
}
} else { } else {
setErrorConfirmMessage(true); setErrorConfirmMessage(true);
} }

View File

@ -1,18 +1,15 @@
import { useState } from 'react'; import { useState } from 'react';
import styles from './QuizCard.module.css'; import styles from './QuizCard.module.css';
export default function QuizCard({ quiz, updateQuiz, deleteQuiz }) { export default function QuizCard({ quiz, index, updateQuiz }) {
const [question, setQuestion] = useState(quiz.question || ''); const [question, setQuestion] = useState(quiz.question || '');
const [answer, setAnswer] = useState(quiz.answer || ''); const [answer, setAnswer] = useState(quiz.answer || '');
const [choices, setChoices] = useState(quiz.choices || []); const [choices, setChoices] = useState(quiz.choices || []);
const [imageFile, setImageFile] = useState(quiz.imageFile || null);
const handleChoiceChange = (num, content) => { const handleChoiceChange = (num, content) => {
const updatedChoices = choices.map((choice) => const updatedChoices = choices.map((choice) => (choice.num === num ? { ...choice, content } : choice));
choice.num === num ? { ...choice, content } : choice
);
setChoices(updatedChoices); setChoices(updatedChoices);
updateQuiz(quiz.id, { ...quiz, question, answer, choices: updatedChoices, imageFile }); updateQuiz(index, { question, answer, choices: updatedChoices });
}; };
const handleAddChoice = () => { const handleAddChoice = () => {
@ -20,7 +17,7 @@ export default function QuizCard({ quiz, updateQuiz, deleteQuiz }) {
const newChoice = { num: choices.length + 1, content: '' }; const newChoice = { num: choices.length + 1, content: '' };
const updatedChoices = [...choices, newChoice]; const updatedChoices = [...choices, newChoice];
setChoices(updatedChoices); setChoices(updatedChoices);
updateQuiz(quiz.id, { ...quiz, question, answer, choices: updatedChoices, imageFile }); updateQuiz(index, { question, answer, choices: updatedChoices });
} }
}; };
@ -28,29 +25,19 @@ export default function QuizCard({ quiz, updateQuiz, deleteQuiz }) {
if (choices.length > 0) { if (choices.length > 0) {
const updatedChoices = choices.slice(0, -1); const updatedChoices = choices.slice(0, -1);
setChoices(updatedChoices); setChoices(updatedChoices);
updateQuiz(quiz.id, { ...quiz, question, answer, choices: updatedChoices, imageFile }); updateQuiz(index, { question, answer, choices: updatedChoices });
} }
}; };
const handleFileChange = (e) => {
const file = e.target.files[0] ?? null;
setImageFile(file);
updateQuiz(quiz.id, { ...quiz, question, answer, choices, imageFile: file });
};
return ( return (
<div className={styles.card}> <div className={styles.card}>
<div className={styles.header}>
<span>퀴즈 생성 카드</span>
<span onClick={() => deleteQuiz(quiz.id)}>X</span> {/* id를 기반으로 삭제 */}
</div>
<label>질문</label> <label>질문</label>
<input <input
type="text" type="text"
value={question} value={question}
onChange={(e) => { onChange={(e) => {
setQuestion(e.target.value); setQuestion(e.target.value);
updateQuiz(quiz.id, { ...quiz, question: e.target.value, answer, choices, imageFile }); updateQuiz(index, { question: e.target.value, answer, choices });
}} }}
placeholder="질문 내용을 입력하세요" placeholder="질문 내용을 입력하세요"
/> />
@ -60,7 +47,7 @@ export default function QuizCard({ quiz, updateQuiz, deleteQuiz }) {
value={answer} value={answer}
onChange={(e) => { onChange={(e) => {
setAnswer(e.target.value); setAnswer(e.target.value);
updateQuiz(quiz.id, { ...quiz, question, answer: e.target.value, choices, imageFile }); updateQuiz(index, { question, answer: e.target.value, choices });
}} }}
placeholder="정답을 입력하세요" placeholder="정답을 입력하세요"
/> />
@ -68,10 +55,18 @@ export default function QuizCard({ quiz, updateQuiz, deleteQuiz }) {
<span>Tip: 선택지를 넣지 않는다면 단답형 문제가 됩니다</span> <span>Tip: 선택지를 넣지 않는다면 단답형 문제가 됩니다</span>
</div> </div>
<div className={styles.buttonsWrapper}> <div className={styles.buttonsWrapper}>
<button type="button" onClick={handleAddChoice} className={styles.button}> <button
type="button"
onClick={handleAddChoice}
className={styles.button}
>
선택지 추가하기 선택지 추가하기
</button> </button>
<button type="button" onClick={handlePopChoice} className={styles.removeButton}> <button
type="button"
onClick={handlePopChoice}
className={styles.removeButton}
>
선택지 줄이기 선택지 줄이기
</button> </button>
</div> </div>
@ -86,8 +81,6 @@ export default function QuizCard({ quiz, updateQuiz, deleteQuiz }) {
/> />
</div> </div>
))} ))}
<label>퀴즈 이미지</label>
<input type="file" accept=".png, .jpg, .jpeg" onChange={handleFileChange} />
</div> </div>
); );
} }

View File

@ -8,12 +8,6 @@
gap: 8px; gap: 8px;
} }
.header {
display: flex;
flex-direction: row;
justify-content: space-between;
}
.buttonsWrapper { .buttonsWrapper {
display: flex; display: flex;
flex-direction: row; flex-direction: row;

View File

@ -6,33 +6,32 @@ import BackIcon from '/src/assets/icons/back.svg?react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
export default function QuizsetForm({ headerTitle, topic, to, onSubmit }) { export default function QuizsetForm({ headerTitle, topic, to, onSubmit }) {
// TODO:
const [title, setTitle] = useState(''); const [title, setTitle] = useState('');
const [quizzes, setQuizzes] = useState([]); const [quizzes, setQuizzes] = useState([]);
const [quizId, setQuizId] = useState(0); const [imageFile, setImageFile] = useState(null);
const handleAddQuiz = () => { const handleAddQuiz = () => {
setQuizzes([ setQuizzes([...quizzes, { question: '', answer: '', choices: [] }]);
...quizzes,
{ id: quizId, question: '', answer: '', choices: [], imageFile: null },
]);
setQuizId(quizId + 1);
}; };
const updateQuiz = (id, updatedQuiz) => { const updateQuiz = (index, updatedQuiz) => {
const updatedQuizzes = quizzes.map((quiz) => const updatedQuizzes = quizzes.map((quiz, i) => (i === index ? updatedQuiz : quiz));
quiz.id === id ? updatedQuiz : quiz
);
setQuizzes(updatedQuizzes); setQuizzes(updatedQuizzes);
}; };
const deleteQuiz = (id) => { const handleFileChange = (e) => {
setQuizzes(quizzes.filter((quiz) => quiz.id !== id)); const file = e.target.files?.[0];
setImageFile(file);
}; };
return ( return (
<div className={styles.quizsetForm}> <div className={styles.quizsetForm}>
<header className={styles.header}> <header className={styles.header}>
<Link to={to} className={styles.goBack}> <Link
to={to}
className={styles.goBack}
>
<BackIcon /> <BackIcon />
<span>{headerTitle}</span> <span>{headerTitle}</span>
</Link> </Link>
@ -40,7 +39,7 @@ export default function QuizsetForm({ headerTitle, topic, to, onSubmit }) {
</header> </header>
<form <form
className={styles.form} className={styles.form}
onSubmit={(e) => onSubmit(e, title, quizzes)} onSubmit={(e) => onSubmit(e, title, quizzes, imageFile)}
> >
<input <input
type="text" type="text"
@ -48,12 +47,12 @@ export default function QuizsetForm({ headerTitle, topic, to, onSubmit }) {
onChange={(e) => setTitle(e.target.value)} onChange={(e) => setTitle(e.target.value)}
placeholder="퀴즈셋 제목을 입력해주세요" placeholder="퀴즈셋 제목을 입력해주세요"
/> />
{quizzes.map((quiz) => ( {quizzes.map((quiz, index) => (
<QuizCard <QuizCard
key={quiz.id} key={index}
quiz={quiz} quiz={quiz}
index={index}
updateQuiz={updateQuiz} updateQuiz={updateQuiz}
deleteQuiz={deleteQuiz}
/> />
))} ))}
<button <button
@ -63,7 +62,16 @@ export default function QuizsetForm({ headerTitle, topic, to, onSubmit }) {
> >
퀴즈 추가하기 퀴즈 추가하기
</button> </button>
<button type="submit" className={styles.button}> <label>퀴즈 이미지</label>
<input
type="file"
accept=".png, .jpg, .jpeg"
onChange={handleFileChange}
/>
<button
type="submit"
className={styles.button}
>
<EditIcon /> <EditIcon />
<div>제출</div> <div>제출</div>
</button> </button>

View File

@ -2,7 +2,7 @@ import BackIcon from '/src/assets/icons/back.svg?react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import styles from './QuizsetDetail.module.css'; import styles from './QuizsetDetail.module.css';
export default function QuizsetDetail({ topic, title, quizzes = [], onDelete }) { export default function QuizsetDetail({ topic, title }) {
return ( return (
<div className={styles.quizsetDetail}> <div className={styles.quizsetDetail}>
<header className={styles.header}> <header className={styles.header}>
@ -17,24 +17,6 @@ export default function QuizsetDetail({ topic, title, quizzes = [], onDelete })
<h1 className={styles.title}>{title}</h1> <h1 className={styles.title}>{title}</h1>
</div> </div>
</header> </header>
<div>
{quizzes.map((quiz, index) => (
<div key={index}>
<div>질문 : {quiz.question}</div>
<img
src={quiz.image}
alt="강의 이미지"
/>
<div>정답이 응답에 없네요</div>
</div>
))}
</div>
<button
type="button"
onClick={onDelete}
>
퀴즈셋 삭제
</button>
</div> </div>
); );
} }

View File

@ -49,29 +49,15 @@ export function useAuth() {
.post(`${API_URL}/user/logout`) .post(`${API_URL}/user/logout`)
.then((response) => { .then((response) => {
console.log(response); console.log(response);
})
.catch((e) => {
console.log(e);
})
.finally(() => {
setUserType(null); setUserType(null);
setToken(null); setToken(null);
}) });
.catch((e) => console.log(e));
}; };
const updateInfo = (name, email) => { return { login, logout, userRegister };
const infoBody = {
name,
email,
};
return instance.put(`${API_URL}/user/updateinfo`, infoBody);
};
const updatePassword = (currentPw, newPw, newPwCheck) => {
const passwordBody = {
currentPassword: currentPw,
newPassword: newPw,
newPasswordCheck: newPwCheck,
};
console.log(passwordBody);
return instance.put(`${API_URL}/user/updatepassword`, passwordBody);
};
return { login, logout, userRegister, updateInfo, updatePassword };
} }

View File

@ -1,10 +0,0 @@
import instance from '../../utils/axios/instance';
import { API_URL } from '../../constants';
export function useQuizsetDelete() {
const quizsetDelete = (quizsetId) => {
return instance.delete(`${API_URL}/quiz/teacher/${quizsetId}`);
};
return { quizsetDelete };
}

View File

@ -1,10 +0,0 @@
import instance from '../../utils/axios/instance';
import { API_URL } from '../../constants';
export function useQuizsetEdit() {
const quizsetEdit = (quizsetId, quizsetObject) => {
return instance.put(`${API_URL}/lecture/${quizsetId}`, quizsetObject);
};
return { quizsetEdit };
}

View File

@ -47,9 +47,7 @@ export default function useChatRoom(roomId) {
}, [client, roomId]); }, [client, roomId]);
useEffect(() => { useEffect(() => {
if (chatListRef.current.scrollHeight - chatListRef.current.scrollTop - chatListRef.current.clientHeight < 200) {
chatListRef.current.scrollTop = chatListRef.current.scrollHeight; chatListRef.current.scrollTop = chatListRef.current.scrollHeight;
}
}, [messages]); }, [messages]);
return { return {

View File

@ -1,70 +0,0 @@
import { Room, RoomEvent } from 'livekit-client';
import { useCallback, useState } from 'react';
import { API_URL, ROOM_URL } from '../../constants';
import instance from '../../utils/axios/instance';
export default function useRoom(roomId) {
const [room, setRoom] = useState(null);
const [localTrack, setLocalTrack] = useState(null);
const [remoteTracks, setRemoteTracks] = useState([]);
const [mainTrack, setMainTrack] = useState(null);
const generateToken = useCallback(async () => {
await instance.post(`${API_URL}/video/makeroom/${roomId}`);
const { data } = await instance.post(`${API_URL}/video/joinroom/${roomId}`);
return data.token;
}, [roomId]);
const leaveRoom = useCallback(async () => {
console.log('leave room');
await room?.disconnect();
setRoom(null);
setLocalTrack(null);
setRemoteTracks([]);
}, [room]);
const joinRoom = useCallback(async () => {
const token = await generateToken();
const room = new Room();
room.prepareConnection(ROOM_URL, token);
room.on(RoomEvent.TrackSubscribed, (_track, publication, participant) => {
try {
const identity = JSON.parse(participant.identity);
const isTeacher = identity.role.startsWith('강사');
if (isTeacher) {
setMainTrack(publication.videoTrack);
}
} catch (e) {
console.log('not json');
} finally {
setRemoteTracks((prev) => [
...prev,
{
trackPublication: publication,
participantIdentity: participant.identity,
},
]);
}
});
room.on(RoomEvent.TrackUnsubscribed, (_track, publication) => {
console.log('unsubscribe remote');
setRemoteTracks((prev) => prev.filter((track) => track.trackPublication !== publication));
});
try {
console.log(token);
await room.connect(ROOM_URL, token);
await room.localParticipant.enableCameraAndMicrophone();
setLocalTrack(room.localParticipant.videoTrackPublications.values().next().value.videoTrack);
setRoom(room);
} catch (error) {
await leaveRoom();
}
}, [generateToken, leaveRoom]);
return { room, joinRoom, leaveRoom, localTrack, remoteTracks, mainTrack };
}

View File

@ -17,12 +17,13 @@ export default function LectureCreatePage() {
}; };
return ( return (
<div>
<h1>강의 생성</h1>
<LectureForm <LectureForm
title={'강의 홈'} title={'강의 생성'}
topic={'강의 생성'}
to={'..'}
onSubmit={handleSubmit} onSubmit={handleSubmit}
onCreate={true} onCreate={true}
/> />
</div>
); );
} }

View File

@ -21,6 +21,7 @@ export default function LecutreEditPage() {
}; };
return ( return (
<div>
<LectureForm <LectureForm
initialValues={initialData} initialValues={initialData}
onSubmit={handleSubmit} onSubmit={handleSubmit}
@ -28,5 +29,6 @@ export default function LecutreEditPage() {
topic={'강의 수정'} topic={'강의 수정'}
to={'..'} to={'..'}
/> />
</div>
); );
} }

View File

@ -1,25 +1,41 @@
import { LiveRoom } from '../../components/LiveRoom'; import { LiveRoom } from '../../components/LiveRoom';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import useRoom from '../../hooks/live/useRoom'; import { useCallback, useEffect } from 'react';
import { useEffect } from 'react'; import { LiveKitRoom } from '@livekit/components-react';
import instance from '../../utils/axios/instance';
import { API_URL, ROOM_URL } from '../../constants';
import useBoundStore from '../../store';
import '@livekit/components-styles';
export default function LivePage() { export default function LivePage() {
const { roomId } = useParams(); const { roomId } = useParams();
const { room, joinRoom, localTrack, remoteTracks, mainTrack, leaveRoom } = useRoom(roomId); const generateToken = useCallback(async () => {
await instance.post(`${API_URL}/video/makeroom/${roomId}`);
const { data } = await instance.post(`${API_URL}/video/joinroom/${roomId}`);
return data.token;
}, [roomId]);
const liveToken = useBoundStore((state) => state.liveToken);
useEffect(() => { useEffect(() => {
if (!room) { if (!liveToken) {
joinRoom(); generateToken().then((token) => {
useBoundStore.setState({ liveToken: token });
});
} }
}, [joinRoom, room]); }, [generateToken, liveToken]);
return ( return (
<LiveRoom liveToken && (
room={room} <LiveKitRoom
localTrack={localTrack} token={liveToken}
remoteTracks={remoteTracks} serverUrl={ROOM_URL}
leaveRoom={leaveRoom} connect={true}
mainTrack={mainTrack} data-lk-theme="default"
/> >
<LiveRoom />
</LiveKitRoom>
)
); );
} }

View File

@ -1,15 +1,5 @@
import { InfoEditForm } from '../../components/InfoEditForm'; import { InfoEditForm } from '../../components/InfoEditForm';
import { useAuth } from '../../hooks/api/useAuth';
export default function MyInfoChangePage() { export default function MyInfoChangePage() {
const { updateInfo } = useAuth(); return <InfoEditForm />;
const handleSubmit = async (e, username, useremail) => {
e.preventDefault();
await updateInfo(username, useremail)
.then((res) => console.log(res))
.catch((err) => console.log(err));
};
return <InfoEditForm onSubmit={handleSubmit} />;
} }

View File

@ -1,12 +1,5 @@
import { PasswordChangeForm } from '../../components/PasswordChangeForm'; import { PasswordChangeForm } from '../../components/PasswordChangeForm';
import { useAuth } from '../../hooks/api/useAuth';
export default function PasswordChangePage() { export default function PasswordChangePage() {
// TODO: 400 return <PasswordChangeForm />;
const { updatePassword } = useAuth();
const handleSubmit = async (currentPw, newPw, newPwCheck) => {
console.log(currentPw, newPw);
await updatePassword(currentPw, newPw, newPwCheck);
};
return <PasswordChangeForm onSubmit={handleSubmit} />;
} }

View File

@ -1,25 +1,11 @@
import { useQuizsetDetail } from '../../hooks/api/useQuizsetDetail'; import { useQuizsetDetail } from '../../hooks/api/useQuizsetDetail';
import { useParams, useNavigate } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import { QuizsetDetail } from '../../components/QuizsetDetail'; import { QuizsetDetail } from '../../components/QuizsetDetail';
import { useQuizsetDelete } from '../../hooks/api/useQuizsetDelete';
export default function QuizsetDetailPage() { export default function QuizsetListPage() {
const navigate = useNavigate(); const { lectureId } = useParams();
const { quizsetId } = useParams(); const { data } = useQuizsetDetail(lectureId);
const { quizsetDelete } = useQuizsetDelete(); const quizset = data?.data ?? [];
const { data } = useQuizsetDetail(quizsetId);
const quizset = data.data;
console.log(quizset); console.log(quizset);
const handleDelete = async () => { return <QuizsetDetail title={quizset.title} />;
await quizsetDelete(quizsetId);
navigate('..');
};
return (
<QuizsetDetail
topic={'퀴즈 목록'}
title={quizset.title}
quizzes={quizset.quizzes}
onDelete={handleDelete}
/>
);
} }

View File

@ -1,47 +1,26 @@
import { QuizsetForm } from '../../components/QuizForm'; import { QuizsetForm } from '../../components/QuizForm';
import { useQuizsetWrite } from '../../hooks/api/useQuizsetWrite'; import { useQuizsetWrite } from '../../hooks/api/useQuizsetWrite';
import { useNavigate } from 'react-router-dom';
export default function QuizsetWritePage() { export default function QuizsetWritePage() {
const navigate = useNavigate(); // TODO: lecture
const { quizsetWrite } = useQuizsetWrite(); const { quizsetWrite } = useQuizsetWrite();
const handleSubmit = async (e, title, quizzes, imageFile = null) => {
const handleSubmit = async (e, title, quizzes) => {
e.preventDefault(); e.preventDefault();
console.log(quizzes)
const images = [];
const quizContents = [];
quizzes.forEach((quiz) => {
const { imageFile, ...quizData } = quiz;
images.push(imageFile);
quizContents.push(quizData);
});
const quizsetObject = { const quizsetObject = {
title, title,
quizzes: quizContents, quizzes,
}; };
console.log(quizsetObject);
console.log(imageFile);
const formData = new FormData(); const formData = new FormData();
formData.append( formData.append('quizSetCreateRequest', new Blob([JSON.stringify(quizsetObject)], { type: 'application/json' }));
'quizSetCreateRequest',
new Blob([JSON.stringify(quizsetObject)], { type: 'application/json' })
);
images.forEach((imageFile) => {
if (imageFile) { if (imageFile) {
formData.append('images', imageFile); formData.append('image', imageFile);
} else {
formData.append('images', new Blob([''], { type: 'image/jpg' }));
} }
}); const response = await quizsetWrite(formData);
console.log(response);
await quizsetWrite(formData);
navigate('..');
}; };
return ( return (
<QuizsetForm <QuizsetForm
onSubmit={handleSubmit} onSubmit={handleSubmit}

View File

@ -3,6 +3,7 @@ import { userTypeSlice } from './userTypeSlice';
import { tokenSlice } from './tokenSlice'; import { tokenSlice } from './tokenSlice';
import { userNameSlice } from './userNameSlice'; import { userNameSlice } from './userNameSlice';
import { persist } from 'zustand/middleware'; import { persist } from 'zustand/middleware';
import { liveSlice } from './liveSlice';
const useBoundStore = create( const useBoundStore = create(
persist( persist(
@ -10,6 +11,7 @@ const useBoundStore = create(
...userTypeSlice(...a), ...userTypeSlice(...a),
...tokenSlice(...a), ...tokenSlice(...a),
...userNameSlice(...a), ...userNameSlice(...a),
...liveSlice(...a),
}), }),
{ name: 'bound-store' } { name: 'bound-store' }
) )

View File

@ -0,0 +1,4 @@
export const liveSlice = (set) => ({
liveToken: null,
setLiveToken: (liveToken) => set({ liveToken }),
});

View File

@ -3,7 +3,6 @@ import useBoundStore from '../../store';
const instance = axios.create({ const instance = axios.create({
baseURL: import.meta.env.VITE_API_URL, baseURL: import.meta.env.VITE_API_URL,
timeout: 1000,
headers: { headers: {
'Content-type': 'application/json;charset=utf-8', 'Content-type': 'application/json;charset=utf-8',
}, },
@ -12,6 +11,7 @@ const instance = axios.create({
instance.interceptors.request.use((config) => { instance.interceptors.request.use((config) => {
const accessToken = useBoundStore.getState().token; const accessToken = useBoundStore.getState().token;
console.log(accessToken); console.log(accessToken);
if (accessToken) { if (accessToken) {
config.headers.Authorization = `${accessToken}`; config.headers.Authorization = `${accessToken}`;
@ -23,7 +23,7 @@ instance.interceptors.request.use((config) => {
instance.interceptors.response.use( instance.interceptors.response.use(
(response) => response, (response) => response,
(error) => { (error) => {
if (error.response.status !== 401) { if (error.response.status !== 401 || error.request.responseURL.includes('/user/refresh')) {
return Promise.reject(error); return Promise.reject(error);
} }
@ -40,7 +40,7 @@ instance.interceptors.response.use(
return instance(error.config); return instance(error.config);
}) })
.catch((error) => { .catch((error) => {
useBoundStore.setState({ token: null }); useBoundStore.setState({ token: null, userType: null });
console.log(error); console.log(error);
console.log('---로그아웃----'); console.log('---로그아웃----');
// TODO: redirect to home // TODO: redirect to home