Compare commits

...

14 Commits

Author SHA1 Message Date
ttipo 30b4b87368 Merge feature/video-meeting-ui: 화상회의 UI 개편 및 코드·로직 검토 반영 2026-06-24 17:57:01 +09:00
ttipo 999a8730dd feat: 화상회의 UI 전면 개편 및 코드·로직 검토 반영
디자인 시안(프로토타입)을 실제 Vue 컴포넌트로 이식하고, 코드·로직·보안
검토에서 도출한 항목을 반영한다.

UI 개편
- 컴포넌트 분리: MeetingLobby/CreateForm/Prejoin/Room/SidePanel/PeopleList/
  Chat/ControlBar/ParticipantTile/QualityBars/MeetingIcon
- 로비: 검색 툴바 + "새 회의 만들기" 모달(BaseModal) + 방 목록(삭제=휴지통)
- 프리조인: 실제 카메라/마이크 미리보기 + 초기 on/off, 실제 프로필 아바타
- 회의실: 축소 스테이지 + 썸네일 그리드(한 페이지 50명) + 페이지네이션,
  말하는 사람 하이라이트/연결 품질 막대
- 채팅: 데이터 채널 실제 구현(Shift+Enter 줄바꿈, 실제 프로필 아바타)

디자인 시스템 정합
- rem 단위/토큰 정렬, 공용 .btn/.form-*/.search/BaseModal/UserAvatar 재사용
- 회의실 다크 캔버스 토큰(--d-*, --d-red/green/amber/accent-soft) 추가

화질/성능
- 스테이지=고화질, 나머지=저화질 구독(setVideoQuality)
- 타일 backdrop-filter 제거 + 발화/품질 이벤트 전체 리렌더 제거(버벅임 완화)

코드·로직·보안 검토 반영
- 채팅 표시 이름은 서명 토큰의 name/identity 만 신뢰(클라이언트 name 사칭 차단)
- promote 는 보낸 사람 metadata role=host 일 때만 적용(인가)
- 원격 오디오를 룸 레벨에서 전원 재생(페이지 밖 참여자 무음 수정)
- 타일 언마운트 시 트랙 detach(누수 방지)
- 마이크/카메라 토글 실패 시 상태 롤백
- 입장 토큰 TTL 1h → 12h(장시간 회의 재연결 안정성)
- 채팅 길이 상한(MAX_CHAT_LENGTH)
- 토큰 metadata 에 avatarUrl 포함(참여자/채팅 아바타)

기타
- 임시 명세/디자인 참고 파일 제거

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 17:56:53 +09:00
ttipo 251830d421 Merge feature/video-meeting-phase6: 화상회의 단계 6(셀프호스팅 배포 구성) 2026-06-23 22:27:10 +09:00
ttipo c5a8fc8697 feat: 화상회의 단계 6 — 셀프호스팅 배포 구성/가이드
운영에서 LiveKit(SFU)을 직접 호스팅하기 위한 구성과 배포 가이드를 추가한다.
실제 배포는 서버/도메인/TLS 가 필요하므로, 클론 후 값만 채워 배포하는 템플릿 형태.

- docker-compose.yml: livekit 서비스를 opt-in 프로파일(selfhost-livekit)로 추가.
  기존 dev/운영 동작 불변(프로파일 미지정 시 미생성). 시그널링 7880 은 expose(프록시 TLS 라우팅),
  미디어 7881/tcp·7882/udp 는 호스트 공개. 키는 LIVEKIT_KEYS env 주입.
- livekit/livekit.prod.yaml: 단일 노드 운영 설정(use_external_ip, tcp/udp 포트).
  클러스터링용 redis 블록은 비밀값이라 주석으로 두고 배포 시 사본에 채움.
- .env.production.example: LIVEKIT_* 섹션(셀프호스팅/ Cloud 양쪽 안내).
- livekit/DEPLOY.md: 사전준비/키생성/기동/프록시(wss)/방화벽/검증(load-test)/
  멀티노드 클러스터링(Redis·캐스케이딩)/운영팁.
