Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 21 additions & 23 deletions frontend/src/app/[meetingId]/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<main className="flex h-screen w-screen flex-col bg-neutral-900">
<MemberVideoBar />

<section className="relative flex-1">
{/* 워크스페이스 / 코드 에디터 등의 컴포넌트가 들어갈 공간 */}
const [isJoined, setIsJoined] = useState<boolean>(false);

{/* 참가자 / 채팅창 */}
{(isMemberOpen || isChatOpen) && (
<div className="absolute top-2 right-2 bottom-2 flex w-80 flex-col gap-2">
{isMemberOpen && <MemberModal />}
{isChatOpen && <ChatModal />}
</div>
)}
</section>
if (!meetingId) {
return <div>잘못된 회의 접근입니다. 다시 시도해주세요.</div>;
}

<MeetingMenu />
// 참여 버튼을 눌렀을 때의 핸들러
const handleJoin = () => {
setIsJoined(true);
};

{isInfoOpen && <InfoModal />}
return (
<main className="min-h-screen">
{!isJoined ? (
<MeetingLobby meetingId={meetingId} onJoin={handleJoin} />
) : (
<MeetingRoom meetingId={meetingId} />
)}
Comment on lines +25 to +29
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

제 PR에 리뷰를 남겨주셨던 부분이 이 부분이었나보네요!

참여 준비 화면과 실제 화상 회의 페이지를 하나로 만드는게 좋을까요?
아니면 별도의 페이지로 분리하는게 좋을까요?

일단 구글 meet에서는 구현해주신대로
새로고침 시 검증 및 준비를 다시 요청하는 방식으로 진행되고 있기는 하네요

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

저도 하나의 페이지에서 핸들하는 게 좋을지, 별도의 페이지로 분리하는 게 좋을지 고민이 되었는데 구글미트를 참고해 우선 진행했어요. 그리고 AI에게 피드백을 받아봤는데 아래와 같은 관점으로 봐도 좋을 것 같아요!

Next.js 관점

  1. 사용자 경험 (Seamless UX): 사용자가 '참여하기'를 눌렀을 때 페이지 전체가 새로고침되지 않고, 필요한 컴포넌트(회의창)만 즉시 로드되게 한다.
  2. 데이터 무결성: meetingId라는 동일한 컨텍스트 안에서 상태가 유지되므로, 로비에서 설정한 오디오/비디오 스트림 객체를 회의실 컴포넌트로 그대로 넘겨주기 쉽다.
  3. URL 간결화: 구글 미트처럼 단일 URL(.../meeting/abc-def)을 유지하면서도 내부 로직만으로 흐름을 완벽히 제어할 수 있다.

추가 포인트

만약 로그인하지 않은 사용자를 /landing으로 보내야 한다면, page.tsx 상단이나 Next.js Middleware에서 체크하는 것이 좋다.

// middleware.ts 예시
if (!session && request.nextUrl.pathname.startsWith('/meeting')) {
  return NextResponse.redirect(new URL('/landing', request.url));
}

⇒ 일단 우리 서비스에선 비로그인 사용자도 참여 가능하다. → 비회원인 경우 넘어갈 페이지로 미들웨어 설정해주기?

</main>
);
}
2 changes: 1 addition & 1 deletion frontend/src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ body {
}

button {
cursor: pointer;
@apply cursor-pointer disabled:cursor-not-allowed;
}

.flex-center {
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/components/common/button/Button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand All @@ -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. 취소 버튼
Expand Down
62 changes: 62 additions & 0 deletions frontend/src/components/meeting/DeviceDropdown.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className={`relative min-w-0 ${className}`}>
<button
type="button"
onClick={() => setIsOpen(!isOpen)}
disabled={isDisabled}
className={`flex w-full items-center justify-between gap-2 rounded-sm border border-neutral-300 px-3 py-2 text-sm transition-colors ${
isDisabled
? 'cursor-not-allowed bg-neutral-100 text-neutral-400' // 비활성화 스타일
: 'border-neutral-300 bg-white text-neutral-700 hover:border-neutral-400'
}`}
>
<Icon className="h-4 w-4 shrink-0" />
<span className="truncate">{selected?.label || `접근 권한 필요`}</span>

<ArrowDownIcon className="h-4 w-4 shrink-0" />
</button>

{!isDisabled && isOpen && (
<ul className="absolute z-10 mt-1 max-h-30 w-full overflow-y-auto rounded-sm border border-neutral-200 bg-white shadow">
{devices.map((device) => (
<li
key={device.deviceId}
onClick={() => {
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}
</li>
))}
</ul>
)}
</div>
);
}
80 changes: 80 additions & 0 deletions frontend/src/components/meeting/MeetingLobby.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<main className="box-border flex min-h-screen items-center justify-center gap-20 px-6 py-4">
{/* 영상, 마이크 설정 부분 */}
<section className="flex w-full max-w-160 flex-col gap-6">
<MediaPreview />

