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>
This commit is contained in:
@@ -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/<방이름></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>
|
||||
@@ -1,19 +1,49 @@
|
||||
<script setup lang="ts">
|
||||
// 참여자 1명의 영상 타일 — 카메라 트랙을 <video>, (원격) 마이크 트랙을 <audio> 에 연결한다.
|
||||
// 로컬 참여자 오디오는 에코 방지를 위해 재생하지 않는다.
|
||||
import { onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { Track, ParticipantEvent, type Participant } from 'livekit-client'
|
||||
// 로컬 참여자 오디오는 에코 방지를 위해 재생하지 않는다. 카메라가 꺼져 있으면 이름 이니셜 아바타.
|
||||
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'
|
||||
|
||||
const props = defineProps<{ participant: Participant; isLocal?: boolean }>()
|
||||
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 audioEl = ref<HTMLAudioElement | null>(null)
|
||||
const hasVideo = ref(false)
|
||||
const micOn = ref(false)
|
||||
// 진행자 여부 — 토큰 metadata({"role":"host"})에서 파생
|
||||
const speaking = ref(false)
|
||||
const quality = ref<MeetingQuality>('good')
|
||||
const isHost = ref(false)
|
||||
|
||||
// 참여자 metadata 문자열에서 진행자 여부 파싱
|
||||
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 {
|
||||
@@ -23,28 +53,37 @@ function parseHost(metadata: string | undefined): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
// 표시 이름 — 토큰에 담은 name, 없으면 식별자
|
||||
const displayName = ref(props.participant.name || props.participant.identity)
|
||||
|
||||
// 현재 트랙 상태를 DOM 에 반영
|
||||
// 현재 트랙/상태를 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
|
||||
}
|
||||
}
|
||||
|
||||
if (!props.isLocal) {
|
||||
const micPub = p.getTrackPublication(Track.Source.Microphone)
|
||||
if (micPub?.track && audioEl.value) micPub.track.attach(audioEl.value)
|
||||
}
|
||||
function onSpeaking() {
|
||||
speaking.value = props.participant.isSpeaking
|
||||
}
|
||||
function onQuality() {
|
||||
quality.value = normalizeQuality(
|
||||
props.participant.connectionQuality as ConnectionQuality,
|
||||
)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
@@ -56,6 +95,8 @@ onMounted(() => {
|
||||
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(() => {
|
||||
@@ -66,46 +107,70 @@ onBeforeUnmount(() => {
|
||||
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">
|
||||
<div
|
||||
class="tile"
|
||||
:class="{ featured, speaking: showSpeaking }"
|
||||
:style="{ '--av': avColor }"
|
||||
>
|
||||
<!-- 영상: 로컬은 좌우 반전(거울)으로 표시 -->
|
||||
<video
|
||||
ref="videoEl"
|
||||
class="vid"
|
||||
:class="{ mirror: props.isLocal, hidden: !hasVideo }"
|
||||
:class="{ mirror: isLocal, hidden: !hasVideo }"
|
||||
autoplay
|
||||
playsinline
|
||||
:muted="props.isLocal"
|
||||
:muted="isLocal"
|
||||
/>
|
||||
<!-- 영상이 없을 때 이름 이니셜 표시 -->
|
||||
<!-- 카메라 off/미발행 시 이름 이니셜 아바타 -->
|
||||
<div
|
||||
v-if="!hasVideo"
|
||||
class="placeholder"
|
||||
class="tile-off"
|
||||
>
|
||||
<span class="initial">{{ displayName.charAt(0) }}</span>
|
||||
<span class="av-lg">{{ initial }}</span>
|
||||
</div>
|
||||
<!-- 원격 오디오 -->
|
||||
<audio
|
||||
v-if="!props.isLocal"
|
||||
ref="audioEl"
|
||||
autoplay
|
||||
/>
|
||||
<!-- 하단 정보 -->
|
||||
<div class="meta">
|
||||
|
||||
<!-- 연결 품질 -->
|
||||
<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="mic"
|
||||
class="mmic"
|
||||
:class="{ off: !micOn }"
|
||||
:title="micOn ? '마이크 켜짐' : '마이크 꺼짐'"
|
||||
>{{ micOn ? '🎙' : '🔇' }}</span>
|
||||
<span class="name">{{ displayName }}{{ props.isLocal ? ' (나)' : '' }}</span>
|
||||
>
|
||||
<MeetingIcon :name="micOn ? 'mic' : 'micOff'" />
|
||||
</span>
|
||||
<span class="mname">{{ displayName }}{{ isLocal ? ' (나)' : '' }}</span>
|
||||
<span
|
||||
v-if="isHost"
|
||||
class="host-badge"
|
||||
class="mhost"
|
||||
title="진행자"
|
||||
>진행자</span>
|
||||
>
|
||||
<MeetingIcon name="host" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -114,12 +179,23 @@ onBeforeUnmount(() => {
|
||||
.tile {
|
||||
position: relative;
|
||||
aspect-ratio: 16 / 9;
|
||||
background: #1b1d23;
|
||||
border-radius: 0.625rem;
|
||||
border-radius: 0.75rem;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border);
|
||||
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;
|
||||
@@ -131,62 +207,124 @@ onBeforeUnmount(() => {
|
||||
.vid.hidden {
|
||||
display: none;
|
||||
}
|
||||
.placeholder {
|
||||
.tile-off {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #1b1d23;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: var(--tile);
|
||||
}
|
||||
.initial {
|
||||
width: 3.5rem;
|
||||
height: 3.5rem;
|
||||
.av-lg {
|
||||
width: clamp(2.875rem, 9vh, 5.75rem);
|
||||
height: clamp(2.875rem, 9vh, 5.75rem);
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
background: var(--av, #555);
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1.5rem;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: clamp(1.25rem, 4vh, 2.375rem);
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.meta {
|
||||
.tile-q {
|
||||
position: absolute;
|
||||
left: 0.5rem;
|
||||
bottom: 0.5rem;
|
||||
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;
|
||||
padding: 0.1875rem 0.5rem;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
border-radius: 0.375rem;
|
||||
max-width: calc(100% - 1rem);
|
||||
}
|
||||
.meta .name {
|
||||
.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;
|
||||
font-size: 0.75rem;
|
||||
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;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.meta .mic {
|
||||
font-size: 0.75rem;
|
||||
line-height: 1;
|
||||
.tile.featured .mname {
|
||||
font-size: 0.875rem;
|
||||
padding: 0.3125rem 0.75rem;
|
||||
}
|
||||
.meta .mic.off {
|
||||
opacity: 0.6;
|
||||
}
|
||||
.host-badge {
|
||||
flex-shrink: 0;
|
||||
padding: 0.0625rem 0.375rem;
|
||||
border-radius: 0.25rem;
|
||||
background: var(--accent);
|
||||
.mhost {
|
||||
width: 1.375rem;
|
||||
height: 1.375rem;
|
||||
border-radius: 0.375rem;
|
||||
background: rgba(124, 92, 240, 0.85);
|
||||
color: #fff;
|
||||
font-size: 0.625rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.4;
|
||||
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>
|
||||
@@ -1,9 +1,65 @@
|
||||
import { ref, shallowRef } from 'vue'
|
||||
import { Room, RoomEvent, type Participant } from 'livekit-client'
|
||||
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() {
|
||||
@@ -19,8 +75,11 @@ export function useLiveKitRoom() {
|
||||
const errorMsg = ref<string | null>(null)
|
||||
// 크게 보기(stage)로 승격된 참여자 identity. 진행자가 데이터 채널로 브로드캐스트 → 전원 동기화.
|
||||
const featuredIdentity = ref<string | null>(null)
|
||||
// 채팅 메시지(인메모리) — 데이터 채널로 주고받음. 서버에 저장하지 않으므로 새로고침 시 사라진다.
|
||||
const messages = ref<ChatMessage[]>([])
|
||||
|
||||
// 로컬 + 원격 참여자 목록 갱신(진행자 화면 구성은 단계 3에서 고도화)
|
||||
// 로컬 + 원격 참여자 목록 갱신. 마이크/발화/품질 등 참여자 상태 변동 시에도 호출해
|
||||
// 배열 참조를 새로 만들어(=동일 Participant 인스턴스 유지) 의존 computed 를 재평가시킨다.
|
||||
function refresh() {
|
||||
const r = room.value
|
||||
if (!r) {
|
||||
@@ -33,21 +92,83 @@ export function useLiveKitRoom() {
|
||||
]
|
||||
}
|
||||
|
||||
// --- 원격 오디오는 화면 타일(페이지네이션)과 무관하게 항상 재생되어야 한다 ---
|
||||
// 타일에 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) 메시지를 받아 featured 갱신
|
||||
function onData(payload: Uint8Array) {
|
||||
// 데이터 채널 수신 — 진행자의 승격(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') {
|
||||
featuredIdentity.value = msg.identity
|
||||
// 진행자가 보낸 승격만 적용 — 일반 참여자가 보낸 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 {
|
||||
// 알 수 없는 메시지는 무시
|
||||
@@ -65,19 +186,55 @@ export function useLiveKitRoom() {
|
||||
await r.localParticipant.publishData(data, { reliable: true })
|
||||
}
|
||||
|
||||
// 방 입장 — 토큰으로 연결 후 카메라/마이크 발행
|
||||
async function connect(token: string): Promise<void> {
|
||||
// 채팅 전송 — 데이터 채널로 브로드캐스트 + 본인 목록에 즉시 추가
|
||||
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, refresh)
|
||||
.on(RoomEvent.TrackUnsubscribed, 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)
|
||||
|
||||
@@ -86,13 +243,24 @@ export function useLiveKitRoom() {
|
||||
connected.value = true
|
||||
|
||||
// 카메라/마이크 권한 요청 및 발행 (실패해도 연결은 유지)
|
||||
try {
|
||||
await r.localParticipant.enableCameraAndMicrophone()
|
||||
camEnabled.value = true
|
||||
micEnabled.value = true
|
||||
} catch {
|
||||
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) {
|
||||
@@ -113,20 +281,33 @@ export function useLiveKitRoom() {
|
||||
micEnabled.value = false
|
||||
participants.value = []
|
||||
featuredIdentity.value = null
|
||||
messages.value = []
|
||||
cleanupAudio()
|
||||
}
|
||||
|
||||
async function toggleMic(): Promise<void> {
|
||||
const r = room.value
|
||||
if (!r) return
|
||||
micEnabled.value = !micEnabled.value
|
||||
await r.localParticipant.setMicrophoneEnabled(micEnabled.value)
|
||||
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
|
||||
camEnabled.value = !camEnabled.value
|
||||
await r.localParticipant.setCameraEnabled(camEnabled.value)
|
||||
const next = !camEnabled.value
|
||||
camEnabled.value = next
|
||||
try {
|
||||
await r.localParticipant.setCameraEnabled(next)
|
||||
} catch {
|
||||
camEnabled.value = !next
|
||||
}
|
||||
refresh()
|
||||
}
|
||||
|
||||
@@ -138,10 +319,12 @@ export function useLiveKitRoom() {
|
||||
camEnabled,
|
||||
errorMsg,
|
||||
featuredIdentity,
|
||||
messages,
|
||||
connect,
|
||||
disconnect,
|
||||
toggleMic,
|
||||
toggleCam,
|
||||
promote,
|
||||
sendChat,
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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) || '?'
|
||||
}
|
||||
@@ -21,3 +21,17 @@ export interface CreateMeetingRoomPayload {
|
||||
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'
|
||||
|
||||
Reference in New Issue
Block a user