- .gitignore: livekit/*.local.yaml(클러스터 설정 사본) 제외.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 22:27:10 +09:00
ttipo b40e795664 Merge feature/video-meeting-phase5: 화상회의 단계 5(부하 테스트 도구) 2026-06-23 21:05:37 +09:00
ttipo 88ef92628d feat: 화상회의 단계 5 — 부하 테스트 도구/방법론
livekit-cli(lk load-test) 기반 부하 테스트 러너와 가이드를 추가한다.
다수 publisher/subscriber 를 시뮬레이션해 SFU 부하·미디어 품질을 측정한다.

- livekit/load-test.sh: docker 로 lk load-test 실행. preset(smoke|medium|full)
  + 환경변수 조정(PUBLISHERS/SUBSCRIBERS/DURATION/RESOLUTION/LAYOUT/SIMULCAST).
  자격증명은 루트 .env 에서 읽는다.
- livekit/LOAD_TEST.md: 실행/결과 해석/합격 기준/실행 대상(Cloud=smoke만,
  셀프호스팅=full·클러스터 부하분산)/서버 모니터링/한계 정리.

검증: smoke(2 pub + 3 sub, 15s) 를 dev(LiveKit Cloud) 대상으로 실행 →
6/6 트랙 구독, 패킷 손실 0%, 정상 비트레이트 확인(도구 동작 검증 완료).
대규모(160명) 검증은 단계 6 셀프호스팅 환경에서 수행한다.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 21:05:36 +09:00
ttipo 423f2197ed Merge feature/video-meeting-phase4: 화상회의 단계 4(방 관리) 2026-06-23 20:48:52 +09:00
ttipo 54fe3d36e7 feat: 화상회의 단계 4 — 방 관리(목록·생성·예약·참여 제한)
암묵적 방 생성을 명시적 방 관리로 확장한다. (기능 완성 + 임시 UI)

백엔드
- meeting_rooms 에 scheduled_at(예약), allowed_user_ids(참여 허용 allow-list) 추가 + 마이그레이션
- GET /meetings/rooms(목록: 접근 가능/진행자 여부 포함)
- POST /meetings/rooms(생성: name+예약+허용멤버, 생성자=진행자, 이름 중복 409)
- DELETE /meetings/rooms/:id(진행자만, 아니면 403)
- 토큰 발급 시 참여 제한 enforcement: 비허용자는 AUTH_003(403).
  방이 없으면 자동 생성(공개)으로 직접 링크 입장은 유지.

프론트
- useMeeting: listRooms/createRoom/removeRoom + types/meeting.ts
- MeetingPage 로비: 방 목록(입장/삭제) + 생성 폼(예약시각·참여 제한 멤버 피커, useUser 검색 재사용) + 빠른 입장
- 화상회의_화면명세.md 단계 4 반영(범위/로비/데이터 계약/엔드포인트)

권한 모델은 기존 결정대로 "누구나 생성=진행자" 유지(전역 admin 없음).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 20:48:51 +09:00
ttipo 2416f728ba Merge feature/video-meeting-phase3: 화상회의 단계 3(화면 구성, 임시 레이아웃) 2026-06-23 18:06:23 +09:00
ttipo 3c7aec86a2 feat: 화상회의 단계 3 — 화면 구성(임시 레이아웃)
진행자/승격 참여자 큰 화면 + 썸네일 + 페이지네이션 + 화면 승격을 구현한다.
(레이아웃은 임시 — UI 는 추후 개편 예정. 데이터/로직만 확정.)

- useLiveKitRoom: featuredIdentity 상태 + promote(identity) 추가.
  진행자가 데이터 채널로 승격을 브로드캐스트 → RoomEvent.DataReceived 로 전원 동기화.
- MeetingPage:
  · stage(featured: 승격>진행자>첫 참여자) 큰 화면 + 나머지 썸네일
  · 진행자에게만 썸네일 "크게"(승격) 버튼
  · 썸네일 페이지네이션(페이지당 12명) — 현재 페이지만 렌더 →
    adaptiveStream 이 안 보이는 참여자 미디어 자동 미구독(대역폭 절약)
- 화상회의_화면명세.md: 단계 2~3 반영(범위/화면 구성/데이터 계약 갱신)

백엔드 변경 없음(승격은 클라이언트 데이터 채널).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 18:06:23 +09:00
ttipo 9a4d287ffd Merge feature/video-meeting-phase2: 화상회의 단계 2(권한 분기) 2026-06-23 17:51:22 +09:00
ttipo f519723260 feat: 화상회의 단계 2 — 권한 분기(생성자=진행자)
방을 처음 만든 사용자를 진행자로 보고, 역할별로 LiveKit 권한을 차등 부여한다.

백엔드
- meeting_rooms 엔티티/마이그레이션: 방 이름(고유)→진행자(host) 보관
- 토큰 발급 시 역할 결정: 방이 없으면 생성자=진행자, 있으면 생성자 일치로 판정
  (동시 생성 경쟁은 name unique 충돌 후 재조회로 처리)
- grant 차등: 진행자 roomAdmin+발행/구독, 일반 발행/구독
- 역할을 토큰 metadata({"role":...})에 실어 다른 참여자도 진행자 식별
- 응답에 role 추가

프론트
- useMeeting: role 반환
- ParticipantTile: metadata 파싱 → 진행자 타일에 "진행자" 배지
- MeetingPage: 본인이 진행자면 헤더에 역할 칩 표시

방 목록/예약/참여 제한 UI 는 단계 4 로 분리.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 17:51:21 +09:00
ttipo 85b6b65e24 Merge feature/video-meeting-phase1: 화상회의 단계 1(인프라 토대) 2026-06-23 17:40:18 +09:00
ttipo bf2321f2d5 feat: 화상회의 단계 1 — LiveKit 기반 인프라 토대
LiveKit(SFU)에 미디어 전송을 위임하고, 앱은 입장 토큰 발급 + 회의 화면만
담당하는 구조로 단계 1(기본 연결)을 구현한다.

백엔드
- meeting 모듈: POST /api/meetings/token (JWT 인증)
  livekit-server-sdk 로 입장 JWT 발급(identity=userId, name, grants:
  roomJoin/publish/subscribe/publishData, TTL 1h). 단계 2에서 역할별 분기 예정.

프론트
- useMeeting(토큰 발급), useLiveKitRoom(연결/참여자/컨트롤 상태)
- ParticipantTile(카메라/마이크 트랙 attach, 본인 거울·음소거, 이니셜 플레이스홀더)
- MeetingPage(로비 + 영상 그리드), 라우트 /meeting/:room?, 사이드바 네비 추가
- VITE_LIVEKIT_URL 주입(build arg + dev 런타임 env)

인프라/설정
- 개발 미디어 서버는 LiveKit Cloud 사용(로컬 self-host 는 WSL2/Docker Desktop
  WebRTC 네트워킹 제약으로 제외). env 만으로 운영 전환 가능.
- livekit/livekit.yaml 은 단계 6 셀프호스팅 참고용으로 보관.
- 화상회의_화면명세.md: 현재 화면 기능/구조/데이터 계약 문서(UI 개편 참고).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 17:40:18 +09:00
44 changed files with 5238 additions and 61 deletions
+16
View File
@@ -52,3 +52,19 @@ CORS_ORIGIN=http://localhost:5273
# AI_TIMEOUT_MS: AI 요청 타임아웃(ms). 백엔드 Claude 호출 abort + 프론트 axios 에 동일 적용.
# 프론트에는 build arg VITE_AI_TIMEOUT_MS 로 주입된다.
AI_TIMEOUT_MS=60000
# ==========================================
# 화상회의 (LiveKit Cloud)
# ==========================================
# 개발은 LiveKit Cloud(무료 dev)를 사용한다. https://cloud.livekit.io 에서 프로젝트 생성 후
# Settings → Keys 에서 발급된 값을 채운다. 코드 변경 없이 env 만으로 동작한다.
# LIVEKIT_API_KEY / LIVEKIT_API_SECRET : 백엔드 입장 토큰(JWT) 서명용 (절대 커밋 금지)
# LIVEKIT_WS_URL : 브라우저 → LiveKit 접속 URL. Cloud 는 wss://<project>.livekit.cloud
# (build arg/런타임 env 로 VITE_LIVEKIT_URL 에 주입)
# LIVEKIT_URL : 백엔드 → LiveKit 서버 API(RoomService 등) 주소. 보통 LIVEKIT_WS_URL 과 동일.
# (단계 1 토큰 발급은 오프라인이라 아직 미사용)
# 운영: .env.production 에 운영용 키/URL 만 넣으면 그대로 전환된다(셀프호스팅 시 자체 URL/키 사용).
LIVEKIT_API_KEY=APIxxxxxxxxxxxx
LIVEKIT_API_SECRET=your-livekit-cloud-api-secret
LIVEKIT_WS_URL=wss://your-project.livekit.cloud
LIVEKIT_URL=wss://your-project.livekit.cloud
+13
View File
@@ -52,3 +52,16 @@ CORS_ORIGIN=http://localhost:5173
# AI_TIMEOUT_MS: AI 요청 타임아웃(ms). 백엔드 Claude 호출 abort + 프론트 axios 에 동일 적용.
# 프론트에는 build arg VITE_AI_TIMEOUT_MS 로 주입된다.
AI_TIMEOUT_MS=60000
# ==========================================
# 화상회의 (LiveKit) — 셀프호스팅 또는 Cloud
# ==========================================
# [셀프호스팅] livekit/DEPLOY.md 참고. selfhost-livekit 프로파일로 컨테이너 기동.
# LIVEKIT_WS_URL : 브라우저 접속(리버스 프록시 TLS) → wss://livekit.<운영도메인>
# LIVEKIT_URL : 백엔드 → LiveKit 내부 API(도커 네트워크) → http://livekit:7880
# [Cloud] LIVEKIT_WS_URL/URL 을 wss://<project>.livekit.cloud 로, 키를 Cloud 값으로.
# LIVEKIT_API_SECRET 은 충분히 강력하게(최소 32자).
LIVEKIT_API_KEY=change-me-livekit-key
LIVEKIT_API_SECRET=change-me-livekit-secret-min-32-characters
LIVEKIT_WS_URL=wss://livekit.example.com
LIVEKIT_URL=http://livekit:7880
+3
View File
@@ -54,3 +54,6 @@ Thumbs.db
# 디자인 시안 단독 HTML(참고용, 대용량 번들) — 커밋 제외
frontend/*단독*.html
frontend/*단일 파일*.html
# LiveKit 클러스터 설정 사본(redis 비밀번호 포함) — 커밋 제외
livekit/*.local.yaml
+106
View File
@@ -31,6 +31,7 @@
"express-rate-limit": "^8.3.2",
"helmet": "^8.1.0",
"ioredis": "^5.11.1",
"livekit-server-sdk": "^2.15.5",
"mammoth": "^1.12.0",
"nest-winston": "^1.10.0",
"passport": "^0.7.0",
@@ -798,6 +799,12 @@
"url": "https://github.com/sponsors/Borewit"
}
},
"node_modules/@bufbuild/protobuf": {
"version": "1.10.1",
"resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-1.10.1.tgz",
"integrity": "sha512-wJ8ReQbHxsAfXhrf9ixl0aYbZorRuOWpBNzm8pL8ftmSxQx/wnJD5Eg861NwJU/czy2VXFIebCeZnZrI9rktIQ==",
"license": "(Apache-2.0 AND BSD-3-Clause)"
},
"node_modules/@colors/colors": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz",
@@ -2176,6 +2183,15 @@
"integrity": "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==",
"license": "MIT"
},
"node_modules/@livekit/protocol": {
"version": "1.48.0",
"resolved": "https://registry.npmjs.org/@livekit/protocol/-/protocol-1.48.0.tgz",
"integrity": "sha512-fYHYgltH6YavAsokl3qsHLkBdQeKCl4UORVTub5crS3t8JtKFZ0uinHDFQ+XXdNKS6Ub9gcOjV+UHcDiqnWXoQ==",
"license": "Apache-2.0",
"dependencies": {
"@bufbuild/protobuf": "^1.10.0"
}
},
"node_modules/@lukeed/csprng": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@lukeed/csprng/-/csprng-1.1.0.tgz",
@@ -6282,6 +6298,60 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/camelcase-keys": {
"version": "9.1.3",
"resolved": "https://registry.npmjs.org/camelcase-keys/-/camelcase-keys-9.1.3.tgz",
"integrity": "sha512-Rircqi9ch8AnZscQcsA1C47NFdaO3wukpmIRzYcDOrmvgt78hM/sj5pZhZNec2NM12uk5vTwRHZ4anGcrC4ZTg==",
"license": "MIT",
"dependencies": {
"camelcase": "^8.0.0",
"map-obj": "5.0.0",
"quick-lru": "^6.1.1",
"type-fest": "^4.3.2"
},
"engines": {
"node": ">=16"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/camelcase-keys/node_modules/camelcase": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-8.0.0.tgz",
"integrity": "sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA==",
"license": "MIT",
"engines": {
"node": ">=16"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/camelcase-keys/node_modules/quick-lru": {
"version": "6.1.2",
"resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-6.1.2.tgz",
"integrity": "sha512-AAFUA5O1d83pIHEhJwWCq/RQcRukCkn/NSm2QsTEMle5f2hP0ChI2+3Xb051PZCkLryI/Ir1MVKviT2FIloaTQ==",
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/camelcase-keys/node_modules/type-fest": {
"version": "4.41.0",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz",
"integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==",
"license": "(MIT OR CC0-1.0)",
"engines": {
"node": ">=16"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001792",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001792.tgz",
@@ -9809,6 +9879,15 @@
"url": "https://github.com/chalk/supports-color?sponsor=1"
}
},
"node_modules/jose": {
"version": "5.10.0",
"resolved": "https://registry.npmjs.org/jose/-/jose-5.10.0.tgz",
"integrity": "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -10064,6 +10143,21 @@
"dev": true,
"license": "MIT"
},
"node_modules/livekit-server-sdk": {
"version": "2.15.5",
"resolved": "https://registry.npmjs.org/livekit-server-sdk/-/livekit-server-sdk-2.15.5.tgz",
"integrity": "sha512-Uzs4tVebvY4/+pO9pG1nMMVRDZPnOJolZIOWIiSEnUXFBywq+eNWHrR8mplKL8Qn13mSTQLjKVXfGCGTAMQeYg==",
"license": "Apache-2.0",
"dependencies": {
"@bufbuild/protobuf": "^1.10.1",
"@livekit/protocol": "^1.46.6",
"camelcase-keys": "^9.0.0",
"jose": "^5.1.2"
},
"engines": {
"node": ">=18"
}
},
"node_modules/load-esm": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/load-esm/-/load-esm-1.0.3.tgz",
@@ -10381,6 +10475,18 @@
"sprintf-js": "~1.0.2"
}
},
"node_modules/map-obj": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/map-obj/-/map-obj-5.0.0.tgz",
"integrity": "sha512-2L3MIgJynYrZ3TYMriLDLWocz15okFakV6J12HXvMXDHui2x/zgChzg1u9mFFGbbGWE+GsLpQByt4POb9Or+uA==",
"license": "MIT",
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+1
View File
@@ -47,6 +47,7 @@
"express-rate-limit": "^8.3.2",
"helmet": "^8.1.0",
"ioredis": "^5.11.1",
"livekit-server-sdk": "^2.15.5",
"mammoth": "^1.12.0",
"nest-winston": "^1.10.0",
"passport": "^0.7.0",
+2
View File
@@ -19,6 +19,7 @@ import { NotificationModule } from './modules/notification/notification.module';
import { AiModule } from './modules/ai/ai.module';
import { ProjectModule } from './modules/project/project.module';
import { ScheduleModule } from './modules/schedule/schedule.module';
import { MeetingModule } from './modules/meeting/meeting.module';
@Module({
imports: [
@@ -100,6 +101,7 @@ import { ScheduleModule } from './modules/schedule/schedule.module';
AiModule,
ProjectModule,
ScheduleModule,
MeetingModule,
],
controllers: [AppController],
providers: [
@@ -0,0 +1,27 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
// 화상회의 방(meeting_rooms) 테이블 추가 — 방 이름(고유)과 진행자(host) 보관.
// 운영(migrationsRun)에서만 실행되며, dev 는 synchronize 로 자동 반영된다.
export class AddMeetingRooms1782700000000 implements MigrationInterface {
name = 'AddMeetingRooms1782700000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "meeting_rooms" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "name" character varying NOT NULL, "created_at" TIMESTAMP NOT NULL DEFAULT now(), "host_id" uuid, CONSTRAINT "PK_meeting_rooms" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`CREATE UNIQUE INDEX "IDX_meeting_rooms_name" ON "meeting_rooms" ("name")`,
);
await queryRunner.query(
`ALTER TABLE "meeting_rooms" ADD CONSTRAINT "FK_meeting_rooms_host" FOREIGN KEY ("host_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE NO ACTION`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "meeting_rooms" DROP CONSTRAINT "FK_meeting_rooms_host"`,
);
await queryRunner.query(`DROP INDEX "IDX_meeting_rooms_name"`);
await queryRunner.query(`DROP TABLE "meeting_rooms"`);
}
}
@@ -0,0 +1,25 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
// 화상회의 방에 예약 시각/참여 허용 사용자 컬럼 추가 (단계 4: 방 관리).
// 운영(migrationsRun)에서만 실행되며, dev 는 synchronize 로 자동 반영된다.
export class AddMeetingRoomFields1782800000000 implements MigrationInterface {
name = 'AddMeetingRoomFields1782800000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "meeting_rooms" ADD "scheduled_at" TIMESTAMP`,
);
await queryRunner.query(
`ALTER TABLE "meeting_rooms" ADD "allowed_user_ids" text`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "meeting_rooms" DROP COLUMN "allowed_user_ids"`,
);
await queryRunner.query(
`ALTER TABLE "meeting_rooms" DROP COLUMN "scheduled_at"`,
);
}
}
@@ -0,0 +1,43 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import {
ArrayMaxSize,
IsArray,
IsISO8601,
IsOptional,
IsString,
IsUUID,
Length,
Matches,
} from 'class-validator';
// 화상회의 방 생성 요청
export class CreateMeetingRoomDto {
@ApiProperty({
description: '방 이름(영문/숫자/-/_ 만 허용, 고유)',
example: 'weekly-sync',
})
@IsString()
@Length(1, 64, { message: '방 이름은 1~64자여야 합니다.' })
@Matches(/^[A-Za-z0-9_-]+$/, {
message: '방 이름은 영문, 숫자, 하이픈(-), 밑줄(_)만 사용할 수 있습니다.',
})
name!: string;
@ApiPropertyOptional({
description: '예약 시각(ISO 8601). 미지정 시 즉시 사용 가능한 방',
example: '2026-07-01T10:00:00.000Z',
})
@IsOptional()
@IsISO8601({}, { message: '예약 시각 형식이 올바르지 않습니다.' })
scheduledAt?: string;
@ApiPropertyOptional({
description: '참여 허용 사용자 ID 목록. 비우면 전체 공개',
type: [String],
})
@IsOptional()
@IsArray()
@ArrayMaxSize(160, { message: '참여 허용 인원은 최대 160명입니다.' })
@IsUUID('4', { each: true, message: '사용자 ID 형식이 올바르지 않습니다.' })
allowedUserIds?: string[];
}
@@ -0,0 +1,16 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsString, Length, Matches } from 'class-validator';
// 화상회의 입장 토큰 발급 요청 — 참여할 방 이름을 전달한다.
export class CreateMeetingTokenDto {
@ApiProperty({
description: '참여할 회의 방 이름(영문/숫자/-/_ 만 허용)',
example: 'team-standup',
})
@IsString()
@Length(1, 64, { message: '방 이름은 1~64자여야 합니다.' })
@Matches(/^[A-Za-z0-9_-]+$/, {
message: '방 이름은 영문, 숫자, 하이픈(-), 밑줄(_)만 사용할 수 있습니다.',
})
roomName!: string;
}
@@ -0,0 +1,42 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
type Relation,
} from 'typeorm';
import { User } from '../../user/entities/user.entity';
// 화상회의 방 — 방 이름과 진행자(생성자)를 보관한다.
// 단계 2(경량): 방 이름으로 토큰을 처음 발급받은 사용자가 진행자(host)가 되고,
// 이후 입장자는 일반 참여자가 된다. (방 목록/예약/참여 제한 UI 는 단계 4)
@Entity('meeting_rooms')
export class MeetingRoom {
@PrimaryGeneratedColumn('uuid')
id!: string;
// 방 이름(고유) — 같은 이름으로 들어온 사람끼리 연결된다
@Index({ unique: true })
@Column()
name!: string;
// 진행자(생성자). 사용자 삭제 시에도 방 기록은 유지(SET NULL)
@ManyToOne(() => User, { onDelete: 'SET NULL', nullable: true })
@JoinColumn({ name: 'host_id' })
host!: Relation<User> | null;
// 예약 시각(선택) — 표시용. 현재는 시간 전 입장을 막지 않는다.
@Column({ name: 'scheduled_at', type: 'timestamp', nullable: true })
scheduledAt!: Date | null;
// 참여 허용 사용자 ID 목록(선택) — 비어있으면(null) 전체 공개.
// 값이 있으면 진행자 + 목록에 포함된 사용자만 입장 가능.
@Column({ name: 'allowed_user_ids', type: 'simple-array', nullable: true })
allowedUserIds!: string[] | null;
@CreateDateColumn({ name: 'created_at' })
createdAt!: Date;
}
@@ -0,0 +1,75 @@
import {
Body,
Controller,
Delete,
Get,
HttpCode,
HttpStatus,
Param,
ParseUUIDPipe,
Post,
UseGuards,
} from '@nestjs/common';
import {
ApiOperation,
ApiResponse,
ApiTags,
ApiBearerAuth,
} from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import type { PublicUser } from '../user/user.service';
import {
MeetingService,
type MeetingTokenResult,
type MeetingRoomResponse,
} from './meeting.service';
import { CreateMeetingTokenDto } from './dto/create-meeting-token.dto';
import { CreateMeetingRoomDto } from './dto/create-meeting-room.dto';
// 화상회의 컨트롤러 — 방 관리 + LiveKit 입장 토큰 발급(인증 필요).
@ApiTags('화상회의')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Controller('meetings')
export class MeetingController {
constructor(private readonly meetingService: MeetingService) {}
@Get('rooms')
@ApiOperation({ summary: '회의 방 목록' })
@ApiResponse({ status: 200, description: '목록 조회 성공' })
listRooms(@CurrentUser() user: PublicUser): Promise<MeetingRoomResponse[]> {
return this.meetingService.listRooms(user.id);
}
@Post('rooms')
@ApiOperation({ summary: '회의 방 생성(생성자=진행자)' })
@ApiResponse({ status: 201, description: '생성 성공' })
createRoom(
@CurrentUser() user: PublicUser,
@Body() dto: CreateMeetingRoomDto,
): Promise<MeetingRoomResponse> {
return this.meetingService.createRoom(user.id, dto);
}
@Delete('rooms/:roomId')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: '회의 방 삭제(진행자만)' })
@ApiResponse({ status: 204, description: '삭제 성공' })
async deleteRoom(
@CurrentUser() user: PublicUser,
@Param('roomId', ParseUUIDPipe) roomId: string,
): Promise<void> {
await this.meetingService.deleteRoom(user.id, roomId);
}
@Post('token')
@ApiOperation({ summary: 'LiveKit 입장 토큰 발급' })
@ApiResponse({ status: 201, description: '토큰 발급 성공' })
issueToken(
@CurrentUser() user: PublicUser,
@Body() dto: CreateMeetingTokenDto,
): Promise<MeetingTokenResult> {
return this.meetingService.issueToken(user, dto.roomName);
}
}
@@ -0,0 +1,15 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { MeetingController } from './meeting.controller';
import { MeetingService } from './meeting.service';
import { MeetingRoom } from './entities/meeting-room.entity';
// 화상회의 모듈 — LiveKit 입장 토큰 발급 + 방/진행자(생성자) 보관.
// ConfigModule 은 전역(app.module)에서 등록되어 있어 별도 import 불필요.
@Module({
imports: [TypeOrmModule.forFeature([MeetingRoom])],
controllers: [MeetingController],
providers: [MeetingService],
exports: [MeetingService],
})
export class MeetingModule {}
@@ -0,0 +1,232 @@
import { HttpStatus, Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { AccessToken } from 'livekit-server-sdk';
import { BusinessException } from '../../common/exceptions/business.exception';
import { UserService, type PublicUser } from '../user/user.service';
import { User } from '../user/entities/user.entity';
import { MeetingRoom } from './entities/meeting-room.entity';
import { CreateMeetingRoomDto } from './dto/create-meeting-room.dto';
// 회의 내 역할 — 진행자(방 생성자) / 일반 참여자
export type MeetingRole = 'host' | 'participant';
// 방 목록/생성 응답 — 진행자·예약·참여제한·접근 가능 여부 포함
export interface MeetingRoomResponse {
id: string;
name: string;
host: PublicUser | null;
scheduledAt: string | null;
restricted: boolean; // 참여 제한(allow-list) 적용 여부
isHost: boolean; // 현재 사용자가 진행자인지
canJoin: boolean; // 현재 사용자가 입장 가능한지(공개 OR 진행자 OR 허용목록 포함)
createdAt: string;
}
// 화상회의 토큰 발급 결과 — 브라우저는 자체 VITE_LIVEKIT_URL 로 접속하므로 URL 은 반환하지 않는다.
export interface MeetingTokenResult {
token: string; // LiveKit 입장 JWT
roomName: string;
identity: string; // 참여자 식별자(사용자 ID)
role: MeetingRole; // 이번 입장자의 역할
}
// 화상회의 서비스(단계 2: 권한 분기) — LiveKit 입장 토큰(JWT) 발급을 담당한다.
// 미디어 전송은 LiveKit(SFU)에 위임하고, 여기서는 인증된 사용자에게 역할별 권한이 담긴 토큰을 만든다.
// 역할: 방을 처음 만든 사용자가 진행자(host), 이후 입장자는 일반 참여자(participant).
@Injectable()
export class MeetingService {
private readonly logger = new Logger(MeetingService.name);
private readonly apiKey: string;
private readonly apiSecret: string;
constructor(
config: ConfigService,
@InjectRepository(MeetingRoom)
private readonly roomRepo: Repository<MeetingRoom>,
) {
this.apiKey = (config.get<string>('LIVEKIT_API_KEY') ?? '').trim();
this.apiSecret = (config.get<string>('LIVEKIT_API_SECRET') ?? '').trim();
if (!this.enabled) {
this.logger.warn(
'LIVEKIT_API_KEY/LIVEKIT_API_SECRET 미설정 — 화상회의 토큰 발급 비활성화',
);
}
}
get enabled(): boolean {
return this.apiKey.length > 0 && this.apiSecret.length > 0;
}
// LiveKit 입장 토큰 발급 — 역할에 따라 권한(grant)을 차등 부여한다.
// · 진행자(host): roomAdmin + 발행/구독 (단계 3의 화면 승격 등 진행자 기능 토대)
// · 일반(participant): 발행/구독
// 역할은 토큰 metadata 에도 실어 다른 참여자가 진행자를 식별할 수 있게 한다.
async issueToken(
user: PublicUser,
roomName: string,
): Promise<MeetingTokenResult> {
if (!this.enabled) {
throw new BusinessException(
'SYS_001',
'화상회의 서비스가 아직 설정되지 않았습니다. 잠시 후 다시 시도해 주세요.',
HttpStatus.SERVICE_UNAVAILABLE,
);
}
const role = await this.resolveAccess(user.id, roomName);
const isHost = role === 'host';
const at = new AccessToken(this.apiKey, this.apiSecret, {
identity: user.id,
name: user.name,
// 장시간 회의 중 네트워크 재연결 시 토큰 만료로 재입장이 실패하지 않도록 넉넉히(12h).
ttl: '12h',
// role 은 진행자 식별용, avatarUrl 은 참여자/채팅 아바타 표시용
metadata: JSON.stringify({ role, avatarUrl: user.avatarUrl }),
});
at.addGrant({
roomJoin: true,
room: roomName,
canPublish: true,
canSubscribe: true,
canPublishData: true,
roomAdmin: isHost,
});
const token = await at.toJwt();
return { token, roomName, identity: user.id, role };
}
// 입장 권한 판정 + 역할 결정.
// · 방이 없으면 이 사용자를 진행자로 생성(공개 방) — 직접 링크 입장 호환.
// · 방이 있으면 참여 제한(allow-list)을 검사하고, 생성자 일치로 역할을 판정.
private async resolveAccess(
userId: string,
roomName: string,
): Promise<MeetingRole> {
let room = await this.roomRepo.findOne({
where: { name: roomName },
relations: ['host'],
});
if (!room) {
// 방이 없으면 생성 시도 — 이 사용자가 진행자가 된다.
try {
await this.roomRepo.save(
this.roomRepo.create({
name: roomName,
host: { id: userId } as User,
}),
);
return 'host';
} catch {
// 동시 생성 경쟁(name unique 충돌) — 재조회로 판정.
room = await this.roomRepo.findOne({
where: { name: roomName },
relations: ['host'],
});
}
}
if (!room) return 'participant';
const isHost = room.host?.id === userId;
if (
!isHost &&
this.isRestricted(room) &&
!room.allowedUserIds!.includes(userId)
) {
throw new BusinessException(
'AUTH_003',
'이 회의에 참여할 권한이 없습니다.',
HttpStatus.FORBIDDEN,
);
}
return isHost ? 'host' : 'participant';
}
// 명시적 방 생성 — 생성자가 진행자. 예약 시각/참여 제한을 함께 설정.
async createRoom(
userId: string,
dto: CreateMeetingRoomDto,
): Promise<MeetingRoomResponse> {
const existing = await this.roomRepo.findOne({ where: { name: dto.name } });
if (existing) {
throw new BusinessException(
'BIZ_001',
'이미 존재하는 방 이름입니다. 다른 이름을 사용해 주세요.',
HttpStatus.CONFLICT,
);
}
const saved = await this.roomRepo.save(
this.roomRepo.create({
name: dto.name,
host: { id: userId } as User,
scheduledAt: dto.scheduledAt ? new Date(dto.scheduledAt) : null,
allowedUserIds: dto.allowedUserIds?.length ? dto.allowedUserIds : null,
}),
);
const room = await this.roomRepo.findOne({
where: { id: saved.id },
relations: ['host'],
});
return this.toRoomResponse(room!, userId);
}
// 방 목록 — 전체 방(최근 생성순). 각 방의 접근 가능 여부/진행자 여부를 포함.
async listRooms(userId: string): Promise<MeetingRoomResponse[]> {
const rows = await this.roomRepo.find({
relations: ['host'],
order: { createdAt: 'DESC' },
});
return rows.map((r) => this.toRoomResponse(r, userId));
}
// 방 삭제 — 진행자(생성자)만 가능.
async deleteRoom(userId: string, roomId: string): Promise<void> {
const room = await this.roomRepo.findOne({
where: { id: roomId },
relations: ['host'],
});
if (!room) {
throw new BusinessException(
'RES_001',
'방을 찾을 수 없습니다.',
HttpStatus.NOT_FOUND,
);
}
if (room.host?.id !== userId) {
throw new BusinessException(
'AUTH_003',
'방 삭제 권한이 없습니다.',
HttpStatus.FORBIDDEN,
);
}
await this.roomRepo.remove(room);
}
private isRestricted(room: MeetingRoom): boolean {
return !!(room.allowedUserIds && room.allowedUserIds.length > 0);
}
private toRoomResponse(
room: MeetingRoom,
userId: string,
): MeetingRoomResponse {
const isHost = room.host?.id === userId;
const restricted = this.isRestricted(room);
const canJoin =
isHost || !restricted || room.allowedUserIds!.includes(userId);
return {
id: room.id,
name: room.name,
host: room.host ? UserService.toPublic(room.host) : null,
scheduledAt: room.scheduledAt ? room.scheduledAt.toISOString() : null,
restricted,
isHost,
canJoin,
createdAt: room.createdAt.toISOString(),
};
}
}
+7
View File
@@ -10,6 +10,9 @@ services:
environment:
- CHOKIDAR_USEPOLLING=true
- WATCHPACK_POLLING=true
# 화상회의 — 브라우저가 접속할 LiveKit URL(개발은 LiveKit Cloud 사용).
# 런타임 env 로 주입해 이미지 재빌드 없이 .env 변경 + 컨테이너 재생성만으로 반영된다.
- VITE_LIVEKIT_URL=${LIVEKIT_WS_URL}
volumes:
- ./${FRONTEND_DIR}:/app
- /app/node_modules
@@ -36,6 +39,10 @@ services:
networks: !override
- app-network
# 화상회의는 개발에서 LiveKit Cloud 를 사용한다(로컬 SFU 컨테이너 없음).
# WSL2/Docker Desktop 의 WebRTC 네트워킹 제약을 우회하고, 운영 전환도 env 만 바꾸면 된다.
# 셀프호스팅(문서 단계 6)으로 갈 때 livekit/livekit.yaml 을 참고해 서비스를 다시 추가한다.
db:
# dev에서는 Docker Desktop 시작 시 자동 실행 방지 (base 의 always 를 덮어씀)
restart: "no"
+39
View File
@@ -11,6 +11,8 @@ services:
- BUILD_MODE=${BUILD_MODE}
- VITE_API_BASE_URL=${BACKEND_API_URL}
- VITE_AI_TIMEOUT_MS=${AI_TIMEOUT_MS}
# 브라우저가 접속할 LiveKit WebSocket URL
- VITE_LIVEKIT_URL=${LIVEKIT_WS_URL}
expose:
- "80"
environment:
@@ -62,6 +64,10 @@ services:
- REDIS_PASSWORD=${REDIS_PASSWORD}
# AI 요청 타임아웃(ms) — 프론트(VITE_AI_TIMEOUT_MS)와 동일 값을 루트 .env 에서 공급
- AI_TIMEOUT_MS=${AI_TIMEOUT_MS}
# 화상회의(LiveKit) — 토큰 발급에 키/시크릿, 서버 API 호출에 내부 URL 사용
- LIVEKIT_API_KEY=${LIVEKIT_API_KEY}
- LIVEKIT_API_SECRET=${LIVEKIT_API_SECRET}
- LIVEKIT_URL=${LIVEKIT_URL}
restart: unless-stopped
networks:
- app-network
@@ -144,6 +150,39 @@ services:
labels:
- "com.centurylinklabs.watchtower.enable=true"
# ----------------------------------------
# 5. LiveKit (화상회의 SFU) — 셀프호스팅 (opt-in 프로파일)
# 실행: docker compose --profile selfhost-livekit --env-file .env.production up -d --build
# 미사용시(예: LiveKit Cloud) 프로파일을 켜지 않으면 생성되지 않는다.
# 배포·클러스터링 가이드: livekit/DEPLOY.md
# ----------------------------------------
livekit:
image: livekit/livekit-server:v1.8.4
container_name: ${PROJECT_NAME}-livekit
profiles:
- selfhost-livekit
command: --config /etc/livekit/livekit.yaml
environment:
# 키/시크릿은 "key: secret" 형식으로 주입(콜론+공백 때문에 전체를 따옴표로 감쌈)
- "LIVEKIT_KEYS=${LIVEKIT_API_KEY}: ${LIVEKIT_API_SECRET}"
expose:
- "7880" # 시그널링/HTTP API — 리버스 프록시(TLS)에서 wss 로 라우팅
ports:
- "7881:7881" # ICE/TCP 폴백
- "7882:7882/udp" # ICE/UDP (mux)
volumes:
# 클러스터링 시에는 redis 비밀번호가 담긴 사본을 마운트(DEPLOY.md 참고)
- ./livekit/livekit.prod.yaml:/etc/livekit/livekit.yaml:ro
restart: unless-stopped
networks:
- app-network
- proxy
deploy:
resources:
limits:
cpus: '2.0'
memory: 2g
networks:
app-network:
driver: bridge
+2
View File
@@ -14,6 +14,8 @@ ARG VITE_API_BASE_URL
ENV VITE_API_BASE_URL=$VITE_API_BASE_URL
ARG VITE_AI_TIMEOUT_MS
ENV VITE_AI_TIMEOUT_MS=$VITE_AI_TIMEOUT_MS
ARG VITE_LIVEKIT_URL
ENV VITE_LIVEKIT_URL=$VITE_LIVEKIT_URL
RUN npm run build-only -- --mode ${BUILD_MODE}
+2
View File
@@ -5,6 +5,8 @@ interface ImportMetaEnv {
readonly VITE_API_BASE_URL: string
/** AI 요청 타임아웃(ms) — 백엔드 AI_TIMEOUT_MS 와 동일 값 */
readonly VITE_AI_TIMEOUT_MS: string
/** 브라우저가 접속할 LiveKit WebSocket URL (예: ws://localhost:7880) */
readonly VITE_LIVEKIT_URL: string
}
interface ImportMeta {
+143 -60
View File
@@ -9,6 +9,7 @@
"version": "0.0.0",
"dependencies": {
"axios": "^1.14.0",
"livekit-client": "^2.19.2",
"pinia": "^3.0.4",
"vue": "^3.5.31",
"vue-router": "^4.4.5"
@@ -556,6 +557,12 @@
"specificity": "bin/cli.js"
}
},
"node_modules/@bufbuild/protobuf": {
"version": "1.10.1",
"resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-1.10.1.tgz",
"integrity": "sha512-wJ8ReQbHxsAfXhrf9ixl0aYbZorRuOWpBNzm8pL8ftmSxQx/wnJD5Eg861NwJU/czy2VXFIebCeZnZrI9rktIQ==",
"license": "(Apache-2.0 AND BSD-3-Clause)"
},
"node_modules/@csstools/color-helpers": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz",
@@ -720,12 +727,13 @@
}
},
"node_modules/@emnapi/runtime": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
"integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz",
"integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"tslib": "^2.4.0"
}
@@ -1074,6 +1082,21 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@livekit/mutex": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@livekit/mutex/-/mutex-1.1.1.tgz",
"integrity": "sha512-EsshAucklmpuUAfkABPxJNhzj9v2sG7JuzFDL4ML1oJQSV14sqrpTYnsaOudMAw9yOaW53NU3QQTlUQoRs4czw==",
"license": "Apache-2.0"
},
"node_modules/@livekit/protocol": {
"version": "1.45.8",
"resolved": "https://registry.npmjs.org/@livekit/protocol/-/protocol-1.45.8.tgz",
"integrity": "sha512-Q+l57E7w/xxOBFVWzdX5rkAZO7ffyF+rlDzNUYq2SU114+5aTyCq+PK4unaEVDNd4952Af7wteKr3sOgasGuaA==",
"license": "Apache-2.0",
"dependencies": {
"@bufbuild/protobuf": "^1.10.0"
}
},
"node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz",
@@ -1275,9 +1298,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1295,9 +1315,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1315,9 +1332,6 @@
"ppc64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1335,9 +1349,6 @@
"riscv64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1355,9 +1366,6 @@
"riscv64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1375,9 +1383,6 @@
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1395,9 +1400,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1415,9 +1417,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1606,9 +1605,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1626,9 +1622,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1646,9 +1639,6 @@
"ppc64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1666,9 +1656,6 @@
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1686,9 +1673,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1706,9 +1690,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1754,6 +1735,17 @@
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/runtime": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
"integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@rolldown/binding-win32-arm64-msvc": {
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.17.tgz",
@@ -1838,6 +1830,13 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/dom-mediacapture-record": {
"version": "1.0.22",
"resolved": "https://registry.npmjs.org/@types/dom-mediacapture-record/-/dom-mediacapture-record-1.0.22.tgz",
"integrity": "sha512-mUMZLK3NvwRLcAAT9qmcK+9p7tpU2FHdDsntR3YI4+GY88XrgG4XiE7u1Q2LAN2/FZOz/tdMDC3GQCR4T8nFuw==",
"license": "MIT",
"peer": true
},
"node_modules/@types/estree": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
@@ -3747,6 +3746,15 @@
"node": ">=0.10.0"
}
},
"node_modules/events": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
"integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==",
"license": "MIT",
"engines": {
"node": ">=0.8.x"
}
},
"node_modules/expect-type": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
@@ -4347,6 +4355,15 @@
"@pkgjs/parseargs": "^0.11.0"
}
},
"node_modules/jose": {
"version": "6.2.3",
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz",
"integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/js-beautify": {
"version": "1.15.4",
"resolved": "https://registry.npmjs.org/js-beautify/-/js-beautify-1.15.4.tgz",
@@ -4717,9 +4734,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -4741,9 +4755,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -4765,9 +4776,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -4789,9 +4797,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -4847,6 +4852,26 @@
"url": "https://opencollective.com/parcel"
}
},
"node_modules/livekit-client": {
"version": "2.19.2",
"resolved": "https://registry.npmjs.org/livekit-client/-/livekit-client-2.19.2.tgz",
"integrity": "sha512-Kvk07QYDWRAbmYNLRll04ZIuxMQobW/oLPYnmR1kCy8GGHpU0gqyHf704Rz+29zfy8IJZRjKqeVbzGSKn9sumw==",
"license": "Apache-2.0",
"dependencies": {
"@livekit/mutex": "1.1.1",
"@livekit/protocol": "1.45.8",
"events": "^3.3.0",
"jose": "^6.1.0",
"loglevel": "^1.9.2",
"sdp-transform": "^2.15.0",
"tslib": "2.8.1",
"typed-emitter": "^2.1.0",
"webrtc-adapter": "9.0.5"
},
"peerDependencies": {
"@types/dom-mediacapture-record": "^1"
}
},
"node_modules/locate-path": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
@@ -4877,6 +4902,19 @@
"dev": true,
"license": "MIT"
},
"node_modules/loglevel": {
"version": "1.9.2",
"resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.9.2.tgz",
"integrity": "sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==",
"license": "MIT",
"engines": {
"node": ">= 0.6.0"
},
"funding": {
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/loglevel"
}
},
"node_modules/lru-cache": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
@@ -5690,6 +5728,16 @@
"queue-microtask": "^1.2.2"
}
},
"node_modules/rxjs": {
"version": "7.8.2",
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
"integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
"license": "Apache-2.0",
"optional": true,
"dependencies": {
"tslib": "^2.1.0"
}
},
"node_modules/saxes": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
@@ -5703,6 +5751,21 @@
"node": ">=v12.22.7"
}
},
"node_modules/sdp": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/sdp/-/sdp-3.2.2.tgz",
"integrity": "sha512-xZocWwfyp4hkbN4hLWxMjmv2Q8aNa9MhmOZ7L9aCZPT+dZsgRr6wZRrSYE3HTdyk/2pZKPSgqI7ns7Een1xMSA==",
"license": "MIT"
},
"node_modules/sdp-transform": {
"version": "2.15.0",
"resolved": "https://registry.npmjs.org/sdp-transform/-/sdp-transform-2.15.0.tgz",
"integrity": "sha512-KrOH82c/W+GYQ0LHqtr3caRpM3ITglq3ljGUIb8LTki7ByacJZ9z+piSGiwZDsRyhQbYBOBJgr2k6X4BZXi3Kw==",
"license": "MIT",
"bin": {
"sdp-verify": "checker.js"
}
},
"node_modules/semver": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
@@ -6136,9 +6199,7 @@
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true,
"license": "0BSD",
"optional": true
"license": "0BSD"
},
"node_modules/type-check": {
"version": "0.4.0",
@@ -6166,6 +6227,15 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/typed-emitter": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/typed-emitter/-/typed-emitter-2.1.0.tgz",
"integrity": "sha512-g/KzbYKbH5C2vPkaXGu8DJlHrGKHLsM25Zg9WuC9pMGfuvT+X25tZQWo5fK1BjBm8+UrVE9LDCvaY0CQk+fXDA==",
"license": "MIT",
"optionalDependencies": {
"rxjs": "*"
}
},
"node_modules/typescript": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
@@ -6833,6 +6903,19 @@
"node": ">=20"
}
},
"node_modules/webrtc-adapter": {
"version": "9.0.5",
"resolved": "https://registry.npmjs.org/webrtc-adapter/-/webrtc-adapter-9.0.5.tgz",
"integrity": "sha512-U9vjByy/sK2OMXu5mmfuZFKTMIUQe34c0JXRO+oDrxJTsntdYT2iIFwYMOV7HhMTuktcZLGf2W1N/OcSf9ssWg==",
"license": "BSD-3-Clause",
"dependencies": {
"sdp": "^3.2.0"
},
"engines": {
"node": ">=6.0.0",
"npm": ">=3.10.0"
}
},
"node_modules/whatwg-mimetype": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz",
+1
View File
@@ -15,6 +15,7 @@
},
"dependencies": {
"axios": "^1.14.0",
"livekit-client": "^2.19.2",
"pinia": "^3.0.4",
"vue": "^3.5.31",
"vue-router": "^4.4.5"
+15
View File
@@ -52,6 +52,21 @@
--shadow-sm: 0 1px 2px rgba(20, 24, 33, 0.05);
--shadow-md: 0 4px 16px rgba(20, 24, 33, 0.1), 0 1px 3px rgba(20, 24, 33, 0.06);
--shadow-pop: 0 8px 28px rgba(20, 24, 33, 0.16), 0 2px 6px rgba(20, 24, 33, 0.08);
/* 다크 캔버스(화상회의 회의실/프리조인 전용) */
--d-bg: #0e1014;
--d-panel: #16191f;
--d-panel-2: #1b1f27;
--d-border: #2a2f39;
--d-text: #eceef2;
--d-text-2: #aab2bf;
--d-text-3: #717a88;
--tile: #1b1d23;
/* 다크 위 상태색(라이트 토큰을 다크에 쓰면 탁해져 밝은 변형을 별도 정의) */
--d-red: #ff7a8a;
--d-green: #46d18a;
--d-amber: #f0c478;
--d-accent-soft: #c7c2ff;
}
/* ============================================================
@@ -0,0 +1,257 @@
<script setup lang="ts">
// 회의실 채팅 — 데이터 채널 메시지 목록 + 입력. 새 메시지 도착 시 맨 아래로 스크롤.
import { nextTick, ref, watch } from 'vue'
import MeetingIcon from './MeetingIcon.vue'
import UserAvatar from '@/components/UserAvatar.vue'
import { MAX_CHAT_LENGTH } from '@/composables/useLiveKitRoom'
import type { ChatMessage } from '@/types/meeting'
interface Props {
messages: ChatMessage[]
}
const props = defineProps<Props>()
const emit = defineEmits<{ (e: 'send', text: string): void }>()
const text = ref('')
const listRef = ref<HTMLElement | null>(null)
const inputRef = ref<HTMLTextAreaElement | null>(null)
// 입력 높이 자동 확장 — 최대 약 7.5rem까지는 스크롤바 없이 늘어나고, 초과 시에만 스크롤.
const MAX_INPUT_HEIGHT = 120
function autoGrow() {
const el = inputRef.value
if (!el) return
el.style.height = 'auto'
const full = el.scrollHeight
el.style.height = `${Math.min(full, MAX_INPUT_HEIGHT)}px`
el.style.overflowY = full > MAX_INPUT_HEIGHT ? 'auto' : 'hidden'
}
// 시각(HH:MM) 표시
function fmtTime(ts: number): string {
const d = new Date(ts)
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
}
function submit() {
const body = text.value.trim()
if (!body) return
emit('send', body)
text.value = ''
void nextTick(autoGrow) // 전송 후 높이 초기화
}
// 메시지 증가 시 맨 아래로
watch(
() => props.messages.length,
async () => {
await nextTick()
const el = listRef.value
if (el) el.scrollTop = el.scrollHeight
},
)
</script>
<template>
<div class="chat">
<div
ref="listRef"
class="chat-list"
>
<div
v-if="messages.length === 0"
class="chat-empty"
>
아직 메시지가 없습니다. 메시지를 보내보세요.
</div>
<div
v-for="m in messages"
:key="m.id"
class="chat-msg"
:class="{ self: m.self }"
>
<span
v-if="!m.self"
class="chat-av"
><UserAvatar
:url="m.senderAvatarUrl"
:name="m.senderName"
/></span>
<div class="chat-col">
<div class="chat-meta">
<span
v-if="!m.self"
class="chat-name"
>{{ m.senderName }}</span>
<span class="chat-time">{{ fmtTime(m.ts) }}</span>
</div>
<div class="chat-bubble">
{{ m.text }}
</div>
</div>
</div>
</div>
<div class="chat-input">
<textarea
ref="inputRef"
v-model="text"
rows="1"
:maxlength="MAX_CHAT_LENGTH"
placeholder="메시지 보내기… (Shift+Enter 줄바꿈)"
aria-label="채팅 메시지"
@input="autoGrow"
@keydown.enter.exact.prevent="submit"
/>
<button
type="button"
class="chat-send"
:disabled="!text.trim()"
title="보내기"
@click="submit"
>
<MeetingIcon name="send" />
</button>
</div>
</div>
</template>
<style scoped>
.chat {
display: flex;
flex-direction: column;
min-height: 0;
flex: 1;
}
.chat-list {
flex: 1;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 0.875rem;
padding: 1rem 0.875rem;
scrollbar-width: thin;
scrollbar-color: var(--d-border) transparent;
}
.chat-list::-webkit-scrollbar {
width: 0.4375rem;
}
.chat-list::-webkit-scrollbar-thumb {
background: var(--d-border);
border-radius: 0.25rem;
}
.chat-empty {
margin: auto;
text-align: center;
font-size: 0.8125rem;
color: var(--d-text-3);
}
.chat-msg {
display: flex;
gap: 0.5625rem;
align-items: flex-start;
}
.chat-msg.self {
flex-direction: row-reverse;
}
.chat-av {
width: 1.875rem;
height: 1.875rem;
flex-shrink: 0;
}
.chat-col {
display: flex;
flex-direction: column;
gap: 0.25rem;
max-width: 80%;
min-width: 0;
}
.chat-msg.self .chat-col {
align-items: flex-end;
}
.chat-meta {
display: flex;
align-items: baseline;
gap: 0.4375rem;
}
.chat-name {
font-size: 0.75rem;
font-weight: 600;
color: var(--d-text-2);
}
.chat-time {
font-size: 0.65625rem;
color: var(--d-text-3);
}
.chat-bubble {
font-size: 0.8125rem;
line-height: 1.5;
color: var(--d-text);
background: var(--d-panel-2);
border: 1px solid var(--d-border);
padding: 0.5rem 0.75rem;
border-radius: 0.75rem;
border-top-left-radius: 0.25rem;
word-break: break-word;
white-space: pre-wrap;
}
.chat-msg.self .chat-bubble {
background: var(--accent);
border-color: var(--accent);
color: #fff;
border-top-left-radius: 0.75rem;
border-top-right-radius: 0.25rem;
}
.chat-input {
display: flex;
align-items: flex-end;
gap: 0.5rem;
padding: 0.75rem;
border-top: 1px solid var(--d-border);
}
.chat-input textarea {
flex: 1;
min-width: 0;
min-height: 2.5rem;
max-height: 7.5rem;
padding: 0.5625rem 0.8125rem;
border: 1px solid var(--d-border);
border-radius: 0.625rem;
background: var(--d-panel-2);
color: var(--d-text);
font-family: inherit;
font-size: 0.8125rem;
line-height: 1.4;
outline: none;
resize: none;
overflow-y: hidden;
}
.chat-input textarea:focus {
border-color: var(--accent);
}
.chat-input textarea::placeholder {
color: var(--d-text-3);
}
.chat-send {
width: 2.5rem;
height: 2.5rem;
flex-shrink: 0;
border: none;
border-radius: 0.625rem;
background: var(--accent);
color: #fff;
cursor: pointer;
display: grid;
place-items: center;
}
.chat-send svg {
width: 1.125rem;
height: 1.125rem;
}
.chat-send:hover:not(:disabled) {
background: var(--accent-hover);
}
.chat-send:disabled {
opacity: 0.45;
cursor: not-allowed;
}
</style>
@@ -0,0 +1,257 @@
<script setup lang="ts">
// 회의실 컨트롤 바 — 마이크/카메라/화면공유(준비중)/채팅/참여자/나가기.
// 위치(pos)는 하단 중앙(bottom) 또는 상단 우측(top).
import MeetingIcon from './MeetingIcon.vue'
interface Props {
pos: 'bottom' | 'top'
micEnabled: boolean
camEnabled: boolean
chatOpen: boolean
peopleOpen: boolean
unread: number
}
defineProps<Props>()
const emit = defineEmits<{
(e: 'toggle-mic'): void
(e: 'toggle-cam'): void
(e: 'toggle-chat'): void
(e: 'toggle-people'): void
(e: 'leave'): void
}>()
</script>
<template>
<div
class="controlbar"
:class="`pos-${pos}`"
>
<button
type="button"
class="cb-btn"
:class="{ 'danger-state': !micEnabled }"
:title="micEnabled ? '마이크 끄기' : '마이크 켜기'"
@click="emit('toggle-mic')"
>
<MeetingIcon :name="micEnabled ? 'mic' : 'micOff'" />
<span class="cb-lbl">{{ micEnabled ? '마이크' : '음소거' }}</span>
</button>
<button
type="button"
class="cb-btn"
:class="{ 'danger-state': !camEnabled }"
:title="camEnabled ? '카메라 끄기' : '카메라 켜기'"
@click="emit('toggle-cam')"
>
<MeetingIcon :name="camEnabled ? 'cam' : 'camOff'" />
<span class="cb-lbl">{{ camEnabled ? '카메라' : '카메라 꺼짐' }}</span>
</button>
<span class="cb-div" />
<button
type="button"
class="cb-btn soon"
disabled
title="화면 공유 (준비 중)"
>
<MeetingIcon name="screen" />
<span class="cb-lbl">화면 공유</span>
<span class="cb-soon"></span>
</button>
<button
type="button"
class="cb-btn"
:class="{ on: chatOpen }"
title="채팅"
@click="emit('toggle-chat')"
>
<MeetingIcon name="chat" />
<span class="cb-lbl">채팅</span>
<span
v-if="unread > 0"
class="cb-badge"
>{{ unread > 9 ? '9+' : unread }}</span>
</button>
<button
type="button"
class="cb-btn"
:class="{ on: peopleOpen }"
title="참여자"
@click="emit('toggle-people')"
>
<MeetingIcon name="users" />
<span class="cb-lbl">참여자</span>
</button>
<span class="cb-div" />
<button
type="button"
class="cb-leave"
title="나가기"
@click="emit('leave')"
>
<MeetingIcon name="hangup" />
<span class="cb-lbl">나가기</span>
</button>
</div>
</template>
<style scoped>
.controlbar {
display: flex;
align-items: center;
gap: 0.5rem;
z-index: 30;
}
.controlbar.pos-bottom {
position: absolute;
left: 50%;
bottom: 1.375rem;
transform: translateX(-50%);
padding: 0.5625rem;
background: rgba(22, 25, 31, 0.86);
border: 1px solid var(--d-border);
border-radius: 1rem;
box-shadow: 0 14px 40px rgba(0, 0, 0, 0.5);
-webkit-backdrop-filter: blur(14px);
backdrop-filter: blur(14px);
}
.controlbar.pos-top {
position: absolute;
right: 1.125rem;
top: 4.25rem;
padding: 0.3125rem;
background: rgba(22, 25, 31, 0.92);
border: 1px solid var(--d-border);
border-radius: 0.75rem;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.4);
-webkit-backdrop-filter: blur(12px);
backdrop-filter: blur(12px);
}
.cb-btn {
position: relative;
display: inline-flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.1875rem;
min-width: 3.75rem;
height: 3.25rem;
padding: 0 0.75rem;
border: none;
border-radius: 0.6875rem;
background: transparent;
color: var(--d-text);
font-family: inherit;
cursor: pointer;
}
.cb-btn svg {
width: 1.3125rem;
height: 1.3125rem;
}
.cb-lbl {
font-size: 0.65625rem;
font-weight: 600;
color: var(--d-text-2);
}
.cb-btn:hover {
background: rgba(255, 255, 255, 0.08);
}
.cb-btn.danger-state,
.cb-btn.danger-state .cb-lbl {
color: var(--d-red);
}
.cb-btn.on {
background: rgba(79, 70, 229, 0.22);
}
.cb-btn.on .cb-lbl,
.cb-btn.on svg {
color: var(--d-accent-soft);
}
.cb-btn.soon {
opacity: 0.5;
cursor: not-allowed;
}
.cb-soon {
position: absolute;
top: 0.25rem;
right: 0.5rem;
font-size: 0.5rem;
font-weight: 700;
color: var(--d-bg);
background: var(--d-text-3);
padding: 1px 0.25rem;
border-radius: 0.5rem;
}
.cb-badge {
position: absolute;
top: 0.1875rem;
right: 0.4375rem;
min-width: 1rem;
height: 1rem;
padding: 0 0.25rem;
border-radius: 0.5rem;
background: #e0364c;
color: #fff;
font-size: 0.59375rem;
font-weight: 700;
display: grid;
place-items: center;
box-shadow: 0 0 0 2px rgba(22, 25, 31, 0.86);
}
.controlbar.pos-top .cb-badge {
top: -0.1875rem;
right: -0.125rem;
box-shadow: 0 0 0 2px var(--d-panel-2);
}
.cb-div {
width: 1px;
height: 1.875rem;
background: var(--d-border);
margin: 0 0.25rem;
}
.cb-leave {
display: inline-flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.1875rem;
min-width: 3.75rem;
height: 3.25rem;
padding: 0 0.875rem;
border: none;
border-radius: 0.6875rem;
background: #e0364c;
color: #fff;
font-family: inherit;
cursor: pointer;
}
.cb-leave svg {
width: 1.375rem;
height: 1.375rem;
}
.cb-leave .cb-lbl {
color: #fff;
}
.cb-leave:hover {
background: #c52a3e;
}
/* 상단 우측 모드: 라벨 숨기고 아이콘만 */
.controlbar.pos-top .cb-lbl {
display: none;
}
.controlbar.pos-top .cb-btn,
.controlbar.pos-top .cb-leave {
min-width: 0;
height: 2.5rem;
padding: 0 0.75rem;
}
.controlbar.pos-top .cb-soon {
display: none;
}
.controlbar.pos-top .cb-div {
height: 1.375rem;
}
</style>
@@ -0,0 +1,443 @@
<script setup lang="ts">
// 새 회의 만들기 — 공용 BaseModal 폼 모달(프로젝트 멤버 초대 모달과 동일 패턴).
// 참여 제한 멤버 선택은 업무 생성의 담당자 할당과 동일한 형태:
// 입력칸 고정 + 후보 드롭다운(focus 시) + 선택된 멤버는 입력칸 "아래"에 칩으로 표기.
import { computed, ref, watch } from 'vue'
import BaseModal from '@/components/common/BaseModal.vue'
import MeetingIcon from './MeetingIcon.vue'
import UserAvatar from '@/components/UserAvatar.vue'
import { useUser } from '@/composables/useUser'
import { useMeeting } from '@/composables/useMeeting'
import type { ApiMember } from '@/types/repo'
import type { ApiMeetingRoom } from '@/types/meeting'
interface Props {
// 이름 중복 즉시 검사용 현재 방 목록
rooms: ApiMeetingRoom[]
}
const props = defineProps<Props>()
const emit = defineEmits<{
// 생성 성공 → 부모가 목록 갱신 + 모달 닫기
(e: 'created'): void
(e: 'cancel'): void
}>()
const NAME_RE = /^[A-Za-z0-9_-]{1,64}$/
const userApi = useUser()
const meetingApi = useMeeting()
const name = ref('')
const scheduledAt = ref('')
const restrict = ref(false)
const error = ref('')
const submitting = ref(false)
// --- 참여 허용 멤버 선택 (업무 담당자 할당과 동일한 형태) ---
const selected = ref<ApiMember[]>([]) // 선택된 멤버(입력칸 아래 칩)
const query = ref('')
const found = ref<ApiMember[]>([]) // 검색 결과
const showDropdown = ref(false)
// 검색어 변경 시 후보 갱신(서버 검색)
watch(query, async (q) => {
if (!restrict.value) return
found.value = await userApi.search(q)
})
// 아직 선택되지 않은 멤버 후보
const candidates = computed(() => {
const picked = new Set(selected.value.map((u) => u.id))
return found.value.filter((u) => !picked.has(u.id)).slice(0, 8)
})
async function onMemberFocus() {
showDropdown.value = true
if (found.value.length === 0) found.value = await userApi.search(query.value)
}
function onMemberBlur() {
// 항목은 @mousedown.prevent 로 선택하므로 약간 지연 후 닫는다
setTimeout(() => (showDropdown.value = false), 120)
}
function addMember(u: ApiMember) {
if (!selected.value.some((x) => x.id === u.id)) selected.value.push(u)
query.value = ''
showDropdown.value = false
}
function removeMember(id: string) {
selected.value = selected.value.filter((u) => u.id !== id)
}
async function submit() {
if (submitting.value) return
const n = name.value.trim()
if (!NAME_RE.test(n)) {
error.value = '방 이름은 영문·숫자·-·_ 1~64자만 가능합니다.'
return
}
if (props.rooms.some((r) => r.name === n)) {
error.value = '이미 같은 이름의 방이 있습니다.'
return
}
error.value = ''
submitting.value = true
try {
await meetingApi.createRoom({
name: n,
scheduledAt: scheduledAt.value
? new Date(scheduledAt.value).toISOString()
: undefined,
allowedUserIds:
restrict.value && selected.value.length
? selected.value.map((u) => u.id)
: undefined,
})
emit('created')
} catch {
error.value = '방 생성에 실패했습니다. (이름이 이미 사용 중일 수 있습니다)'
submitting.value = false
}
}
</script>
<template>
<BaseModal
title="새 회의 만들기"
@close="emit('cancel')"
>
<template #subtitle>
방을 만들면 내가 진행자가 됩니다.
</template>
<form
class="cf-form"
@submit.prevent="submit"
>
<div class="form-field">
<label class="form-label"><span class="req">*</span> 이름</label>
<input
v-model="name"
class="form-input"
autofocus
placeholder="방 이름을 입력하세요"
maxlength="64"
@input="error = ''"
>
</div>
<div class="form-field">
<label class="form-label">예약 시각 <span class="opt">선택</span></label>
<input
v-model="scheduledAt"
class="form-input"
type="datetime-local"
>
</div>
<div class="form-field">
<label class="cf-toggle">
<input
v-model="restrict"
type="checkbox"
>
<span class="cf-tg-box"><i v-if="restrict" /></span>
<span class="cf-tg-txt"><b>참여 제한</b> 지정한 멤버만 입장할 있습니다</span>
</label>
<div
v-if="restrict"
class="cf-picker"
>
<!-- 입력칸은 고정 선택된 멤버는 아래에 표기 -->
<div class="combo">
<div class="combo-field">
<input
v-model="query"
class="combo-input"
placeholder="멤버 검색…"
autocomplete="off"
@focus="onMemberFocus"
@blur="onMemberBlur"
>
<span class="combo-caret">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
><path d="m6 9 6 6 6-6" /></svg>
</span>
</div>
<!-- 멤버 후보 드롭다운 -->
<div
v-if="showDropdown"
class="assignee-dd"
>
<button
v-for="m in candidates"
:key="m.id"
type="button"
class="assignee-dd-item"
@mousedown.prevent="addMember(m)"
>
<span class="avatar"><UserAvatar
:url="m.avatarUrl"
:name="m.name"
/></span>
<span class="ad-nm">{{ m.name }}</span>
<span class="ad-em">{{ m.email }}</span>
</button>
<div
v-if="candidates.length === 0"
class="assignee-dd-msg"
>
추가할 멤버가 없습니다.
</div>
</div>
</div>
<!-- 선택된 멤버 (입력칸 아래에 추가) -->
<div
v-if="selected.length"
class="assignee-chips"
>
<span
v-for="a in selected"
:key="a.id"
class="chip"
>
<span class="avatar"><UserAvatar
:url="a.avatarUrl"
:name="a.name"
/></span>
{{ a.name }}
<span
class="x"
@click="removeMember(a.id)"
>×</span>
</span>
</div>
</div>
</div>
<p
v-if="error"
class="cf-err"
>
<MeetingIcon name="warn" />{{ error }}
</p>
</form>
<template #foot>
<button
type="button"
class="mbtn"
@click="emit('cancel')"
>
취소
</button>
<button
type="button"
class="mbtn primary"
:disabled="submitting"
@click="submit"
>
<MeetingIcon name="plus" />{{ submitting ? '생성 중…' : '방 만들기' }}
</button>
</template>
</BaseModal>
</template>
<style scoped>
/* 폼은 BaseModal 본문(.bm-body)의 세로 간격을 그대로 상속받도록 display:contents */
.cf-form {
display: contents;
}
.form-label .opt {
font-weight: 500;
color: var(--text-3);
}
/* 참여 제한 토글 */
.cf-toggle {
display: flex;
align-items: center;
gap: 0.625rem;
cursor: pointer;
user-select: none;
}
.cf-toggle input {
display: none;
}
.cf-tg-box {
width: 1.125rem;
height: 1.125rem;
border: 0.09375rem solid var(--border-strong);
border-radius: 0.3125rem;
background: #fff;
display: grid;
place-items: center;
flex-shrink: 0;
}
.cf-toggle input:checked + .cf-tg-box {
background: var(--accent);
border-color: var(--accent);
}
.cf-tg-box i {
width: 0.5625rem;
height: 0.5625rem;
border-radius: 0.125rem;
background: #fff;
}
.cf-tg-txt {
font-size: 0.8125rem;
color: var(--text-2);
}
.cf-tg-txt b {
color: var(--text);
font-weight: 600;
}
/* 멤버 피커 (업무 담당자 할당과 동일) */
.cf-picker {
margin-top: 0.625rem;
}
.combo-field {
border: 1px solid var(--border-strong);
border-radius: var(--radius);
padding: 0.4375rem 0.5rem;
background: #fff;
display: flex;
align-items: center;
gap: 0.375rem;
}
.combo-field:focus-within {
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-weak);
}
.combo-input {
flex: 1;
min-width: 4.375rem;
border: none;
outline: none;
font-family: inherit;
font-size: 0.8125rem;
padding: 0.25rem 0.125rem;
background: transparent;
}
.combo-input::placeholder {
color: var(--text-3);
}
.combo-caret {
color: var(--text-3);
display: grid;
place-items: center;
margin-left: auto;
flex-shrink: 0;
}
.combo-caret svg {
width: 1rem;
height: 1rem;
}
/* 모달 안에서는 절대배치 시 스크롤 영역에 잘리므로 인라인(흐름 내)으로 펼친다 */
.assignee-dd {
margin-top: 0.375rem;
background: #fff;
border: 1px solid var(--border);
border-radius: 0.5625rem;
box-shadow: var(--shadow-sm);
padding: 0.375rem;
max-height: 14rem;
overflow-y: auto;
}
.assignee-dd-item {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
padding: 0.4375rem 0.5rem;
border: none;
background: transparent;
border-radius: var(--radius-sm);
cursor: pointer;
font-family: inherit;
text-align: left;
}
.assignee-dd-item:hover {
background: #f5f6f8;
}
.assignee-dd-item .avatar {
width: 1.5rem;
height: 1.5rem;
flex-shrink: 0;
}
.ad-nm {
font-size: 0.8125rem;
font-weight: 600;
color: var(--text);
white-space: nowrap;
}
.ad-em {
font-size: 0.719rem;
color: var(--text-3);
margin-left: auto;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 9rem;
}
.assignee-dd-msg {
padding: 0.625rem 0.5rem;
font-size: 0.75rem;
color: var(--text-3);
text-align: center;
}
.assignee-chips {
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
margin-top: 0.5rem;
}
.chip {
display: inline-flex;
align-items: center;
gap: 0.375rem;
background: #f1f2f4;
border: 1px solid var(--border);
border-radius: 1.25rem;
padding: 0.1875rem 0.5rem 0.1875rem 0.1875rem;
font-size: 0.78125rem;
font-weight: 600;
color: var(--text);
white-space: nowrap;
}
.chip .avatar {
width: 1.375rem;
height: 1.375rem;
flex-shrink: 0;
}
.chip .x {
color: var(--text-3);
cursor: pointer;
font-size: 0.8125rem;
line-height: 1;
margin-left: 0.0625rem;
}
.chip .x:hover {
color: var(--red);
}
.cf-err {
display: flex;
align-items: center;
gap: 0.4375rem;
font-size: 0.78125rem;
color: var(--red);
font-weight: 500;
}
.cf-err svg {
width: 0.9375rem;
height: 0.9375rem;
flex-shrink: 0;
}
</style>
@@ -0,0 +1,177 @@
<script lang="ts">
// 사용 가능한 화상회의 아이콘 이름(별도 <script> 블록에서 export → 다른 SFC 에서 import 가능)
export type MeetingIconName =
| 'cam'
| 'camOff'
| 'mic'
| 'micOff'
| 'screen'
| 'chat'
| 'send'
| 'users'
| 'hangup'
| 'expand'
| 'plus'
| 'host'
| 'grid'
| 'speaker'
| 'left'
| 'right'
| 'close'
| 'search'
| 'pin'
| 'warn'
| 'lock'
| 'trash'
</script>
<script setup lang="ts">
// 화상회의 전용 라인 아이콘 모음 — name 으로 SVG 를 선택해 렌더한다.
// (svg 의 stroke/fill 은 currentColor 를 사용하므로 색상은 부모 color 로 제어)
defineProps<{ name: MeetingIconName }>()
</script>
<template>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<template v-if="name === 'cam'">
<path d="m23 7-7 5 7 5V7Z" /><rect
x="1"
y="5"
width="15"
height="14"
rx="2"
/>
</template>
<template v-else-if="name === 'camOff'">
<path d="M16 16v2a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h2m4 0h5a2 2 0 0 1 2 2v3l4-3v9" /><path d="m1 1 22 22" />
</template>
<template v-else-if="name === 'mic'">
<rect
x="9"
y="2"
width="6"
height="12"
rx="3"
/><path d="M5 10a7 7 0 0 0 14 0M12 19v3" />
</template>
<template v-else-if="name === 'micOff'">
<path d="M9 9v1a3 3 0 0 0 5.12 2.12M15 9.34V5a3 3 0 0 0-5.94-.6" /><path d="M17 10a7 7 0 0 1-.11 1.23M5 10a7 7 0 0 0 10.91 5.81M12 19v3M8 22h8" /><path d="m1 1 22 22" />
</template>
<template v-else-if="name === 'screen'">
<rect
x="2"
y="3"
width="20"
height="14"
rx="2"
/><path d="M8 21h8M12 17v4" /><path d="m9 9.5 3-3 3 3M12 6.5v6" />
</template>
<template v-else-if="name === 'chat'">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2Z" />
</template>
<template v-else-if="name === 'send'">
<path d="M22 2 11 13M22 2l-7 20-4-9-9-4 20-7Z" />
</template>
<template v-else-if="name === 'users'">
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" /><circle
cx="9"
cy="7"
r="4"
/><path d="M22 21v-2a4 4 0 0 0-3-3.87M16 3.13A4 4 0 0 1 16 11" />
</template>
<template v-else-if="name === 'hangup'">
<path
fill="currentColor"
stroke="none"
d="M12 9c-1.6 0-3.15.25-4.6.7v3.1c0 .39-.23.74-.56.9-.98.49-1.87 1.12-2.66 1.85a.99.99 0 0 1-1.36-.02L.29 13.36a.99.99 0 0 1 0-1.41C3.34 9.07 7.46 7.3 12 7.3s8.66 1.77 11.71 4.65a.99.99 0 0 1 0 1.41l-2.57 3.07a.99.99 0 0 1-1.36.02 11.5 11.5 0 0 0-2.66-1.85.998.998 0 0 1-.56-.9v-3.1C15.15 9.25 13.6 9 12 9Z"
/>
</template>
<template v-else-if="name === 'expand'">
<path d="M15 3h6v6M9 21H3v-6M21 3l-7 7M3 21l7-7" />
</template>
<template v-else-if="name === 'plus'">
<path d="M5 12h14M12 5v14" />
</template>
<template v-else-if="name === 'host'">
<path d="M12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2z" />
</template>
<template v-else-if="name === 'grid'">
<rect
x="3"
y="3"
width="7"
height="7"
rx="1"
/><rect
x="14"
y="3"
width="7"
height="7"
rx="1"
/><rect
x="3"
y="14"
width="7"
height="7"
rx="1"
/><rect
x="14"
y="14"
width="7"
height="7"
rx="1"
/>
</template>
<template v-else-if="name === 'speaker'">
<rect
x="3"
y="3"
width="18"
height="13"
rx="2"
/><path d="M3 19h5M11 19h2M16 19h5" />
</template>
<template v-else-if="name === 'left'">
<path d="m15 18-6-6 6-6" />
</template>
<template v-else-if="name === 'right'">
<path d="m9 18 6-6-6-6" />
</template>
<template v-else-if="name === 'close'">
<path d="M18 6 6 18M6 6l12 12" />
</template>
<template v-else-if="name === 'search'">
<circle
cx="11"
cy="11"
r="7"
/><path d="m21 21-4-4" />
</template>
<template v-else-if="name === 'pin'">
<path d="M9 4h6l-1 7 4 3v2H6v-2l4-3z" /><path d="M12 16v5" />
</template>
<template v-else-if="name === 'warn'">
<path d="M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0Z" /><path d="M12 9v4M12 17h.01" />
</template>
<template v-else-if="name === 'lock'">
<rect
x="3"
y="11"
width="18"
height="11"
rx="2"
/><path d="M7 11V7a5 5 0 0 1 10 0v4" />
</template>
<template v-else-if="name === 'trash'">
<path d="M3 6h18M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2m2 0v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6" /><path d="M10 11v6M14 11v6" />
</template>
</svg>
</template>
@@ -0,0 +1,391 @@
<script setup lang="ts">
// 화상회의 로비 — 검색 + 새 회의 만들기 + 방 목록.
import { computed, ref } from 'vue'
import MeetingIcon from './MeetingIcon.vue'
import MeetingCreateForm from './MeetingCreateForm.vue'
import { relativeTimeKo, formatDate } from '@/shared/utils/format'
import type { ApiMeetingRoom } from '@/types/meeting'
interface Props {
rooms: ApiMeetingRoom[]
joinError: string | null
}
const props = defineProps<Props>()
const emit = defineEmits<{
(e: 'join', name: string): void
(e: 'created'): void
(e: 'delete', room: ApiMeetingRoom): void
(e: 'clear-error'): void
}>()
const showForm = ref(false)
const keyword = ref('')
// 방 이름·진행자 이름으로 목록 검색(클라이언트 필터)
const filteredRooms = computed(() => {
const q = keyword.value.trim().toLowerCase()
if (!q) return props.rooms
return props.rooms.filter(
(r) =>
r.name.toLowerCase().includes(q) ||
(r.host?.name ?? '').toLowerCase().includes(q),
)
})
function onSearchInput() {
if (props.joinError) emit('clear-error')
}
function onCreated() {
showForm.value = false
emit('created')
}
// 예약 시각 표시(날짜+시간)
function fmtSchedule(iso: string): string {
return new Date(iso).toLocaleString('ko-KR', {
dateStyle: 'short',
timeStyle: 'short',
})
}
</script>
<template>
<div class="page">
<div class="crumb">
<b>화상회의</b>
</div>
<div class="pagehead">
<h1>화상회의</h1>
<span class="count-pill">{{ rooms.length }}</span>
</div>
<p class="lede">
회의 방을 만들거나 목록에서 선택해 입장하세요. 공유 링크(<code>/meeting/&lt;방이름&gt;</code>)로도 바로 입장할 있습니다.
</p>
<!-- 툴바: 검색 + 회의 만들기 -->
<div class="toolbar">
<div class="search">
<MeetingIcon name="search" />
<input
v-model="keyword"
type="text"
placeholder="회의방 검색…"
@input="onSearchInput"
>
</div>
<button
type="button"
class="btn primary new-room"
@click="showForm = true"
>
<MeetingIcon name="plus" /> 회의 만들기
</button>
</div>
<div
v-if="joinError"
class="join-err"
>
<MeetingIcon name="warn" /><span>{{ joinError }}</span>
</div>
<MeetingCreateForm
v-if="showForm"
:rooms="rooms"
@created="onCreated"
@cancel="showForm = false"
/>
<!-- 목록 -->
<div class="roomlist">
<div
v-for="r in filteredRooms"
:key="r.id"
class="room-row"
>
<div class="rr-ico">
<MeetingIcon name="cam" />
</div>
<div class="rr-main">
<div class="rr-title">
<span class="rr-name">{{ r.name }}</span>
<span
v-if="r.restricted"
class="rr-badge lock"
><MeetingIcon name="lock" />제한</span>
</div>
<div class="rr-meta">
<span
v-if="r.host"
class="rr-mi"
><MeetingIcon name="host" />{{ r.host.name }}</span>
<span
v-if="r.scheduledAt"
class="rr-mi"
>예약 {{ fmtSchedule(r.scheduledAt) }}</span>
<span
class="rr-mi rr-dim"
:title="formatDate(r.createdAt)"
>{{ relativeTimeKo(r.createdAt) }} 생성</span>
</div>
</div>
<div class="rr-actions">
<button
v-if="r.canJoin"
type="button"
class="btn primary rr-join"
@click="emit('join', r.name)"
>
입장
</button>
<button
v-else
type="button"
class="btn rr-noaccess"
disabled
title="참여 허용 멤버만 입장할 수 있습니다"
>
<MeetingIcon name="lock" />권한 없음
</button>
<button
v-if="r.isHost"
type="button"
class="btn danger rr-del"
title="방 삭제"
@click="emit('delete', r)"
>
<MeetingIcon name="trash" />
</button>
</div>
</div>
<div
v-if="rooms.length === 0"
class="rl-empty"
>
<MeetingIcon name="cam" />
<p>아직 생성된 회의방이 없습니다.</p>
<span>"새 회의 만들기" 회의방을 만들어 보세요.</span>
</div>
<div
v-else-if="filteredRooms.length === 0"
class="rl-empty"
>
<MeetingIcon name="search" />
<p>검색 결과가 없습니다.</p>
<span>다른 검색어로 다시 시도해 보세요.</span>
</div>
</div>
</div>
</template>
<style scoped>
/* 콘텐츠 폭/여백은 전역 .page(max-width 67.5rem) 사용 — 프로젝트·내 업무 화면과 동일 */
/* .crumb / .count-pill 은 전역(relay.css) 스타일 사용 */
.crumb b {
color: var(--text-2);
font-weight: 600;
}
.pagehead {
display: flex;
align-items: center;
gap: 0.6875rem;
padding: 0.5625rem 0 0.25rem;
}
.pagehead h1 {
font-size: 1.375rem;
font-weight: 700;
letter-spacing: -0.025rem;
}
.lede {
color: var(--text-2);
font-size: 0.844rem;
margin: 0.25rem 0 1.125rem;
}
code {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 0.92em;
background: #eceef1;
padding: 1px 0.3125rem;
border-radius: 0.25rem;
color: var(--text-2);
}
/* 툴바: 검색(.toolbar/.search 는 전역) + 우측 새 회의 버튼 */
.new-room {
margin-left: auto;
}
.join-err {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 0.75rem;
padding: 0.6875rem 0.875rem;
background: var(--red-weak);
border: 1px solid var(--red-border);
border-radius: var(--radius);
color: var(--red);
font-size: 0.8125rem;
font-weight: 500;
}
.join-err svg {
width: 1.0625rem;
height: 1.0625rem;
flex-shrink: 0;
}
/* 방 목록 */
.roomlist {
display: flex;
flex-direction: column;
gap: 0.5625rem;
}
.room-row {
display: flex;
align-items: center;
gap: 1rem;
padding: 1rem 1.25rem;
background: var(--panel);
border: 1px solid var(--border);
border-radius: 0.625rem;
box-shadow: var(--shadow-sm);
}
.room-row:hover {
border-color: var(--border-strong);
}
.rr-ico {
width: 2.5rem;
height: 2.5rem;
border-radius: 0.5625rem;
background: var(--accent-weak);
color: var(--accent);
display: grid;
place-items: center;
flex-shrink: 0;
}
.rr-ico svg {
width: 1.3125rem;
height: 1.3125rem;
}
.rr-main {
flex: 1;
min-width: 0;
}
.rr-title {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.5rem;
}
.rr-name {
font-size: 0.9375rem;
font-weight: 600;
color: var(--text);
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
letter-spacing: -0.0125rem;
}
.rr-badge {
display: inline-flex;
align-items: center;
gap: 0.25rem;
font-size: 0.6875rem;
font-weight: 600;
padding: 0.125rem 0.5rem;
border-radius: 1.25rem;
line-height: 1.5;
}
.rr-badge svg {
width: 0.6875rem;
height: 0.6875rem;
}
.rr-badge.lock {
color: var(--text-2);
background: #f1f2f4;
border: 1px solid var(--border);
}
.rr-meta {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.9375rem;
margin-top: 0.25rem;
font-size: 0.75rem;
color: var(--text-3);
}
.rr-mi {
display: flex;
align-items: center;
gap: 0.3125rem;
}
.rr-mi svg {
width: 0.8125rem;
height: 0.8125rem;
}
.rr-dim {
color: var(--text-3);
}
.rr-actions {
display: flex;
align-items: center;
gap: 0.5rem;
flex-shrink: 0;
}
.rr-join {
padding: 0 1.25rem;
}
.rr-noaccess {
color: var(--text-3);
}
.rr-del {
width: 2.25rem;
padding: 0;
justify-content: center;
}
.rr-del svg {
width: 0.9375rem;
height: 0.9375rem;
}
.rl-empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.375rem;
padding: 3.125rem 1.25rem;
background: var(--panel);
border: 1px dashed var(--border-strong);
border-radius: 0.75rem;
color: var(--text-3);
}
.rl-empty svg {
width: 2rem;
height: 2rem;
color: var(--border-strong);
}
.rl-empty p {
font-size: 0.875rem;
font-weight: 600;
color: var(--text-2);
}
.rl-empty span {
font-size: 0.78125rem;
}
/* 버튼 기본/primary 는 전역 .btn 사용. 여기선 danger·비활성·사이즈 변형만 보강. */
.btn.danger {
background: #fff;
border-color: var(--red-border);
color: var(--red);
}
.btn.danger:hover {
background: var(--red-weak);
border-color: var(--red);
color: var(--red);
}
.btn:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.btn:disabled:hover {
background: var(--panel);
color: var(--text-2);
}
</style>
@@ -0,0 +1,291 @@
<script setup lang="ts">
// 참여자 목록 — 진행자/참여자 그룹 구분, 이름 검색, 마이크·품질 표시, 진행자의 승격 버튼.
import { computed, ref } from 'vue'
import { ConnectionQuality, type Participant } from 'livekit-client'
import MeetingIcon from './MeetingIcon.vue'
import QualityBars from './QualityBars.vue'
import { avatarColor, avatarInitial } from '@/shared/utils/avatar'
import { normalizeQuality } from '@/composables/useLiveKitRoom'
interface Props {
participants: Participant[]
featuredId: string | null
localId: string | null
isHost: boolean
}
const props = defineProps<Props>()
const emit = defineEmits<{ (e: 'promote', identity: string): void }>()
const query = ref('')
// metadata 로 진행자 판정
function isHostMeta(p: Participant): boolean {
if (!p.metadata) return false
try {
return (JSON.parse(p.metadata) as { role?: string }).role === 'host'
} catch {
return false
}
}
const filtered = computed(() => {
const q = query.value.trim()
return props.participants.filter((p) =>
(p.name || p.identity).includes(q),
)
})
const hosts = computed(() => filtered.value.filter(isHostMeta))
const guests = computed(() => filtered.value.filter((p) => !isHostMeta(p)))
function quality(p: Participant) {
return normalizeQuality(p.connectionQuality as ConnectionQuality)
}
</script>
<template>
<div class="people">
<div class="pp-search">
<MeetingIcon name="search" />
<input
v-model="query"
type="text"
placeholder="이름 검색…"
aria-label="참여자 검색"
>
</div>
<div class="pp-list">
<template v-if="hosts.length">
<div class="pp-group">
진행자
</div>
<div
v-for="p in hosts"
:key="p.identity"
class="pp-row"
:class="{ featured: p.identity === featuredId }"
>
<span
class="pp-av"
:style="{ background: avatarColor(p.name || p.identity) }"
>{{ avatarInitial(p.name || p.identity) }}</span>
<span class="pp-name">{{ p.name || p.identity }}{{ p.identity === localId ? ' (나)' : '' }}</span>
<span
v-if="p.identity === featuredId"
class="pp-pin"
title="스테이지"
><MeetingIcon name="pin" /></span>
<span class="pp-grow" />
<QualityBars
:quality="quality(p)"
variant="bare"
/>
<span
class="pp-mic"
:class="{ off: !p.isMicrophoneEnabled }"
><MeetingIcon :name="p.isMicrophoneEnabled ? 'mic' : 'micOff'" /></span>
<button
v-if="isHost && p.identity !== featuredId"
type="button"
class="pp-promote"
title="스테이지로 크게 보기"
@click="emit('promote', p.identity)"
>
<MeetingIcon name="expand" />
</button>
</div>
</template>
<template v-if="guests.length">
<div class="pp-group">
참여자 {{ guests.length }}
</div>
<div
v-for="p in guests"
:key="p.identity"
class="pp-row"
:class="{ featured: p.identity === featuredId }"
>
<span
class="pp-av"
:style="{ background: avatarColor(p.name || p.identity) }"
>{{ avatarInitial(p.name || p.identity) }}</span>
<span class="pp-name">{{ p.name || p.identity }}{{ p.identity === localId ? ' (나)' : '' }}</span>
<span
v-if="p.identity === featuredId"
class="pp-pin"
title="스테이지"
><MeetingIcon name="pin" /></span>
<span class="pp-grow" />
<QualityBars
:quality="quality(p)"
variant="bare"
/>
<span
class="pp-mic"
:class="{ off: !p.isMicrophoneEnabled }"
><MeetingIcon :name="p.isMicrophoneEnabled ? 'mic' : 'micOff'" /></span>
<button
v-if="isHost && p.identity !== featuredId"
type="button"
class="pp-promote"
title="스테이지로 크게 보기"
@click="emit('promote', p.identity)"
>
<MeetingIcon name="expand" />
</button>
</div>
</template>
<div
v-if="filtered.length === 0"
class="pp-empty"
>
검색 결과가 없습니다.
</div>
</div>
</div>
</template>
<style scoped>
.people {
display: flex;
flex-direction: column;
min-height: 0;
flex: 1;
}
.pp-search {
display: flex;
align-items: center;
gap: 0.5rem;
margin: 0.75rem 1rem;
height: 2.25rem;
padding: 0 0.6875rem;
background: var(--d-panel-2);
border: 1px solid var(--d-border);
border-radius: 0.5rem;
}
.pp-search svg {
width: 0.9375rem;
height: 0.9375rem;
color: var(--d-text-3);
flex-shrink: 0;
}
.pp-search input {
flex: 1;
min-width: 0;
border: none;
outline: none;
background: transparent;
font-family: inherit;
font-size: 0.8125rem;
color: var(--d-text);
}
.pp-search input::placeholder {
color: var(--d-text-3);
}
.pp-list {
flex: 1;
overflow-y: auto;
padding: 0 0.5rem 0.75rem;
scrollbar-width: thin;
scrollbar-color: var(--d-border) transparent;
}
.pp-list::-webkit-scrollbar {
width: 0.4375rem;
}
.pp-list::-webkit-scrollbar-thumb {
background: var(--d-border);
border-radius: 0.25rem;
}
.pp-group {
font-size: 0.6875rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--d-text-3);
padding: 0.75rem 0.5rem 0.3125rem;
}
.pp-row {
display: flex;
align-items: center;
gap: 0.5625rem;
padding: 0.4375rem 0.5rem;
border-radius: 0.5rem;
}
.pp-row:hover {
background: var(--d-panel-2);
}
.pp-row.featured {
background: rgba(79, 70, 229, 0.13);
}
.pp-av {
width: 1.875rem;
height: 1.875rem;
border-radius: 50%;
color: #fff;
display: grid;
place-items: center;
font-size: 0.75rem;
font-weight: 700;
flex-shrink: 0;
text-transform: uppercase;
}
.pp-name {
font-size: 0.8125rem;
font-weight: 500;
color: var(--d-text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.pp-pin {
color: #9b91ff;
display: grid;
place-items: center;
}
.pp-pin svg {
width: 0.8125rem;
height: 0.8125rem;
}
.pp-grow {
flex: 1;
}
.pp-mic {
color: var(--d-text-2);
display: grid;
place-items: center;
}
.pp-mic svg {
width: 0.9375rem;
height: 0.9375rem;
}
.pp-mic.off {
color: var(--d-red);
}
.pp-promote {
width: 1.75rem;
height: 1.75rem;
border: 1px solid var(--d-border);
background: transparent;
color: var(--d-text-2);
border-radius: 0.4375rem;
cursor: pointer;
display: grid;
place-items: center;
}
.pp-promote svg {
width: 0.875rem;
height: 0.875rem;
}
.pp-promote:hover {
background: var(--accent);
border-color: var(--accent);
color: #fff;
}
.pp-empty {
padding: 1.875rem;
text-align: center;
font-size: 0.8125rem;
color: var(--d-text-3);
}
</style>
@@ -0,0 +1,405 @@
<script setup lang="ts">
// 입장 전 프리조인 — 실제 로컬 카메라/마이크를 미리보기하고 초기 on/off 상태를 정한 뒤 입장한다.
// 권한이 거부되면 미리보기 없이 '권한 거부 상태로 입장(참관)'을 안내한다.
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
import MeetingIcon from './MeetingIcon.vue'
import UserAvatar from '@/components/UserAvatar.vue'
interface PrejoinRoom {
name: string
hostName: string | null
restricted: boolean
isHost: boolean
}
interface Props {
room: PrejoinRoom
myName: string
myAvatarUrl: string | null
}
defineProps<Props>()
const emit = defineEmits<{
(e: 'enter', opts: { mic: boolean; cam: boolean }): void
(e: 'cancel'): void
}>()
const videoEl = ref<HTMLVideoElement | null>(null)
const micOn = ref(true)
const camOn = ref(true)
const denied = ref(false) // 미디어 권한 획득 실패 여부
let stream: MediaStream | null = null
// 트랙 enabled 동기화
function applyTrackState() {
if (!stream) return
stream.getAudioTracks().forEach((t) => (t.enabled = micOn.value))
stream.getVideoTracks().forEach((t) => (t.enabled = camOn.value))
}
watch([micOn, camOn], applyTrackState)
onMounted(async () => {
try {
stream = await navigator.mediaDevices.getUserMedia({
video: true,
audio: true,
})
if (videoEl.value) videoEl.value.srcObject = stream
applyTrackState()
} catch {
// 권한 거부/장치 없음 — 미리보기 불가, 토글은 off 로
denied.value = true
micOn.value = false
camOn.value = false
}
})
onBeforeUnmount(stopPreview)
// 미리보기 트랙 정리(입장 시 LiveKit 이 다시 장치를 획득하므로 여기서 해제)
function stopPreview() {
if (stream) {
stream.getTracks().forEach((t) => t.stop())
stream = null
}
if (videoEl.value) videoEl.value.srcObject = null
}
function enter() {
stopPreview()
emit('enter', { mic: micOn.value, cam: camOn.value })
}
</script>
<template>
<div class="prejoin">
<div class="pj-card">
<div class="pj-preview">
<video
ref="videoEl"
class="pj-video"
:class="{ hidden: !camOn }"
autoplay
playsinline
muted
/>
<div
v-if="!camOn"
class="pj-off"
>
<span class="av-lg"><UserAvatar
:url="myAvatarUrl"
:name="myName"
/></span>
</div>
<div class="pj-selflbl">
{{ myName }} ()
</div>
<div class="pj-prectrl">
<button
type="button"
class="pj-rb"
:class="{ off: !micOn }"
:disabled="denied"
title="마이크"
@click="micOn = !micOn"
>
<MeetingIcon :name="micOn ? 'mic' : 'micOff'" />
</button>
<button
type="button"
class="pj-rb"
:class="{ off: !camOn }"
:disabled="denied"
title="카메라"
@click="camOn = !camOn"
>
<MeetingIcon :name="camOn ? 'cam' : 'camOff'" />
</button>
</div>
<div
v-if="denied"
class="pj-camoff-note"
>
카메라·마이크 권한이 없습니다
</div>
</div>
<div class="pj-side">
<div class="pj-room">
<span class="pj-kicker">입장하려는 </span>
<div class="pj-roomname">
{{ room.name }}
<span
v-if="room.restricted"
class="rr-badge lock"
><MeetingIcon name="lock" />제한</span>
</div>
<div
v-if="room.hostName"
class="pj-roomhost"
>
<MeetingIcon name="host" /><span>{{ room.hostName }}</span>
</div>
</div>
<div class="pj-checklist">
<div
class="pj-chk"
:class="{ ok: micOn }"
>
<MeetingIcon :name="micOn ? 'mic' : 'micOff'" /><span>마이크 {{ micOn ? '켜짐' : '꺼짐' }}</span>
</div>
<div
class="pj-chk"
:class="{ ok: camOn }"
>
<MeetingIcon :name="camOn ? 'cam' : 'camOff'" /><span>카메라 {{ camOn ? '켜짐' : '꺼짐' }}</span>
</div>
</div>
<button
type="button"
class="btn primary pj-enter"
@click="enter"
>
지금 입장
</button>
<button
type="button"
class="pj-cancel"
@click="emit('cancel')"
>
로비로 돌아가기
</button>
</div>
</div>
</div>
</template>
<style scoped>
.prejoin {
height: 100%;
display: grid;
place-items: center;
padding: 1.875rem;
}
.pj-card {
display: flex;
gap: 1.375rem;
width: min(56.25rem, 100%);
background: var(--d-panel);
border: 1px solid var(--d-border);
border-radius: 1.125rem;
padding: 1.375rem;
box-shadow: 0 30px 80px rgba(0, 0, 0, 0.45);
}
.pj-preview {
position: relative;
flex: 1;
aspect-ratio: 16/9;
border-radius: 0.8125rem;
overflow: hidden;
background: var(--tile);
}
.pj-video {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
transform: scaleX(-1);
}
.pj-video.hidden {
display: none;
}
.pj-off {
position: absolute;
inset: 0;
display: grid;
place-items: center;
background: var(--tile);
}
.av-lg {
width: clamp(3.5rem, 12vh, 6rem);
height: clamp(3.5rem, 12vh, 6rem);
flex-shrink: 0;
}
.pj-selflbl {
position: absolute;
left: 0.875rem;
bottom: 0.875rem;
z-index: 3;
font-size: 0.8125rem;
font-weight: 600;
color: #fff;
background: rgba(0, 0, 0, 0.42);
padding: 0.3125rem 0.6875rem;
border-radius: 0.4375rem;
-webkit-backdrop-filter: blur(6px);
backdrop-filter: blur(6px);
}
.pj-prectrl {
position: absolute;
left: 0;
right: 0;
bottom: 0.875rem;
z-index: 3;
display: flex;
justify-content: center;
gap: 0.75rem;
}
.pj-rb {
width: 3rem;
height: 3rem;
border-radius: 50%;
border: none;
background: rgba(255, 255, 255, 0.16);
color: #fff;
display: grid;
place-items: center;
cursor: pointer;
-webkit-backdrop-filter: blur(8px);
backdrop-filter: blur(8px);
}
.pj-rb svg {
width: 1.3125rem;
height: 1.3125rem;
}
.pj-rb:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.28);
}
.pj-rb.off {
background: var(--red);
}
.pj-rb:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.pj-camoff-note {
position: absolute;
left: 0;
right: 0;
top: 42%;
transform: translateY(-50%);
text-align: center;
color: var(--d-text-3);
font-size: 0.8125rem;
z-index: 1;
}
.pj-side {
width: 17.875rem;
flex-shrink: 0;
display: flex;
flex-direction: column;
gap: 0.875rem;
}
.pj-room {
padding-bottom: 0.875rem;
border-bottom: 1px solid var(--d-border);
}
.pj-kicker {
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--d-text-3);
}
.pj-roomname {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
margin-top: 0.375rem;
font-size: 1.25rem;
font-weight: 700;
color: var(--d-text);
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
}
.rr-badge.lock {
display: inline-flex;
align-items: center;
gap: 0.25rem;
font-size: 0.6875rem;
font-weight: 600;
padding: 0.125rem 0.5rem;
border-radius: 1.25rem;
line-height: 1.5;
color: var(--d-text-2);
background: rgba(255, 255, 255, 0.08);
border: 1px solid var(--d-border);
}
.rr-badge.lock svg {
width: 0.6875rem;
height: 0.6875rem;
}
.pj-roomhost {
display: flex;
align-items: center;
gap: 0.375rem;
margin-top: 0.5rem;
font-size: 0.8125rem;
color: var(--d-text-2);
}
.pj-roomhost span {
line-height: 1;
}
.pj-roomhost svg {
width: 0.875rem;
height: 0.875rem;
flex-shrink: 0;
}
.pj-checklist {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.pj-chk {
display: flex;
align-items: center;
gap: 0.5625rem;
font-size: 0.8125rem;
color: var(--d-text-3);
}
.pj-chk svg {
width: 1rem;
height: 1rem;
}
.pj-chk.ok {
color: var(--d-text);
}
.pj-enter {
height: 2.875rem;
border-radius: 0.625rem;
justify-content: center;
font-size: 0.9375rem;
margin-top: auto;
}
.pj-cancel {
border: none;
background: transparent;
color: var(--d-text-2);
font-family: inherit;
font-size: 0.8125rem;
cursor: pointer;
padding: 0.25rem;
}
.pj-cancel:hover {
color: var(--d-text);
}
/* 공용 .btn primary 가 라이트 테마 기준이므로 입장 버튼 색을 명시 */
.pj-enter.btn.primary {
background: var(--accent);
border: none;
color: #fff;
}
.pj-enter.btn.primary:hover {
background: var(--accent-hover);
}
@media (max-width: 53.75rem) {
.pj-card {
flex-direction: column;
}
.pj-side {
width: 100%;
}
}
</style>
@@ -0,0 +1,493 @@
<script setup lang="ts">
// 연결된 회의실 — 상단 바 + (스피커/그리드 뷰) 본문 + 오른쪽 패널(참여자/채팅) + 컨트롤 바.
// 모든 라이브 상태(participants/featuredIdentity/mic/cam/messages)는 부모(MeetingPage)가 주입한다.
import { computed, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import type { Participant } from 'livekit-client'
import ParticipantTile from './ParticipantTile.vue'
import MeetingIcon from './MeetingIcon.vue'
import MeetingControlBar from './MeetingControlBar.vue'
import MeetingSidePanel from './MeetingSidePanel.vue'
import type { ChatMessage } from '@/types/meeting'
interface Props {
roomName: string
participants: Participant[]
featuredIdentity: string | null
localId: string | null
micEnabled: boolean
camEnabled: boolean
isHost: boolean
errorMsg: string | null
messages: ChatMessage[]
// 말하는 사람 하이라이트 사용 여부(기본 on)
highlight?: boolean
}
const props = withDefaults(defineProps<Props>(), { highlight: true })
const emit = defineEmits<{
(e: 'toggle-mic'): void
(e: 'toggle-cam'): void
(e: 'promote', identity: string): void
(e: 'send', text: string): void
(e: 'leave'): void
(e: 'dismiss-error'): void
}>()
// 한 페이지에 보일 참여자 수 — 스피커 뷰는 진행자(스테이지) 제외 나머지 최대 50명,
// 그리드 뷰는 전원 기준 50명. 초과분만 페이지네이션으로 넘긴다.
const PER_PAGE = 50
const page = ref(0)
const panel = ref<'people' | 'chat' | null>(null)
const unread = ref(0)
function isHostMeta(p: Participant): boolean {
if (!p.metadata) return false
try {
return (JSON.parse(p.metadata) as { role?: string }).role === 'host'
} catch {
return false
}
}
// --- 레이아웃 검증용 가상 참여자(개발 모드 전용) ---
// 사용법: /meeting/<방>?mock=100 → 미디어 없이 타일만 N명 추가(프로덕션 빌드에선 비활성).
const route = useRoute()
const MOCK_NAMES = [
'김민준',
'이서연',
'박지후',
'최예은',
'정도윤',
'강하늘',
'윤시우',
'임수아',
'한지안',
'오재현',
]
function makeMock(i: number): Participant {
const name = `${MOCK_NAMES[i % MOCK_NAMES.length]}${i + 1}`
return {
sid: `mock-${i}`,
identity: `mock-${i}`,
name,
isMicrophoneEnabled: i % 3 !== 0,
isSpeaking: i % 7 === 0,
connectionQuality: i % 5 === 0 ? 'poor' : i % 3 === 0 ? 'good' : 'excellent',
metadata: undefined,
getTrackPublication: () => undefined,
on() {
return this
},
off() {
return this
},
} as unknown as Participant
}
const mockCount = computed(() => {
if (!import.meta.env.DEV) return 0
const n = Number(route.query.mock)
return Number.isFinite(n) && n > 0 ? Math.min(Math.floor(n), 200) : 0
})
const mocks = computed<Participant[]>(() =>
Array.from({ length: mockCount.value }, (_, i) => makeMock(i)),
)
// 실제 참여자 + 가상 참여자
const allParticipants = computed<Participant[]>(() =>
mockCount.value ? [...props.participants, ...mocks.value] : props.participants,
)
// 스테이지에 올릴 참여자 — 승격됨 > 진행자 > 첫 참여자
const featured = computed<Participant | null>(() => {
const list = allParticipants.value
if (!list.length) return null
if (props.featuredIdentity) {
const f = list.find((p) => p.identity === props.featuredIdentity)
if (f) return f
}
return list.find(isHostMeta) ?? list[0] ?? null
})
// 스테이지(진행자) 제외 나머지 — 썸네일 그리드에 표시
const others = computed(() =>
allParticipants.value.filter((p) => p !== featured.value),
)
const pageCount = computed(() =>
Math.max(1, Math.ceil(others.value.length / PER_PAGE)),
)
const safePage = computed(() => Math.min(page.value, pageCount.value - 1))
const pageSlice = computed(() => {
const start = safePage.value * PER_PAGE
return others.value.slice(start, start + PER_PAGE)
})
function togglePanel(tab: 'people' | 'chat') {
panel.value = panel.value === tab ? null : tab
if (panel.value === 'chat') unread.value = 0
}
function setTab(tab: 'people' | 'chat') {
panel.value = tab
if (tab === 'chat') unread.value = 0
}
// 채팅이 닫혀 있을 때 도착한 상대 메시지를 미읽음으로 집계
watch(
() => props.messages.length,
(len, prev) => {
const added = props.messages.slice(prev ?? 0)
if (panel.value !== 'chat') {
unread.value += added.filter((m) => !m.self).length
}
},
)
</script>
<template>
<div
class="room"
:class="{ 'panel-open': panel }"
>
<!-- 상단 -->
<div class="room-bar">
<div class="rb-left">
<span class="rb-live"><i />LIVE</span>
<span class="rb-name">{{ roomName }}</span>
</div>
<div class="rb-right">
<span class="rb-count"><MeetingIcon name="users" />{{ allParticipants.length }}</span>
</div>
</div>
<p
v-if="errorMsg"
class="room-err"
>
<span class="re-ic"><MeetingIcon name="warn" /></span>
<span>{{ errorMsg }}</span>
<button
type="button"
class="re-x"
@click="emit('dismiss-error')"
>
<MeetingIcon name="close" />
</button>
</p>
<!-- 본문 -->
<div class="room-body">
<div class="room-main">
<!-- 축소된 스테이지() + 썸네일 그리드(아래) -->
<div class="speaker-wrap">
<div class="stage">
<ParticipantTile
v-if="featured"
:key="featured.identity"
:participant="featured"
featured
:is-local="featured.identity === localId"
:highlight="highlight"
/>
<div
v-else
class="stage-empty"
>
참여자를 기다리는
</div>
</div>
<div
v-if="others.length"
class="thumbs"
>
<div class="thumb-scroll">
<div class="thumb-grid">
<ParticipantTile
v-for="p in pageSlice"
:key="p.sid || p.identity"
:participant="p"
:is-local="p.identity === localId"
:can-promote="isHost"
:highlight="highlight"
@promote="(id) => emit('promote', id)"
/>
</div>
</div>
<div
v-if="pageCount > 1"
class="pager"
>
<button
type="button"
class="pg-btn"
:disabled="safePage === 0"
@click="page = safePage - 1"
>
<MeetingIcon name="left" />
</button>
<span class="pg-info">썸네일 · {{ safePage + 1 }}/{{ pageCount }}</span>
<button
type="button"
class="pg-btn"
:disabled="safePage >= pageCount - 1"
@click="page = safePage + 1"
>
<MeetingIcon name="right" />
</button>
</div>
</div>
</div>
</div>
<!-- 오른쪽 패널 -->
<MeetingSidePanel
v-if="panel"
:tab="panel"
:participants="allParticipants"
:featured-id="featured?.identity ?? null"
:local-id="localId"
:is-host="isHost"
:messages="messages"
@tab="setTab"
@close="panel = null"
@promote="(id) => emit('promote', id)"
@send="(t) => emit('send', t)"
/>
</div>
<!-- 컨트롤 -->
<MeetingControlBar
pos="bottom"
:mic-enabled="micEnabled"
:cam-enabled="camEnabled"
:chat-open="panel === 'chat'"
:people-open="panel === 'people'"
:unread="unread"
@toggle-mic="emit('toggle-mic')"
@toggle-cam="emit('toggle-cam')"
@toggle-chat="togglePanel('chat')"
@toggle-people="togglePanel('people')"
@leave="emit('leave')"
/>
</div>
</template>
<style scoped>
.room {
height: 100%;
display: flex;
flex-direction: column;
position: relative;
}
.room-bar {
display: flex;
align-items: center;
gap: 0.875rem;
height: 3.5rem;
flex-shrink: 0;
padding: 0 1.125rem;
border-bottom: 1px solid var(--d-border);
background: var(--d-panel);
}
.rb-left {
display: flex;
align-items: center;
gap: 0.6875rem;
min-width: 0;
}
.rb-live {
display: inline-flex;
align-items: center;
gap: 0.375rem;
font-size: 0.6875rem;
font-weight: 800;
line-height: 1;
letter-spacing: 0.05em;
color: #ff5a6e;
}
.rb-live i {
width: 0.4375rem;
height: 0.4375rem;
border-radius: 50%;
background: #ff5a6e;
animation: livedot 1.6s infinite;
}
@keyframes livedot {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.35;
}
}
.rb-name {
font-size: 0.9375rem;
font-weight: 700;
line-height: 1;
color: var(--d-text);
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.rb-right {
margin-left: auto;
display: flex;
align-items: center;
gap: 0.75rem;
}
.rb-count {
display: inline-flex;
align-items: center;
gap: 0.375rem;
font-size: 0.8125rem;
font-weight: 600;
color: var(--d-text-2);
}
.rb-count svg {
width: 0.9375rem;
height: 0.9375rem;
}
.room-err {
display: flex;
align-items: center;
gap: 0.625rem;
margin: 0.75rem 1.125rem 0;
padding: 0.6875rem 0.875rem;
background: rgba(183, 121, 31, 0.16);
border: 1px solid rgba(183, 121, 31, 0.45);
border-radius: 0.5625rem;
color: var(--d-amber);
font-size: 0.8125rem;
}
.room-err .re-ic {
display: grid;
place-items: center;
}
.room-err .re-ic svg {
width: 1.0625rem;
height: 1.0625rem;
}
.room-err span {
flex: 1;
}
.re-x {
border: none;
background: transparent;
color: var(--d-amber);
cursor: pointer;
display: grid;
place-items: center;
padding: 0.125rem;
flex: 0 0 auto;
}
.re-x svg {
width: 0.9375rem;
height: 0.9375rem;
}
.room-body {
flex: 1;
display: flex;
min-height: 0;
}
.room-main {
flex: 1;
min-width: 0;
padding: 1rem 1.125rem 6.25rem;
}
/* 스피커 뷰 — 위: 축소된 스테이지 / 아래: 썸네일 그리드(50명 채움) */
.speaker-wrap {
display: flex;
flex-direction: column;
gap: 0.875rem;
height: 100%;
min-height: 0;
}
.stage {
flex: 0 0 45%;
min-height: 0;
display: flex;
justify-content: center;
}
/* 축소된 스테이지에서 큰 화면은 16:9 비율 유지 + 높이에 맞춰 중앙 정렬 */
.stage :deep(.tile.featured) {
width: auto;
max-width: 100%;
aspect-ratio: 16 / 9;
}
.stage-empty {
width: 100%;
height: 100%;
display: grid;
place-items: center;
color: var(--d-text-3);
border: 1px dashed var(--d-border);
border-radius: 0.875rem;
}
.thumbs {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
gap: 0.625rem;
}
.thumb-scroll {
flex: 1;
min-height: 0;
overflow-y: auto;
padding-right: 0.125rem;
scrollbar-width: thin;
scrollbar-color: var(--d-border) transparent;
}
.thumb-scroll::-webkit-scrollbar {
width: 0.4375rem;
}
.thumb-scroll::-webkit-scrollbar-thumb {
background: var(--d-border);
border-radius: 0.25rem;
}
.thumb-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(10.5rem, 1fr));
gap: 0.5rem;
align-content: start;
}
/* 페이지네이션 */
.pager {
display: flex;
align-items: center;
justify-content: center;
gap: 0.75rem;
flex-shrink: 0;
}
.pg-btn {
width: 1.875rem;
height: 1.875rem;
border: 1px solid var(--d-border);
background: var(--d-panel-2);
color: var(--d-text-2);
border-radius: 0.4375rem;
cursor: pointer;
display: grid;
place-items: center;
}
.pg-btn svg {
width: 0.9375rem;
height: 0.9375rem;
}
.pg-btn:hover:not(:disabled) {
color: var(--d-text);
border-color: var(--d-text-3);
}
.pg-btn:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.pg-info {
font-size: 0.75rem;
font-weight: 600;
color: var(--d-text-3);
}
</style>
@@ -0,0 +1,145 @@
<script setup lang="ts">
// 회의실 오른쪽 패널 — 참여자/채팅 탭 전환 컨테이너. 활성 탭은 부모가 제어(컨트롤 바와 연동).
import type { Participant } from 'livekit-client'
import MeetingIcon from './MeetingIcon.vue'
import MeetingPeopleList from './MeetingPeopleList.vue'
import MeetingChat from './MeetingChat.vue'
import type { ChatMessage } from '@/types/meeting'
type Tab = 'people' | 'chat'
interface Props {
tab: Tab
participants: Participant[]
featuredId: string | null
localId: string | null
isHost: boolean
messages: ChatMessage[]
}
defineProps<Props>()
const emit = defineEmits<{
(e: 'tab', tab: Tab): void
(e: 'close'): void
(e: 'promote', identity: string): void
(e: 'send', text: string): void
}>()
</script>
<template>
<aside class="ppanel">
<div class="sp-tabs">
<button
type="button"
class="sp-tab"
:class="{ on: tab === 'people' }"
@click="emit('tab', 'people')"
>
참여자 <span class="sp-cnt">{{ participants.length }}</span>
</button>
<button
type="button"
class="sp-tab"
:class="{ on: tab === 'chat' }"
@click="emit('tab', 'chat')"
>
채팅
</button>
<span class="sp-grow" />
<button
type="button"
class="pp-x"
title="패널 닫기"
@click="emit('close')"
>
<MeetingIcon name="close" />
</button>
</div>
<MeetingChat
v-if="tab === 'chat'"
:messages="messages"
@send="(t) => emit('send', t)"
/>
<MeetingPeopleList
v-else
:participants="participants"
:featured-id="featuredId"
:local-id="localId"
:is-host="isHost"
@promote="(id) => emit('promote', id)"
/>
</aside>
</template>
<style scoped>
.ppanel {
width: 19.5rem;
flex-shrink: 0;
border-left: 1px solid var(--d-border);
background: var(--d-panel);
display: flex;
flex-direction: column;
min-height: 0;
}
.sp-tabs {
display: flex;
align-items: center;
gap: 0.25rem;
padding: 0.5625rem 0.5rem 0.5625rem 0.625rem;
border-bottom: 1px solid var(--d-border);
}
.sp-tab {
border: none;
background: transparent;
font-family: inherit;
font-size: 0.84375rem;
font-weight: 600;
color: var(--d-text-3);
padding: 0.4375rem 0.75rem;
border-radius: 0.5rem;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 0.4375rem;
}
.sp-tab:hover {
color: var(--d-text-2);
}
.sp-tab.on {
color: var(--d-text);
background: var(--d-panel-2);
}
.sp-cnt {
font-size: 0.6875rem;
font-weight: 700;
color: var(--d-text-2);
background: rgba(255, 255, 255, 0.08);
padding: 1px 0.4375rem;
border-radius: 1.25rem;
}
.sp-tab.on .sp-cnt {
color: var(--d-accent-soft);
background: rgba(124, 92, 240, 0.22);
}
.sp-grow {
flex: 1;
}
.pp-x {
width: 1.875rem;
height: 1.875rem;
border: none;
background: transparent;
color: var(--d-text-3);
border-radius: 0.4375rem;
cursor: pointer;
display: grid;
place-items: center;
}
.pp-x svg {
width: 1rem;
height: 1rem;
}
.pp-x:hover {
background: var(--d-panel-2);
color: var(--d-text);
}
</style>
@@ -0,0 +1,330 @@
<script setup lang="ts">
// 참여자 1명의 영상 타일 — 카메라 트랙을 <video>, (원격) 마이크 트랙을 <audio> 에 연결한다.
// 로컬 참여자 오디오는 에코 방지를 위해 재생하지 않는다. 카메라가 꺼져 있으면 이름 이니셜 아바타.
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import {
Track,
ParticipantEvent,
ConnectionQuality,
RemoteTrackPublication,
VideoQuality,
type Participant,
} from 'livekit-client'
import QualityBars from './QualityBars.vue'
import MeetingIcon from './MeetingIcon.vue'
import { avatarColor, avatarInitial } from '@/shared/utils/avatar'
import { normalizeQuality } from '@/composables/useLiveKitRoom'
import type { MeetingQuality } from '@/types/meeting'
interface Props {
participant: Participant
isLocal?: boolean
// 큰 화면(stage) 렌더 여부
featured?: boolean
// 진행자가 이 타일을 승격할 수 있는지(호버 시 "크게" 버튼)
canPromote?: boolean
// 말하는 사람 하이라이트 사용 여부
highlight?: boolean
}
const props = defineProps<Props>()
const emit = defineEmits<{ (e: 'promote', identity: string): void }>()
const videoEl = ref<HTMLVideoElement | null>(null)
const hasVideo = ref(false)
const micOn = ref(false)
const speaking = ref(false)
const quality = ref<MeetingQuality>('good')
const isHost = ref(false)
const displayName = ref(props.participant.name || props.participant.identity)
const avColor = computed(() => avatarColor(displayName.value))
const initial = computed(() => avatarInitial(displayName.value))
const showSpeaking = computed(
() => props.highlight && speaking.value && micOn.value,
)
// 참여자 metadata 문자열에서 진행자 여부 파싱({"role":"host"})
function parseHost(metadata: string | undefined): boolean {
if (!metadata) return false
try {
return (JSON.parse(metadata) as { role?: string }).role === 'host'
} catch {
return false
}
}
// 현재 트랙/상태를 DOM 과 반응형 값에 반영
function sync() {
const p = props.participant
displayName.value = p.name || p.identity
micOn.value = p.isMicrophoneEnabled
speaking.value = p.isSpeaking
quality.value = normalizeQuality(p.connectionQuality as ConnectionQuality)
isHost.value = parseHost(p.metadata)
// 오디오는 룸 레벨(useLiveKitRoom)에서 전원 재생하므로 여기서는 영상만 다룬다.
const camPub = p.getTrackPublication(Track.Source.Camera)
if (camPub?.track && videoEl.value) {
camPub.track.attach(videoEl.value)
hasVideo.value = !camPub.isMuted
// 원격 참여자: 스테이지(featured)는 고화질, 썸네일은 저화질로 구독한다.
// (adaptiveStream 의 타일 크기 기반 선택과도 일치 — 대역폭 절약)
if (!props.isLocal && camPub instanceof RemoteTrackPublication) {
camPub.setVideoQuality(props.featured ? VideoQuality.HIGH : VideoQuality.LOW)
}
} else {
hasVideo.value = false
}
}
function onSpeaking() {
speaking.value = props.participant.isSpeaking
}
function onQuality() {
quality.value = normalizeQuality(
props.participant.connectionQuality as ConnectionQuality,
)
}
onMounted(() => {
sync()
const p = props.participant
p.on(ParticipantEvent.TrackSubscribed, sync)
p.on(ParticipantEvent.TrackUnsubscribed, sync)
p.on(ParticipantEvent.LocalTrackPublished, sync)
p.on(ParticipantEvent.LocalTrackUnpublished, sync)
p.on(ParticipantEvent.TrackMuted, sync)
p.on(ParticipantEvent.TrackUnmuted, sync)
p.on(ParticipantEvent.IsSpeakingChanged, onSpeaking)
p.on(ParticipantEvent.ConnectionQualityChanged, onQuality)
})
onBeforeUnmount(() => {
const p = props.participant
p.off(ParticipantEvent.TrackSubscribed, sync)
p.off(ParticipantEvent.TrackUnsubscribed, sync)
p.off(ParticipantEvent.LocalTrackPublished, sync)
p.off(ParticipantEvent.LocalTrackUnpublished, sync)
p.off(ParticipantEvent.TrackMuted, sync)
p.off(ParticipantEvent.TrackUnmuted, sync)
p.off(ParticipantEvent.IsSpeakingChanged, onSpeaking)
p.off(ParticipantEvent.ConnectionQualityChanged, onQuality)
// 타일 언마운트 시 비디오 트랙을 엘리먼트에서 분리(미분리 시 attachedElements 누수)
const camPub = p.getTrackPublication(Track.Source.Camera)
if (camPub?.track && videoEl.value) camPub.track.detach(videoEl.value)
})
</script>
<template>
<div
class="tile"
:class="{ featured, speaking: showSpeaking }"
:style="{ '--av': avColor }"
>
<!-- 영상: 로컬은 좌우 반전(거울)으로 표시 -->
<video
ref="videoEl"
class="vid"
:class="{ mirror: isLocal, hidden: !hasVideo }"
autoplay
playsinline
:muted="isLocal"
/>
<!-- 카메라 off/미발행 이름 이니셜 아바타 -->
<div
v-if="!hasVideo"
class="tile-off"
>
<span class="av-lg">{{ initial }}</span>
</div>
<!-- 연결 품질 -->
<div class="tile-q">
<QualityBars :quality="quality" />
</div>
<!-- 진행자 승격 버튼(호버) -->
<button
v-if="canPromote && !featured"
type="button"
class="tile-promote"
title="스테이지로 크게 보기"
@click="emit('promote', participant.identity)"
>
<MeetingIcon name="expand" /><span>크게</span>
</button>
<!-- 하단 메타 -->
<div class="tile-meta">
<span
class="mmic"
:class="{ off: !micOn }"
:title="micOn ? '마이크 켜짐' : '마이크 꺼짐'"
>
<MeetingIcon :name="micOn ? 'mic' : 'micOff'" />
</span>
<span class="mname">{{ displayName }}{{ isLocal ? ' (나)' : '' }}</span>
<span
v-if="isHost"
class="mhost"
title="진행자"
>
<MeetingIcon name="host" />
</span>
</div>
</div>
</template>
<style scoped>
.tile {
position: relative;
aspect-ratio: 16 / 9;
border-radius: 0.75rem;
overflow: hidden;
background: var(--tile);
border: 0.125rem solid transparent;
}
.tile.featured {
aspect-ratio: auto;
height: 100%;
border-radius: 0.875rem;
}
.tile.speaking {
border-color: var(--green);
box-shadow: 0 0 0 1px var(--green), 0 0 22px -4px rgba(31, 157, 87, 0.7);
}
.vid {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.vid.mirror {
transform: scaleX(-1);
}
.vid.hidden {
display: none;
}
.tile-off {
position: absolute;
inset: 0;
display: grid;
place-items: center;
background: var(--tile);
}
.av-lg {
width: clamp(2.875rem, 9vh, 5.75rem);
height: clamp(2.875rem, 9vh, 5.75rem);
border-radius: 50%;
background: var(--av, #555);
color: #fff;
display: grid;
place-items: center;
font-size: clamp(1.25rem, 4vh, 2.375rem);
font-weight: 700;
text-transform: uppercase;
}
.tile-q {
position: absolute;
top: 0.5625rem;
left: 0.5625rem;
z-index: 3;
}
.tile-meta {
position: absolute;
left: 0.5625rem;
bottom: 0.5625rem;
right: 0.5625rem;
z-index: 3;
display: flex;
align-items: center;
gap: 0.375rem;
}
.tile.featured .tile-meta {
left: 0.875rem;
bottom: 0.875rem;
}
.mmic {
width: 1.375rem;
height: 1.375rem;
border-radius: 0.375rem;
background: rgba(0, 0, 0, 0.6);
color: #fff;
display: grid;
place-items: center;
flex-shrink: 0;
}
.mmic svg {
width: 0.8125rem;
height: 0.8125rem;
}
.mmic.off {
color: var(--d-red);
}
.mname {
font-size: 0.78125rem;
font-weight: 600;
color: #fff;
background: rgba(0, 0, 0, 0.55);
padding: 0.1875rem 0.5625rem;
border-radius: 0.375rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.tile.featured .mname {
font-size: 0.875rem;
padding: 0.3125rem 0.75rem;
}
.mhost {
width: 1.375rem;
height: 1.375rem;
border-radius: 0.375rem;
background: rgba(124, 92, 240, 0.85);
color: #fff;
display: grid;
place-items: center;
flex-shrink: 0;
}
.mhost svg {
width: 0.75rem;
height: 0.75rem;
}
.tile-promote {
position: absolute;
top: 0.5rem;
right: 0.5rem;
z-index: 4;
display: inline-flex;
align-items: center;
gap: 0.3125rem;
height: 1.75rem;
padding: 0 0.625rem;
border: none;
border-radius: 0.4375rem;
background: rgba(0, 0, 0, 0.55);
color: #fff;
font-family: inherit;
font-size: 0.75rem;
font-weight: 600;
cursor: pointer;
opacity: 0;
transform: translateY(-0.125rem);
transition:
opacity 0.12s,
transform 0.12s;
}
.tile-promote svg {
width: 0.8125rem;
height: 0.8125rem;
}
.tile:hover .tile-promote {
opacity: 1;
transform: none;
}
.tile-promote:hover {
background: var(--accent);
}
</style>
@@ -0,0 +1,79 @@
<script setup lang="ts">
// 연결 품질 막대(3/2/1칸) — good/medium/poor 단계에 따라 채워진 칸 수와 색이 달라진다.
import { computed } from 'vue'
import type { MeetingQuality } from '@/types/meeting'
interface Props {
quality: MeetingQuality
// 'tile'(다크 배경 위 반투명 칩) | 'bare'(배경 없이 막대만, 목록·상단바용)
variant?: 'tile' | 'bare'
}
const props = withDefaults(defineProps<Props>(), { variant: 'tile' })
const level = computed(() =>
props.quality === 'good' ? 3 : props.quality === 'medium' ? 2 : 1,
)
const label = computed(() =>
props.quality === 'good'
? '원활'
: props.quality === 'medium'
? '보통'
: '불안정',
)
</script>
<template>
<span
class="qbars"
:class="[`q-${quality}`, variant === 'bare' ? 'bare' : '']"
:title="`연결 품질: ${label}`"
>
<i
v-for="n in 3"
:key="n"
:class="{ on: n <= level }"
/>
</span>
</template>
<style scoped>
.qbars {
display: inline-flex;
align-items: flex-end;
gap: 0.125rem;
height: 0.8125rem;
padding: 0.1875rem;
background: rgba(0, 0, 0, 0.55);
border-radius: 0.3125rem;
}
.qbars.bare {
background: transparent;
padding: 0;
}
.qbars i {
width: 0.1875rem;
border-radius: 1px;
background: rgba(255, 255, 255, 0.32);
}
.qbars.bare i {
background: #3a3f4a;
}
.qbars i:nth-child(1) {
height: 0.25rem;
}
.qbars i:nth-child(2) {
height: 0.4375rem;
}
.qbars i:nth-child(3) {
height: 0.625rem;
}
.qbars.q-good i.on {
background: var(--d-green);
}
.qbars.q-medium i.on {
background: var(--d-amber);
}
.qbars.q-poor i.on {
background: var(--d-red);
}
</style>
+330
View File
@@ -0,0 +1,330 @@
import { ref, shallowRef } from 'vue'
import {
Room,
RoomEvent,
Track,
ConnectionQuality,
type Participant,
type RemoteParticipant,
type RemoteTrack,
type RemoteTrackPublication,
} from 'livekit-client'
import type { ChatMessage, MeetingQuality } from '@/types/meeting'
// 브라우저가 접속할 LiveKit WebSocket URL (build arg/env 로 주입, 미설정 시 로컬 기본값)
const LIVEKIT_URL = import.meta.env.VITE_LIVEKIT_URL || 'ws://localhost:7880'
// 채팅 메시지 최대 길이(데이터 채널 패킷 과대 방지) — 입력/전송에서 공유한다.
export const MAX_CHAT_LENGTH = 2000
// 입장 옵션 — 프리조인에서 정한 초기 마이크/카메라 상태
export interface ConnectOptions {
cam?: boolean
mic?: boolean
}
// LiveKit ConnectionQuality → UI 막대 단계로 정규화
export function normalizeQuality(q: ConnectionQuality | undefined): MeetingQuality {
switch (q) {
case ConnectionQuality.Excellent:
case ConnectionQuality.Good:
return 'good'
case ConnectionQuality.Poor:
return 'medium'
case ConnectionQuality.Lost:
return 'poor'
default:
return 'good'
}
}
// 참여자 metadata(JSON)에서 프로필 이미지 URL 파싱 — 토큰 발급 시 백엔드가 실어준다.
function parseMetaAvatar(metadata: string | undefined): string | null {
if (!metadata) return null
try {
return (
(JSON.parse(metadata) as { avatarUrl?: string | null }).avatarUrl ?? null
)
} catch {
return null
}
}
// 참여자 metadata 가 진행자(host)인지 — 서버 서명 토큰의 값이라 위조 불가.
function isHostMeta(metadata: string | undefined): boolean {
if (!metadata) return false
try {
return (JSON.parse(metadata) as { role?: string }).role === 'host'
} catch {
return false
}
}
// LiveKit 방 연결/생명주기 관리 Composable
// 미디어 전송 자체는 LiveKit SDK 가 담당하고, 여기서는 연결 상태와 참여자 목록만 반응형으로 노출한다.
export function useLiveKitRoom() {
// Room 인스턴스는 깊은 반응형이 불필요(내부적으로 자체 이벤트 관리) → shallowRef
const room = shallowRef<Room | null>(null)
// 참여자 배열도 shallowRef — Participant 는 비공개 멤버가 있는 클래스라 깊은 언랩 시
// 구조적 타입으로 바뀌어 타입 불일치가 난다. 매번 배열을 통째로 교체하므로 shallow 로 충분.
const participants = shallowRef<Participant[]>([])
const connected = ref(false)
const connecting = ref(false)
const micEnabled = ref(false)
const camEnabled = ref(false)
const errorMsg = ref<string | null>(null)
// 크게 보기(stage)로 승격된 참여자 identity. 진행자가 데이터 채널로 브로드캐스트 → 전원 동기화.
const featuredIdentity = ref<string | null>(null)
// 채팅 메시지(인메모리) — 데이터 채널로 주고받음. 서버에 저장하지 않으므로 새로고침 시 사라진다.
const messages = ref<ChatMessage[]>([])
// 로컬 + 원격 참여자 목록 갱신. 마이크/발화/품질 등 참여자 상태 변동 시에도 호출해
// 배열 참조를 새로 만들어(=동일 Participant 인스턴스 유지) 의존 computed 를 재평가시킨다.
function refresh() {
const r = room.value
if (!r) {
participants.value = []
return
}
participants.value = [
r.localParticipant,
...Array.from(r.remoteParticipants.values()),
]
}
// --- 원격 오디오는 화면 타일(페이지네이션)과 무관하게 항상 재생되어야 한다 ---
// 타일에 attach 하면 현재 페이지에 없는 참여자(>50번째)는 무음이 되므로,
// 숨은 컨테이너에 트랙별 audio 엘리먼트를 붙여 룸 레벨에서 전원 오디오를 재생한다.
let audioContainer: HTMLElement | null = null
const audioEls = new Map<string, HTMLMediaElement>()
function attachRemoteAudio(track: RemoteTrack, pub: RemoteTrackPublication) {
if (track.kind !== Track.Kind.Audio) return
if (!audioContainer) {
audioContainer = document.createElement('div')
audioContainer.style.display = 'none'
document.body.appendChild(audioContainer)
}
const el = track.attach() // 새 audio 엘리먼트 생성
audioContainer.appendChild(el)
audioEls.set(pub.trackSid, el)
}
function detachRemoteAudio(track: RemoteTrack, pub: RemoteTrackPublication) {
if (track.kind !== Track.Kind.Audio) return
track.detach()
const el = audioEls.get(pub.trackSid)
if (el) {
el.remove()
audioEls.delete(pub.trackSid)
}
}
function cleanupAudio() {
audioEls.forEach((el) => el.remove())
audioEls.clear()
audioContainer?.remove()
audioContainer = null
}
// TrackSubscribed/Unsubscribed: 목록 갱신 + (오디오면) 룸 레벨 재생 관리
function onTrackSubscribed(track: RemoteTrack, pub: RemoteTrackPublication) {
refresh()
attachRemoteAudio(track, pub)
}
function onTrackUnsubscribed(track: RemoteTrack, pub: RemoteTrackPublication) {
refresh()
detachRemoteAudio(track, pub)
}
function onDisconnected() {
connected.value = false
participants.value = []
featuredIdentity.value = null
messages.value = []
cleanupAudio()
}
// 데이터 채널 수신 — 진행자의 승격(promote) + 채팅(chat) 메시지를 처리
function onData(payload: Uint8Array, sender?: RemoteParticipant) {
try {
const msg = JSON.parse(new TextDecoder().decode(payload)) as {
type?: string
identity?: string
text?: string
ts?: number
}
if (msg.type === 'promote' && typeof msg.identity === 'string') {
// 진행자가 보낸 승격만 적용 — 일반 참여자가 보낸 promote 는 무시(인가).
if (isHostMeta(sender?.metadata)) featuredIdentity.value = msg.identity
} else if (msg.type === 'chat' && typeof msg.text === 'string') {
messages.value = [
...messages.value,
{
id: `${sender?.identity ?? 'remote'}-${msg.ts ?? performance.now()}`,
senderId: sender?.identity ?? 'unknown',
// 표시 이름은 서버 서명 토큰의 name/identity 만 신뢰(클라이언트가 보낸 name 은 사칭 위험이라 무시).
senderName: sender?.name || sender?.identity || '참여자',
senderAvatarUrl: parseMetaAvatar(sender?.metadata),
text: msg.text,
ts: msg.ts ?? Date.now(),
self: false,
},
]
}
} catch {
// 알 수 없는 메시지는 무시
}
}
// 특정 참여자를 크게 보기로 승격 — 진행자만 호출(UI에서 제한). 전원에게 브로드캐스트.
async function promote(identity: string): Promise<void> {
const r = room.value
if (!r) return
featuredIdentity.value = identity
const data = new TextEncoder().encode(
JSON.stringify({ type: 'promote', identity }),
)
await r.localParticipant.publishData(data, { reliable: true })
}
// 채팅 전송 — 데이터 채널로 브로드캐스트 + 본인 목록에 즉시 추가
async function sendChat(text: string): Promise<void> {
const r = room.value
// 데이터 채널 패킷 권장 크기를 넘지 않도록 길이 제한(과도한 페이로드 방지).
const body = text.trim().slice(0, MAX_CHAT_LENGTH)
if (!r || !body) return
const ts = Date.now()
const me = r.localParticipant
messages.value = [
...messages.value,
{
id: `${me.identity}-${ts}`,
senderId: me.identity,
senderName: me.name || me.identity,
senderAvatarUrl: parseMetaAvatar(me.metadata),
text: body,
ts,
self: true,
},
]
// name 은 보내지 않는다 — 수신 측은 서명된 sender.name 을 사용(사칭 방지).
const data = new TextEncoder().encode(
JSON.stringify({ type: 'chat', text: body, ts }),
)
await me.publishData(data, { reliable: true })
}
// 방 입장 — 토큰으로 연결 후 옵션에 맞춰 카메라/마이크 발행
async function connect(token: string, opts: ConnectOptions = {}): Promise<void> {
if (connecting.value || connected.value) return
const wantCam = opts.cam ?? true
const wantMic = opts.mic ?? true
connecting.value = true
errorMsg.value = null
try {
const r = new Room({ adaptiveStream: true, dynacast: true })
// 참여자 목록(배열) 재생성이 필요한 이벤트만 구독한다.
// 발화(ActiveSpeakers)·연결품질(ConnectionQuality)은 매우 잦게 발생하는데
// 각 타일이 ParticipantEvent 로 자체 갱신하므로, 여기서 전체 배열을 재생성하면
// 대규모(수십 명) 화면에서 불필요한 리렌더로 버벅임을 유발한다 → 제외.
r.on(RoomEvent.ParticipantConnected, refresh)
.on(RoomEvent.ParticipantDisconnected, refresh)
.on(RoomEvent.TrackSubscribed, onTrackSubscribed)
.on(RoomEvent.TrackUnsubscribed, onTrackUnsubscribed)
.on(RoomEvent.TrackMuted, refresh)
.on(RoomEvent.TrackUnmuted, refresh)
.on(RoomEvent.LocalTrackPublished, refresh)
.on(RoomEvent.LocalTrackUnpublished, refresh)
.on(RoomEvent.ParticipantMetadataChanged, refresh)
.on(RoomEvent.DataReceived, onData)
.on(RoomEvent.Disconnected, onDisconnected)
await r.connect(LIVEKIT_URL, token)
room.value = r
connected.value = true
// 카메라/마이크 권한 요청 및 발행 (실패해도 연결은 유지)
if (wantCam || wantMic) {
try {
if (wantCam && wantMic) {
await r.localParticipant.enableCameraAndMicrophone()
} else {
if (wantMic) await r.localParticipant.setMicrophoneEnabled(true)
if (wantCam) await r.localParticipant.setCameraEnabled(true)
}
camEnabled.value = wantCam
micEnabled.value = wantMic
} catch {
errorMsg.value =
'카메라/마이크를 사용할 수 없습니다. 권한을 확인해 주세요.'
}
} else {
// 권한 거부 입장 — 영상·음성 없이 참관
errorMsg.value =
'카메라·마이크 없이 참여 중입니다. 아래 컨트롤에서 언제든 켤 수 있습니다.'
}
refresh()
} catch (e) {
errorMsg.value = e instanceof Error ? e.message : '연결에 실패했습니다.'
await disconnect()
} finally {
connecting.value = false
}
}
// 방 나가기
async function disconnect(): Promise<void> {
const r = room.value
if (r) await r.disconnect()
room.value = null
connected.value = false
camEnabled.value = false
micEnabled.value = false
participants.value = []
featuredIdentity.value = null
messages.value = []
cleanupAudio()
}
async function toggleMic(): Promise<void> {
const r = room.value
if (!r) return
const next = !micEnabled.value
micEnabled.value = next
try {
await r.localParticipant.setMicrophoneEnabled(next)
} catch {
micEnabled.value = !next // 실패 시 상태 원복(표시와 실제 송출 불일치 방지)
}
refresh()
}
async function toggleCam(): Promise<void> {
const r = room.value
if (!r) return
const next = !camEnabled.value
camEnabled.value = next
try {
await r.localParticipant.setCameraEnabled(next)
} catch {
camEnabled.value = !next
}
refresh()
}
return {
participants,
connected,
connecting,
micEnabled,
camEnabled,
errorMsg,
featuredIdentity,
messages,
connect,
disconnect,
toggleMic,
toggleCam,
promote,
sendChat,
}
}
+50
View File
@@ -0,0 +1,50 @@
import { useApi } from './useApi'
import type {
ApiMeetingRoom,
CreateMeetingRoomPayload,
} from '@/types/meeting'
// 회의 내 역할 — 진행자(방 생성자) / 일반 참여자
export type MeetingRole = 'host' | 'participant'
// 화상회의 토큰 응답 — 백엔드 MeetingTokenResult 와 1:1
export interface MeetingToken {
token: string // LiveKit 입장 JWT
roomName: string
identity: string // 참여자 식별자(사용자 ID)
role: MeetingRole // 이번 입장자의 역할
}
// 화상회의 도메인 API Composable — 인터셉터가 success/data 를 언래핑
export function useMeeting() {
const api = useApi()
// LiveKit 입장 토큰 발급
async function getToken(roomName: string): Promise<MeetingToken> {
return (await api.post('/meetings/token', {
roomName,
})) as unknown as MeetingToken
}
// 방 목록
async function listRooms(): Promise<ApiMeetingRoom[]> {
return (await api.get('/meetings/rooms')) as unknown as ApiMeetingRoom[]
}
// 방 생성(생성자=진행자)
async function createRoom(
payload: CreateMeetingRoomPayload,
): Promise<ApiMeetingRoom> {
return (await api.post(
'/meetings/rooms',
payload,
)) as unknown as ApiMeetingRoom
}
// 방 삭제(진행자만)
async function removeRoom(roomId: string): Promise<void> {
await api.delete(`/meetings/rooms/${roomId}`)
}
return { getToken, listRooms, createRoom, removeRoom }
}
+27 -1
View File
@@ -29,9 +29,10 @@ function toggleCollapse() {
const mobileOpen = ref(false)
// 현재 경로 기준으로 사이드바 네비 활성 항목을 판별
const activeNav = computed<'tasks' | 'projects' | 'schedule'>(() => {
const activeNav = computed<'tasks' | 'projects' | 'schedule' | 'meeting'>(() => {
if (route.path.startsWith('/projects')) return 'projects'
if (route.path.startsWith('/schedule')) return 'schedule'
if (route.path.startsWith('/meeting')) return 'meeting'
return 'tasks'
})
@@ -201,6 +202,31 @@ async function onLogout() {
</svg>
<span>프로젝트</span>
</RouterLink>
<RouterLink
to="/meeting"
class="nav-item"
title="화상회의"
:class="{ active: activeNav === 'meeting' }"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="m22 8-6 4 6 4V8Z" />
<rect
x="2"
y="6"
width="14"
height="12"
rx="2"
/>
</svg>
<span>화상회의</span>
</RouterLink>
</nav>
<!-- 하단: 접기 토글 + 사용자 프로필 + 메뉴(위로 펼침) -->
+290
View File
@@ -0,0 +1,290 @@
<script setup lang="ts">
// 화상회의 — 상태 머신(로비 → 프리조인 → 연결 중 → 회의실).
// 라이브 상태는 useLiveKitRoom 이 보유하고, 화면 조각은 components/meeting/* 가 담당한다.
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import AppShell from '@/layouts/AppShell.vue'
import MeetingLobby from '@/components/meeting/MeetingLobby.vue'
import MeetingPrejoin from '@/components/meeting/MeetingPrejoin.vue'
import MeetingRoom from '@/components/meeting/MeetingRoom.vue'
import { useMeeting, type MeetingRole } from '@/composables/useMeeting'
import { useLiveKitRoom } from '@/composables/useLiveKitRoom'
import { useDialog } from '@/composables/useDialog'
import { useAuthStore } from '@/stores/auth.store'
import type { ApiMeetingRoom } from '@/types/meeting'
const route = useRoute()
const router = useRouter()
const meetingApi = useMeeting()
const dialog = useDialog()
const authStore = useAuthStore()
const {
participants,
connected,
connecting,
micEnabled,
camEnabled,
errorMsg,
featuredIdentity,
messages,
connect,
disconnect,
toggleMic,
toggleCam,
promote,
sendChat,
} = useLiveKitRoom()
const ROOM_PATTERN = /^[A-Za-z0-9_-]{1,64}$/
// 프리조인 대상 방 정보(표시용)
interface PrejoinTarget {
name: string
hostName: string | null
restricted: boolean
isHost: boolean
}
const phase = ref<'lobby' | 'prejoin'>('lobby')
const entering = ref(false) // 토큰 발급~연결 시작 사이 깜빡임 방지
const target = ref<PrejoinTarget | null>(null)
const joinError = ref<string | null>(null)
const myRole = ref<MeetingRole | null>(null)
const rooms = ref<ApiMeetingRoom[]>([])
const me = computed(() => authStore.user)
const myName = computed(() => me.value?.name || '나')
const myAvatarUrl = computed(() => me.value?.avatarUrl ?? null)
const localId = computed(() => participants.value[0]?.identity ?? null)
const isHost = computed(() => myRole.value === 'host')
// 현재 보여줄 화면 결정
const screen = computed<'lobby' | 'prejoin' | 'connecting' | 'room'>(() => {
if (connected.value) return 'room'
if (entering.value || connecting.value) return 'connecting'
if (phase.value === 'prejoin' && target.value) return 'prejoin'
return 'lobby'
})
async function loadRooms() {
try {
rooms.value = await meetingApi.listRooms()
} catch {
// 목록 조회 실패는 인터셉터에서 전역 처리
}
}
// 로비에서 방 선택/입력 → 프리조인으로
function onJoin(name: string) {
const n = (name || '').trim()
if (!n) {
joinError.value = '방 이름을 입력해 주세요.'
return
}
if (!ROOM_PATTERN.test(n)) {
joinError.value =
'방 이름은 영문·숫자·-·_ 1~64자만 가능합니다. (한글·공백 불가)'
return
}
const room = rooms.value.find((r) => r.name === n)
if (room && room.restricted && !room.canJoin) {
joinError.value = `'${n}' 방은 참여가 제한되어 있습니다. 진행자에게 허용을 요청하세요.`
return
}
joinError.value = null
target.value = room
? {
name: room.name,
hostName: room.host?.name ?? null,
restricted: room.restricted,
isHost: room.isHost,
}
: {
name: n,
hostName: myName.value, // 새 방은 입장자가 진행자가 된다
restricted: false,
isHost: true,
}
phase.value = 'prejoin'
}
// 프리조인 → 입장(토큰 발급 + 연결)
async function onEnter(opts: { mic: boolean; cam: boolean }) {
const name = target.value?.name
if (!name) return
entering.value = true
try {
const { token, role } = await meetingApi.getToken(name)
myRole.value = role
await connect(token, opts)
if (route.params.room !== name) {
void router.replace(`/meeting/${name}`)
}
} catch {
joinError.value = '입장에 실패했습니다. 잠시 후 다시 시도해 주세요.'
phase.value = 'lobby'
target.value = null
} finally {
entering.value = false
}
}
// 프리조인 취소 → 로비
function onCancelPrejoin() {
phase.value = 'lobby'
target.value = null
}
// 나가기 → 로비
async function onLeave() {
await disconnect()
myRole.value = null
phase.value = 'lobby'
target.value = null
void router.replace('/meeting')
void loadRooms()
}
// 방 삭제(진행자만)
async function onDelete(r: ApiMeetingRoom) {
const ok = await dialog.confirm(
`'${r.name}' 방을 삭제할까요? 이 작업은 되돌릴 수 없습니다.`,
{ title: '방 삭제', confirmText: '삭제', variant: 'danger' },
)
if (!ok) return
try {
await meetingApi.removeRoom(r.id)
await loadRooms()
} catch {
// 전역 처리
}
}
// 방 생성 성공 → 목록 갱신
function onCreated() {
void loadRooms()
}
// 진입 시 방 목록 로드. 공유 링크(/meeting/:room)면 그 방 프리조인으로 바로 이동.
onMounted(async () => {
await loadRooms()
const param = route.params.room
if (typeof param === 'string' && param) onJoin(param)
})
// 화면 이탈 시 연결 정리
onBeforeUnmount(() => {
void disconnect()
})
</script>
<template>
<AppShell>
<!-- 로비(라이트) -->
<MeetingLobby
v-if="screen === 'lobby'"
:rooms="rooms"
:join-error="joinError"
@join="onJoin"
@created="onCreated"
@delete="onDelete"
@clear-error="joinError = null"
/>
<!-- 다크 캔버스(프리조인 / 연결 / 회의실) -->
<div
v-else
class="meeting-canvas"
>
<MeetingPrejoin
v-if="screen === 'prejoin' && target"
:room="target"
:my-name="myName"
:my-avatar-url="myAvatarUrl"
@enter="onEnter"
@cancel="onCancelPrejoin"
/>
<div
v-else-if="screen === 'connecting'"
class="connecting"
>
<div class="cn-spin" />
<div class="cn-name">
{{ target?.name }}
</div>
<div class="cn-txt">
회의에 연결하는
</div>
</div>
<MeetingRoom
v-else-if="screen === 'room'"
:room-name="target?.name ?? ''"
:participants="participants"
:featured-identity="featuredIdentity"
:local-id="localId"
:mic-enabled="micEnabled"
:cam-enabled="camEnabled"
:is-host="isHost"
:error-msg="errorMsg"
:messages="messages"
@toggle-mic="toggleMic"
@toggle-cam="toggleCam"
@promote="promote"
@send="sendChat"
@leave="onLeave"
@dismiss-error="errorMsg = null"
/>
</div>
</AppShell>
</template>
<style scoped>
/* 다크 캔버스 — 사이드바/상단바(3.25rem) 아래 영역을 가득 채운다 */
.meeting-canvas {
height: calc(100vh - 3.25rem);
background: var(--d-bg);
overflow: hidden;
}
/* 연결 중 */
.connecting {
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1rem;
}
.cn-spin {
width: 2.875rem;
height: 2.875rem;
border-radius: 50%;
border: 0.1875rem solid var(--d-border);
border-top-color: var(--accent);
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.cn-name {
font-size: 1.125rem;
font-weight: 700;
color: var(--d-text);
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
}
.cn-txt {
font-size: 0.84375rem;
color: var(--d-text-3);
}
@media (max-width: 48rem) {
.meeting-canvas {
height: calc(100vh - 3.25rem);
}
}
</style>
+7
View File
@@ -108,6 +108,13 @@ const routes: RouteRecordRaw[] = [
component: () => import('@/pages/relay/SchedulePage.vue'),
meta: { requiresAuth: true },
},
{
// 화상회의 — LiveKit 기반(단계 1: 방 입장/연결). :room 으로 공유 링크 가능
path: '/meeting/:room?',
name: 'meeting',
component: () => import('@/pages/relay/MeetingPage.vue'),
meta: { requiresAuth: true },
},
{
path: '/:pathMatch(.*)*',
name: 'not-found',
+29
View File
@@ -0,0 +1,29 @@
// 아바타 색상/이니셜 유틸 — 이미지가 없는 곳(화상회의 다크 타일·참여자 목록·채팅)에서
// 이름 기반으로 일관된 배경색과 첫 글자를 만든다. 순수 함수.
// 이름 해시로 배정하는 아바타 배경색 팔레트
const AVATAR_COLORS = [
'#d0982a',
'#e0518d',
'#3d8bf2',
'#16a37b',
'#b3631f',
'#7c5cf0',
'#0e9594',
'#d6455d',
'#0284c7',
'#65a30d',
]
// 이름 문자코드 합을 팔레트 길이로 나눈 나머지로 색을 고른다(동일 이름 → 동일 색).
export function avatarColor(name: string): string {
const key = name || '?'
let sum = 0
for (const ch of key) sum += ch.charCodeAt(0)
return AVATAR_COLORS[sum % AVATAR_COLORS.length] as string
}
// 표시용 첫 글자(공백 제거). 비어 있으면 물음표.
export function avatarInitial(name: string): string {
return (name || '?').trim().charAt(0) || '?'
}
+37
View File
@@ -0,0 +1,37 @@
// 화상회의 도메인 타입 — 백엔드 MeetingService 응답/DTO 와 1:1
// 회의 내 역할
export type MeetingRole = 'host' | 'participant'
// 회의 방(API 원시) — 백엔드 MeetingRoomResponse 와 1:1
export interface ApiMeetingRoom {
id: string
name: string
host: { id: string; name: string; avatarUrl: string | null } | null
scheduledAt: string | null // ISO, 예약 시각(없으면 null)
restricted: boolean // 참여 제한(allow-list) 여부
isHost: boolean // 현재 사용자가 진행자
canJoin: boolean // 현재 사용자가 입장 가능
createdAt: string
}
// 방 생성 요청
export interface CreateMeetingRoomPayload {
name: string
scheduledAt?: string // ISO
allowedUserIds?: string[] // 비우면 전체 공개
}
// 회의실 채팅 메시지 — LiveKit 데이터 채널로 주고받는 인메모리 메시지(서버 저장 없음)
export interface ChatMessage {
id: string
senderId: string // 보낸 사람 identity(사용자 ID)
senderName: string // 보낸 사람 표시 이름
senderAvatarUrl: string | null // 보낸 사람 프로필 이미지(참여자 metadata 에서 파생)
text: string
ts: number // 보낸 시각(epoch ms)
self: boolean // 본인이 보낸 메시지 여부
}
// 연결 품질 단계 — UI 막대(3/2/1)용으로 정규화한 값
export type MeetingQuality = 'good' | 'medium' | 'poor'
+136
View File
@@ -0,0 +1,136 @@
# LiveKit 셀프호스팅 배포 가이드 (화상회의 단계 6)
운영에서 LiveKit(SFU)을 직접 호스팅하는 방법. **단일 노드 → 멀티 노드 클러스터** 순으로 설명한다.
(간단히 가려면 운영도 LiveKit Cloud 를 그대로 쓸 수 있다 — 그 경우 `.env.production``LIVEKIT_*` 만 Cloud 값으로 두고 아래 셀프호스팅 절차는 생략.)
> 로컬 dev 에서 셀프호스팅이 막혔던 건 WSL2/Docker Desktop 네트워킹 제약 때문이며, **공인 IP 가 있는 실서버에서는 발생하지 않는다.**
---
## 1. 사전 준비
- 공인 IP 를 가진 리눅스 서버(도커 설치)
- 도메인 + DNS: `livekit.<도메인>` A 레코드 → 서버 공인 IP
- **리버스 프록시(TLS 종단)**: 기존 `proxy` 외부 네트워크의 프록시(Traefik/nginx 등)
- 방화벽 개방:
- `443/tcp` — 시그널링(wss, 프록시 경유)
- `7881/tcp` — ICE/TCP 폴백
- `7882/udp` — ICE/UDP(미디어)
---
## 2. 키 생성
API Key 는 임의 식별자, Secret 은 32자 이상 강력한 난수.
```bash
echo "LIVEKIT_API_KEY=$(openssl rand -hex 8)"
echo "LIVEKIT_API_SECRET=$(openssl rand -hex 32)"
```
`.env.production` 에 채운다:
```dotenv
LIVEKIT_API_KEY=<생성한 key>
LIVEKIT_API_SECRET=<생성한 secret(32자+)>
LIVEKIT_WS_URL=wss://livekit.<도메인> # 브라우저 접속(프론트 빌드에 주입)
LIVEKIT_URL=http://livekit:7880 # 백엔드 → LiveKit 내부 API
```
> `LIVEKIT_WS_URL` 은 프론트 빌드 시 `VITE_LIVEKIT_URL` 로 **굽혀진다**. 값 변경 시 프론트 재빌드 필요.
---
## 3. 기동 (단일 노드)
```bash
docker compose --profile selfhost-livekit --env-file .env.production up -d --build
```
- `selfhost-livekit` 프로파일을 켜야 LiveKit 컨테이너가 생성된다(미지정 시 미생성).
- 설정: `livekit/livekit.prod.yaml`(키는 `LIVEKIT_KEYS` env 로 주입), `use_external_ip: true` 로 공인 IP 자동 광고.
---
## 4. 리버스 프록시 라우팅 (시그널링 wss)
`wss://livekit.<도메인>` → 컨테이너 `livekit:7880` 으로 라우팅(WebSocket 업그레이드 + TLS 종단).
미디어(7881/7882)는 프록시를 거치지 않는다 — 직접 공인 IP 로 통신.
**Traefik(라벨) 예:**
```yaml
# docker-compose 의 livekit 서비스에 labels 추가(운영 프록시 구성에 맞게):
labels:
- "traefik.enable=true"
- "traefik.http.routers.livekit.rule=Host(`livekit.<도메인>`)"
- "traefik.http.routers.livekit.entrypoints=websecure"
- "traefik.http.routers.livekit.tls.certresolver=<resolver>"
- "traefik.http.services.livekit.loadbalancer.server.port=7880"
```
**nginx 예:**
```nginx
server {
listen 443 ssl;
server_name livekit.<도메인>;
# ssl_certificate ... ;
location / {
proxy_pass http://livekit:7880;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_read_timeout 600s;
}
}
```
---
## 5. 검증
1. 브라우저에서 회의 입장(두 기기/네트워크에서 영상 확인)
2. 부하 테스트:
```bash
LIVEKIT_URL=wss://livekit.<도메인> \
LIVEKIT_API_KEY=<key> LIVEKIT_API_SECRET=<secret> \
./livekit/load-test.sh full
```
---
## 6. 멀티 노드 클러스터링 (수평 확장)
대규모(예: 160명 다수 방)에서 노드 간 트래픽을 분산하려면 Redis 로 LiveKit 노드들을 묶는다.
1. **설정에 redis 블록 추가** — 비밀번호가 들어가므로 커밋 파일이 아닌 사본에 둔다.
```bash
cp livekit/livekit.prod.yaml livekit/livekit.prod.local.yaml # gitignore 대상
```
`livekit.prod.local.yaml` 에 추가:
```yaml
redis:
address: redis:6379
db: 1 # 앱 캐시(db 0)와 분리
password: <REDIS_PASSWORD> # 루트 .env 의 REDIS_PASSWORD
```
compose 의 livekit 볼륨을 이 사본으로 교체(override 파일 권장).
2. **노드 확장**
- 단일 호스트에서 replica 만 늘리는 것은 **진짜 분산이 아니다**(같은 머신·NIC).
- 실제 분산 = **여러 호스트** 각각 LiveKit 실행 + **공유 Redis** + 시그널링은 프록시가 여러 노드로 LB. RTC 는 각 노드의 공인 IP 로 직접.
- LiveKit 이 방을 노드에 분배(room→node)하고, 한 방이 여러 노드에 걸치면 **캐스케이딩**으로 노드 간 미디어를 릴레이한다.
- Simulcast/SVC 로 수신측 상황에 맞는 화질 레이어만 전달(기본 활성).
3. 부하 분산 검증: `./livekit/load-test.sh full` 로 다수 참여자를 투입하고, 노드별 참여자/대역폭 분포를 모니터링.
---
## 7. 운영 팁
- **UDP 수신 버퍼 상향**(권장): `sysctl -w net.core.rmem_max=16777216 net.core.wmem_max=16777216` (영구화는 `/etc/sysctl.conf`). 미설정 시 LiveKit 이 경고 로그를 남긴다.
- **모니터링**: LiveKit Prometheus 메트릭(`/metrics`) + Grafana — 노드별 참여자/트랙/CPU/대역폭.
- **리소스**: compose 의 `deploy.resources.limits` 를 트래픽에 맞게 조정(기본 2 CPU / 2GB).
- `.gitignore` 에 `livekit/*.local.yaml` 추가(비밀번호 포함 사본 커밋 방지).
+100
View File
@@ -0,0 +1,100 @@
# 화상회의 부하 테스트 가이드 (단계 5: 확장 검증)
`lk load-test`(livekit-cli)로 가상 참여자를 대량 접속시켜 **SFU 부하·미디어 품질**을 측정한다.
러너: [`livekit/load-test.sh`](./load-test.sh)
---
## 1. 목적
- 다수 참여자 접속 시 **품질(지연·패킷 손실·대역폭)** 이 유지되는지 검증
- (셀프호스팅 클러스터) 여러 SFU 노드로 **미디어 트래픽이 분산**되는지 검증
- 문서 목표: 최대 **160명** 단일 방, 동시 표시 ~50명, Simulcast/SVC 로 수신측 화질 차등
---
## 2. 사전 준비
- Docker 설치 (러너가 `livekit/livekit-cli` 이미지를 사용)
- 자격증명: 루트 `.env.${APP_ENV:-development}``LIVEKIT_URL` / `LIVEKIT_API_KEY` / `LIVEKIT_API_SECRET`
- 러너가 자동으로 읽어 컨테이너에 주입한다.
---
## 3. 실행
```bash
# 소규모(도구/연결 검증) — Cloud 에서도 안전
./livekit/load-test.sh smoke # 2 pub + 3 sub, 15s, 저해상도
# 중간 규모
./livekit/load-test.sh medium # 5 pub + 30 sub, 1m
# 대규모(문서 목표) — 반드시 셀프호스팅/전용 인스턴스에서!
./livekit/load-test.sh full # 10 pub + 150 sub, 2m, 고해상도
```
세부 조정(환경변수):
| 변수 | 의미 | 예 |
|---|---|---|
| `PUBLISHERS` | 영상 발행 가상 참여자 수 | `10` |
| `SUBSCRIBERS` | 구독 전용 가상 참여자 수 | `150` |
| `DURATION` | 지속 시간 | `2m`, `30s` |
| `RESOLUTION` | 발행 화질 | `low` / `medium` / `high` |
| `LAYOUT` | 구독 레이아웃(구독 수 모사) | `speaker` / `3x3` / `4x4` / `5x5` |
| `SIMULCAST` | Simulcast 발행 | `on`(기본) / `off` |
| `ROOM` | 방 이름 | `loadtest-full` |
| `NUM_PER_SECOND` | 초당 입장 가속 | `5` |
```bash
# 예) 160명 규모, 진행자 1명 고해상도 + 159명 구독, 3분
PUBLISHERS=1 SUBSCRIBERS=159 RESOLUTION=high DURATION=3m ./livekit/load-test.sh full
```
---
## 4. 결과 읽는 법
러너는 두 표를 출력한다.
- **Track loading**: 구독자별 각 트랙의 수신 패킷 수, 비트레이트, 패킷 손실
- **Subscriber summaries**: 구독자별 `Tracks N/N`(구독 성공 비율), 총 비트레이트, 총 패킷 손실, 에러
**합격 기준(목표치)**
- `Tracks` 가 모두 `N/N` (요청 트랙 전부 구독)
- `Pkt. Loss` 가 낮게 유지 (목표 < 2~3%)
- `Error` = 0
- 참여자 수를 늘려도 위 지표가 급격히 악화되지 않을 것
> smoke 예시 결과: 6/6 트랙, 패킷 손실 0%, ~119kbps/구독자 — 정상.
---
## 5. 어디서 실행하나 (중요)
| 대상 | 권장 |
|---|---|
| **LiveKit Cloud** (현재 dev) | `smoke` 만. 대량 부하는 동시접속 쿼터·약관 위반 소지 → 금지 |
| **셀프호스팅 단일 노드** (단계 6) | `medium`~`full` 로 단일 노드 한계 측정 |
| **셀프호스팅 클러스터** (단계 6) | `full` 이상으로 **부하 분산(노드 간 트래픽 분배)** 검증 |
- 부하 **분산** 검증은 SFU 노드가 2개 이상인 클러스터 구성에서만 의미가 있다(단일 노드는 분산 대상이 없음).
---
## 6. 서버 측 모니터링 (셀프호스팅)
부하 중 서버 지표를 함께 본다.
- LiveKit Prometheus 메트릭(`/metrics`) + Grafana: 노드별 참여자/트랙 수, CPU, 송수신 대역폭
- 호스트: CPU, 네트워크 대역폭, UDP 버퍼
- 노드별 참여자 분포(클러스터링/캐스케이딩이 고르게 분산하는지)
---
## 7. 한계 / 참고
- `lk load-test` 의 발행 미디어는 합성 트래픽이라 실제 카메라 화질과 100% 동일하지는 않다(상대적 부하 비교용으로 충분).
- 클라이언트 측 다운링크(시청자 회선) 부하는 별도 검증 필요.
- 본 단계는 **검증 도구·방법론**이며, 실제 대규모 검증은 단계 6(셀프호스팅 배포) 환경에서 수행한다.
+34
View File
@@ -0,0 +1,34 @@
# LiveKit 셀프호스팅 운영 설정 (단계 6)
# - 단일 노드 기준. 멀티 노드 클러스터링은 아래 redis 블록을 활성화한다(livekit/DEPLOY.md 참고).
# - API 키/시크릿은 환경변수 LIVEKIT_KEYS 로 주입한다(이 파일에 비밀값을 두지 않는다).
# - 시그널링(7880)은 리버스 프록시에서 TLS 종단 후 라우팅한다(wss://livekit.<도메인>).
# - 미디어(RTC)는 프록시를 거치지 않고 서버 공인 IP 로 직접 통신한다.
# 시그널링 / HTTP API (리버스 프록시 뒤)
port: 7880
rtc:
# ICE/TCP 폴백 포트 (UDP 차단 환경 대비)
tcp_port: 7881
# ICE/UDP 단일 mux 포트 (방화벽 단순화 — 이 포트만 열면 됨)
udp_port: 7882
# 운영: 서버의 공인 IP 를 ICE 후보로 자동 광고
use_external_ip: true
logging:
level: info
# 내장 TURN 은 기본 비활성. 엄격한 방화벽 뒤 클라이언트를 지원하려면
# DEPLOY.md 의 TURN/TLS 설정을 참고해 활성화한다.
turn:
enabled: false
# ── 멀티 노드 클러스터링(수평 확장) ──────────────────────────────
# 2대 이상의 LiveKit 노드를 Redis 로 묶어 트래픽을 분산한다.
# Redis 비밀번호가 들어가므로(비밀값) 이 블록은 커밋 파일에 두지 않고,
# 배포 시 이 파일을 복사한 사본(gitignore)에 채워 마운트한다. (livekit/DEPLOY.md 참고)
#
# redis:
# address: redis:6379
# db: 1 # 앱 캐시(db 0)와 분리
# password: <REDIS_PASSWORD>
+20
View File
@@ -0,0 +1,20 @@
# LiveKit 셀프호스팅 설정 (개발용 / 로컬 단일 노드)
# - 미디어(WebRTC) 전송 계층을 담당하는 SFU. 애플리케이션은 토큰만 발급한다.
# - API 키/시크릿은 환경변수 LIVEKIT_KEYS 로 주입한다(여기에 두지 않음).
# - 로컬 개발에서는 노드 IP 를 127.0.0.1 로 광고해야 브라우저가 ICE 후보에 접속할 수 있다.
# (docker-compose 의 command 에서 --node-ip 127.0.0.1 지정)
# 시그널링 / HTTP API 포트
port: 7880
rtc:
# ICE/TCP 포트 — Docker Desktop 에서 가장 안정적인 경로(브라우저가 연결을 거는 단일 스트림)
tcp_port: 7881
# ICE/UDP 단일 포트(매핑됨). udp_port:0 은 50000~60000 레인지를 켜버려(미매핑) NIC flapping 을
# 유발하므로 반드시 매핑된 단일 포트를 명시한다.
udp_port: 7882
# 외부 IP 자동 탐지를 끄고 --node-ip 로 지정한 127.0.0.1 을 후보로 사용
use_external_ip: false
logging:
level: info
+85
View File
@@ -0,0 +1,85 @@
#!/usr/bin/env bash
# =====================================================================
# LiveKit 부하 테스트 러너 (화상회의 단계 5: 확장 검증)
# ---------------------------------------------------------------------
# livekit-cli(lk load-test)로 가상 publisher/subscriber 를 대량 접속시켜
# SFU 의 부하·미디어 품질(지연/패킷손실/대역폭)을 측정한다.
#
# 사용법:
# ./livekit/load-test.sh [preset]
# preset: smoke(기본) | medium | full
#
# 세부 조정(환경변수로 덮어쓰기):
# ROOM, PUBLISHERS, SUBSCRIBERS, DURATION, RESOLUTION(low|medium|high),
# LAYOUT(speaker|3x3|4x4|5x5), SIMULCAST(on|off), NUM_PER_SECOND
#
# 자격증명은 루트 .env.${APP_ENV:-development} 에서 읽는다
# (LIVEKIT_URL / LIVEKIT_API_KEY / LIVEKIT_API_SECRET).
#
# ⚠️ 주의:
# - 대규모(full=160참여자) 테스트는 반드시 "셀프호스팅 LiveKit(단계 6)" 또는
# 전용 테스트 인스턴스를 대상으로 실행하세요.
# - LiveKit Cloud 무료/개발 티어에 대한 대량 부하는 동시 접속 쿼터 초과 및
# 이용약관 위반 소지가 있습니다. Cloud 에서는 smoke(소규모)로 도구 검증만 하세요.
# - 부하 분산(클러스터링) 검증은 SFU 노드가 2개 이상인 셀프호스팅 구성에서만 의미가 있습니다.
# =====================================================================
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
ENV_FILE="${ENV_FILE:-$ROOT_DIR/.env.${APP_ENV:-development}}"
PRESET="${1:-smoke}"
# 프리셋별 기본값(환경변수가 있으면 그것을 우선)
case "$PRESET" in
smoke)
PUBLISHERS="${PUBLISHERS:-2}"; SUBSCRIBERS="${SUBSCRIBERS:-3}"
DURATION="${DURATION:-15s}"; RESOLUTION="${RESOLUTION:-low}"; LAYOUT="${LAYOUT:-3x3}" ;;
medium)
PUBLISHERS="${PUBLISHERS:-5}"; SUBSCRIBERS="${SUBSCRIBERS:-30}"
DURATION="${DURATION:-1m}"; RESOLUTION="${RESOLUTION:-medium}"; LAYOUT="${LAYOUT:-5x5}" ;;
full)
# 문서 목표: 최대 160명(진행자 소수 발행 + 다수 구독), 동시 표시 ~50명
PUBLISHERS="${PUBLISHERS:-10}"; SUBSCRIBERS="${SUBSCRIBERS:-150}"
DURATION="${DURATION:-2m}"; RESOLUTION="${RESOLUTION:-high}"; LAYOUT="${LAYOUT:-speaker}" ;;
*)
echo "알 수 없는 preset: $PRESET (smoke|medium|full)" >&2; exit 1 ;;
esac
ROOM="${ROOM:-loadtest-$PRESET}"
NUM_PER_SECOND="${NUM_PER_SECOND:-5}"
SIMULCAST="${SIMULCAST:-on}"
SIMULCAST_FLAG=()
[ "$SIMULCAST" = "off" ] && SIMULCAST_FLAG=(--no-simulcast)
# .env 에서 필요한 값만 읽는다(CRLF 제거). 파일이 없으면 기존 환경변수 사용.
read_env() {
if [ -f "$ENV_FILE" ]; then
grep -E "^$1=" "$ENV_FILE" | tail -1 | cut -d= -f2- | tr -d '\r'
fi
}
export LIVEKIT_URL="${LIVEKIT_URL:-$(read_env LIVEKIT_URL)}"
export LIVEKIT_API_KEY="${LIVEKIT_API_KEY:-$(read_env LIVEKIT_API_KEY)}"
export LIVEKIT_API_SECRET="${LIVEKIT_API_SECRET:-$(read_env LIVEKIT_API_SECRET)}"
if [ -z "${LIVEKIT_URL:-}" ] || [ -z "${LIVEKIT_API_KEY:-}" ] || [ -z "${LIVEKIT_API_SECRET:-}" ]; then
echo "LIVEKIT_URL / LIVEKIT_API_KEY / LIVEKIT_API_SECRET 를 찾을 수 없습니다 ($ENV_FILE)." >&2
exit 1
fi
echo "▶ LiveKit load-test [$PRESET]"
echo " URL=$LIVEKIT_URL ROOM=$ROOM"
echo " publishers=$PUBLISHERS subscribers=$SUBSCRIBERS duration=$DURATION resolution=$RESOLUTION layout=$LAYOUT simulcast=$SIMULCAST"
echo
# livekit-cli(lk)는 $LIVEKIT_URL/$LIVEKIT_API_KEY/$LIVEKIT_API_SECRET 를 자동으로 읽는다.
docker run --rm \
-e LIVEKIT_URL -e LIVEKIT_API_KEY -e LIVEKIT_API_SECRET \
livekit/livekit-cli load-test \
--room "$ROOM" \
--duration "$DURATION" \
--publishers "$PUBLISHERS" \
--subscribers "$SUBSCRIBERS" \
--video-resolution "$RESOLUTION" \
--layout "$LAYOUT" \
--num-per-second "$NUM_PER_SECOND" \
"${SIMULCAST_FLAG[@]}"