diff --git a/frontend/src/app/[meetingId]/page.tsx b/frontend/src/app/[meetingId]/page.tsx index e020cfd8f..dcdc5fd75 100644 --- a/frontend/src/app/[meetingId]/page.tsx +++ b/frontend/src/app/[meetingId]/page.tsx @@ -1,34 +1,32 @@ 'use client'; -import ChatModal from '@/components/meeting/ChatModal'; -import InfoModal from '@/components/meeting/InfoModal'; -import MeetingMenu from '@/components/meeting/MeetingMenu'; -import MemberModal from '@/components/meeting/MemberModal'; -import MemberVideoBar from '@/components/meeting/MemberVideoBar'; -import { useMeeingStore } from '@/store/useMeetingStore'; +import MeetingLobby from '@/components/meeting/MeetingLobby'; +import MeetingRoom from '@/components/meeting/MeetingRoom'; +import { useParams } from 'next/navigation'; +import { useState } from 'react'; export default function MeetingPage() { - const { isInfoOpen, isMemberOpen, isChatOpen } = useMeeingStore(); + const params = useParams<{ meetingId: string }>(); + const meetingId = params.meetingId; - return ( -
- - -
- {/* 워크스페이스 / 코드 에디터 등의 컴포넌트가 들어갈 공간 */} + const [isJoined, setIsJoined] = useState(false); - {/* 참가자 / 채팅창 */} - {(isMemberOpen || isChatOpen) && ( -
- {isMemberOpen && } - {isChatOpen && } -
- )} -
+ if (!meetingId) { + return
잘못된 회의 접근입니다. 다시 시도해주세요.
; + } - + // 참여 버튼을 눌렀을 때의 핸들러 + const handleJoin = () => { + setIsJoined(true); + }; - {isInfoOpen && } + return ( +
+ {!isJoined ? ( + + ) : ( + + )}
); } diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 406dcb037..b34b5b30e 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -22,7 +22,7 @@ body { } button { - cursor: pointer; + @apply cursor-pointer disabled:cursor-not-allowed; } .flex-center { diff --git a/frontend/src/components/common/button/Button.tsx b/frontend/src/components/common/button/Button.tsx index fb837bb90..86a46695e 100644 --- a/frontend/src/components/common/button/Button.tsx +++ b/frontend/src/components/common/button/Button.tsx @@ -11,7 +11,7 @@ const style: { } = { base: 'inline-flex items-center justify-center box-border select-none m-0 p-0 w-fit h-fit cursor-pointer disabled:cursor-default', size: { - sm: 'h-full max-h-[50px] px-3 py-[6px] text-sm font-bold', + sm: 'h-full h-auto px-3 px-1.5 text-sm font-bold', lg: 'w-full h-full max-h-[52px] py-4 px-2 text-base font-bold', }, shape: { @@ -20,7 +20,7 @@ const style: { }, color: { active: 'text-white', // TODO: 버튼 호버 시 활성화되는 스타일 - primary: 'bg-sky-600 text-white', // sky + primary: 'bg-sky-600 text-white hover:bg-sky-700', // sky secondary: 'bg-sky-700 text-white', // dark-sky outlinePrimary: 'bg-white border border-sky-600 text-sky-600', disabled: 'bg-neutral-500 text-white', // ex. 취소 버튼 diff --git a/frontend/src/components/meeting/DeviceDropdown.tsx b/frontend/src/components/meeting/DeviceDropdown.tsx new file mode 100644 index 000000000..c563fc628 --- /dev/null +++ b/frontend/src/components/meeting/DeviceDropdown.tsx @@ -0,0 +1,62 @@ +import { ArrowDownIcon } from '@/assets/icons/common'; +import { useState } from 'react'; + +interface Props { + label: string; + devices: MediaDeviceInfo[]; + icon: React.ComponentType<{ className?: string }>; + selectedId: string; + onSelect: (id: string) => void; + className?: string; +} + +export function DeviceDropdown({ + label, + devices, + icon: Icon, + selectedId, + onSelect, + className, +}: Props) { + const [isOpen, setIsOpen] = useState(false); + + const selected = devices.find((d) => d.deviceId === selectedId); + const isDisabled = devices.length === 0; + + return ( +
+ + + {!isDisabled && isOpen && ( +
    + {devices.map((device) => ( +
  • { + onSelect(device.deviceId); + setIsOpen(false); + }} + className="cursor-pointer px-3 py-2 text-sm first:rounded-t-sm last:rounded-b-sm hover:bg-neutral-100" + > + {device.label} +
  • + ))} +
+ )} +
+ ); +} diff --git a/frontend/src/components/meeting/MeetingLobby.tsx b/frontend/src/components/meeting/MeetingLobby.tsx new file mode 100644 index 000000000..152eed255 --- /dev/null +++ b/frontend/src/components/meeting/MeetingLobby.tsx @@ -0,0 +1,80 @@ +import { useMediaDevices } from '@/hooks/useMediaDevices'; +import Button from '../common/button'; +import { DeviceDropdown } from './DeviceDropdown'; +import { MediaPreview } from './media/MediaPreview'; +import { CamOnIcon, MicOnIcon, VolumnIcon } from '@/assets/icons/meeting'; + +export default function MeetingLobby({ + meetingId, + onJoin, +}: { + meetingId: string; + onJoin: () => void; +}) { + const meetingLeader = 'Tony'; + const meetingMemberCnt = 9; + + const { + microphones, + cameras, + speakers, + micId, + cameraId, + speakerId, + setMicId, + setCameraId, + setSpeakerId, + } = useMediaDevices(); + + return ( +
+ {/* 영상, 마이크 설정 부분 */} +
+ + +
+ + + + + +
+
+ + {/* 회의 참여 부분 */} +
+

+ {meetingLeader} 님의 회의실 +

+ + 현재 참여자: {meetingMemberCnt}명 + + + +
+
+ ); +} diff --git a/frontend/src/components/meeting/MeetingRoom.tsx b/frontend/src/components/meeting/MeetingRoom.tsx new file mode 100644 index 000000000..ec352d030 --- /dev/null +++ b/frontend/src/components/meeting/MeetingRoom.tsx @@ -0,0 +1,34 @@ +'use client'; + +import ChatModal from '@/components/meeting/ChatModal'; +import InfoModal from '@/components/meeting/InfoModal'; +import MeetingMenu from '@/components/meeting/MeetingMenu'; +import MemberModal from '@/components/meeting/MemberModal'; +import MemberVideoBar from '@/components/meeting/MemberVideoBar'; +import { useMeeingStore } from '@/store/useMeetingStore'; + +export default function MeetingRoom({ meetingId }: { meetingId: string }) { + const { isInfoOpen, isMemberOpen, isChatOpen } = useMeeingStore(); + + return ( +
+ + +
+ {/* 워크스페이스 / 코드 에디터 등의 컴포넌트가 들어갈 공간 */} + + {/* 참가자 / 채팅창 */} + {(isMemberOpen || isChatOpen) && ( +
+ {isMemberOpen && } + {isChatOpen && } +
+ )} +
+ + + + {isInfoOpen && } +
+ ); +} diff --git a/frontend/src/components/meeting/media/MediaPreview.tsx b/frontend/src/components/meeting/media/MediaPreview.tsx new file mode 100644 index 000000000..9b42a4359 --- /dev/null +++ b/frontend/src/components/meeting/media/MediaPreview.tsx @@ -0,0 +1,75 @@ +import { + CamOffIcon, + CamOnIcon, + MicOffIcon, + MicOnIcon, +} from '@/assets/icons/meeting'; +import Button from '@/components/common/button'; +import VideoView from './VideoView'; +import { useMediaPreview } from '@/hooks/useMediaPreview'; + +export function MediaPreview() { + const { media, stream, canRenderVideo, toggleAudio, toggleVideo } = + useMediaPreview(); + + return ( +
+ {/* Video Layer */} + {canRenderVideo && stream && } + + {/* Placeholder Layer */} + {!canRenderVideo && ( +
+

+ {media.cameraPermission === 'denied' ? ( + <> + 브라우저 주소창의 자물쇠 아이콘을 눌러 +
+ 카메라와 마이크 권한을 허용해 주세요. + + ) : ( + <> + 마이크와 카메라를 사용하려면 +
+ 접근 권한이 필요해요 + + )} +

+ + {(media.cameraPermission === 'unknown' || + media.micPermission === 'unknown') && ( + + )} +
+ )} + + {/* Control Layer */} +
+ + + +
+
+ ); +} diff --git a/frontend/src/components/meeting/media/VideoView.tsx b/frontend/src/components/meeting/media/VideoView.tsx new file mode 100644 index 000000000..26a01b39c --- /dev/null +++ b/frontend/src/components/meeting/media/VideoView.tsx @@ -0,0 +1,46 @@ +'use client'; + +import { useEffect, useRef } from 'react'; + +interface VideoViewProps { + stream: MediaStream; + muted?: boolean; + mirrored?: boolean; +} + +export default function VideoView({ + stream, + muted = true, + mirrored = true, +}: VideoViewProps) { + const videoRef = useRef(null); + + useEffect(() => { + const video = videoRef.current; + if (!video) return; + + video.srcObject = stream; + + // Safari와 iOS 대응 + video.onloadedmetadata = () => { + video.play().catch(() => { + // autoplay 정책 실패 시 무시 + }); + }; + + return () => { + video.pause(); + video.srcObject = null; + }; + }, [stream]); + + return ( +