<div className="flex w-full items-center gap-4 text-sm">
<DeviceDropdown
label="스피커"
devices={speakers}
icon={VolumnIcon}
selectedId={speakerId}
onSelect={setSpeakerId}
className="flex-1"
/>

<DeviceDropdown
label="마이크"
devices={microphones}
icon={MicOnIcon}
selectedId={micId}
onSelect={setMicId}
className="flex-1"
/>

<DeviceDropdown
label="카메라"
devices={cameras}
icon={CamOnIcon}
selectedId={cameraId}
onSelect={setCameraId}
className="flex-1"
/>
</div>
</section>

{/* 회의 참여 부분 */}
<section className="flex w-full max-w-60 flex-col items-center justify-center">
<h1 className="mb-2 text-2xl text-neutral-900">
<b>{meetingLeader}</b> 님의 회의실
</h1>
<span className="text-base text-neutral-600">
현재 참여자: {meetingMemberCnt}명
</span>

<Button className="mt-6" onClick={onJoin}>
회의 참여하기
</Button>
</section>
</main>
);
}
34 changes: 34 additions & 0 deletions frontend/src/components/meeting/MeetingRoom.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<main className="flex h-screen w-screen flex-col bg-neutral-900">
<MemberVideoBar />

<section className="relative flex-1">
{/* 워크스페이스 / 코드 에디터 등의 컴포넌트가 들어갈 공간 */}

{/* 참가자 / 채팅창 */}
{(isMemberOpen || isChatOpen) && (
<div className="absolute top-2 right-2 bottom-2 flex w-80 flex-col gap-2">
{isMemberOpen && <MemberModal />}
{isChatOpen && <ChatModal />}
</div>
)}
</section>

<MeetingMenu />

{isInfoOpen && <InfoModal />}
</main>
);
}
75 changes: 75 additions & 0 deletions frontend/src/components/meeting/media/MediaPreview.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div
className={`relative box-border h-90 w-160 overflow-hidden rounded-2xl bg-neutral-700 ${!canRenderVideo && 'p-4'}`}
>
{/* Video Layer */}
{canRenderVideo && stream && <VideoView stream={stream} />}

{/* Placeholder Layer */}
{!canRenderVideo && (
<div className="flex h-full flex-col items-center justify-center gap-6 text-center">
<p className="text-sm font-bold text-white">
{media.cameraPermission === 'denied' ? (
<>
브라우저 주소창의 자물쇠 아이콘을 눌러
<br />
카메라와 마이크 권한을 허용해 주세요.
</>
) : (
<>
마이크와 카메라를 사용하려면
<br />
접근 권한이 필요해요
</>
)}
</p>

{(media.cameraPermission === 'unknown' ||
media.micPermission === 'unknown') && (
<Button
size="sm"
shape="square"
className="px-3 py-2 text-sm"
// onClick={requestPermission}
>
마이크 및 카메라 접근 허용
</Button>
)}
</div>
)}

{/* Control Layer */}
<div className="absolute bottom-6 left-1/2 flex -translate-x-1/2 gap-6">
<button onClick={toggleVideo} className="group">
{media.videoOn ? (
<CamOnIcon className="h-12 w-12 rounded-full bg-white p-3 text-neutral-700 shadow-lg transition-all group-active:scale-95" />
) : (
<CamOffIcon className="h-12 w-12 rounded-full bg-white p-3 shadow-lg transition-all group-active:scale-95" />
)}
</button>

<button onClick={toggleAudio} className="group">
{media.audioOn ? (
<MicOnIcon className="h-12 w-12 rounded-full bg-white p-3 text-neutral-700 shadow-lg transition-all group-active:scale-95" />
) : (
<MicOffIcon className="h-12 w-12 rounded-full bg-white p-3 shadow-lg transition-all group-active:scale-95" />
)}
</button>
</div>
</div>
);
}
46 changes: 46 additions & 0 deletions frontend/src/components/meeting/media/VideoView.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLVideoElement | null>(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 (
<video
ref={videoRef}
muted={muted}
playsInline
autoPlay
className={`h-full w-full object-cover ${mirrored ? '-scale-x-100' : ''}`}
/>
);
}
Loading