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:
2026-06-24 17:56:53 +09:00
parent 251830d421
commit 999a8730dd
18 changed files with 3618 additions and 1164 deletions
+15
View File
@@ -52,6 +52,21 @@
--shadow-sm: 0 1px 2px rgba(20, 24, 33, 0.05);
--shadow-md: 0 4px 16px rgba(20, 24, 33, 0.1), 0 1px 3px rgba(20, 24, 33, 0.06);
--shadow-pop: 0 8px 28px rgba(20, 24, 33, 0.16), 0 2px 6px rgba(20, 24, 33, 0.08);
/* 다크 캔버스(화상회의 회의실/프리조인 전용) */
--d-bg: #0e1014;
--d-panel: #16191f;
--d-panel-2: #1b1f27;
--d-border: #2a2f39;
--d-text: #eceef2;
--d-text-2: #aab2bf;
--d-text-3: #717a88;
--tile: #1b1d23;
/* 다크 위 상태색(라이트 토큰을 다크에 쓰면 탁해져 밝은 변형을 별도 정의) */
--d-red: #ff7a8a;
--d-green: #46d18a;
--d-amber: #f0c478;
--d-accent-soft: #c7c2ff;
}
/* ============================================================
@@ -0,0 +1,257 @@
<script setup lang="ts">
// 회의실 채팅 — 데이터 채널 메시지 목록 + 입력. 새 메시지 도착 시 맨 아래로 스크롤.
import { nextTick, ref, watch } from 'vue'
import MeetingIcon from './MeetingIcon.vue'
import UserAvatar from '@/components/UserAvatar.vue'
import { MAX_CHAT_LENGTH } from '@/composables/useLiveKitRoom'
import type { ChatMessage } from '@/types/meeting'
interface Props {
messages: ChatMessage[]
}
const props = defineProps<Props>()
const emit = defineEmits<{ (e: 'send', text: string): void }>()
const text = ref('')
const listRef = ref<HTMLElement | null>(null)
const inputRef = ref<HTMLTextAreaElement | null>(null)
// 입력 높이 자동 확장 — 최대 약 7.5rem까지는 스크롤바 없이 늘어나고, 초과 시에만 스크롤.
const MAX_INPUT_HEIGHT = 120
function autoGrow() {
const el = inputRef.value
if (!el) return
el.style.height = 'auto'
const full = el.scrollHeight
el.style.height = `${Math.min(full, MAX_INPUT_HEIGHT)}px`
el.style.overflowY = full > MAX_INPUT_HEIGHT ? 'auto' : 'hidden'
}
// 시각(HH:MM) 표시
function fmtTime(ts: number): string {
const d = new Date(ts)
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
}
function submit() {
const body = text.value.trim()
if (!body) return
emit('send', body)
text.value = ''
void nextTick(autoGrow) // 전송 후 높이 초기화
}
// 메시지 증가 시 맨 아래로
watch(
() => props.messages.length,
async () => {
await nextTick()
const el = listRef.value
if (el) el.scrollTop = el.scrollHeight
},
)
</script>
<template>
<div class="chat">
<div
ref="listRef"
class="chat-list"
>
<div
v-if="messages.length === 0"
class="chat-empty"
>
아직 메시지가 없습니다. 메시지를 보내보세요.
</div>
<div
v-for="m in messages"
:key="m.id"
class="chat-msg"
:class="{ self: m.self }"
>
<span
v-if="!m.self"
class="chat-av"
><UserAvatar
:url="m.senderAvatarUrl"
:name="m.senderName"
/></span>
<div class="chat-col">
<div class="chat-meta">
<span
v-if="!m.self"
class="chat-name"
>{{ m.senderName }}</span>
<span class="chat-time">{{ fmtTime(m.ts) }}</span>
</div>
<div class="chat-bubble">
{{ m.text }}
</div>
</div>
</div>
</div>
<div class="chat-input">
<textarea
ref="inputRef"
v-model="text"
rows="1"
:maxlength="MAX_CHAT_LENGTH"
placeholder="메시지 보내기… (Shift+Enter 줄바꿈)"
aria-label="채팅 메시지"
@input="autoGrow"
@keydown.enter.exact.prevent="submit"
/>
<button
type="button"
class="chat-send"
:disabled="!text.trim()"
title="보내기"
@click="submit"
>
<MeetingIcon name="send" />
</button>
</div>
</div>
</template>
<style scoped>
.chat {
display: flex;
flex-direction: column;
min-height: 0;
flex: 1;
}
.chat-list {
flex: 1;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 0.875rem;
padding: 1rem 0.875rem;
scrollbar-width: thin;
scrollbar-color: var(--d-border) transparent;
}
.chat-list::-webkit-scrollbar {
width: 0.4375rem;
}
.chat-list::-webkit-scrollbar-thumb {
background: var(--d-border);
border-radius: 0.25rem;
}
.chat-empty {
margin: auto;
text-align: center;
font-size: 0.8125rem;
color: var(--d-text-3);
}
.chat-msg {
display: flex;
gap: 0.5625rem;
align-items: flex-start;
}
.chat-msg.self {
flex-direction: row-reverse;
}
.chat-av {
width: 1.875rem;
height: 1.875rem;
flex-shrink: 0;
}
.chat-col {
display: flex;
flex-direction: column;
gap: 0.25rem;
max-width: 80%;
min-width: 0;
}
.chat-msg.self .chat-col {
align-items: flex-end;
}
.chat-meta {
display: flex;
align-items: baseline;
gap: 0.4375rem;
}
.chat-name {
font-size: 0.75rem;
font-weight: 600;
color: var(--d-text-2);
}
.chat-time {
font-size: 0.65625rem;
color: var(--d-text-3);
}
.chat-bubble {
font-size: 0.8125rem;
line-height: 1.5;
color: var(--d-text);
background: var(--d-panel-2);
border: 1px solid var(--d-border);
padding: 0.5rem 0.75rem;
border-radius: 0.75rem;
border-top-left-radius: 0.25rem;
word-break: break-word;
white-space: pre-wrap;
}
.chat-msg.self .chat-bubble {
background: var(--accent);
border-color: var(--accent);
color: #fff;
border-top-left-radius: 0.75rem;
border-top-right-radius: 0.25rem;
}
.chat-input {
display: flex;
align-items: flex-end;
gap: 0.5rem;
padding: 0.75rem;
border-top: 1px solid var(--d-border);
}
.chat-input textarea {
flex: 1;
min-width: 0;
min-height: 2.5rem;
max-height: 7.5rem;
padding: 0.5625rem 0.8125rem;
border: 1px solid var(--d-border);
border-radius: 0.625rem;
background: var(--d-panel-2);
color: var(--d-text);
font-family: inherit;
font-size: 0.8125rem;
line-height: 1.4;
outline: none;
resize: none;
overflow-y: hidden;
}
.chat-input textarea:focus {
border-color: var(--accent);
}
.chat-input textarea::placeholder {
color: var(--d-text-3);
}
.chat-send {
width: 2.5rem;
height: 2.5rem;
flex-shrink: 0;
border: none;
border-radius: 0.625rem;
background: var(--accent);
color: #fff;
cursor: pointer;
display: grid;
place-items: center;
}
.chat-send svg {
width: 1.125rem;
height: 1.125rem;
}
.chat-send:hover:not(:disabled) {
background: var(--accent-hover);
}
.chat-send:disabled {
opacity: 0.45;
cursor: not-allowed;
}
</style>
@@ -0,0 +1,257 @@
<script setup lang="ts">
// 회의실 컨트롤 바 — 마이크/카메라/화면공유(준비중)/채팅/참여자/나가기.
// 위치(pos)는 하단 중앙(bottom) 또는 상단 우측(top).
import MeetingIcon from './MeetingIcon.vue'
interface Props {
pos: 'bottom' | 'top'
micEnabled: boolean
camEnabled: boolean
chatOpen: boolean
peopleOpen: boolean
unread: number
}
defineProps<Props>()
const emit = defineEmits<{
(e: 'toggle-mic'): void
(e: 'toggle-cam'): void
(e: 'toggle-chat'): void
(e: 'toggle-people'): void
(e: 'leave'): void
}>()
</script>
<template>
<div
class="controlbar"
:class="`pos-${pos}`"
>
<button
type="button"
class="cb-btn"
:class="{ 'danger-state': !micEnabled }"
:title="micEnabled ? '마이크 끄기' : '마이크 켜기'"
@click="emit('toggle-mic')"
>
<MeetingIcon :name="micEnabled ? 'mic' : 'micOff'" />
<span class="cb-lbl">{{ micEnabled ? '마이크' : '음소거' }}</span>
</button>
<button
type="button"
class="cb-btn"
:class="{ 'danger-state': !camEnabled }"
:title="camEnabled ? '카메라 끄기' : '카메라 켜기'"
@click="emit('toggle-cam')"
>
<MeetingIcon :name="camEnabled ? 'cam' : 'camOff'" />
<span class="cb-lbl">{{ camEnabled ? '카메라' : '카메라 꺼짐' }}</span>
</button>
<span class="cb-div" />
<button
type="button"
class="cb-btn soon"
disabled
title="화면 공유 (준비 중)"
>
<MeetingIcon name="screen" />
<span class="cb-lbl">화면 공유</span>
<span class="cb-soon"></span>
</button>
<button
type="button"
class="cb-btn"
:class="{ on: chatOpen }"
title="채팅"
@click="emit('toggle-chat')"
>
<MeetingIcon name="chat" />
<span class="cb-lbl">채팅</span>
<span
v-if="unread > 0"
class="cb-badge"
>{{ unread > 9 ? '9+' : unread }}</span>
</button>
<button
type="button"
class="cb-btn"
:class="{ on: peopleOpen }"
title="참여자"
@click="emit('toggle-people')"
>
<MeetingIcon name="users" />
<span class="cb-lbl">참여자</span>
</button>
<span class="cb-div" />
<button
type="button"
class="cb-leave"
title="나가기"
@click="emit('leave')"
>
<MeetingIcon name="hangup" />
<span class="cb-lbl">나가기</span>
</button>
</div>
</template>
<style scoped>
.controlbar {
display: flex;
align-items: center;
gap: 0.5rem;
z-index: 30;
}
.controlbar.pos-bottom {
position: absolute;
left: 50%;
bottom: 1.375rem;
transform: translateX(-50%);
padding: 0.5625rem;
background: rgba(22, 25, 31, 0.86);
border: 1px solid var(--d-border);
border-radius: 1rem;
box-shadow: 0 14px 40px rgba(0, 0, 0, 0.5);
-webkit-backdrop-filter: blur(14px);
backdrop-filter: blur(14px);
}
.controlbar.pos-top {
position: absolute;
right: 1.125rem;
top: 4.25rem;
padding: 0.3125rem;
background: rgba(22, 25, 31, 0.92);
border: 1px solid var(--d-border);
border-radius: 0.75rem;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.4);
-webkit-backdrop-filter: blur(12px);
backdrop-filter: blur(12px);
}
.cb-btn {
position: relative;
display: inline-flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.1875rem;
min-width: 3.75rem;
height: 3.25rem;
padding: 0 0.75rem;
border: none;
border-radius: 0.6875rem;
background: transparent;
color: var(--d-text);
font-family: inherit;
cursor: pointer;
}
.cb-btn svg {
width: 1.3125rem;
height: 1.3125rem;
}
.cb-lbl {
font-size: 0.65625rem;
font-weight: 600;
color: var(--d-text-2);
}
.cb-btn:hover {
background: rgba(255, 255, 255, 0.08);
}
.cb-btn.danger-state,
.cb-btn.danger-state .cb-lbl {
color: var(--d-red);
}
.cb-btn.on {
background: rgba(79, 70, 229, 0.22);
}
.cb-btn.on .cb-lbl,
.cb-btn.on svg {
color: var(--d-accent-soft);
}
.cb-btn.soon {
opacity: 0.5;
cursor: not-allowed;
}
.cb-soon {
position: absolute;
top: 0.25rem;
right: 0.5rem;
font-size: 0.5rem;
font-weight: 700;
color: var(--d-bg);
background: var(--d-text-3);
padding: 1px 0.25rem;
border-radius: 0.5rem;
}
.cb-badge {
position: absolute;
top: 0.1875rem;
right: 0.4375rem;
min-width: 1rem;
height: 1rem;
padding: 0 0.25rem;
border-radius: 0.5rem;
background: #e0364c;
color: #fff;
font-size: 0.59375rem;
font-weight: 700;
display: grid;
place-items: center;
box-shadow: 0 0 0 2px rgba(22, 25, 31, 0.86);
}
.controlbar.pos-top .cb-badge {
top: -0.1875rem;
right: -0.125rem;
box-shadow: 0 0 0 2px var(--d-panel-2);
}
.cb-div {
width: 1px;
height: 1.875rem;
background: var(--d-border);
margin: 0 0.25rem;
}
.cb-leave {
display: inline-flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.1875rem;
min-width: 3.75rem;
height: 3.25rem;
padding: 0 0.875rem;
border: none;
border-radius: 0.6875rem;
background: #e0364c;
color: #fff;
font-family: inherit;
cursor: pointer;
}
.cb-leave svg {
width: 1.375rem;
height: 1.375rem;
}
.cb-leave .cb-lbl {
color: #fff;
}
.cb-leave:hover {
background: #c52a3e;
}
/* 상단 우측 모드: 라벨 숨기고 아이콘만 */
.controlbar.pos-top .cb-lbl {
display: none;
}
.controlbar.pos-top .cb-btn,
.controlbar.pos-top .cb-leave {
min-width: 0;
height: 2.5rem;
padding: 0 0.75rem;
}
.controlbar.pos-top .cb-soon {
display: none;
}
.controlbar.pos-top .cb-div {
height: 1.375rem;
}
</style>
@@ -0,0 +1,443 @@
<script setup lang="ts">
// 새 회의 만들기 — 공용 BaseModal 폼 모달(프로젝트 멤버 초대 모달과 동일 패턴).
// 참여 제한 멤버 선택은 업무 생성의 담당자 할당과 동일한 형태:
// 입력칸 고정 + 후보 드롭다운(focus 시) + 선택된 멤버는 입력칸 "아래"에 칩으로 표기.
import { computed, ref, watch } from 'vue'
import BaseModal from '@/components/common/BaseModal.vue'
import MeetingIcon from './MeetingIcon.vue'
import UserAvatar from '@/components/UserAvatar.vue'
import { useUser } from '@/composables/useUser'
import { useMeeting } from '@/composables/useMeeting'
import type { ApiMember } from '@/types/repo'
import type { ApiMeetingRoom } from '@/types/meeting'
interface Props {
// 이름 중복 즉시 검사용 현재 방 목록
rooms: ApiMeetingRoom[]
}
const props = defineProps<Props>()
const emit = defineEmits<{
// 생성 성공 → 부모가 목록 갱신 + 모달 닫기
(e: 'created'): void
(e: 'cancel'): void
}>()
const NAME_RE = /^[A-Za-z0-9_-]{1,64}$/
const userApi = useUser()
const meetingApi = useMeeting()
const name = ref('')
const scheduledAt = ref('')
const restrict = ref(false)
const error = ref('')
const submitting = ref(false)
// --- 참여 허용 멤버 선택 (업무 담당자 할당과 동일한 형태) ---
const selected = ref<ApiMember[]>([]) // 선택된 멤버(입력칸 아래 칩)
const query = ref('')
const found = ref<ApiMember[]>([]) // 검색 결과
const showDropdown = ref(false)
// 검색어 변경 시 후보 갱신(서버 검색)
watch(query, async (q) => {
if (!restrict.value) return
found.value = await userApi.search(q)
})
// 아직 선택되지 않은 멤버 후보
const candidates = computed(() => {
const picked = new Set(selected.value.map((u) => u.id))
return found.value.filter((u) => !picked.has(u.id)).slice(0, 8)
})
async function onMemberFocus() {
showDropdown.value = true
if (found.value.length === 0) found.value = await userApi.search(query.value)
}
function onMemberBlur() {
// 항목은 @mousedown.prevent 로 선택하므로 약간 지연 후 닫는다
setTimeout(() => (showDropdown.value = false), 120)
}
function addMember(u: ApiMember) {
if (!selected.value.some((x) => x.id === u.id)) selected.value.push(u)
query.value = ''
showDropdown.value = false
}
function removeMember(id: string) {
selected.value = selected.value.filter((u) => u.id !== id)
}
async function submit() {
if (submitting.value) return
const n = name.value.trim()
if (!NAME_RE.test(n)) {
error.value = '방 이름은 영문·숫자·-·_ 1~64자만 가능합니다.'
return
}
if (props.rooms.some((r) => r.name === n)) {
error.value = '이미 같은 이름의 방이 있습니다.'
return
}
error.value = ''
submitting.value = true
try {
await meetingApi.createRoom({
name: n,
scheduledAt: scheduledAt.value
? new Date(scheduledAt.value).toISOString()
: undefined,
allowedUserIds:
restrict.value && selected.value.length
? selected.value.map((u) => u.id)
: undefined,
})
emit('created')
} catch {
error.value = '방 생성에 실패했습니다. (이름이 이미 사용 중일 수 있습니다)'
submitting.value = false
}
}
</script>
<template>
<BaseModal
title="새 회의 만들기"
@close="emit('cancel')"
>
<template #subtitle>
방을 만들면 내가 진행자가 됩니다.
</template>
<form
class="cf-form"
@submit.prevent="submit"
>
<div class="form-field">
<label class="form-label"><span class="req">*</span> 이름</label>
<input
v-model="name"
class="form-input"
autofocus
placeholder="방 이름을 입력하세요"
maxlength="64"
@input="error = ''"
>
</div>
<div class="form-field">
<label class="form-label">예약 시각 <span class="opt">선택</span></label>
<input
v-model="scheduledAt"
class="form-input"
type="datetime-local"
>
</div>
<div class="form-field">
<label class="cf-toggle">
<input
v-model="restrict"
type="checkbox"
>
<span class="cf-tg-box"><i v-if="restrict" /></span>
<span class="cf-tg-txt"><b>참여 제한</b> 지정한 멤버만 입장할 있습니다</span>
</label>
<div
v-if="restrict"
class="cf-picker"
>
<!-- 입력칸은 고정 선택된 멤버는 아래에 표기 -->
<div class="combo">
<div class="combo-field">
<input
v-model="query"
class="combo-input"
placeholder="멤버 검색…"
autocomplete="off"
@focus="onMemberFocus"
@blur="onMemberBlur"
>
<span class="combo-caret">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
><path d="m6 9 6 6 6-6" /></svg>
</span>
</div>
<!-- 멤버 후보 드롭다운 -->
<div
v-if="showDropdown"
class="assignee-dd"
>
<button
v-for="m in candidates"
:key="m.id"
type="button"
class="assignee-dd-item"
@mousedown.prevent="addMember(m)"
>
<span class="avatar"><UserAvatar
:url="m.avatarUrl"
:name="m.name"
/></span>
<span class="ad-nm">{{ m.name }}</span>
<span class="ad-em">{{ m.email }}</span>
</button>
<div
v-if="candidates.length === 0"
class="assignee-dd-msg"
>
추가할 멤버가 없습니다.
</div>
</div>
</div>
<!-- 선택된 멤버 (입력칸 아래에 추가) -->
<div
v-if="selected.length"
class="assignee-chips"
>
<span
v-for="a in selected"
:key="a.id"
class="chip"
>
<span class="avatar"><UserAvatar
:url="a.avatarUrl"
:name="a.name"
/></span>
{{ a.name }}
<span
class="x"
@click="removeMember(a.id)"
>×</span>
</span>
</div>
</div>
</div>
<p
v-if="error"
class="cf-err"
>
<MeetingIcon name="warn" />{{ error }}
</p>
</form>
<template #foot>
<button
type="button"
class="mbtn"
@click="emit('cancel')"
>
취소
</button>
<button
type="button"
class="mbtn primary"
:disabled="submitting"
@click="submit"
>
<MeetingIcon name="plus" />{{ submitting ? '생성 중…' : '방 만들기' }}
</button>
</template>
</BaseModal>
</template>
<style scoped>
/* 폼은 BaseModal 본문(.bm-body)의 세로 간격을 그대로 상속받도록 display:contents */
.cf-form {
display: contents;
}
.form-label .opt {
font-weight: 500;
color: var(--text-3);
}
/* 참여 제한 토글 */
.cf-toggle {
display: flex;
align-items: center;
gap: 0.625rem;
cursor: pointer;
user-select: none;
}
.cf-toggle input {
display: none;
}
.cf-tg-box {
width: 1.125rem;
height: 1.125rem;
border: 0.09375rem solid var(--border-strong);
border-radius: 0.3125rem;
background: #fff;
display: grid;
place-items: center;
flex-shrink: 0;
}
.cf-toggle input:checked + .cf-tg-box {
background: var(--accent);
border-color: var(--accent);
}
.cf-tg-box i {
width: 0.5625rem;
height: 0.5625rem;
border-radius: 0.125rem;
background: #fff;
}
.cf-tg-txt {
font-size: 0.8125rem;
color: var(--text-2);
}
.cf-tg-txt b {
color: var(--text);
font-weight: 600;
}
/* 멤버 피커 (업무 담당자 할당과 동일) */
.cf-picker {
margin-top: 0.625rem;
}
.combo-field {
border: 1px solid var(--border-strong);
border-radius: var(--radius);
padding: 0.4375rem 0.5rem;
background: #fff;
display: flex;
align-items: center;
gap: 0.375rem;
}
.combo-field:focus-within {
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-weak);
}
.combo-input {
flex: 1;
min-width: 4.375rem;
border: none;
outline: none;
font-family: inherit;
font-size: 0.8125rem;
padding: 0.25rem 0.125rem;
background: transparent;
}
.combo-input::placeholder {
color: var(--text-3);
}
.combo-caret {
color: var(--text-3);
display: grid;
place-items: center;
margin-left: auto;
flex-shrink: 0;
}
.combo-caret svg {
width: 1rem;
height: 1rem;
}
/* 모달 안에서는 절대배치 시 스크롤 영역에 잘리므로 인라인(흐름 내)으로 펼친다 */
.assignee-dd {
margin-top: 0.375rem;
background: #fff;
border: 1px solid var(--border);
border-radius: 0.5625rem;
box-shadow: var(--shadow-sm);
padding: 0.375rem;
max-height: 14rem;
overflow-y: auto;
}
.assignee-dd-item {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
padding: 0.4375rem 0.5rem;
border: none;
background: transparent;
border-radius: var(--radius-sm);
cursor: pointer;
font-family: inherit;
text-align: left;
}
.assignee-dd-item:hover {
background: #f5f6f8;
}
.assignee-dd-item .avatar {
width: 1.5rem;
height: 1.5rem;
flex-shrink: 0;
}
.ad-nm {
font-size: 0.8125rem;
font-weight: 600;
color: var(--text);
white-space: nowrap;
}
.ad-em {
font-size: 0.719rem;
color: var(--text-3);
margin-left: auto;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 9rem;
}
.assignee-dd-msg {
padding: 0.625rem 0.5rem;
font-size: 0.75rem;
color: var(--text-3);
text-align: center;
}
.assignee-chips {
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
margin-top: 0.5rem;
}
.chip {
display: inline-flex;
align-items: center;
gap: 0.375rem;
background: #f1f2f4;
border: 1px solid var(--border);
border-radius: 1.25rem;
padding: 0.1875rem 0.5rem 0.1875rem 0.1875rem;
font-size: 0.78125rem;
font-weight: 600;
color: var(--text);
white-space: nowrap;
}
.chip .avatar {
width: 1.375rem;
height: 1.375rem;
flex-shrink: 0;
}
.chip .x {
color: var(--text-3);
cursor: pointer;
font-size: 0.8125rem;
line-height: 1;
margin-left: 0.0625rem;
}
.chip .x:hover {
color: var(--red);
}
.cf-err {
display: flex;
align-items: center;
gap: 0.4375rem;
font-size: 0.78125rem;
color: var(--red);
font-weight: 500;
}
.cf-err svg {
width: 0.9375rem;
height: 0.9375rem;
flex-shrink: 0;
}
</style>
@@ -0,0 +1,177 @@
<script lang="ts">
// 사용 가능한 화상회의 아이콘 이름(별도 <script> 블록에서 export → 다른 SFC 에서 import 가능)
export type MeetingIconName =
| 'cam'
| 'camOff'
| 'mic'
| 'micOff'
| 'screen'
| 'chat'
| 'send'
| 'users'
| 'hangup'
| 'expand'
| 'plus'
| 'host'
| 'grid'
| 'speaker'
| 'left'
| 'right'
| 'close'
| 'search'
| 'pin'
| 'warn'
| 'lock'
| 'trash'
</script>
<script setup lang="ts">
// 화상회의 전용 라인 아이콘 모음 — name 으로 SVG 를 선택해 렌더한다.
// (svg 의 stroke/fill 은 currentColor 를 사용하므로 색상은 부모 color 로 제어)
defineProps<{ name: MeetingIconName }>()
</script>
<template>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<template v-if="name === 'cam'">
<path d="m23 7-7 5 7 5V7Z" /><rect
x="1"
y="5"
width="15"
height="14"
rx="2"
/>
</template>
<template v-else-if="name === 'camOff'">
<path d="M16 16v2a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h2m4 0h5a2 2 0 0 1 2 2v3l4-3v9" /><path d="m1 1 22 22" />
</template>
<template v-else-if="name === 'mic'">
<rect
x="9"
y="2"
width="6"
height="12"
rx="3"
/><path d="M5 10a7 7 0 0 0 14 0M12 19v3" />
</template>
<template v-else-if="name === 'micOff'">
<path d="M9 9v1a3 3 0 0 0 5.12 2.12M15 9.34V5a3 3 0 0 0-5.94-.6" /><path d="M17 10a7 7 0 0 1-.11 1.23M5 10a7 7 0 0 0 10.91 5.81M12 19v3M8 22h8" /><path d="m1 1 22 22" />
</template>
<template v-else-if="name === 'screen'">
<rect
x="2"
y="3"
width="20"
height="14"
rx="2"
/><path d="M8 21h8M12 17v4" /><path d="m9 9.5 3-3 3 3M12 6.5v6" />
</template>
<template v-else-if="name === 'chat'">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2Z" />
</template>
<template v-else-if="name === 'send'">
<path d="M22 2 11 13M22 2l-7 20-4-9-9-4 20-7Z" />
</template>
<template v-else-if="name === 'users'">
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" /><circle
cx="9"
cy="7"
r="4"
/><path d="M22 21v-2a4 4 0 0 0-3-3.87M16 3.13A4 4 0 0 1 16 11" />
</template>
<template v-else-if="name === 'hangup'">
<path
fill="currentColor"
stroke="none"
d="M12 9c-1.6 0-3.15.25-4.6.7v3.1c0 .39-.23.74-.56.9-.98.49-1.87 1.12-2.66 1.85a.99.99 0 0 1-1.36-.02L.29 13.36a.99.99 0 0 1 0-1.41C3.34 9.07 7.46 7.3 12 7.3s8.66 1.77 11.71 4.65a.99.99 0 0 1 0 1.41l-2.57 3.07a.99.99 0 0 1-1.36.02 11.5 11.5 0 0 0-2.66-1.85.998.998 0 0 1-.56-.9v-3.1C15.15 9.25 13.6 9 12 9Z"
/>
</template>
<template v-else-if="name === 'expand'">
<path d="M15 3h6v6M9 21H3v-6M21 3l-7 7M3 21l7-7" />
</template>
<template v-else-if="name === 'plus'">
<path d="M5 12h14M12 5v14" />
</template>
<template v-else-if="name === 'host'">
<path d="M12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2z" />
</template>
<template v-else-if="name === 'grid'">
<rect
x="3"
y="3"
width="7"
height="7"
rx="1"
/><rect
x="14"
y="3"
width="7"
height="7"
rx="1"
/><rect
x="3"
y="14"
width="7"
height="7"
rx="1"
/><rect
x="14"
y="14"
width="7"
height="7"
rx="1"
/>
</template>
<template v-else-if="name === 'speaker'">
<rect
x="3"
y="3"
width="18"
height="13"
rx="2"
/><path d="M3 19h5M11 19h2M16 19h5" />
</template>
<template v-else-if="name === 'left'">
<path d="m15 18-6-6 6-6" />
</template>
<template v-else-if="name === 'right'">
<path d="m9 18 6-6-6-6" />
</template>
<template v-else-if="name === 'close'">
<path d="M18 6 6 18M6 6l12 12" />
</template>
<template v-else-if="name === 'search'">
<circle
cx="11"
cy="11"
r="7"
/><path d="m21 21-4-4" />
</template>
<template v-else-if="name === 'pin'">
<path d="M9 4h6l-1 7 4 3v2H6v-2l4-3z" /><path d="M12 16v5" />
</template>
<template v-else-if="name === 'warn'">
<path d="M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0Z" /><path d="M12 9v4M12 17h.01" />
</template>
<template v-else-if="name === 'lock'">
<rect
x="3"
y="11"
width="18"
height="11"
rx="2"
/><path d="M7 11V7a5 5 0 0 1 10 0v4" />
</template>
<template v-else-if="name === 'trash'">
<path d="M3 6h18M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2m2 0v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6" /><path d="M10 11v6M14 11v6" />
</template>
</svg>
</template>
@@ -0,0 +1,391 @@
<script setup lang="ts">
// 화상회의 로비 — 검색 + 새 회의 만들기 + 방 목록.
import { computed, ref } from 'vue'
import MeetingIcon from './MeetingIcon.vue'
import MeetingCreateForm from './MeetingCreateForm.vue'
import { relativeTimeKo, formatDate } from '@/shared/utils/format'
import type { ApiMeetingRoom } from '@/types/meeting'
interface Props {
rooms: ApiMeetingRoom[]
joinError: string | null
}
const props = defineProps<Props>()
const emit = defineEmits<{
(e: 'join', name: string): void
(e: 'created'): void
(e: 'delete', room: ApiMeetingRoom): void
(e: 'clear-error'): void
}>()
const showForm = ref(false)
const keyword = ref('')
// 방 이름·진행자 이름으로 목록 검색(클라이언트 필터)
const filteredRooms = computed(() => {
const q = keyword.value.trim().toLowerCase()
if (!q) return props.rooms
return props.rooms.filter(
(r) =>
r.name.toLowerCase().includes(q) ||
(r.host?.name ?? '').toLowerCase().includes(q),
)
})
function onSearchInput() {
if (props.joinError) emit('clear-error')
}
function onCreated() {
showForm.value = false
emit('created')
}
// 예약 시각 표시(날짜+시간)
function fmtSchedule(iso: string): string {
return new Date(iso).toLocaleString('ko-KR', {
dateStyle: 'short',
timeStyle: 'short',
})
}
</script>
<template>
<div class="page">
<div class="crumb">
<b>화상회의</b>
</div>
<div class="pagehead">
<h1>화상회의</h1>
<span class="count-pill">{{ rooms.length }}</span>
</div>
<p class="lede">
회의 방을 만들거나 목록에서 선택해 입장하세요. 공유 링크(<code>/meeting/&lt;방이름&gt;</code>)로도 바로 입장할 있습니다.
</p>
<!-- 툴바: 검색 + 회의 만들기 -->
<div class="toolbar">
<div class="search">
<MeetingIcon name="search" />
<input
v-model="keyword"
type="text"
placeholder="회의방 검색…"
@input="onSearchInput"
>
</div>
<button
type="button"
class="btn primary new-room"
@click="showForm = true"
>
<MeetingIcon name="plus" /> 회의 만들기
</button>
</div>
<div
v-if="joinError"
class="join-err"
>
<MeetingIcon name="warn" /><span>{{ joinError }}</span>
</div>
<MeetingCreateForm
v-if="showForm"
:rooms="rooms"
@created="onCreated"
@cancel="showForm = false"
/>
<!-- 목록 -->
<div class="roomlist">
<div
v-for="r in filteredRooms"
:key="r.id"
class="room-row"
>
<div class="rr-ico">
<MeetingIcon name="cam" />
</div>
<div class="rr-main">
<div class="rr-title">
<span class="rr-name">{{ r.name }}</span>
<span
v-if="r.restricted"
class="rr-badge lock"
><MeetingIcon name="lock" />제한</span>
</div>
<div class="rr-meta">
<span
v-if="r.host"
class="rr-mi"
><MeetingIcon name="host" />{{ r.host.name }}</span>
<span
v-if="r.scheduledAt"
class="rr-mi"
>예약 {{ fmtSchedule(r.scheduledAt) }}</span>
<span
class="rr-mi rr-dim"
:title="formatDate(r.createdAt)"
>{{ relativeTimeKo(r.createdAt) }} 생성</span>
</div>
</div>
<div class="rr-actions">
<button
v-if="r.canJoin"
type="button"
class="btn primary rr-join"
@click="emit('join', r.name)"
>
입장
</button>
<button
v-else
type="button"
class="btn rr-noaccess"
disabled
title="참여 허용 멤버만 입장할 수 있습니다"
>
<MeetingIcon name="lock" />권한 없음
</button>
<button
v-if="r.isHost"
type="button"
class="btn danger rr-del"
title="방 삭제"
@click="emit('delete', r)"
>
<MeetingIcon name="trash" />
</button>
</div>
</div>
<div
v-if="rooms.length === 0"
class="rl-empty"
>
<MeetingIcon name="cam" />
<p>아직 생성된 회의방이 없습니다.</p>
<span>"새 회의 만들기" 회의방을 만들어 보세요.</span>
</div>
<div
v-else-if="filteredRooms.length === 0"
class="rl-empty"
>
<MeetingIcon name="search" />
<p>검색 결과가 없습니다.</p>
<span>다른 검색어로 다시 시도해 보세요.</span>
</div>
</div>
</div>
</template>
<style scoped>
/* 콘텐츠 폭/여백은 전역 .page(max-width 67.5rem) 사용 — 프로젝트·내 업무 화면과 동일 */
/* .crumb / .count-pill 은 전역(relay.css) 스타일 사용 */
.crumb b {
color: var(--text-2);
font-weight: 600;
}
.pagehead {
display: flex;
align-items: center;
gap: 0.6875rem;
padding: 0.5625rem 0 0.25rem;
}
.pagehead h1 {
font-size: 1.375rem;
font-weight: 700;
letter-spacing: -0.025rem;
}
.lede {
color: var(--text-2);
font-size: 0.844rem;
margin: 0.25rem 0 1.125rem;
}
code {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 0.92em;
background: #eceef1;
padding: 1px 0.3125rem;
border-radius: 0.25rem;
color: var(--text-2);
}
/* 툴바: 검색(.toolbar/.search 는 전역) + 우측 새 회의 버튼 */
.new-room {
margin-left: auto;
}
.join-err {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 0.75rem;
padding: 0.6875rem 0.875rem;
background: var(--red-weak);
border: 1px solid var(--red-border);
border-radius: var(--radius);
color: var(--red);
font-size: 0.8125rem;
font-weight: 500;
}
.join-err svg {
width: 1.0625rem;
height: 1.0625rem;
flex-shrink: 0;
}
/* 방 목록 */
.roomlist {
display: flex;
flex-direction: column;
gap: 0.5625rem;
}
.room-row {
display: flex;
align-items: center;
gap: 1rem;
padding: 1rem 1.25rem;
background: var(--panel);
border: 1px solid var(--border);
border-radius: 0.625rem;
box-shadow: var(--shadow-sm);
}
.room-row:hover {
border-color: var(--border-strong);
}
.rr-ico {
width: 2.5rem;
height: 2.5rem;
border-radius: 0.5625rem;
background: var(--accent-weak);
color: var(--accent);
display: grid;
place-items: center;
flex-shrink: 0;
}
.rr-ico svg {
width: 1.3125rem;
height: 1.3125rem;
}
.rr-main {
flex: 1;
min-width: 0;
}
.rr-title {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.5rem;
}
.rr-name {
font-size: 0.9375rem;
font-weight: 600;
color: var(--text);
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
letter-spacing: -0.0125rem;
}
.rr-badge {
display: inline-flex;
align-items: center;
gap: 0.25rem;
font-size: 0.6875rem;
font-weight: 600;
padding: 0.125rem 0.5rem;
border-radius: 1.25rem;
line-height: 1.5;
}
.rr-badge svg {
width: 0.6875rem;
height: 0.6875rem;
}
.rr-badge.lock {
color: var(--text-2);
background: #f1f2f4;
border: 1px solid var(--border);
}
.rr-meta {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.9375rem;
margin-top: 0.25rem;
font-size: 0.75rem;
color: var(--text-3);
}
.rr-mi {
display: flex;
align-items: center;
gap: 0.3125rem;
}
.rr-mi svg {
width: 0.8125rem;
height: 0.8125rem;
}
.rr-dim {
color: var(--text-3);
}
.rr-actions {
display: flex;
align-items: center;
gap: 0.5rem;
flex-shrink: 0;
}
.rr-join {
padding: 0 1.25rem;
}
.rr-noaccess {
color: var(--text-3);
}
.rr-del {
width: 2.25rem;
padding: 0;
justify-content: center;
}
.rr-del svg {
width: 0.9375rem;
height: 0.9375rem;
}
.rl-empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.375rem;
padding: 3.125rem 1.25rem;
background: var(--panel);
border: 1px dashed var(--border-strong);
border-radius: 0.75rem;
color: var(--text-3);
}
.rl-empty svg {
width: 2rem;
height: 2rem;
color: var(--border-strong);
}
.rl-empty p {
font-size: 0.875rem;
font-weight: 600;
color: var(--text-2);
}
.rl-empty span {
font-size: 0.78125rem;
}
/* 버튼 기본/primary 는 전역 .btn 사용. 여기선 danger·비활성·사이즈 변형만 보강. */
.btn.danger {
background: #fff;
border-color: var(--red-border);
color: var(--red);
}
.btn.danger:hover {
background: var(--red-weak);
border-color: var(--red);
color: var(--red);
}
.btn:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.btn:disabled:hover {
background: var(--panel);
color: var(--text-2);
}
</style>
@@ -0,0 +1,291 @@
<script setup lang="ts">
// 참여자 목록 — 진행자/참여자 그룹 구분, 이름 검색, 마이크·품질 표시, 진행자의 승격 버튼.
import { computed, ref } from 'vue'
import { ConnectionQuality, type Participant } from 'livekit-client'
import MeetingIcon from './MeetingIcon.vue'
import QualityBars from './QualityBars.vue'
import { avatarColor, avatarInitial } from '@/shared/utils/avatar'
import { normalizeQuality } from '@/composables/useLiveKitRoom'
interface Props {
participants: Participant[]
featuredId: string | null
localId: string | null
isHost: boolean
}
const props = defineProps<Props>()
const emit = defineEmits<{ (e: 'promote', identity: string): void }>()
const query = ref('')
// metadata 로 진행자 판정
function isHostMeta(p: Participant): boolean {
if (!p.metadata) return false
try {
return (JSON.parse(p.metadata) as { role?: string }).role === 'host'
} catch {
return false
}
}
const filtered = computed(() => {
const q = query.value.trim()
return props.participants.filter((p) =>
(p.name || p.identity).includes(q),
)
})
const hosts = computed(() => filtered.value.filter(isHostMeta))
const guests = computed(() => filtered.value.filter((p) => !isHostMeta(p)))
function quality(p: Participant) {
return normalizeQuality(p.connectionQuality as ConnectionQuality)
}
</script>
<template>
<div class="people">
<div class="pp-search">
<MeetingIcon name="search" />
<input
v-model="query"
type="text"
placeholder="이름 검색…"
aria-label="참여자 검색"
>
</div>
<div class="pp-list">
<template v-if="hosts.length">
<div class="pp-group">
진행자
</div>
<div
v-for="p in hosts"
:key="p.identity"
class="pp-row"
:class="{ featured: p.identity === featuredId }"
>
<span
class="pp-av"
:style="{ background: avatarColor(p.name || p.identity) }"
>{{ avatarInitial(p.name || p.identity) }}</span>
<span class="pp-name">{{ p.name || p.identity }}{{ p.identity === localId ? ' (나)' : '' }}</span>
<span
v-if="p.identity === featuredId"
class="pp-pin"
title="스테이지"
><MeetingIcon name="pin" /></span>
<span class="pp-grow" />
<QualityBars
:quality="quality(p)"
variant="bare"
/>
<span
class="pp-mic"
:class="{ off: !p.isMicrophoneEnabled }"
><MeetingIcon :name="p.isMicrophoneEnabled ? 'mic' : 'micOff'" /></span>
<button
v-if="isHost && p.identity !== featuredId"
type="button"
class="pp-promote"
title="스테이지로 크게 보기"
@click="emit('promote', p.identity)"
>
<MeetingIcon name="expand" />
</button>
</div>
</template>
<template v-if="guests.length">
<div class="pp-group">
참여자 {{ guests.length }}
</div>
<div
v-for="p in guests"
:key="p.identity"
class="pp-row"
:class="{ featured: p.identity === featuredId }"
>
<span
class="pp-av"
:style="{ background: avatarColor(p.name || p.identity) }"
>{{ avatarInitial(p.name || p.identity) }}</span>
<span class="pp-name">{{ p.name || p.identity }}{{ p.identity === localId ? ' (나)' : '' }}</span>
<span
v-if="p.identity === featuredId"
class="pp-pin"
title="스테이지"
><MeetingIcon name="pin" /></span>
<span class="pp-grow" />
<QualityBars
:quality="quality(p)"
variant="bare"
/>
<span
class="pp-mic"
:class="{ off: !p.isMicrophoneEnabled }"
><MeetingIcon :name="p.isMicrophoneEnabled ? 'mic' : 'micOff'" /></span>
<button
v-if="isHost && p.identity !== featuredId"
type="button"
class="pp-promote"
title="스테이지로 크게 보기"
@click="emit('promote', p.identity)"
>
<MeetingIcon name="expand" />
</button>
</div>
</template>
<div
v-if="filtered.length === 0"
class="pp-empty"
>
검색 결과가 없습니다.
</div>
</div>
</div>
</template>
<style scoped>
.people {
display: flex;
flex-direction: column;
min-height: 0;
flex: 1;
}
.pp-search {
display: flex;
align-items: center;
gap: 0.5rem;
margin: 0.75rem 1rem;
height: 2.25rem;
padding: 0 0.6875rem;
background: var(--d-panel-2);
border: 1px solid var(--d-border);
border-radius: 0.5rem;
}
.pp-search svg {
width: 0.9375rem;
height: 0.9375rem;
color: var(--d-text-3);
flex-shrink: 0;
}
.pp-search input {
flex: 1;
min-width: 0;
border: none;
outline: none;
background: transparent;
font-family: inherit;
font-size: 0.8125rem;
color: var(--d-text);
}
.pp-search input::placeholder {
color: var(--d-text-3);
}
.pp-list {
flex: 1;
overflow-y: auto;
padding: 0 0.5rem 0.75rem;
scrollbar-width: thin;
scrollbar-color: var(--d-border) transparent;
}
.pp-list::-webkit-scrollbar {
width: 0.4375rem;
}
.pp-list::-webkit-scrollbar-thumb {
background: var(--d-border);
border-radius: 0.25rem;
}
.pp-group {
font-size: 0.6875rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--d-text-3);
padding: 0.75rem 0.5rem 0.3125rem;
}
.pp-row {
display: flex;
align-items: center;
gap: 0.5625rem;
padding: 0.4375rem 0.5rem;
border-radius: 0.5rem;
}
.pp-row:hover {
background: var(--d-panel-2);
}
.pp-row.featured {
background: rgba(79, 70, 229, 0.13);
}
.pp-av {
width: 1.875rem;
height: 1.875rem;
border-radius: 50%;
color: #fff;
display: grid;
place-items: center;
font-size: 0.75rem;
font-weight: 700;
flex-shrink: 0;
text-transform: uppercase;
}
.pp-name {
font-size: 0.8125rem;
font-weight: 500;
color: var(--d-text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.pp-pin {
color: #9b91ff;
display: grid;
place-items: center;
}
.pp-pin svg {
width: 0.8125rem;
height: 0.8125rem;
}
.pp-grow {
flex: 1;
}
.pp-mic {
color: var(--d-text-2);
display: grid;
place-items: center;
}
.pp-mic svg {
width: 0.9375rem;
height: 0.9375rem;
}
.pp-mic.off {
color: var(--d-red);
}
.pp-promote {
width: 1.75rem;
height: 1.75rem;
border: 1px solid var(--d-border);
background: transparent;
color: var(--d-text-2);
border-radius: 0.4375rem;
cursor: pointer;
display: grid;
place-items: center;
}
.pp-promote svg {
width: 0.875rem;
height: 0.875rem;
}
.pp-promote:hover {
background: var(--accent);
border-color: var(--accent);
color: #fff;
}
.pp-empty {
padding: 1.875rem;
text-align: center;
font-size: 0.8125rem;
color: var(--d-text-3);
}
</style>
@@ -0,0 +1,405 @@
<script setup lang="ts">
// 입장 전 프리조인 — 실제 로컬 카메라/마이크를 미리보기하고 초기 on/off 상태를 정한 뒤 입장한다.
// 권한이 거부되면 미리보기 없이 '권한 거부 상태로 입장(참관)'을 안내한다.
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
import MeetingIcon from './MeetingIcon.vue'
import UserAvatar from '@/components/UserAvatar.vue'
interface PrejoinRoom {
name: string
hostName: string | null
restricted: boolean
isHost: boolean
}
interface Props {
room: PrejoinRoom
myName: string
myAvatarUrl: string | null
}
defineProps<Props>()
const emit = defineEmits<{
(e: 'enter', opts: { mic: boolean; cam: boolean }): void
(e: 'cancel'): void
}>()
const videoEl = ref<HTMLVideoElement | null>(null)
const micOn = ref(true)
const camOn = ref(true)
const denied = ref(false) // 미디어 권한 획득 실패 여부
let stream: MediaStream | null = null
// 트랙 enabled 동기화
function applyTrackState() {
if (!stream) return
stream.getAudioTracks().forEach((t) => (t.enabled = micOn.value))
stream.getVideoTracks().forEach((t) => (t.enabled = camOn.value))
}
watch([micOn, camOn], applyTrackState)
onMounted(async () => {
try {
stream = await navigator.mediaDevices.getUserMedia({
video: true,
audio: true,
})
if (videoEl.value) videoEl.value.srcObject = stream
applyTrackState()
} catch {
// 권한 거부/장치 없음 — 미리보기 불가, 토글은 off 로
denied.value = true
micOn.value = false
camOn.value = false
}
})
onBeforeUnmount(stopPreview)
// 미리보기 트랙 정리(입장 시 LiveKit 이 다시 장치를 획득하므로 여기서 해제)
function stopPreview() {
if (stream) {
stream.getTracks().forEach((t) => t.stop())
stream = null
}
if (videoEl.value) videoEl.value.srcObject = null
}
function enter() {
stopPreview()
emit('enter', { mic: micOn.value, cam: camOn.value })
}
</script>
<template>
<div class="prejoin">
<div class="pj-card">
<div class="pj-preview">
<video
ref="videoEl"
class="pj-video"
:class="{ hidden: !camOn }"
autoplay
playsinline
muted
/>
<div
v-if="!camOn"
class="pj-off"
>
<span class="av-lg"><UserAvatar
:url="myAvatarUrl"
:name="myName"
/></span>
</div>
<div class="pj-selflbl">
{{ myName }} ()
</div>
<div class="pj-prectrl">
<button
type="button"
class="pj-rb"
:class="{ off: !micOn }"
:disabled="denied"
title="마이크"
@click="micOn = !micOn"
>
<MeetingIcon :name="micOn ? 'mic' : 'micOff'" />
</button>
<button
type="button"
class="pj-rb"
:class="{ off: !camOn }"
:disabled="denied"
title="카메라"
@click="camOn = !camOn"
>
<MeetingIcon :name="camOn ? 'cam' : 'camOff'" />
</button>
</div>
<div
v-if="denied"
class="pj-camoff-note"
>
카메라·마이크 권한이 없습니다
</div>
</div>
<div class="pj-side">
<div class="pj-room">
<span class="pj-kicker">입장하려는 </span>
<div class="pj-roomname">
{{ room.name }}
<span
v-if="room.restricted"
class="rr-badge lock"
><MeetingIcon name="lock" />제한</span>
</div>
<div
v-if="room.hostName"
class="pj-roomhost"
>
<MeetingIcon name="host" /><span>{{ room.hostName }}</span>
</div>
</div>
<div class="pj-checklist">
<div
class="pj-chk"
:class="{ ok: micOn }"
>
<MeetingIcon :name="micOn ? 'mic' : 'micOff'" /><span>마이크 {{ micOn ? '켜짐' : '꺼짐' }}</span>
</div>
<div
class="pj-chk"
:class="{ ok: camOn }"
>
<MeetingIcon :name="camOn ? 'cam' : 'camOff'" /><span>카메라 {{ camOn ? '켜짐' : '꺼짐' }}</span>
</div>
</div>
<button
type="button"
class="btn primary pj-enter"
@click="enter"
>
지금 입장
</button>
<button
type="button"
class="pj-cancel"
@click="emit('cancel')"
>
로비로 돌아가기
</button>
</div>
</div>
</div>
</template>
<style scoped>
.prejoin {
height: 100%;
display: grid;
place-items: center;
padding: 1.875rem;
}
.pj-card {
display: flex;
gap: 1.375rem;
width: min(56.25rem, 100%);
background: var(--d-panel);
border: 1px solid var(--d-border);
border-radius: 1.125rem;
padding: 1.375rem;
box-shadow: 0 30px 80px rgba(0, 0, 0, 0.45);
}
.pj-preview {
position: relative;
flex: 1;
aspect-ratio: 16/9;
border-radius: 0.8125rem;
overflow: hidden;
background: var(--tile);
}
.pj-video {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
transform: scaleX(-1);
}
.pj-video.hidden {
display: none;
}
.pj-off {
position: absolute;
inset: 0;
display: grid;
place-items: center;
background: var(--tile);
}
.av-lg {
width: clamp(3.5rem, 12vh, 6rem);
height: clamp(3.5rem, 12vh, 6rem);
flex-shrink: 0;
}
.pj-selflbl {
position: absolute;
left: 0.875rem;
bottom: 0.875rem;
z-index: 3;
font-size: 0.8125rem;
font-weight: 600;
color: #fff;
background: rgba(0, 0, 0, 0.42);
padding: 0.3125rem 0.6875rem;
border-radius: 0.4375rem;
-webkit-backdrop-filter: blur(6px);
backdrop-filter: blur(6px);
}
.pj-prectrl {
position: absolute;
left: 0;
right: 0;
bottom: 0.875rem;
z-index: 3;
display: flex;
justify-content: center;
gap: 0.75rem;
}
.pj-rb {
width: 3rem;
height: 3rem;
border-radius: 50%;
border: none;
background: rgba(255, 255, 255, 0.16);
color: #fff;
display: grid;
place-items: center;
cursor: pointer;
-webkit-backdrop-filter: blur(8px);
backdrop-filter: blur(8px);
}
.pj-rb svg {
width: 1.3125rem;
height: 1.3125rem;
}
.pj-rb:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.28);
}
.pj-rb.off {
background: var(--red);
}
.pj-rb:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.pj-camoff-note {
position: absolute;
left: 0;
right: 0;
top: 42%;
transform: translateY(-50%);
text-align: center;
color: var(--d-text-3);
font-size: 0.8125rem;
z-index: 1;
}
.pj-side {
width: 17.875rem;
flex-shrink: 0;
display: flex;
flex-direction: column;
gap: 0.875rem;
}
.pj-room {
padding-bottom: 0.875rem;
border-bottom: 1px solid var(--d-border);
}
.pj-kicker {
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--d-text-3);
}
.pj-roomname {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
margin-top: 0.375rem;
font-size: 1.25rem;
font-weight: 700;
color: var(--d-text);
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
}
.rr-badge.lock {
display: inline-flex;
align-items: center;
gap: 0.25rem;
font-size: 0.6875rem;
font-weight: 600;
padding: 0.125rem 0.5rem;
border-radius: 1.25rem;
line-height: 1.5;
color: var(--d-text-2);
background: rgba(255, 255, 255, 0.08);
border: 1px solid var(--d-border);
}
.rr-badge.lock svg {
width: 0.6875rem;
height: 0.6875rem;
}
.pj-roomhost {
display: flex;
align-items: center;
gap: 0.375rem;
margin-top: 0.5rem;
font-size: 0.8125rem;
color: var(--d-text-2);
}
.pj-roomhost span {
line-height: 1;
}
.pj-roomhost svg {
width: 0.875rem;
height: 0.875rem;
flex-shrink: 0;
}
.pj-checklist {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.pj-chk {
display: flex;
align-items: center;
gap: 0.5625rem;
font-size: 0.8125rem;
color: var(--d-text-3);
}
.pj-chk svg {
width: 1rem;
height: 1rem;
}
.pj-chk.ok {
color: var(--d-text);
}
.pj-enter {
height: 2.875rem;
border-radius: 0.625rem;
justify-content: center;
font-size: 0.9375rem;
margin-top: auto;
}
.pj-cancel {
border: none;
background: transparent;
color: var(--d-text-2);
font-family: inherit;
font-size: 0.8125rem;
cursor: pointer;
padding: 0.25rem;
}
.pj-cancel:hover {
color: var(--d-text);
}
/* 공용 .btn primary 가 라이트 테마 기준이므로 입장 버튼 색을 명시 */
.pj-enter.btn.primary {
background: var(--accent);
border: none;
color: #fff;
}
.pj-enter.btn.primary:hover {
background: var(--accent-hover);
}
@media (max-width: 53.75rem) {
.pj-card {
flex-direction: column;
}
.pj-side {
width: 100%;
}
}
</style>
@@ -0,0 +1,493 @@
<script setup lang="ts">
// 연결된 회의실 — 상단 바 + (스피커/그리드 뷰) 본문 + 오른쪽 패널(참여자/채팅) + 컨트롤 바.
// 모든 라이브 상태(participants/featuredIdentity/mic/cam/messages)는 부모(MeetingPage)가 주입한다.
import { computed, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import type { Participant } from 'livekit-client'
import ParticipantTile from './ParticipantTile.vue'
import MeetingIcon from './MeetingIcon.vue'
import MeetingControlBar from './MeetingControlBar.vue'
import MeetingSidePanel from './MeetingSidePanel.vue'
import type { ChatMessage } from '@/types/meeting'
interface Props {
roomName: string
participants: Participant[]
featuredIdentity: string | null
localId: string | null
micEnabled: boolean
camEnabled: boolean
isHost: boolean
errorMsg: string | null
messages: ChatMessage[]
// 말하는 사람 하이라이트 사용 여부(기본 on)
highlight?: boolean
}
const props = withDefaults(defineProps<Props>(), { highlight: true })
const emit = defineEmits<{
(e: 'toggle-mic'): void
(e: 'toggle-cam'): void
(e: 'promote', identity: string): void
(e: 'send', text: string): void
(e: 'leave'): void
(e: 'dismiss-error'): void
}>()
// 한 페이지에 보일 참여자 수 — 스피커 뷰는 진행자(스테이지) 제외 나머지 최대 50명,
// 그리드 뷰는 전원 기준 50명. 초과분만 페이지네이션으로 넘긴다.
const PER_PAGE = 50
const page = ref(0)
const panel = ref<'people' | 'chat' | null>(null)
const unread = ref(0)
function isHostMeta(p: Participant): boolean {
if (!p.metadata) return false
try {
return (JSON.parse(p.metadata) as { role?: string }).role === 'host'
} catch {
return false
}
}
// --- 레이아웃 검증용 가상 참여자(개발 모드 전용) ---
// 사용법: /meeting/<방>?mock=100 → 미디어 없이 타일만 N명 추가(프로덕션 빌드에선 비활성).
const route = useRoute()
const MOCK_NAMES = [
'김민준',
'이서연',
'박지후',
'최예은',
'정도윤',
'강하늘',
'윤시우',
'임수아',
'한지안',
'오재현',
]
function makeMock(i: number): Participant {
const name = `${MOCK_NAMES[i % MOCK_NAMES.length]}${i + 1}`
return {
sid: `mock-${i}`,
identity: `mock-${i}`,
name,
isMicrophoneEnabled: i % 3 !== 0,
isSpeaking: i % 7 === 0,
connectionQuality: i % 5 === 0 ? 'poor' : i % 3 === 0 ? 'good' : 'excellent',
metadata: undefined,
getTrackPublication: () => undefined,
on() {
return this
},
off() {
return this
},
} as unknown as Participant
}
const mockCount = computed(() => {
if (!import.meta.env.DEV) return 0
const n = Number(route.query.mock)
return Number.isFinite(n) && n > 0 ? Math.min(Math.floor(n), 200) : 0
})
const mocks = computed<Participant[]>(() =>
Array.from({ length: mockCount.value }, (_, i) => makeMock(i)),
)
// 실제 참여자 + 가상 참여자
const allParticipants = computed<Participant[]>(() =>
mockCount.value ? [...props.participants, ...mocks.value] : props.participants,
)
// 스테이지에 올릴 참여자 — 승격됨 > 진행자 > 첫 참여자
const featured = computed<Participant | null>(() => {
const list = allParticipants.value
if (!list.length) return null
if (props.featuredIdentity) {
const f = list.find((p) => p.identity === props.featuredIdentity)
if (f) return f
}
return list.find(isHostMeta) ?? list[0] ?? null
})
// 스테이지(진행자) 제외 나머지 — 썸네일 그리드에 표시
const others = computed(() =>
allParticipants.value.filter((p) => p !== featured.value),
)
const pageCount = computed(() =>
Math.max(1, Math.ceil(others.value.length / PER_PAGE)),
)
const safePage = computed(() => Math.min(page.value, pageCount.value - 1))
const pageSlice = computed(() => {
const start = safePage.value * PER_PAGE
return others.value.slice(start, start + PER_PAGE)
})
function togglePanel(tab: 'people' | 'chat') {
panel.value = panel.value === tab ? null : tab
if (panel.value === 'chat') unread.value = 0
}
function setTab(tab: 'people' | 'chat') {
panel.value = tab
if (tab === 'chat') unread.value = 0
}
// 채팅이 닫혀 있을 때 도착한 상대 메시지를 미읽음으로 집계
watch(
() => props.messages.length,
(len, prev) => {
const added = props.messages.slice(prev ?? 0)
if (panel.value !== 'chat') {
unread.value += added.filter((m) => !m.self).length
}
},
)
</script>
<template>
<div
class="room"
:class="{ 'panel-open': panel }"
>
<!-- 상단 -->
<div class="room-bar">
<div class="rb-left">
<span class="rb-live"><i />LIVE</span>
<span class="rb-name">{{ roomName }}</span>
</div>
<div class="rb-right">
<span class="rb-count"><MeetingIcon name="users" />{{ allParticipants.length }}</span>
</div>
</div>
<p
v-if="errorMsg"
class="room-err"
>
<span class="re-ic"><MeetingIcon name="warn" /></span>
<span>{{ errorMsg }}</span>
<button
type="button"
class="re-x"
@click="emit('dismiss-error')"
>
<MeetingIcon name="close" />
</button>
</p>
<!-- 본문 -->
<div class="room-body">
<div class="room-main">
<!-- 축소된 스테이지() + 썸네일 그리드(아래) -->
<div class="speaker-wrap">
<div class="stage">
<ParticipantTile
v-if="featured"
:key="featured.identity"
:participant="featured"
featured
:is-local="featured.identity === localId"
:highlight="highlight"
/>
<div
v-else
class="stage-empty"
>
참여자를 기다리는
</div>
</div>
<div
v-if="others.length"
class="thumbs"
>
<div class="thumb-scroll">
<div class="thumb-grid">
<ParticipantTile
v-for="p in pageSlice"
:key="p.sid || p.identity"
:participant="p"
:is-local="p.identity === localId"
:can-promote="isHost"
:highlight="highlight"
@promote="(id) => emit('promote', id)"
/>
</div>
</div>
<div
v-if="pageCount > 1"
class="pager"
>
<button
type="button"
class="pg-btn"
:disabled="safePage === 0"
@click="page = safePage - 1"
>
<MeetingIcon name="left" />
</button>
<span class="pg-info">썸네일 · {{ safePage + 1 }}/{{ pageCount }}</span>
<button
type="button"
class="pg-btn"
:disabled="safePage >= pageCount - 1"
@click="page = safePage + 1"
>
<MeetingIcon name="right" />
</button>
</div>
</div>
</div>
</div>
<!-- 오른쪽 패널 -->
<MeetingSidePanel
v-if="panel"
:tab="panel"
:participants="allParticipants"
:featured-id="featured?.identity ?? null"
:local-id="localId"
:is-host="isHost"
:messages="messages"
@tab="setTab"
@close="panel = null"
@promote="(id) => emit('promote', id)"
@send="(t) => emit('send', t)"
/>
</div>
<!-- 컨트롤 -->
<MeetingControlBar
pos="bottom"
:mic-enabled="micEnabled"
:cam-enabled="camEnabled"
:chat-open="panel === 'chat'"
:people-open="panel === 'people'"
:unread="unread"
@toggle-mic="emit('toggle-mic')"
@toggle-cam="emit('toggle-cam')"
@toggle-chat="togglePanel('chat')"
@toggle-people="togglePanel('people')"
@leave="emit('leave')"
/>
</div>
</template>
<style scoped>
.room {
height: 100%;
display: flex;
flex-direction: column;
position: relative;
}
.room-bar {
display: flex;
align-items: center;
gap: 0.875rem;
height: 3.5rem;
flex-shrink: 0;
padding: 0 1.125rem;
border-bottom: 1px solid var(--d-border);
background: var(--d-panel);
}
.rb-left {
display: flex;
align-items: center;
gap: 0.6875rem;
min-width: 0;
}
.rb-live {
display: inline-flex;
align-items: center;
gap: 0.375rem;
font-size: 0.6875rem;
font-weight: 800;
line-height: 1;
letter-spacing: 0.05em;
color: #ff5a6e;
}
.rb-live i {
width: 0.4375rem;
height: 0.4375rem;
border-radius: 50%;
background: #ff5a6e;
animation: livedot 1.6s infinite;
}
@keyframes livedot {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.35;
}
}
.rb-name {
font-size: 0.9375rem;
font-weight: 700;
line-height: 1;
color: var(--d-text);
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.rb-right {
margin-left: auto;
display: flex;
align-items: center;
gap: 0.75rem;
}
.rb-count {
display: inline-flex;
align-items: center;
gap: 0.375rem;
font-size: 0.8125rem;
font-weight: 600;
color: var(--d-text-2);
}
.rb-count svg {
width: 0.9375rem;
height: 0.9375rem;
}
.room-err {
display: flex;
align-items: center;
gap: 0.625rem;
margin: 0.75rem 1.125rem 0;
padding: 0.6875rem 0.875rem;
background: rgba(183, 121, 31, 0.16);
border: 1px solid rgba(183, 121, 31, 0.45);
border-radius: 0.5625rem;
color: var(--d-amber);
font-size: 0.8125rem;
}
.room-err .re-ic {
display: grid;
place-items: center;
}
.room-err .re-ic svg {
width: 1.0625rem;
height: 1.0625rem;
}
.room-err span {
flex: 1;
}
.re-x {
border: none;
background: transparent;
color: var(--d-amber);
cursor: pointer;
display: grid;
place-items: center;
padding: 0.125rem;
flex: 0 0 auto;
}
.re-x svg {
width: 0.9375rem;
height: 0.9375rem;
}
.room-body {
flex: 1;
display: flex;
min-height: 0;
}
.room-main {
flex: 1;
min-width: 0;
padding: 1rem 1.125rem 6.25rem;
}
/* 스피커 뷰 — 위: 축소된 스테이지 / 아래: 썸네일 그리드(50명 채움) */
.speaker-wrap {
display: flex;
flex-direction: column;
gap: 0.875rem;
height: 100%;
min-height: 0;
}
.stage {
flex: 0 0 45%;
min-height: 0;
display: flex;
justify-content: center;
}
/* 축소된 스테이지에서 큰 화면은 16:9 비율 유지 + 높이에 맞춰 중앙 정렬 */
.stage :deep(.tile.featured) {
width: auto;
max-width: 100%;
aspect-ratio: 16 / 9;
}
.stage-empty {
width: 100%;
height: 100%;
display: grid;
place-items: center;
color: var(--d-text-3);
border: 1px dashed var(--d-border);
border-radius: 0.875rem;
}
.thumbs {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
gap: 0.625rem;
}
.thumb-scroll {
flex: 1;
min-height: 0;
overflow-y: auto;
padding-right: 0.125rem;
scrollbar-width: thin;
scrollbar-color: var(--d-border) transparent;
}
.thumb-scroll::-webkit-scrollbar {
width: 0.4375rem;
}
.thumb-scroll::-webkit-scrollbar-thumb {
background: var(--d-border);
border-radius: 0.25rem;
}
.thumb-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(10.5rem, 1fr));
gap: 0.5rem;
align-content: start;
}
/* 페이지네이션 */
.pager {
display: flex;
align-items: center;
justify-content: center;
gap: 0.75rem;
flex-shrink: 0;
}
.pg-btn {
width: 1.875rem;
height: 1.875rem;
border: 1px solid var(--d-border);
background: var(--d-panel-2);
color: var(--d-text-2);
border-radius: 0.4375rem;
cursor: pointer;
display: grid;
place-items: center;
}
.pg-btn svg {
width: 0.9375rem;
height: 0.9375rem;
}
.pg-btn:hover:not(:disabled) {
color: var(--d-text);
border-color: var(--d-text-3);
}
.pg-btn:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.pg-info {
font-size: 0.75rem;
font-weight: 600;
color: var(--d-text-3);
}
</style>
@@ -0,0 +1,145 @@
<script setup lang="ts">
// 회의실 오른쪽 패널 — 참여자/채팅 탭 전환 컨테이너. 활성 탭은 부모가 제어(컨트롤 바와 연동).
import type { Participant } from 'livekit-client'
import MeetingIcon from './MeetingIcon.vue'
import MeetingPeopleList from './MeetingPeopleList.vue'
import MeetingChat from './MeetingChat.vue'
import type { ChatMessage } from '@/types/meeting'
type Tab = 'people' | 'chat'
interface Props {
tab: Tab
participants: Participant[]
featuredId: string | null
localId: string | null
isHost: boolean
messages: ChatMessage[]
}
defineProps<Props>()
const emit = defineEmits<{
(e: 'tab', tab: Tab): void
(e: 'close'): void
(e: 'promote', identity: string): void
(e: 'send', text: string): void
}>()
</script>
<template>
<aside class="ppanel">
<div class="sp-tabs">
<button
type="button"
class="sp-tab"
:class="{ on: tab === 'people' }"
@click="emit('tab', 'people')"
>
참여자 <span class="sp-cnt">{{ participants.length }}</span>
</button>
<button
type="button"
class="sp-tab"
:class="{ on: tab === 'chat' }"
@click="emit('tab', 'chat')"
>
채팅
</button>
<span class="sp-grow" />
<button
type="button"
class="pp-x"
title="패널 닫기"
@click="emit('close')"
>
<MeetingIcon name="close" />
</button>
</div>
<MeetingChat
v-if="tab === 'chat'"
:messages="messages"
@send="(t) => emit('send', t)"
/>
<MeetingPeopleList
v-else
:participants="participants"
:featured-id="featuredId"
:local-id="localId"
:is-host="isHost"
@promote="(id) => emit('promote', id)"
/>
</aside>
</template>
<style scoped>
.ppanel {
width: 19.5rem;
flex-shrink: 0;
border-left: 1px solid var(--d-border);
background: var(--d-panel);
display: flex;
flex-direction: column;
min-height: 0;
}
.sp-tabs {
display: flex;
align-items: center;
gap: 0.25rem;
padding: 0.5625rem 0.5rem 0.5625rem 0.625rem;
border-bottom: 1px solid var(--d-border);
}
.sp-tab {
border: none;
background: transparent;
font-family: inherit;
font-size: 0.84375rem;
font-weight: 600;
color: var(--d-text-3);
padding: 0.4375rem 0.75rem;
border-radius: 0.5rem;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 0.4375rem;
}
.sp-tab:hover {
color: var(--d-text-2);
}
.sp-tab.on {
color: var(--d-text);
background: var(--d-panel-2);
}
.sp-cnt {
font-size: 0.6875rem;
font-weight: 700;
color: var(--d-text-2);
background: rgba(255, 255, 255, 0.08);
padding: 1px 0.4375rem;
border-radius: 1.25rem;
}
.sp-tab.on .sp-cnt {
color: var(--d-accent-soft);
background: rgba(124, 92, 240, 0.22);
}
.sp-grow {
flex: 1;
}
.pp-x {
width: 1.875rem;
height: 1.875rem;
border: none;
background: transparent;
color: var(--d-text-3);
border-radius: 0.4375rem;
cursor: pointer;
display: grid;
place-items: center;
}
.pp-x svg {
width: 1rem;
height: 1rem;
}
.pp-x:hover {
background: var(--d-panel-2);
color: var(--d-text);
}
</style>
@@ -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>
+202 -19
View File
@@ -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
+29
View File
@@ -0,0 +1,29 @@
// 아바타 색상/이니셜 유틸 — 이미지가 없는 곳(화상회의 다크 타일·참여자 목록·채팅)에서
// 이름 기반으로 일관된 배경색과 첫 글자를 만든다. 순수 함수.
// 이름 해시로 배정하는 아바타 배경색 팔레트
const AVATAR_COLORS = [
'#d0982a',
'#e0518d',
'#3d8bf2',
'#16a37b',
'#b3631f',
'#7c5cf0',
'#0e9594',
'#d6455d',
'#0284c7',
'#65a30d',
]
// 이름 문자코드 합을 팔레트 길이로 나눈 나머지로 색을 고른다(동일 이름 → 동일 색).
export function avatarColor(name: string): string {
const key = name || '?'
let sum = 0
for (const ch of key) sum += ch.charCodeAt(0)
return AVATAR_COLORS[sum % AVATAR_COLORS.length] as string
}
// 표시용 첫 글자(공백 제거). 비어 있으면 물음표.
export function avatarInitial(name: string): string {
return (name || '?').trim().charAt(0) || '?'
}
+14
View File
@@ -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'