diff --git a/frontend/package-lock.json b/frontend/package-lock.json index e75d0fd..979cf90 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,6 +8,8 @@ "name": "edufocus", "version": "0.0.0", "dependencies": { + "@livekit/components-react": "^2.4.3", + "@livekit/components-styles": "^1.0.12", "@stomp/stompjs": "^7.0.0", "@tanstack/react-query": "^5.49.2", "axios": "^1.7.2", @@ -905,6 +907,31 @@ "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": { "version": "0.11.14", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.14.tgz", @@ -1009,6 +1036,55 @@ "@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": { "version": "1.19.1", "resolved": "https://registry.npmjs.org/@livekit/protocol/-/protocol-1.19.1.tgz", @@ -2420,6 +2496,15 @@ "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": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -4324,6 +4409,12 @@ "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": { "version": "4.6.2", "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", "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", "license": "Apache-2.0", - "optional": true, "dependencies": { "tslib": "^2.1.0" } @@ -5873,6 +5963,21 @@ "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": { "version": "5.3.3", "resolved": "https://registry.npmjs.org/vite/-/vite-5.3.3.tgz", diff --git a/frontend/package.json b/frontend/package.json index aff3018..7ce0580 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -13,6 +13,8 @@ "test:run": "vitest run" }, "dependencies": { + "@livekit/components-react": "^2.4.3", + "@livekit/components-styles": "^1.0.12", "@stomp/stompjs": "^7.0.0", "@tanstack/react-query": "^5.49.2", "axios": "^1.7.2", diff --git a/frontend/src/components/ChatRoom/ChatRoom.jsx b/frontend/src/components/ChatRoom/ChatRoom.jsx index 11e7035..01f8ee3 100644 --- a/frontend/src/components/ChatRoom/ChatRoom.jsx +++ b/frontend/src/components/ChatRoom/ChatRoom.jsx @@ -1,42 +1,112 @@ import styles from './ChatRoom.module.css'; -import { useParams } from 'react-router-dom'; 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() { - const { roomId } = useParams(); - const { messages, handleSubmit, inputRef, chatListRef } = useChatRoom(roomId); +export default function ChatRoom({ ...props }) { + const chatInputRef = useRef(null); + 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 ( -
+

채팅

-
    - {messages.map?.((message) => ( -
  1. - {message.name} - {message.text} -
  2. - ))} -
+ {props.children + ? chatMessages.map((msg, idx) => + cloneElement(props.children, { + entry: msg, + key: msg.id ?? idx, + }) + ) + : chatMessages.map((msg, idx, allMsg) => { + const hideName = idx >= 1 && allMsg[idx - 1].from === msg.from; + const hideTimestamp = idx >= 1 && msg.timestamp - allMsg[idx - 1].timestamp < 60_000; + + return ( + + ); + })} +
ev.stopPropagation()} + onKeyDown={(ev) => ev.stopPropagation()} + onKeyUp={(ev) => ev.stopPropagation()} /> -
-
+ ); } diff --git a/frontend/src/components/ChatRoom/ChatRoom.module.css b/frontend/src/components/ChatRoom/ChatRoom.module.css index 5c7bdb2..cafedf0 100644 --- a/frontend/src/components/ChatRoom/ChatRoom.module.css +++ b/frontend/src/components/ChatRoom/ChatRoom.module.css @@ -3,7 +3,7 @@ flex-direction: column; justify-content: space-between; position: relative; - width: 100%; + width: 400px; height: 100%; overflow-y: hidden; } @@ -20,8 +20,9 @@ align-items: start; gap: 12px; list-style: none; + max-height: 100%; margin: 0; - padding: 0 16px; + padding: 0 8px 8px; white-space: nowrap; overflow-x: hidden; overflow-y: auto; @@ -91,8 +92,8 @@ align-items: center; gap: 8px; margin: 16px; - background-color: var(--background); - border: 1px solid var(--border-color); + background-color: black; + border: 1px solid var(--border-color-tertiary); border-radius: 12px; box-shadow: var(--shadow); overflow: hidden; @@ -103,7 +104,7 @@ width: 100%; border: none; border-radius: 12px; - color: var(--text-color); + /* color: var(--text-color); */ font-size: 16px; line-height: 1.4; font-weight: 400; @@ -124,7 +125,11 @@ line-height: 1.4; font-weight: 500; cursor: pointer; - background-color: var(--background); - stroke: var(--text-color); + background-color: black; + stroke: white; } } + +.button { + stroke: white; +} diff --git a/frontend/src/components/InfoEditForm/InfoEditForm.jsx b/frontend/src/components/InfoEditForm/InfoEditForm.jsx index 447add7..4938ea7 100644 --- a/frontend/src/components/InfoEditForm/InfoEditForm.jsx +++ b/frontend/src/components/InfoEditForm/InfoEditForm.jsx @@ -1,15 +1,11 @@ import styles from './InfoEditForm.module.css'; import { useState } from 'react'; -export default function InfoEditForm({ onSubmit }) { +export default function InfoEditForm() { const [username, setUsername] = useState(''); const [useremail, setUseremail] = useState(''); - return ( -
onSubmit(e, username, useremail)} - className={styles.infoEditForm} - > +

이름 변경

- - - + + + + ); } diff --git a/frontend/src/components/LiveRoom/LiveRoom.module.css b/frontend/src/components/LiveRoom/LiveRoom.module.css index f95e967..ec58bd1 100644 --- a/frontend/src/components/LiveRoom/LiveRoom.module.css +++ b/frontend/src/components/LiveRoom/LiveRoom.module.css @@ -1,11 +1,17 @@ +.main { + display: flex; + flex-direction: column; +} + .videoWrapper { - flex-grow: 0; + flex: 0 0 auto; display: flex; overflow-x: auto; height: 80px; gap: 10px; padding: 10px; border-bottom: 1px solid var(--border-color); + box-sizing: border-box; & > audio { display: none; @@ -21,7 +27,41 @@ } } -.myVideo { - border-radius: 12px; - border: 2px solid var(--border-color); +.mainContent { + display: flex; + 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); + } + } } diff --git a/frontend/src/components/LiveVideo/LiveVideo.jsx b/frontend/src/components/LiveVideo/LiveVideo.jsx deleted file mode 100644 index a261834..0000000 --- a/frontend/src/components/LiveVideo/LiveVideo.jsx +++ /dev/null @@ -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 ( -