Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 30b4b87368 | |||
| 999a8730dd | |||
| 251830d421 | |||
| c5a8fc8697 | |||
| b40e795664 | |||
| 88ef92628d | |||
| 423f2197ed | |||
| 54fe3d36e7 | |||
| 2416f728ba | |||
| 3c7aec86a2 | |||
| 9a4d287ffd | |||
| f519723260 | |||
| 85b6b65e24 | |||
| bf2321f2d5 |
@@ -52,3 +52,19 @@ CORS_ORIGIN=http://localhost:5273
|
||||
# AI_TIMEOUT_MS: AI 요청 타임아웃(ms). 백엔드 Claude 호출 abort + 프론트 axios 에 동일 적용.
|
||||
# 프론트에는 build arg VITE_AI_TIMEOUT_MS 로 주입된다.
|
||||
AI_TIMEOUT_MS=60000
|
||||
|
||||
# ==========================================
|
||||
# 화상회의 (LiveKit Cloud)
|
||||
# ==========================================
|
||||
# 개발은 LiveKit Cloud(무료 dev)를 사용한다. https://cloud.livekit.io 에서 프로젝트 생성 후
|
||||
# Settings → Keys 에서 발급된 값을 채운다. 코드 변경 없이 env 만으로 동작한다.
|
||||
# LIVEKIT_API_KEY / LIVEKIT_API_SECRET : 백엔드 입장 토큰(JWT) 서명용 (절대 커밋 금지)
|
||||
# LIVEKIT_WS_URL : 브라우저 → LiveKit 접속 URL. Cloud 는 wss://<project>.livekit.cloud
|
||||
# (build arg/런타임 env 로 VITE_LIVEKIT_URL 에 주입)
|
||||
# LIVEKIT_URL : 백엔드 → LiveKit 서버 API(RoomService 등) 주소. 보통 LIVEKIT_WS_URL 과 동일.
|
||||
# (단계 1 토큰 발급은 오프라인이라 아직 미사용)
|
||||
# 운영: .env.production 에 운영용 키/URL 만 넣으면 그대로 전환된다(셀프호스팅 시 자체 URL/키 사용).
|
||||
LIVEKIT_API_KEY=APIxxxxxxxxxxxx
|
||||
LIVEKIT_API_SECRET=your-livekit-cloud-api-secret
|
||||
LIVEKIT_WS_URL=wss://your-project.livekit.cloud
|
||||
LIVEKIT_URL=wss://your-project.livekit.cloud
|
||||
|
||||
@@ -52,3 +52,16 @@ CORS_ORIGIN=http://localhost:5173
|
||||
# AI_TIMEOUT_MS: AI 요청 타임아웃(ms). 백엔드 Claude 호출 abort + 프론트 axios 에 동일 적용.
|
||||
# 프론트에는 build arg VITE_AI_TIMEOUT_MS 로 주입된다.
|
||||
AI_TIMEOUT_MS=60000
|
||||
|
||||
# ==========================================
|
||||
# 화상회의 (LiveKit) — 셀프호스팅 또는 Cloud
|
||||
# ==========================================
|
||||
# [셀프호스팅] livekit/DEPLOY.md 참고. selfhost-livekit 프로파일로 컨테이너 기동.
|
||||
# LIVEKIT_WS_URL : 브라우저 접속(리버스 프록시 TLS) → wss://livekit.<운영도메인>
|
||||
# LIVEKIT_URL : 백엔드 → LiveKit 내부 API(도커 네트워크) → http://livekit:7880
|
||||
# [Cloud] LIVEKIT_WS_URL/URL 을 wss://<project>.livekit.cloud 로, 키를 Cloud 값으로.
|
||||
# LIVEKIT_API_SECRET 은 충분히 강력하게(최소 32자).
|
||||
LIVEKIT_API_KEY=change-me-livekit-key
|
||||
LIVEKIT_API_SECRET=change-me-livekit-secret-min-32-characters
|
||||
LIVEKIT_WS_URL=wss://livekit.example.com
|
||||
LIVEKIT_URL=http://livekit:7880
|
||||
|
||||
@@ -54,3 +54,6 @@ Thumbs.db
|
||||
# 디자인 시안 단독 HTML(참고용, 대용량 번들) — 커밋 제외
|
||||
frontend/*단독*.html
|
||||
frontend/*단일 파일*.html
|
||||
|
||||
# LiveKit 클러스터 설정 사본(redis 비밀번호 포함) — 커밋 제외
|
||||
livekit/*.local.yaml
|
||||
|
||||
Generated
+106
@@ -31,6 +31,7 @@
|
||||
"express-rate-limit": "^8.3.2",
|
||||
"helmet": "^8.1.0",
|
||||
"ioredis": "^5.11.1",
|
||||
"livekit-server-sdk": "^2.15.5",
|
||||
"mammoth": "^1.12.0",
|
||||
"nest-winston": "^1.10.0",
|
||||
"passport": "^0.7.0",
|
||||
@@ -798,6 +799,12 @@
|
||||
"url": "https://github.com/sponsors/Borewit"
|
||||
}
|
||||
},
|
||||
"node_modules/@bufbuild/protobuf": {
|
||||
"version": "1.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-1.10.1.tgz",
|
||||
"integrity": "sha512-wJ8ReQbHxsAfXhrf9ixl0aYbZorRuOWpBNzm8pL8ftmSxQx/wnJD5Eg861NwJU/czy2VXFIebCeZnZrI9rktIQ==",
|
||||
"license": "(Apache-2.0 AND BSD-3-Clause)"
|
||||
},
|
||||
"node_modules/@colors/colors": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz",
|
||||
@@ -2176,6 +2183,15 @@
|
||||
"integrity": "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@livekit/protocol": {
|
||||
"version": "1.48.0",
|
||||
"resolved": "https://registry.npmjs.org/@livekit/protocol/-/protocol-1.48.0.tgz",
|
||||
"integrity": "sha512-fYHYgltH6YavAsokl3qsHLkBdQeKCl4UORVTub5crS3t8JtKFZ0uinHDFQ+XXdNKS6Ub9gcOjV+UHcDiqnWXoQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@bufbuild/protobuf": "^1.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lukeed/csprng": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@lukeed/csprng/-/csprng-1.1.0.tgz",
|
||||
@@ -6282,6 +6298,60 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/camelcase-keys": {
|
||||
"version": "9.1.3",
|
||||
"resolved": "https://registry.npmjs.org/camelcase-keys/-/camelcase-keys-9.1.3.tgz",
|
||||
"integrity": "sha512-Rircqi9ch8AnZscQcsA1C47NFdaO3wukpmIRzYcDOrmvgt78hM/sj5pZhZNec2NM12uk5vTwRHZ4anGcrC4ZTg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"camelcase": "^8.0.0",
|
||||
"map-obj": "5.0.0",
|
||||
"quick-lru": "^6.1.1",
|
||||
"type-fest": "^4.3.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/camelcase-keys/node_modules/camelcase": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-8.0.0.tgz",
|
||||
"integrity": "sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/camelcase-keys/node_modules/quick-lru": {
|
||||
"version": "6.1.2",
|
||||
"resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-6.1.2.tgz",
|
||||
"integrity": "sha512-AAFUA5O1d83pIHEhJwWCq/RQcRukCkn/NSm2QsTEMle5f2hP0ChI2+3Xb051PZCkLryI/Ir1MVKviT2FIloaTQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/camelcase-keys/node_modules/type-fest": {
|
||||
"version": "4.41.0",
|
||||
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz",
|
||||
"integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==",
|
||||
"license": "(MIT OR CC0-1.0)",
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/caniuse-lite": {
|
||||
"version": "1.0.30001792",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001792.tgz",
|
||||
@@ -9809,6 +9879,15 @@
|
||||
"url": "https://github.com/chalk/supports-color?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/jose": {
|
||||
"version": "5.10.0",
|
||||
"resolved": "https://registry.npmjs.org/jose/-/jose-5.10.0.tgz",
|
||||
"integrity": "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/panva"
|
||||
}
|
||||
},
|
||||
"node_modules/js-tokens": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||
@@ -10064,6 +10143,21 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/livekit-server-sdk": {
|
||||
"version": "2.15.5",
|
||||
"resolved": "https://registry.npmjs.org/livekit-server-sdk/-/livekit-server-sdk-2.15.5.tgz",
|
||||
"integrity": "sha512-Uzs4tVebvY4/+pO9pG1nMMVRDZPnOJolZIOWIiSEnUXFBywq+eNWHrR8mplKL8Qn13mSTQLjKVXfGCGTAMQeYg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@bufbuild/protobuf": "^1.10.1",
|
||||
"@livekit/protocol": "^1.46.6",
|
||||
"camelcase-keys": "^9.0.0",
|
||||
"jose": "^5.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/load-esm": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/load-esm/-/load-esm-1.0.3.tgz",
|
||||
@@ -10381,6 +10475,18 @@
|
||||
"sprintf-js": "~1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/map-obj": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/map-obj/-/map-obj-5.0.0.tgz",
|
||||
"integrity": "sha512-2L3MIgJynYrZ3TYMriLDLWocz15okFakV6J12HXvMXDHui2x/zgChzg1u9mFFGbbGWE+GsLpQByt4POb9Or+uA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/math-intrinsics": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
|
||||
@@ -47,6 +47,7 @@
|
||||
"express-rate-limit": "^8.3.2",
|
||||
"helmet": "^8.1.0",
|
||||
"ioredis": "^5.11.1",
|
||||
"livekit-server-sdk": "^2.15.5",
|
||||
"mammoth": "^1.12.0",
|
||||
"nest-winston": "^1.10.0",
|
||||
"passport": "^0.7.0",
|
||||
|
||||
@@ -19,6 +19,7 @@ import { NotificationModule } from './modules/notification/notification.module';
|
||||
import { AiModule } from './modules/ai/ai.module';
|
||||
import { ProjectModule } from './modules/project/project.module';
|
||||
import { ScheduleModule } from './modules/schedule/schedule.module';
|
||||
import { MeetingModule } from './modules/meeting/meeting.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -100,6 +101,7 @@ import { ScheduleModule } from './modules/schedule/schedule.module';
|
||||
AiModule,
|
||||
ProjectModule,
|
||||
ScheduleModule,
|
||||
MeetingModule,
|
||||
],
|
||||
controllers: [AppController],
|
||||
providers: [
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
// 화상회의 방(meeting_rooms) 테이블 추가 — 방 이름(고유)과 진행자(host) 보관.
|
||||
// 운영(migrationsRun)에서만 실행되며, dev 는 synchronize 로 자동 반영된다.
|
||||
export class AddMeetingRooms1782700000000 implements MigrationInterface {
|
||||
name = 'AddMeetingRooms1782700000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "meeting_rooms" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "name" character varying NOT NULL, "created_at" TIMESTAMP NOT NULL DEFAULT now(), "host_id" uuid, CONSTRAINT "PK_meeting_rooms" PRIMARY KEY ("id"))`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE UNIQUE INDEX "IDX_meeting_rooms_name" ON "meeting_rooms" ("name")`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "meeting_rooms" ADD CONSTRAINT "FK_meeting_rooms_host" FOREIGN KEY ("host_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE NO ACTION`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "meeting_rooms" DROP CONSTRAINT "FK_meeting_rooms_host"`,
|
||||
);
|
||||
await queryRunner.query(`DROP INDEX "IDX_meeting_rooms_name"`);
|
||||
await queryRunner.query(`DROP TABLE "meeting_rooms"`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
// 화상회의 방에 예약 시각/참여 허용 사용자 컬럼 추가 (단계 4: 방 관리).
|
||||
// 운영(migrationsRun)에서만 실행되며, dev 는 synchronize 로 자동 반영된다.
|
||||
export class AddMeetingRoomFields1782800000000 implements MigrationInterface {
|
||||
name = 'AddMeetingRoomFields1782800000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "meeting_rooms" ADD "scheduled_at" TIMESTAMP`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "meeting_rooms" ADD "allowed_user_ids" text`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "meeting_rooms" DROP COLUMN "allowed_user_ids"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "meeting_rooms" DROP COLUMN "scheduled_at"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import {
|
||||
ArrayMaxSize,
|
||||
IsArray,
|
||||
IsISO8601,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
Length,
|
||||
Matches,
|
||||
} from 'class-validator';
|
||||
|
||||
// 화상회의 방 생성 요청
|
||||
export class CreateMeetingRoomDto {
|
||||
@ApiProperty({
|
||||
description: '방 이름(영문/숫자/-/_ 만 허용, 고유)',
|
||||
example: 'weekly-sync',
|
||||
})
|
||||
@IsString()
|
||||
@Length(1, 64, { message: '방 이름은 1~64자여야 합니다.' })
|
||||
@Matches(/^[A-Za-z0-9_-]+$/, {
|
||||
message: '방 이름은 영문, 숫자, 하이픈(-), 밑줄(_)만 사용할 수 있습니다.',
|
||||
})
|
||||
name!: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '예약 시각(ISO 8601). 미지정 시 즉시 사용 가능한 방',
|
||||
example: '2026-07-01T10:00:00.000Z',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsISO8601({}, { message: '예약 시각 형식이 올바르지 않습니다.' })
|
||||
scheduledAt?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '참여 허용 사용자 ID 목록. 비우면 전체 공개',
|
||||
type: [String],
|
||||
})
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ArrayMaxSize(160, { message: '참여 허용 인원은 최대 160명입니다.' })
|
||||
@IsUUID('4', { each: true, message: '사용자 ID 형식이 올바르지 않습니다.' })
|
||||
allowedUserIds?: string[];
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsString, Length, Matches } from 'class-validator';
|
||||
|
||||
// 화상회의 입장 토큰 발급 요청 — 참여할 방 이름을 전달한다.
|
||||
export class CreateMeetingTokenDto {
|
||||
@ApiProperty({
|
||||
description: '참여할 회의 방 이름(영문/숫자/-/_ 만 허용)',
|
||||
example: 'team-standup',
|
||||
})
|
||||
@IsString()
|
||||
@Length(1, 64, { message: '방 이름은 1~64자여야 합니다.' })
|
||||
@Matches(/^[A-Za-z0-9_-]+$/, {
|
||||
message: '방 이름은 영문, 숫자, 하이픈(-), 밑줄(_)만 사용할 수 있습니다.',
|
||||
})
|
||||
roomName!: string;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
Index,
|
||||
JoinColumn,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
type Relation,
|
||||
} from 'typeorm';
|
||||
import { User } from '../../user/entities/user.entity';
|
||||
|
||||
// 화상회의 방 — 방 이름과 진행자(생성자)를 보관한다.
|
||||
// 단계 2(경량): 방 이름으로 토큰을 처음 발급받은 사용자가 진행자(host)가 되고,
|
||||
// 이후 입장자는 일반 참여자가 된다. (방 목록/예약/참여 제한 UI 는 단계 4)
|
||||
@Entity('meeting_rooms')
|
||||
export class MeetingRoom {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string;
|
||||
|
||||
// 방 이름(고유) — 같은 이름으로 들어온 사람끼리 연결된다
|
||||
@Index({ unique: true })
|
||||
@Column()
|
||||
name!: string;
|
||||
|
||||
// 진행자(생성자). 사용자 삭제 시에도 방 기록은 유지(SET NULL)
|
||||
@ManyToOne(() => User, { onDelete: 'SET NULL', nullable: true })
|
||||
@JoinColumn({ name: 'host_id' })
|
||||
host!: Relation<User> | null;
|
||||
|
||||
// 예약 시각(선택) — 표시용. 현재는 시간 전 입장을 막지 않는다.
|
||||
@Column({ name: 'scheduled_at', type: 'timestamp', nullable: true })
|
||||
scheduledAt!: Date | null;
|
||||
|
||||
// 참여 허용 사용자 ID 목록(선택) — 비어있으면(null) 전체 공개.
|
||||
// 값이 있으면 진행자 + 목록에 포함된 사용자만 입장 가능.
|
||||
@Column({ name: 'allowed_user_ids', type: 'simple-array', nullable: true })
|
||||
allowedUserIds!: string[] | null;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt!: Date;
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Post,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiOperation,
|
||||
ApiResponse,
|
||||
ApiTags,
|
||||
ApiBearerAuth,
|
||||
} from '@nestjs/swagger';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||
import type { PublicUser } from '../user/user.service';
|
||||
import {
|
||||
MeetingService,
|
||||
type MeetingTokenResult,
|
||||
type MeetingRoomResponse,
|
||||
} from './meeting.service';
|
||||
import { CreateMeetingTokenDto } from './dto/create-meeting-token.dto';
|
||||
import { CreateMeetingRoomDto } from './dto/create-meeting-room.dto';
|
||||
|
||||
// 화상회의 컨트롤러 — 방 관리 + LiveKit 입장 토큰 발급(인증 필요).
|
||||
@ApiTags('화상회의')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('meetings')
|
||||
export class MeetingController {
|
||||
constructor(private readonly meetingService: MeetingService) {}
|
||||
|
||||
@Get('rooms')
|
||||
@ApiOperation({ summary: '회의 방 목록' })
|
||||
@ApiResponse({ status: 200, description: '목록 조회 성공' })
|
||||
listRooms(@CurrentUser() user: PublicUser): Promise<MeetingRoomResponse[]> {
|
||||
return this.meetingService.listRooms(user.id);
|
||||
}
|
||||
|
||||
@Post('rooms')
|
||||
@ApiOperation({ summary: '회의 방 생성(생성자=진행자)' })
|
||||
@ApiResponse({ status: 201, description: '생성 성공' })
|
||||
createRoom(
|
||||
@CurrentUser() user: PublicUser,
|
||||
@Body() dto: CreateMeetingRoomDto,
|
||||
): Promise<MeetingRoomResponse> {
|
||||
return this.meetingService.createRoom(user.id, dto);
|
||||
}
|
||||
|
||||
@Delete('rooms/:roomId')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@ApiOperation({ summary: '회의 방 삭제(진행자만)' })
|
||||
@ApiResponse({ status: 204, description: '삭제 성공' })
|
||||
async deleteRoom(
|
||||
@CurrentUser() user: PublicUser,
|
||||
@Param('roomId', ParseUUIDPipe) roomId: string,
|
||||
): Promise<void> {
|
||||
await this.meetingService.deleteRoom(user.id, roomId);
|
||||
}
|
||||
|
||||
@Post('token')
|
||||
@ApiOperation({ summary: 'LiveKit 입장 토큰 발급' })
|
||||
@ApiResponse({ status: 201, description: '토큰 발급 성공' })
|
||||
issueToken(
|
||||
@CurrentUser() user: PublicUser,
|
||||
@Body() dto: CreateMeetingTokenDto,
|
||||
): Promise<MeetingTokenResult> {
|
||||
return this.meetingService.issueToken(user, dto.roomName);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { MeetingController } from './meeting.controller';
|
||||
import { MeetingService } from './meeting.service';
|
||||
import { MeetingRoom } from './entities/meeting-room.entity';
|
||||
|
||||
// 화상회의 모듈 — LiveKit 입장 토큰 발급 + 방/진행자(생성자) 보관.
|
||||
// ConfigModule 은 전역(app.module)에서 등록되어 있어 별도 import 불필요.
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([MeetingRoom])],
|
||||
controllers: [MeetingController],
|
||||
providers: [MeetingService],
|
||||
exports: [MeetingService],
|
||||
})
|
||||
export class MeetingModule {}
|
||||
@@ -0,0 +1,232 @@
|
||||
import { HttpStatus, Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { AccessToken } from 'livekit-server-sdk';
|
||||
import { BusinessException } from '../../common/exceptions/business.exception';
|
||||
import { UserService, type PublicUser } from '../user/user.service';
|
||||
import { User } from '../user/entities/user.entity';
|
||||
import { MeetingRoom } from './entities/meeting-room.entity';
|
||||
import { CreateMeetingRoomDto } from './dto/create-meeting-room.dto';
|
||||
|
||||
// 회의 내 역할 — 진행자(방 생성자) / 일반 참여자
|
||||
export type MeetingRole = 'host' | 'participant';
|
||||
|
||||
// 방 목록/생성 응답 — 진행자·예약·참여제한·접근 가능 여부 포함
|
||||
export interface MeetingRoomResponse {
|
||||
id: string;
|
||||
name: string;
|
||||
host: PublicUser | null;
|
||||
scheduledAt: string | null;
|
||||
restricted: boolean; // 참여 제한(allow-list) 적용 여부
|
||||
isHost: boolean; // 현재 사용자가 진행자인지
|
||||
canJoin: boolean; // 현재 사용자가 입장 가능한지(공개 OR 진행자 OR 허용목록 포함)
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
// 화상회의 토큰 발급 결과 — 브라우저는 자체 VITE_LIVEKIT_URL 로 접속하므로 URL 은 반환하지 않는다.
|
||||
export interface MeetingTokenResult {
|
||||
token: string; // LiveKit 입장 JWT
|
||||
roomName: string;
|
||||
identity: string; // 참여자 식별자(사용자 ID)
|
||||
role: MeetingRole; // 이번 입장자의 역할
|
||||
}
|
||||
|
||||
// 화상회의 서비스(단계 2: 권한 분기) — LiveKit 입장 토큰(JWT) 발급을 담당한다.
|
||||
// 미디어 전송은 LiveKit(SFU)에 위임하고, 여기서는 인증된 사용자에게 역할별 권한이 담긴 토큰을 만든다.
|
||||
// 역할: 방을 처음 만든 사용자가 진행자(host), 이후 입장자는 일반 참여자(participant).
|
||||
@Injectable()
|
||||
export class MeetingService {
|
||||
private readonly logger = new Logger(MeetingService.name);
|
||||
private readonly apiKey: string;
|
||||
private readonly apiSecret: string;
|
||||
|
||||
constructor(
|
||||
config: ConfigService,
|
||||
@InjectRepository(MeetingRoom)
|
||||
private readonly roomRepo: Repository<MeetingRoom>,
|
||||
) {
|
||||
this.apiKey = (config.get<string>('LIVEKIT_API_KEY') ?? '').trim();
|
||||
this.apiSecret = (config.get<string>('LIVEKIT_API_SECRET') ?? '').trim();
|
||||
if (!this.enabled) {
|
||||
this.logger.warn(
|
||||
'LIVEKIT_API_KEY/LIVEKIT_API_SECRET 미설정 — 화상회의 토큰 발급 비활성화',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
get enabled(): boolean {
|
||||
return this.apiKey.length > 0 && this.apiSecret.length > 0;
|
||||
}
|
||||
|
||||
// LiveKit 입장 토큰 발급 — 역할에 따라 권한(grant)을 차등 부여한다.
|
||||
// · 진행자(host): roomAdmin + 발행/구독 (단계 3의 화면 승격 등 진행자 기능 토대)
|
||||
// · 일반(participant): 발행/구독
|
||||
// 역할은 토큰 metadata 에도 실어 다른 참여자가 진행자를 식별할 수 있게 한다.
|
||||
async issueToken(
|
||||
user: PublicUser,
|
||||
roomName: string,
|
||||
): Promise<MeetingTokenResult> {
|
||||
if (!this.enabled) {
|
||||
throw new BusinessException(
|
||||
'SYS_001',
|
||||
'화상회의 서비스가 아직 설정되지 않았습니다. 잠시 후 다시 시도해 주세요.',
|
||||
HttpStatus.SERVICE_UNAVAILABLE,
|
||||
);
|
||||
}
|
||||
|
||||
const role = await this.resolveAccess(user.id, roomName);
|
||||
const isHost = role === 'host';
|
||||
|
||||
const at = new AccessToken(this.apiKey, this.apiSecret, {
|
||||
identity: user.id,
|
||||
name: user.name,
|
||||
// 장시간 회의 중 네트워크 재연결 시 토큰 만료로 재입장이 실패하지 않도록 넉넉히(12h).
|
||||
ttl: '12h',
|
||||
// role 은 진행자 식별용, avatarUrl 은 참여자/채팅 아바타 표시용
|
||||
metadata: JSON.stringify({ role, avatarUrl: user.avatarUrl }),
|
||||
});
|
||||
at.addGrant({
|
||||
roomJoin: true,
|
||||
room: roomName,
|
||||
canPublish: true,
|
||||
canSubscribe: true,
|
||||
canPublishData: true,
|
||||
roomAdmin: isHost,
|
||||
});
|
||||
|
||||
const token = await at.toJwt();
|
||||
return { token, roomName, identity: user.id, role };
|
||||
}
|
||||
|
||||
// 입장 권한 판정 + 역할 결정.
|
||||
// · 방이 없으면 이 사용자를 진행자로 생성(공개 방) — 직접 링크 입장 호환.
|
||||
// · 방이 있으면 참여 제한(allow-list)을 검사하고, 생성자 일치로 역할을 판정.
|
||||
private async resolveAccess(
|
||||
userId: string,
|
||||
roomName: string,
|
||||
): Promise<MeetingRole> {
|
||||
let room = await this.roomRepo.findOne({
|
||||
where: { name: roomName },
|
||||
relations: ['host'],
|
||||
});
|
||||
|
||||
if (!room) {
|
||||
// 방이 없으면 생성 시도 — 이 사용자가 진행자가 된다.
|
||||
try {
|
||||
await this.roomRepo.save(
|
||||
this.roomRepo.create({
|
||||
name: roomName,
|
||||
host: { id: userId } as User,
|
||||
}),
|
||||
);
|
||||
return 'host';
|
||||
} catch {
|
||||
// 동시 생성 경쟁(name unique 충돌) — 재조회로 판정.
|
||||
room = await this.roomRepo.findOne({
|
||||
where: { name: roomName },
|
||||
relations: ['host'],
|
||||
});
|
||||
}
|
||||
}
|
||||
if (!room) return 'participant';
|
||||
|
||||
const isHost = room.host?.id === userId;
|
||||
if (
|
||||
!isHost &&
|
||||
this.isRestricted(room) &&
|
||||
!room.allowedUserIds!.includes(userId)
|
||||
) {
|
||||
throw new BusinessException(
|
||||
'AUTH_003',
|
||||
'이 회의에 참여할 권한이 없습니다.',
|
||||
HttpStatus.FORBIDDEN,
|
||||
);
|
||||
}
|
||||
return isHost ? 'host' : 'participant';
|
||||
}
|
||||
|
||||
// 명시적 방 생성 — 생성자가 진행자. 예약 시각/참여 제한을 함께 설정.
|
||||
async createRoom(
|
||||
userId: string,
|
||||
dto: CreateMeetingRoomDto,
|
||||
): Promise<MeetingRoomResponse> {
|
||||
const existing = await this.roomRepo.findOne({ where: { name: dto.name } });
|
||||
if (existing) {
|
||||
throw new BusinessException(
|
||||
'BIZ_001',
|
||||
'이미 존재하는 방 이름입니다. 다른 이름을 사용해 주세요.',
|
||||
HttpStatus.CONFLICT,
|
||||
);
|
||||
}
|
||||
const saved = await this.roomRepo.save(
|
||||
this.roomRepo.create({
|
||||
name: dto.name,
|
||||
host: { id: userId } as User,
|
||||
scheduledAt: dto.scheduledAt ? new Date(dto.scheduledAt) : null,
|
||||
allowedUserIds: dto.allowedUserIds?.length ? dto.allowedUserIds : null,
|
||||
}),
|
||||
);
|
||||
const room = await this.roomRepo.findOne({
|
||||
where: { id: saved.id },
|
||||
relations: ['host'],
|
||||
});
|
||||
return this.toRoomResponse(room!, userId);
|
||||
}
|
||||
|
||||
// 방 목록 — 전체 방(최근 생성순). 각 방의 접근 가능 여부/진행자 여부를 포함.
|
||||
async listRooms(userId: string): Promise<MeetingRoomResponse[]> {
|
||||
const rows = await this.roomRepo.find({
|
||||
relations: ['host'],
|
||||
order: { createdAt: 'DESC' },
|
||||
});
|
||||
return rows.map((r) => this.toRoomResponse(r, userId));
|
||||
}
|
||||
|
||||
// 방 삭제 — 진행자(생성자)만 가능.
|
||||
async deleteRoom(userId: string, roomId: string): Promise<void> {
|
||||
const room = await this.roomRepo.findOne({
|
||||
where: { id: roomId },
|
||||
relations: ['host'],
|
||||
});
|
||||
if (!room) {
|
||||
throw new BusinessException(
|
||||
'RES_001',
|
||||
'방을 찾을 수 없습니다.',
|
||||
HttpStatus.NOT_FOUND,
|
||||
);
|
||||
}
|
||||
if (room.host?.id !== userId) {
|
||||
throw new BusinessException(
|
||||
'AUTH_003',
|
||||
'방 삭제 권한이 없습니다.',
|
||||
HttpStatus.FORBIDDEN,
|
||||
);
|
||||
}
|
||||
await this.roomRepo.remove(room);
|
||||
}
|
||||
|
||||
private isRestricted(room: MeetingRoom): boolean {
|
||||
return !!(room.allowedUserIds && room.allowedUserIds.length > 0);
|
||||
}
|
||||
|
||||
private toRoomResponse(
|
||||
room: MeetingRoom,
|
||||
userId: string,
|
||||
): MeetingRoomResponse {
|
||||
const isHost = room.host?.id === userId;
|
||||
const restricted = this.isRestricted(room);
|
||||
const canJoin =
|
||||
isHost || !restricted || room.allowedUserIds!.includes(userId);
|
||||
return {
|
||||
id: room.id,
|
||||
name: room.name,
|
||||
host: room.host ? UserService.toPublic(room.host) : null,
|
||||
scheduledAt: room.scheduledAt ? room.scheduledAt.toISOString() : null,
|
||||
restricted,
|
||||
isHost,
|
||||
canJoin,
|
||||
createdAt: room.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,9 @@ services:
|
||||
environment:
|
||||
- CHOKIDAR_USEPOLLING=true
|
||||
- WATCHPACK_POLLING=true
|
||||
# 화상회의 — 브라우저가 접속할 LiveKit URL(개발은 LiveKit Cloud 사용).
|
||||
# 런타임 env 로 주입해 이미지 재빌드 없이 .env 변경 + 컨테이너 재생성만으로 반영된다.
|
||||
- VITE_LIVEKIT_URL=${LIVEKIT_WS_URL}
|
||||
volumes:
|
||||
- ./${FRONTEND_DIR}:/app
|
||||
- /app/node_modules
|
||||
@@ -36,6 +39,10 @@ services:
|
||||
networks: !override
|
||||
- app-network
|
||||
|
||||
# 화상회의는 개발에서 LiveKit Cloud 를 사용한다(로컬 SFU 컨테이너 없음).
|
||||
# WSL2/Docker Desktop 의 WebRTC 네트워킹 제약을 우회하고, 운영 전환도 env 만 바꾸면 된다.
|
||||
# 셀프호스팅(문서 단계 6)으로 갈 때 livekit/livekit.yaml 을 참고해 서비스를 다시 추가한다.
|
||||
|
||||
db:
|
||||
# dev에서는 Docker Desktop 시작 시 자동 실행 방지 (base 의 always 를 덮어씀)
|
||||
restart: "no"
|
||||
|
||||
@@ -11,6 +11,8 @@ services:
|
||||
- BUILD_MODE=${BUILD_MODE}
|
||||
- VITE_API_BASE_URL=${BACKEND_API_URL}
|
||||
- VITE_AI_TIMEOUT_MS=${AI_TIMEOUT_MS}
|
||||
# 브라우저가 접속할 LiveKit WebSocket URL
|
||||
- VITE_LIVEKIT_URL=${LIVEKIT_WS_URL}
|
||||
expose:
|
||||
- "80"
|
||||
environment:
|
||||
@@ -62,6 +64,10 @@ services:
|
||||
- REDIS_PASSWORD=${REDIS_PASSWORD}
|
||||
# AI 요청 타임아웃(ms) — 프론트(VITE_AI_TIMEOUT_MS)와 동일 값을 루트 .env 에서 공급
|
||||
- AI_TIMEOUT_MS=${AI_TIMEOUT_MS}
|
||||
# 화상회의(LiveKit) — 토큰 발급에 키/시크릿, 서버 API 호출에 내부 URL 사용
|
||||
- LIVEKIT_API_KEY=${LIVEKIT_API_KEY}
|
||||
- LIVEKIT_API_SECRET=${LIVEKIT_API_SECRET}
|
||||
- LIVEKIT_URL=${LIVEKIT_URL}
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- app-network
|
||||
@@ -144,6 +150,39 @@ services:
|
||||
labels:
|
||||
- "com.centurylinklabs.watchtower.enable=true"
|
||||
|
||||
# ----------------------------------------
|
||||
# 5. LiveKit (화상회의 SFU) — 셀프호스팅 (opt-in 프로파일)
|
||||
# 실행: docker compose --profile selfhost-livekit --env-file .env.production up -d --build
|
||||
# 미사용시(예: LiveKit Cloud) 프로파일을 켜지 않으면 생성되지 않는다.
|
||||
# 배포·클러스터링 가이드: livekit/DEPLOY.md
|
||||
# ----------------------------------------
|
||||
livekit:
|
||||
image: livekit/livekit-server:v1.8.4
|
||||
container_name: ${PROJECT_NAME}-livekit
|
||||
profiles:
|
||||
- selfhost-livekit
|
||||
command: --config /etc/livekit/livekit.yaml
|
||||
environment:
|
||||
# 키/시크릿은 "key: secret" 형식으로 주입(콜론+공백 때문에 전체를 따옴표로 감쌈)
|
||||
- "LIVEKIT_KEYS=${LIVEKIT_API_KEY}: ${LIVEKIT_API_SECRET}"
|
||||
expose:
|
||||
- "7880" # 시그널링/HTTP API — 리버스 프록시(TLS)에서 wss 로 라우팅
|
||||
ports:
|
||||
- "7881:7881" # ICE/TCP 폴백
|
||||
- "7882:7882/udp" # ICE/UDP (mux)
|
||||
volumes:
|
||||
# 클러스터링 시에는 redis 비밀번호가 담긴 사본을 마운트(DEPLOY.md 참고)
|
||||
- ./livekit/livekit.prod.yaml:/etc/livekit/livekit.yaml:ro
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- app-network
|
||||
- proxy
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
cpus: '2.0'
|
||||
memory: 2g
|
||||
|
||||
networks:
|
||||
app-network:
|
||||
driver: bridge
|
||||
|
||||
@@ -14,6 +14,8 @@ ARG VITE_API_BASE_URL
|
||||
ENV VITE_API_BASE_URL=$VITE_API_BASE_URL
|
||||
ARG VITE_AI_TIMEOUT_MS
|
||||
ENV VITE_AI_TIMEOUT_MS=$VITE_AI_TIMEOUT_MS
|
||||
ARG VITE_LIVEKIT_URL
|
||||
ENV VITE_LIVEKIT_URL=$VITE_LIVEKIT_URL
|
||||
|
||||
RUN npm run build-only -- --mode ${BUILD_MODE}
|
||||
|
||||
|
||||
Vendored
+2
@@ -5,6 +5,8 @@ interface ImportMetaEnv {
|
||||
readonly VITE_API_BASE_URL: string
|
||||
/** AI 요청 타임아웃(ms) — 백엔드 AI_TIMEOUT_MS 와 동일 값 */
|
||||
readonly VITE_AI_TIMEOUT_MS: string
|
||||
/** 브라우저가 접속할 LiveKit WebSocket URL (예: ws://localhost:7880) */
|
||||
readonly VITE_LIVEKIT_URL: string
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
|
||||
Generated
+143
-60
@@ -9,6 +9,7 @@
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"axios": "^1.14.0",
|
||||
"livekit-client": "^2.19.2",
|
||||
"pinia": "^3.0.4",
|
||||
"vue": "^3.5.31",
|
||||
"vue-router": "^4.4.5"
|
||||
@@ -556,6 +557,12 @@
|
||||
"specificity": "bin/cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/@bufbuild/protobuf": {
|
||||
"version": "1.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-1.10.1.tgz",
|
||||
"integrity": "sha512-wJ8ReQbHxsAfXhrf9ixl0aYbZorRuOWpBNzm8pL8ftmSxQx/wnJD5Eg861NwJU/czy2VXFIebCeZnZrI9rktIQ==",
|
||||
"license": "(Apache-2.0 AND BSD-3-Clause)"
|
||||
},
|
||||
"node_modules/@csstools/color-helpers": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz",
|
||||
@@ -720,12 +727,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/runtime": {
|
||||
"version": "1.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
|
||||
"integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
|
||||
"version": "1.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz",
|
||||
"integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
@@ -1074,6 +1082,21 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@livekit/mutex": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@livekit/mutex/-/mutex-1.1.1.tgz",
|
||||
"integrity": "sha512-EsshAucklmpuUAfkABPxJNhzj9v2sG7JuzFDL4ML1oJQSV14sqrpTYnsaOudMAw9yOaW53NU3QQTlUQoRs4czw==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@livekit/protocol": {
|
||||
"version": "1.45.8",
|
||||
"resolved": "https://registry.npmjs.org/@livekit/protocol/-/protocol-1.45.8.tgz",
|
||||
"integrity": "sha512-Q+l57E7w/xxOBFVWzdX5rkAZO7ffyF+rlDzNUYq2SU114+5aTyCq+PK4unaEVDNd4952Af7wteKr3sOgasGuaA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@bufbuild/protobuf": "^1.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/wasm-runtime": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz",
|
||||
@@ -1275,9 +1298,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1295,9 +1315,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1315,9 +1332,6 @@
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1335,9 +1349,6 @@
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1355,9 +1366,6 @@
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1375,9 +1383,6 @@
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1395,9 +1400,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1415,9 +1417,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1606,9 +1605,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1626,9 +1622,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1646,9 +1639,6 @@
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1666,9 +1656,6 @@
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1686,9 +1673,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1706,9 +1690,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1754,6 +1735,17 @@
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/runtime": {
|
||||
"version": "1.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
|
||||
"integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-win32-arm64-msvc": {
|
||||
"version": "1.0.0-rc.17",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.17.tgz",
|
||||
@@ -1838,6 +1830,13 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/dom-mediacapture-record": {
|
||||
"version": "1.0.22",
|
||||
"resolved": "https://registry.npmjs.org/@types/dom-mediacapture-record/-/dom-mediacapture-record-1.0.22.tgz",
|
||||
"integrity": "sha512-mUMZLK3NvwRLcAAT9qmcK+9p7tpU2FHdDsntR3YI4+GY88XrgG4XiE7u1Q2LAN2/FZOz/tdMDC3GQCR4T8nFuw==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@types/estree": {
|
||||
"version": "1.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
|
||||
@@ -3747,6 +3746,15 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/events": {
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
|
||||
"integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.8.x"
|
||||
}
|
||||
},
|
||||
"node_modules/expect-type": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
|
||||
@@ -4347,6 +4355,15 @@
|
||||
"@pkgjs/parseargs": "^0.11.0"
|
||||
}
|
||||
},
|
||||
"node_modules/jose": {
|
||||
"version": "6.2.3",
|
||||
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz",
|
||||
"integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/panva"
|
||||
}
|
||||
},
|
||||
"node_modules/js-beautify": {
|
||||
"version": "1.15.4",
|
||||
"resolved": "https://registry.npmjs.org/js-beautify/-/js-beautify-1.15.4.tgz",
|
||||
@@ -4717,9 +4734,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -4741,9 +4755,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -4765,9 +4776,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -4789,9 +4797,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -4847,6 +4852,26 @@
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/livekit-client": {
|
||||
"version": "2.19.2",
|
||||
"resolved": "https://registry.npmjs.org/livekit-client/-/livekit-client-2.19.2.tgz",
|
||||
"integrity": "sha512-Kvk07QYDWRAbmYNLRll04ZIuxMQobW/oLPYnmR1kCy8GGHpU0gqyHf704Rz+29zfy8IJZRjKqeVbzGSKn9sumw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@livekit/mutex": "1.1.1",
|
||||
"@livekit/protocol": "1.45.8",
|
||||
"events": "^3.3.0",
|
||||
"jose": "^6.1.0",
|
||||
"loglevel": "^1.9.2",
|
||||
"sdp-transform": "^2.15.0",
|
||||
"tslib": "2.8.1",
|
||||
"typed-emitter": "^2.1.0",
|
||||
"webrtc-adapter": "9.0.5"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/dom-mediacapture-record": "^1"
|
||||
}
|
||||
},
|
||||
"node_modules/locate-path": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
|
||||
@@ -4877,6 +4902,19 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/loglevel": {
|
||||
"version": "1.9.2",
|
||||
"resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.9.2.tgz",
|
||||
"integrity": "sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "tidelift",
|
||||
"url": "https://tidelift.com/funding/github/npm/loglevel"
|
||||
}
|
||||
},
|
||||
"node_modules/lru-cache": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
|
||||
@@ -5690,6 +5728,16 @@
|
||||
"queue-microtask": "^1.2.2"
|
||||
}
|
||||
},
|
||||
"node_modules/rxjs": {
|
||||
"version": "7.8.2",
|
||||
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
|
||||
"integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/saxes": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
|
||||
@@ -5703,6 +5751,21 @@
|
||||
"node": ">=v12.22.7"
|
||||
}
|
||||
},
|
||||
"node_modules/sdp": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/sdp/-/sdp-3.2.2.tgz",
|
||||
"integrity": "sha512-xZocWwfyp4hkbN4hLWxMjmv2Q8aNa9MhmOZ7L9aCZPT+dZsgRr6wZRrSYE3HTdyk/2pZKPSgqI7ns7Een1xMSA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/sdp-transform": {
|
||||
"version": "2.15.0",
|
||||
"resolved": "https://registry.npmjs.org/sdp-transform/-/sdp-transform-2.15.0.tgz",
|
||||
"integrity": "sha512-KrOH82c/W+GYQ0LHqtr3caRpM3ITglq3ljGUIb8LTki7ByacJZ9z+piSGiwZDsRyhQbYBOBJgr2k6X4BZXi3Kw==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"sdp-verify": "checker.js"
|
||||
}
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "6.3.1",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
|
||||
@@ -6136,9 +6199,7 @@
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"dev": true,
|
||||
"license": "0BSD",
|
||||
"optional": true
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/type-check": {
|
||||
"version": "0.4.0",
|
||||
@@ -6166,6 +6227,15 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/typed-emitter": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/typed-emitter/-/typed-emitter-2.1.0.tgz",
|
||||
"integrity": "sha512-g/KzbYKbH5C2vPkaXGu8DJlHrGKHLsM25Zg9WuC9pMGfuvT+X25tZQWo5fK1BjBm8+UrVE9LDCvaY0CQk+fXDA==",
|
||||
"license": "MIT",
|
||||
"optionalDependencies": {
|
||||
"rxjs": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "6.0.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
|
||||
@@ -6833,6 +6903,19 @@
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/webrtc-adapter": {
|
||||
"version": "9.0.5",
|
||||
"resolved": "https://registry.npmjs.org/webrtc-adapter/-/webrtc-adapter-9.0.5.tgz",
|
||||
"integrity": "sha512-U9vjByy/sK2OMXu5mmfuZFKTMIUQe34c0JXRO+oDrxJTsntdYT2iIFwYMOV7HhMTuktcZLGf2W1N/OcSf9ssWg==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"sdp": "^3.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0.0",
|
||||
"npm": ">=3.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/whatwg-mimetype": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz",
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.14.0",
|
||||
"livekit-client": "^2.19.2",
|
||||
"pinia": "^3.0.4",
|
||||
"vue": "^3.5.31",
|
||||
"vue-router": "^4.4.5"
|
||||
|
||||
@@ -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>
|
||||
@@ -0,0 +1,330 @@
|
||||
<script setup lang="ts">
|
||||
// 참여자 1명의 영상 타일 — 카메라 트랙을 <video>, (원격) 마이크 트랙을 <audio> 에 연결한다.
|
||||
// 로컬 참여자 오디오는 에코 방지를 위해 재생하지 않는다. 카메라가 꺼져 있으면 이름 이니셜 아바타.
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import {
|
||||
Track,
|
||||
ParticipantEvent,
|
||||
ConnectionQuality,
|
||||
RemoteTrackPublication,
|
||||
VideoQuality,
|
||||
type Participant,
|
||||
} from 'livekit-client'
|
||||
import QualityBars from './QualityBars.vue'
|
||||
import MeetingIcon from './MeetingIcon.vue'
|
||||
import { avatarColor, avatarInitial } from '@/shared/utils/avatar'
|
||||
import { normalizeQuality } from '@/composables/useLiveKitRoom'
|
||||
import type { MeetingQuality } from '@/types/meeting'
|
||||
|
||||
interface Props {
|
||||
participant: Participant
|
||||
isLocal?: boolean
|
||||
// 큰 화면(stage) 렌더 여부
|
||||
featured?: boolean
|
||||
// 진행자가 이 타일을 승격할 수 있는지(호버 시 "크게" 버튼)
|
||||
canPromote?: boolean
|
||||
// 말하는 사람 하이라이트 사용 여부
|
||||
highlight?: boolean
|
||||
}
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<{ (e: 'promote', identity: string): void }>()
|
||||
|
||||
const videoEl = ref<HTMLVideoElement | null>(null)
|
||||
const hasVideo = ref(false)
|
||||
const micOn = ref(false)
|
||||
const speaking = ref(false)
|
||||
const quality = ref<MeetingQuality>('good')
|
||||
const isHost = ref(false)
|
||||
|
||||
const displayName = ref(props.participant.name || props.participant.identity)
|
||||
const avColor = computed(() => avatarColor(displayName.value))
|
||||
const initial = computed(() => avatarInitial(displayName.value))
|
||||
const showSpeaking = computed(
|
||||
() => props.highlight && speaking.value && micOn.value,
|
||||
)
|
||||
|
||||
// 참여자 metadata 문자열에서 진행자 여부 파싱({"role":"host"})
|
||||
function parseHost(metadata: string | undefined): boolean {
|
||||
if (!metadata) return false
|
||||
try {
|
||||
return (JSON.parse(metadata) as { role?: string }).role === 'host'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// 현재 트랙/상태를 DOM 과 반응형 값에 반영
|
||||
function sync() {
|
||||
const p = props.participant
|
||||
displayName.value = p.name || p.identity
|
||||
micOn.value = p.isMicrophoneEnabled
|
||||
speaking.value = p.isSpeaking
|
||||
quality.value = normalizeQuality(p.connectionQuality as ConnectionQuality)
|
||||
isHost.value = parseHost(p.metadata)
|
||||
|
||||
// 오디오는 룸 레벨(useLiveKitRoom)에서 전원 재생하므로 여기서는 영상만 다룬다.
|
||||
const camPub = p.getTrackPublication(Track.Source.Camera)
|
||||
if (camPub?.track && videoEl.value) {
|
||||
camPub.track.attach(videoEl.value)
|
||||
hasVideo.value = !camPub.isMuted
|
||||
// 원격 참여자: 스테이지(featured)는 고화질, 썸네일은 저화질로 구독한다.
|
||||
// (adaptiveStream 의 타일 크기 기반 선택과도 일치 — 대역폭 절약)
|
||||
if (!props.isLocal && camPub instanceof RemoteTrackPublication) {
|
||||
camPub.setVideoQuality(props.featured ? VideoQuality.HIGH : VideoQuality.LOW)
|
||||
}
|
||||
} else {
|
||||
hasVideo.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onSpeaking() {
|
||||
speaking.value = props.participant.isSpeaking
|
||||
}
|
||||
function onQuality() {
|
||||
quality.value = normalizeQuality(
|
||||
props.participant.connectionQuality as ConnectionQuality,
|
||||
)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
sync()
|
||||
const p = props.participant
|
||||
p.on(ParticipantEvent.TrackSubscribed, sync)
|
||||
p.on(ParticipantEvent.TrackUnsubscribed, sync)
|
||||
p.on(ParticipantEvent.LocalTrackPublished, sync)
|
||||
p.on(ParticipantEvent.LocalTrackUnpublished, sync)
|
||||
p.on(ParticipantEvent.TrackMuted, sync)
|
||||
p.on(ParticipantEvent.TrackUnmuted, sync)
|
||||
p.on(ParticipantEvent.IsSpeakingChanged, onSpeaking)
|
||||
p.on(ParticipantEvent.ConnectionQualityChanged, onQuality)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
const p = props.participant
|
||||
p.off(ParticipantEvent.TrackSubscribed, sync)
|
||||
p.off(ParticipantEvent.TrackUnsubscribed, sync)
|
||||
p.off(ParticipantEvent.LocalTrackPublished, sync)
|
||||
p.off(ParticipantEvent.LocalTrackUnpublished, sync)
|
||||
p.off(ParticipantEvent.TrackMuted, sync)
|
||||
p.off(ParticipantEvent.TrackUnmuted, sync)
|
||||
p.off(ParticipantEvent.IsSpeakingChanged, onSpeaking)
|
||||
p.off(ParticipantEvent.ConnectionQualityChanged, onQuality)
|
||||
// 타일 언마운트 시 비디오 트랙을 엘리먼트에서 분리(미분리 시 attachedElements 누수)
|
||||
const camPub = p.getTrackPublication(Track.Source.Camera)
|
||||
if (camPub?.track && videoEl.value) camPub.track.detach(videoEl.value)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="tile"
|
||||
:class="{ featured, speaking: showSpeaking }"
|
||||
:style="{ '--av': avColor }"
|
||||
>
|
||||
<!-- 영상: 로컬은 좌우 반전(거울)으로 표시 -->
|
||||
<video
|
||||
ref="videoEl"
|
||||
class="vid"
|
||||
:class="{ mirror: isLocal, hidden: !hasVideo }"
|
||||
autoplay
|
||||
playsinline
|
||||
:muted="isLocal"
|
||||
/>
|
||||
<!-- 카메라 off/미발행 시 이름 이니셜 아바타 -->
|
||||
<div
|
||||
v-if="!hasVideo"
|
||||
class="tile-off"
|
||||
>
|
||||
<span class="av-lg">{{ initial }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 연결 품질 -->
|
||||
<div class="tile-q">
|
||||
<QualityBars :quality="quality" />
|
||||
</div>
|
||||
|
||||
<!-- 진행자 승격 버튼(호버) -->
|
||||
<button
|
||||
v-if="canPromote && !featured"
|
||||
type="button"
|
||||
class="tile-promote"
|
||||
title="스테이지로 크게 보기"
|
||||
@click="emit('promote', participant.identity)"
|
||||
>
|
||||
<MeetingIcon name="expand" /><span>크게</span>
|
||||
</button>
|
||||
|
||||
<!-- 하단 메타 -->
|
||||
<div class="tile-meta">
|
||||
<span
|
||||
class="mmic"
|
||||
:class="{ off: !micOn }"
|
||||
:title="micOn ? '마이크 켜짐' : '마이크 꺼짐'"
|
||||
>
|
||||
<MeetingIcon :name="micOn ? 'mic' : 'micOff'" />
|
||||
</span>
|
||||
<span class="mname">{{ displayName }}{{ isLocal ? ' (나)' : '' }}</span>
|
||||
<span
|
||||
v-if="isHost"
|
||||
class="mhost"
|
||||
title="진행자"
|
||||
>
|
||||
<MeetingIcon name="host" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.tile {
|
||||
position: relative;
|
||||
aspect-ratio: 16 / 9;
|
||||
border-radius: 0.75rem;
|
||||
overflow: hidden;
|
||||
background: var(--tile);
|
||||
border: 0.125rem solid transparent;
|
||||
}
|
||||
.tile.featured {
|
||||
aspect-ratio: auto;
|
||||
height: 100%;
|
||||
border-radius: 0.875rem;
|
||||
}
|
||||
.tile.speaking {
|
||||
border-color: var(--green);
|
||||
box-shadow: 0 0 0 1px var(--green), 0 0 22px -4px rgba(31, 157, 87, 0.7);
|
||||
}
|
||||
.vid {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
.vid.mirror {
|
||||
transform: scaleX(-1);
|
||||
}
|
||||
.vid.hidden {
|
||||
display: none;
|
||||
}
|
||||
.tile-off {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: var(--tile);
|
||||
}
|
||||
.av-lg {
|
||||
width: clamp(2.875rem, 9vh, 5.75rem);
|
||||
height: clamp(2.875rem, 9vh, 5.75rem);
|
||||
border-radius: 50%;
|
||||
background: var(--av, #555);
|
||||
color: #fff;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: clamp(1.25rem, 4vh, 2.375rem);
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.tile-q {
|
||||
position: absolute;
|
||||
top: 0.5625rem;
|
||||
left: 0.5625rem;
|
||||
z-index: 3;
|
||||
}
|
||||
.tile-meta {
|
||||
position: absolute;
|
||||
left: 0.5625rem;
|
||||
bottom: 0.5625rem;
|
||||
right: 0.5625rem;
|
||||
z-index: 3;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
.tile.featured .tile-meta {
|
||||
left: 0.875rem;
|
||||
bottom: 0.875rem;
|
||||
}
|
||||
.mmic {
|
||||
width: 1.375rem;
|
||||
height: 1.375rem;
|
||||
border-radius: 0.375rem;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
color: #fff;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.mmic svg {
|
||||
width: 0.8125rem;
|
||||
height: 0.8125rem;
|
||||
}
|
||||
.mmic.off {
|
||||
color: var(--d-red);
|
||||
}
|
||||
.mname {
|
||||
font-size: 0.78125rem;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
padding: 0.1875rem 0.5625rem;
|
||||
border-radius: 0.375rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.tile.featured .mname {
|
||||
font-size: 0.875rem;
|
||||
padding: 0.3125rem 0.75rem;
|
||||
}
|
||||
.mhost {
|
||||
width: 1.375rem;
|
||||
height: 1.375rem;
|
||||
border-radius: 0.375rem;
|
||||
background: rgba(124, 92, 240, 0.85);
|
||||
color: #fff;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.mhost svg {
|
||||
width: 0.75rem;
|
||||
height: 0.75rem;
|
||||
}
|
||||
.tile-promote {
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
right: 0.5rem;
|
||||
z-index: 4;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3125rem;
|
||||
height: 1.75rem;
|
||||
padding: 0 0.625rem;
|
||||
border: none;
|
||||
border-radius: 0.4375rem;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
color: #fff;
|
||||
font-family: inherit;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transform: translateY(-0.125rem);
|
||||
transition:
|
||||
opacity 0.12s,
|
||||
transform 0.12s;
|
||||
}
|
||||
.tile-promote svg {
|
||||
width: 0.8125rem;
|
||||
height: 0.8125rem;
|
||||
}
|
||||
.tile:hover .tile-promote {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
.tile-promote:hover {
|
||||
background: var(--accent);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,79 @@
|
||||
<script setup lang="ts">
|
||||
// 연결 품질 막대(3/2/1칸) — good/medium/poor 단계에 따라 채워진 칸 수와 색이 달라진다.
|
||||
import { computed } from 'vue'
|
||||
import type { MeetingQuality } from '@/types/meeting'
|
||||
|
||||
interface Props {
|
||||
quality: MeetingQuality
|
||||
// 'tile'(다크 배경 위 반투명 칩) | 'bare'(배경 없이 막대만, 목록·상단바용)
|
||||
variant?: 'tile' | 'bare'
|
||||
}
|
||||
const props = withDefaults(defineProps<Props>(), { variant: 'tile' })
|
||||
|
||||
const level = computed(() =>
|
||||
props.quality === 'good' ? 3 : props.quality === 'medium' ? 2 : 1,
|
||||
)
|
||||
const label = computed(() =>
|
||||
props.quality === 'good'
|
||||
? '원활'
|
||||
: props.quality === 'medium'
|
||||
? '보통'
|
||||
: '불안정',
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
class="qbars"
|
||||
:class="[`q-${quality}`, variant === 'bare' ? 'bare' : '']"
|
||||
:title="`연결 품질: ${label}`"
|
||||
>
|
||||
<i
|
||||
v-for="n in 3"
|
||||
:key="n"
|
||||
:class="{ on: n <= level }"
|
||||
/>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.qbars {
|
||||
display: inline-flex;
|
||||
align-items: flex-end;
|
||||
gap: 0.125rem;
|
||||
height: 0.8125rem;
|
||||
padding: 0.1875rem;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
border-radius: 0.3125rem;
|
||||
}
|
||||
.qbars.bare {
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
}
|
||||
.qbars i {
|
||||
width: 0.1875rem;
|
||||
border-radius: 1px;
|
||||
background: rgba(255, 255, 255, 0.32);
|
||||
}
|
||||
.qbars.bare i {
|
||||
background: #3a3f4a;
|
||||
}
|
||||
.qbars i:nth-child(1) {
|
||||
height: 0.25rem;
|
||||
}
|
||||
.qbars i:nth-child(2) {
|
||||
height: 0.4375rem;
|
||||
}
|
||||
.qbars i:nth-child(3) {
|
||||
height: 0.625rem;
|
||||
}
|
||||
.qbars.q-good i.on {
|
||||
background: var(--d-green);
|
||||
}
|
||||
.qbars.q-medium i.on {
|
||||
background: var(--d-amber);
|
||||
}
|
||||
.qbars.q-poor i.on {
|
||||
background: var(--d-red);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,330 @@
|
||||
import { ref, shallowRef } from 'vue'
|
||||
import {
|
||||
Room,
|
||||
RoomEvent,
|
||||
Track,
|
||||
ConnectionQuality,
|
||||
type Participant,
|
||||
type RemoteParticipant,
|
||||
type RemoteTrack,
|
||||
type RemoteTrackPublication,
|
||||
} from 'livekit-client'
|
||||
import type { ChatMessage, MeetingQuality } from '@/types/meeting'
|
||||
|
||||
// 브라우저가 접속할 LiveKit WebSocket URL (build arg/env 로 주입, 미설정 시 로컬 기본값)
|
||||
const LIVEKIT_URL = import.meta.env.VITE_LIVEKIT_URL || 'ws://localhost:7880'
|
||||
|
||||
// 채팅 메시지 최대 길이(데이터 채널 패킷 과대 방지) — 입력/전송에서 공유한다.
|
||||
export const MAX_CHAT_LENGTH = 2000
|
||||
|
||||
// 입장 옵션 — 프리조인에서 정한 초기 마이크/카메라 상태
|
||||
export interface ConnectOptions {
|
||||
cam?: boolean
|
||||
mic?: boolean
|
||||
}
|
||||
|
||||
// LiveKit ConnectionQuality → UI 막대 단계로 정규화
|
||||
export function normalizeQuality(q: ConnectionQuality | undefined): MeetingQuality {
|
||||
switch (q) {
|
||||
case ConnectionQuality.Excellent:
|
||||
case ConnectionQuality.Good:
|
||||
return 'good'
|
||||
case ConnectionQuality.Poor:
|
||||
return 'medium'
|
||||
case ConnectionQuality.Lost:
|
||||
return 'poor'
|
||||
default:
|
||||
return 'good'
|
||||
}
|
||||
}
|
||||
|
||||
// 참여자 metadata(JSON)에서 프로필 이미지 URL 파싱 — 토큰 발급 시 백엔드가 실어준다.
|
||||
function parseMetaAvatar(metadata: string | undefined): string | null {
|
||||
if (!metadata) return null
|
||||
try {
|
||||
return (
|
||||
(JSON.parse(metadata) as { avatarUrl?: string | null }).avatarUrl ?? null
|
||||
)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// 참여자 metadata 가 진행자(host)인지 — 서버 서명 토큰의 값이라 위조 불가.
|
||||
function isHostMeta(metadata: string | undefined): boolean {
|
||||
if (!metadata) return false
|
||||
try {
|
||||
return (JSON.parse(metadata) as { role?: string }).role === 'host'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// LiveKit 방 연결/생명주기 관리 Composable
|
||||
// 미디어 전송 자체는 LiveKit SDK 가 담당하고, 여기서는 연결 상태와 참여자 목록만 반응형으로 노출한다.
|
||||
export function useLiveKitRoom() {
|
||||
// Room 인스턴스는 깊은 반응형이 불필요(내부적으로 자체 이벤트 관리) → shallowRef
|
||||
const room = shallowRef<Room | null>(null)
|
||||
// 참여자 배열도 shallowRef — Participant 는 비공개 멤버가 있는 클래스라 깊은 언랩 시
|
||||
// 구조적 타입으로 바뀌어 타입 불일치가 난다. 매번 배열을 통째로 교체하므로 shallow 로 충분.
|
||||
const participants = shallowRef<Participant[]>([])
|
||||
const connected = ref(false)
|
||||
const connecting = ref(false)
|
||||
const micEnabled = ref(false)
|
||||
const camEnabled = ref(false)
|
||||
const errorMsg = ref<string | null>(null)
|
||||
// 크게 보기(stage)로 승격된 참여자 identity. 진행자가 데이터 채널로 브로드캐스트 → 전원 동기화.
|
||||
const featuredIdentity = ref<string | null>(null)
|
||||
// 채팅 메시지(인메모리) — 데이터 채널로 주고받음. 서버에 저장하지 않으므로 새로고침 시 사라진다.
|
||||
const messages = ref<ChatMessage[]>([])
|
||||
|
||||
// 로컬 + 원격 참여자 목록 갱신. 마이크/발화/품질 등 참여자 상태 변동 시에도 호출해
|
||||
// 배열 참조를 새로 만들어(=동일 Participant 인스턴스 유지) 의존 computed 를 재평가시킨다.
|
||||
function refresh() {
|
||||
const r = room.value
|
||||
if (!r) {
|
||||
participants.value = []
|
||||
return
|
||||
}
|
||||
participants.value = [
|
||||
r.localParticipant,
|
||||
...Array.from(r.remoteParticipants.values()),
|
||||
]
|
||||
}
|
||||
|
||||
// --- 원격 오디오는 화면 타일(페이지네이션)과 무관하게 항상 재생되어야 한다 ---
|
||||
// 타일에 attach 하면 현재 페이지에 없는 참여자(>50번째)는 무음이 되므로,
|
||||
// 숨은 컨테이너에 트랙별 audio 엘리먼트를 붙여 룸 레벨에서 전원 오디오를 재생한다.
|
||||
let audioContainer: HTMLElement | null = null
|
||||
const audioEls = new Map<string, HTMLMediaElement>()
|
||||
|
||||
function attachRemoteAudio(track: RemoteTrack, pub: RemoteTrackPublication) {
|
||||
if (track.kind !== Track.Kind.Audio) return
|
||||
if (!audioContainer) {
|
||||
audioContainer = document.createElement('div')
|
||||
audioContainer.style.display = 'none'
|
||||
document.body.appendChild(audioContainer)
|
||||
}
|
||||
const el = track.attach() // 새 audio 엘리먼트 생성
|
||||
audioContainer.appendChild(el)
|
||||
audioEls.set(pub.trackSid, el)
|
||||
}
|
||||
function detachRemoteAudio(track: RemoteTrack, pub: RemoteTrackPublication) {
|
||||
if (track.kind !== Track.Kind.Audio) return
|
||||
track.detach()
|
||||
const el = audioEls.get(pub.trackSid)
|
||||
if (el) {
|
||||
el.remove()
|
||||
audioEls.delete(pub.trackSid)
|
||||
}
|
||||
}
|
||||
function cleanupAudio() {
|
||||
audioEls.forEach((el) => el.remove())
|
||||
audioEls.clear()
|
||||
audioContainer?.remove()
|
||||
audioContainer = null
|
||||
}
|
||||
|
||||
// TrackSubscribed/Unsubscribed: 목록 갱신 + (오디오면) 룸 레벨 재생 관리
|
||||
function onTrackSubscribed(track: RemoteTrack, pub: RemoteTrackPublication) {
|
||||
refresh()
|
||||
attachRemoteAudio(track, pub)
|
||||
}
|
||||
function onTrackUnsubscribed(track: RemoteTrack, pub: RemoteTrackPublication) {
|
||||
refresh()
|
||||
detachRemoteAudio(track, pub)
|
||||
}
|
||||
|
||||
function onDisconnected() {
|
||||
connected.value = false
|
||||
participants.value = []
|
||||
featuredIdentity.value = null
|
||||
messages.value = []
|
||||
cleanupAudio()
|
||||
}
|
||||
|
||||
// 데이터 채널 수신 — 진행자의 승격(promote) + 채팅(chat) 메시지를 처리
|
||||
function onData(payload: Uint8Array, sender?: RemoteParticipant) {
|
||||
try {
|
||||
const msg = JSON.parse(new TextDecoder().decode(payload)) as {
|
||||
type?: string
|
||||
identity?: string
|
||||
text?: string
|
||||
ts?: number
|
||||
}
|
||||
if (msg.type === 'promote' && typeof msg.identity === 'string') {
|
||||
// 진행자가 보낸 승격만 적용 — 일반 참여자가 보낸 promote 는 무시(인가).
|
||||
if (isHostMeta(sender?.metadata)) featuredIdentity.value = msg.identity
|
||||
} else if (msg.type === 'chat' && typeof msg.text === 'string') {
|
||||
messages.value = [
|
||||
...messages.value,
|
||||
{
|
||||
id: `${sender?.identity ?? 'remote'}-${msg.ts ?? performance.now()}`,
|
||||
senderId: sender?.identity ?? 'unknown',
|
||||
// 표시 이름은 서버 서명 토큰의 name/identity 만 신뢰(클라이언트가 보낸 name 은 사칭 위험이라 무시).
|
||||
senderName: sender?.name || sender?.identity || '참여자',
|
||||
senderAvatarUrl: parseMetaAvatar(sender?.metadata),
|
||||
text: msg.text,
|
||||
ts: msg.ts ?? Date.now(),
|
||||
self: false,
|
||||
},
|
||||
]
|
||||
}
|
||||
} catch {
|
||||
// 알 수 없는 메시지는 무시
|
||||
}
|
||||
}
|
||||
|
||||
// 특정 참여자를 크게 보기로 승격 — 진행자만 호출(UI에서 제한). 전원에게 브로드캐스트.
|
||||
async function promote(identity: string): Promise<void> {
|
||||
const r = room.value
|
||||
if (!r) return
|
||||
featuredIdentity.value = identity
|
||||
const data = new TextEncoder().encode(
|
||||
JSON.stringify({ type: 'promote', identity }),
|
||||
)
|
||||
await r.localParticipant.publishData(data, { reliable: true })
|
||||
}
|
||||
|
||||
// 채팅 전송 — 데이터 채널로 브로드캐스트 + 본인 목록에 즉시 추가
|
||||
async function sendChat(text: string): Promise<void> {
|
||||
const r = room.value
|
||||
// 데이터 채널 패킷 권장 크기를 넘지 않도록 길이 제한(과도한 페이로드 방지).
|
||||
const body = text.trim().slice(0, MAX_CHAT_LENGTH)
|
||||
if (!r || !body) return
|
||||
const ts = Date.now()
|
||||
const me = r.localParticipant
|
||||
messages.value = [
|
||||
...messages.value,
|
||||
{
|
||||
id: `${me.identity}-${ts}`,
|
||||
senderId: me.identity,
|
||||
senderName: me.name || me.identity,
|
||||
senderAvatarUrl: parseMetaAvatar(me.metadata),
|
||||
text: body,
|
||||
ts,
|
||||
self: true,
|
||||
},
|
||||
]
|
||||
// name 은 보내지 않는다 — 수신 측은 서명된 sender.name 을 사용(사칭 방지).
|
||||
const data = new TextEncoder().encode(
|
||||
JSON.stringify({ type: 'chat', text: body, ts }),
|
||||
)
|
||||
await me.publishData(data, { reliable: true })
|
||||
}
|
||||
|
||||
// 방 입장 — 토큰으로 연결 후 옵션에 맞춰 카메라/마이크 발행
|
||||
async function connect(token: string, opts: ConnectOptions = {}): Promise<void> {
|
||||
if (connecting.value || connected.value) return
|
||||
const wantCam = opts.cam ?? true
|
||||
const wantMic = opts.mic ?? true
|
||||
connecting.value = true
|
||||
errorMsg.value = null
|
||||
try {
|
||||
const r = new Room({ adaptiveStream: true, dynacast: true })
|
||||
// 참여자 목록(배열) 재생성이 필요한 이벤트만 구독한다.
|
||||
// 발화(ActiveSpeakers)·연결품질(ConnectionQuality)은 매우 잦게 발생하는데
|
||||
// 각 타일이 ParticipantEvent 로 자체 갱신하므로, 여기서 전체 배열을 재생성하면
|
||||
// 대규모(수십 명) 화면에서 불필요한 리렌더로 버벅임을 유발한다 → 제외.
|
||||
r.on(RoomEvent.ParticipantConnected, refresh)
|
||||
.on(RoomEvent.ParticipantDisconnected, refresh)
|
||||
.on(RoomEvent.TrackSubscribed, onTrackSubscribed)
|
||||
.on(RoomEvent.TrackUnsubscribed, onTrackUnsubscribed)
|
||||
.on(RoomEvent.TrackMuted, refresh)
|
||||
.on(RoomEvent.TrackUnmuted, refresh)
|
||||
.on(RoomEvent.LocalTrackPublished, refresh)
|
||||
.on(RoomEvent.LocalTrackUnpublished, refresh)
|
||||
.on(RoomEvent.ParticipantMetadataChanged, refresh)
|
||||
.on(RoomEvent.DataReceived, onData)
|
||||
.on(RoomEvent.Disconnected, onDisconnected)
|
||||
|
||||
await r.connect(LIVEKIT_URL, token)
|
||||
room.value = r
|
||||
connected.value = true
|
||||
|
||||
// 카메라/마이크 권한 요청 및 발행 (실패해도 연결은 유지)
|
||||
if (wantCam || wantMic) {
|
||||
try {
|
||||
if (wantCam && wantMic) {
|
||||
await r.localParticipant.enableCameraAndMicrophone()
|
||||
} else {
|
||||
if (wantMic) await r.localParticipant.setMicrophoneEnabled(true)
|
||||
if (wantCam) await r.localParticipant.setCameraEnabled(true)
|
||||
}
|
||||
camEnabled.value = wantCam
|
||||
micEnabled.value = wantMic
|
||||
} catch {
|
||||
errorMsg.value =
|
||||
'카메라/마이크를 사용할 수 없습니다. 권한을 확인해 주세요.'
|
||||
}
|
||||
} else {
|
||||
// 권한 거부 입장 — 영상·음성 없이 참관
|
||||
errorMsg.value =
|
||||
'카메라·마이크 없이 참여 중입니다. 아래 컨트롤에서 언제든 켤 수 있습니다.'
|
||||
}
|
||||
refresh()
|
||||
} catch (e) {
|
||||
errorMsg.value = e instanceof Error ? e.message : '연결에 실패했습니다.'
|
||||
await disconnect()
|
||||
} finally {
|
||||
connecting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 방 나가기
|
||||
async function disconnect(): Promise<void> {
|
||||
const r = room.value
|
||||
if (r) await r.disconnect()
|
||||
room.value = null
|
||||
connected.value = false
|
||||
camEnabled.value = false
|
||||
micEnabled.value = false
|
||||
participants.value = []
|
||||
featuredIdentity.value = null
|
||||
messages.value = []
|
||||
cleanupAudio()
|
||||
}
|
||||
|
||||
async function toggleMic(): Promise<void> {
|
||||
const r = room.value
|
||||
if (!r) return
|
||||
const next = !micEnabled.value
|
||||
micEnabled.value = next
|
||||
try {
|
||||
await r.localParticipant.setMicrophoneEnabled(next)
|
||||
} catch {
|
||||
micEnabled.value = !next // 실패 시 상태 원복(표시와 실제 송출 불일치 방지)
|
||||
}
|
||||
refresh()
|
||||
}
|
||||
|
||||
async function toggleCam(): Promise<void> {
|
||||
const r = room.value
|
||||
if (!r) return
|
||||
const next = !camEnabled.value
|
||||
camEnabled.value = next
|
||||
try {
|
||||
await r.localParticipant.setCameraEnabled(next)
|
||||
} catch {
|
||||
camEnabled.value = !next
|
||||
}
|
||||
refresh()
|
||||
}
|
||||
|
||||
return {
|
||||
participants,
|
||||
connected,
|
||||
connecting,
|
||||
micEnabled,
|
||||
camEnabled,
|
||||
errorMsg,
|
||||
featuredIdentity,
|
||||
messages,
|
||||
connect,
|
||||
disconnect,
|
||||
toggleMic,
|
||||
toggleCam,
|
||||
promote,
|
||||
sendChat,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { useApi } from './useApi'
|
||||
import type {
|
||||
ApiMeetingRoom,
|
||||
CreateMeetingRoomPayload,
|
||||
} from '@/types/meeting'
|
||||
|
||||
// 회의 내 역할 — 진행자(방 생성자) / 일반 참여자
|
||||
export type MeetingRole = 'host' | 'participant'
|
||||
|
||||
// 화상회의 토큰 응답 — 백엔드 MeetingTokenResult 와 1:1
|
||||
export interface MeetingToken {
|
||||
token: string // LiveKit 입장 JWT
|
||||
roomName: string
|
||||
identity: string // 참여자 식별자(사용자 ID)
|
||||
role: MeetingRole // 이번 입장자의 역할
|
||||
}
|
||||
|
||||
// 화상회의 도메인 API Composable — 인터셉터가 success/data 를 언래핑
|
||||
export function useMeeting() {
|
||||
const api = useApi()
|
||||
|
||||
// LiveKit 입장 토큰 발급
|
||||
async function getToken(roomName: string): Promise<MeetingToken> {
|
||||
return (await api.post('/meetings/token', {
|
||||
roomName,
|
||||
})) as unknown as MeetingToken
|
||||
}
|
||||
|
||||
// 방 목록
|
||||
async function listRooms(): Promise<ApiMeetingRoom[]> {
|
||||
return (await api.get('/meetings/rooms')) as unknown as ApiMeetingRoom[]
|
||||
}
|
||||
|
||||
// 방 생성(생성자=진행자)
|
||||
async function createRoom(
|
||||
payload: CreateMeetingRoomPayload,
|
||||
): Promise<ApiMeetingRoom> {
|
||||
return (await api.post(
|
||||
'/meetings/rooms',
|
||||
payload,
|
||||
)) as unknown as ApiMeetingRoom
|
||||
}
|
||||
|
||||
// 방 삭제(진행자만)
|
||||
async function removeRoom(roomId: string): Promise<void> {
|
||||
await api.delete(`/meetings/rooms/${roomId}`)
|
||||
}
|
||||
|
||||
return { getToken, listRooms, createRoom, removeRoom }
|
||||
}
|
||||
@@ -29,9 +29,10 @@ function toggleCollapse() {
|
||||
const mobileOpen = ref(false)
|
||||
|
||||
// 현재 경로 기준으로 사이드바 네비 활성 항목을 판별
|
||||
const activeNav = computed<'tasks' | 'projects' | 'schedule'>(() => {
|
||||
const activeNav = computed<'tasks' | 'projects' | 'schedule' | 'meeting'>(() => {
|
||||
if (route.path.startsWith('/projects')) return 'projects'
|
||||
if (route.path.startsWith('/schedule')) return 'schedule'
|
||||
if (route.path.startsWith('/meeting')) return 'meeting'
|
||||
return 'tasks'
|
||||
})
|
||||
|
||||
@@ -201,6 +202,31 @@ async function onLogout() {
|
||||
</svg>
|
||||
<span>프로젝트</span>
|
||||
</RouterLink>
|
||||
<RouterLink
|
||||
to="/meeting"
|
||||
class="nav-item"
|
||||
title="화상회의"
|
||||
:class="{ active: activeNav === 'meeting' }"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="m22 8-6 4 6 4V8Z" />
|
||||
<rect
|
||||
x="2"
|
||||
y="6"
|
||||
width="14"
|
||||
height="12"
|
||||
rx="2"
|
||||
/>
|
||||
</svg>
|
||||
<span>화상회의</span>
|
||||
</RouterLink>
|
||||
</nav>
|
||||
|
||||
<!-- 하단: 접기 토글 + 사용자 프로필 + 메뉴(위로 펼침) -->
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
<script setup lang="ts">
|
||||
// 화상회의 — 상태 머신(로비 → 프리조인 → 연결 중 → 회의실).
|
||||
// 라이브 상태는 useLiveKitRoom 이 보유하고, 화면 조각은 components/meeting/* 가 담당한다.
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import AppShell from '@/layouts/AppShell.vue'
|
||||
import MeetingLobby from '@/components/meeting/MeetingLobby.vue'
|
||||
import MeetingPrejoin from '@/components/meeting/MeetingPrejoin.vue'
|
||||
import MeetingRoom from '@/components/meeting/MeetingRoom.vue'
|
||||
import { useMeeting, type MeetingRole } from '@/composables/useMeeting'
|
||||
import { useLiveKitRoom } from '@/composables/useLiveKitRoom'
|
||||
import { useDialog } from '@/composables/useDialog'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import type { ApiMeetingRoom } from '@/types/meeting'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const meetingApi = useMeeting()
|
||||
const dialog = useDialog()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const {
|
||||
participants,
|
||||
connected,
|
||||
connecting,
|
||||
micEnabled,
|
||||
camEnabled,
|
||||
errorMsg,
|
||||
featuredIdentity,
|
||||
messages,
|
||||
connect,
|
||||
disconnect,
|
||||
toggleMic,
|
||||
toggleCam,
|
||||
promote,
|
||||
sendChat,
|
||||
} = useLiveKitRoom()
|
||||
|
||||
const ROOM_PATTERN = /^[A-Za-z0-9_-]{1,64}$/
|
||||
|
||||
// 프리조인 대상 방 정보(표시용)
|
||||
interface PrejoinTarget {
|
||||
name: string
|
||||
hostName: string | null
|
||||
restricted: boolean
|
||||
isHost: boolean
|
||||
}
|
||||
|
||||
const phase = ref<'lobby' | 'prejoin'>('lobby')
|
||||
const entering = ref(false) // 토큰 발급~연결 시작 사이 깜빡임 방지
|
||||
const target = ref<PrejoinTarget | null>(null)
|
||||
const joinError = ref<string | null>(null)
|
||||
const myRole = ref<MeetingRole | null>(null)
|
||||
const rooms = ref<ApiMeetingRoom[]>([])
|
||||
|
||||
const me = computed(() => authStore.user)
|
||||
const myName = computed(() => me.value?.name || '나')
|
||||
const myAvatarUrl = computed(() => me.value?.avatarUrl ?? null)
|
||||
const localId = computed(() => participants.value[0]?.identity ?? null)
|
||||
const isHost = computed(() => myRole.value === 'host')
|
||||
|
||||
// 현재 보여줄 화면 결정
|
||||
const screen = computed<'lobby' | 'prejoin' | 'connecting' | 'room'>(() => {
|
||||
if (connected.value) return 'room'
|
||||
if (entering.value || connecting.value) return 'connecting'
|
||||
if (phase.value === 'prejoin' && target.value) return 'prejoin'
|
||||
return 'lobby'
|
||||
})
|
||||
|
||||
async function loadRooms() {
|
||||
try {
|
||||
rooms.value = await meetingApi.listRooms()
|
||||
} catch {
|
||||
// 목록 조회 실패는 인터셉터에서 전역 처리
|
||||
}
|
||||
}
|
||||
|
||||
// 로비에서 방 선택/입력 → 프리조인으로
|
||||
function onJoin(name: string) {
|
||||
const n = (name || '').trim()
|
||||
if (!n) {
|
||||
joinError.value = '방 이름을 입력해 주세요.'
|
||||
return
|
||||
}
|
||||
if (!ROOM_PATTERN.test(n)) {
|
||||
joinError.value =
|
||||
'방 이름은 영문·숫자·-·_ 1~64자만 가능합니다. (한글·공백 불가)'
|
||||
return
|
||||
}
|
||||
const room = rooms.value.find((r) => r.name === n)
|
||||
if (room && room.restricted && !room.canJoin) {
|
||||
joinError.value = `'${n}' 방은 참여가 제한되어 있습니다. 진행자에게 허용을 요청하세요.`
|
||||
return
|
||||
}
|
||||
joinError.value = null
|
||||
target.value = room
|
||||
? {
|
||||
name: room.name,
|
||||
hostName: room.host?.name ?? null,
|
||||
restricted: room.restricted,
|
||||
isHost: room.isHost,
|
||||
}
|
||||
: {
|
||||
name: n,
|
||||
hostName: myName.value, // 새 방은 입장자가 진행자가 된다
|
||||
restricted: false,
|
||||
isHost: true,
|
||||
}
|
||||
phase.value = 'prejoin'
|
||||
}
|
||||
|
||||
// 프리조인 → 입장(토큰 발급 + 연결)
|
||||
async function onEnter(opts: { mic: boolean; cam: boolean }) {
|
||||
const name = target.value?.name
|
||||
if (!name) return
|
||||
entering.value = true
|
||||
try {
|
||||
const { token, role } = await meetingApi.getToken(name)
|
||||
myRole.value = role
|
||||
await connect(token, opts)
|
||||
if (route.params.room !== name) {
|
||||
void router.replace(`/meeting/${name}`)
|
||||
}
|
||||
} catch {
|
||||
joinError.value = '입장에 실패했습니다. 잠시 후 다시 시도해 주세요.'
|
||||
phase.value = 'lobby'
|
||||
target.value = null
|
||||
} finally {
|
||||
entering.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 프리조인 취소 → 로비
|
||||
function onCancelPrejoin() {
|
||||
phase.value = 'lobby'
|
||||
target.value = null
|
||||
}
|
||||
|
||||
// 나가기 → 로비
|
||||
async function onLeave() {
|
||||
await disconnect()
|
||||
myRole.value = null
|
||||
phase.value = 'lobby'
|
||||
target.value = null
|
||||
void router.replace('/meeting')
|
||||
void loadRooms()
|
||||
}
|
||||
|
||||
// 방 삭제(진행자만)
|
||||
async function onDelete(r: ApiMeetingRoom) {
|
||||
const ok = await dialog.confirm(
|
||||
`'${r.name}' 방을 삭제할까요? 이 작업은 되돌릴 수 없습니다.`,
|
||||
{ title: '방 삭제', confirmText: '삭제', variant: 'danger' },
|
||||
)
|
||||
if (!ok) return
|
||||
try {
|
||||
await meetingApi.removeRoom(r.id)
|
||||
await loadRooms()
|
||||
} catch {
|
||||
// 전역 처리
|
||||
}
|
||||
}
|
||||
|
||||
// 방 생성 성공 → 목록 갱신
|
||||
function onCreated() {
|
||||
void loadRooms()
|
||||
}
|
||||
|
||||
// 진입 시 방 목록 로드. 공유 링크(/meeting/:room)면 그 방 프리조인으로 바로 이동.
|
||||
onMounted(async () => {
|
||||
await loadRooms()
|
||||
const param = route.params.room
|
||||
if (typeof param === 'string' && param) onJoin(param)
|
||||
})
|
||||
|
||||
// 화면 이탈 시 연결 정리
|
||||
onBeforeUnmount(() => {
|
||||
void disconnect()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppShell>
|
||||
<!-- 로비(라이트) -->
|
||||
<MeetingLobby
|
||||
v-if="screen === 'lobby'"
|
||||
:rooms="rooms"
|
||||
:join-error="joinError"
|
||||
@join="onJoin"
|
||||
@created="onCreated"
|
||||
@delete="onDelete"
|
||||
@clear-error="joinError = null"
|
||||
/>
|
||||
|
||||
<!-- 다크 캔버스(프리조인 / 연결 중 / 회의실) -->
|
||||
<div
|
||||
v-else
|
||||
class="meeting-canvas"
|
||||
>
|
||||
<MeetingPrejoin
|
||||
v-if="screen === 'prejoin' && target"
|
||||
:room="target"
|
||||
:my-name="myName"
|
||||
:my-avatar-url="myAvatarUrl"
|
||||
@enter="onEnter"
|
||||
@cancel="onCancelPrejoin"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-else-if="screen === 'connecting'"
|
||||
class="connecting"
|
||||
>
|
||||
<div class="cn-spin" />
|
||||
<div class="cn-name">
|
||||
{{ target?.name }}
|
||||
</div>
|
||||
<div class="cn-txt">
|
||||
회의에 연결하는 중…
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<MeetingRoom
|
||||
v-else-if="screen === 'room'"
|
||||
:room-name="target?.name ?? ''"
|
||||
:participants="participants"
|
||||
:featured-identity="featuredIdentity"
|
||||
:local-id="localId"
|
||||
:mic-enabled="micEnabled"
|
||||
:cam-enabled="camEnabled"
|
||||
:is-host="isHost"
|
||||
:error-msg="errorMsg"
|
||||
:messages="messages"
|
||||
@toggle-mic="toggleMic"
|
||||
@toggle-cam="toggleCam"
|
||||
@promote="promote"
|
||||
@send="sendChat"
|
||||
@leave="onLeave"
|
||||
@dismiss-error="errorMsg = null"
|
||||
/>
|
||||
</div>
|
||||
</AppShell>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 다크 캔버스 — 사이드바/상단바(3.25rem) 아래 영역을 가득 채운다 */
|
||||
.meeting-canvas {
|
||||
height: calc(100vh - 3.25rem);
|
||||
background: var(--d-bg);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 연결 중 */
|
||||
.connecting {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
.cn-spin {
|
||||
width: 2.875rem;
|
||||
height: 2.875rem;
|
||||
border-radius: 50%;
|
||||
border: 0.1875rem solid var(--d-border);
|
||||
border-top-color: var(--accent);
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
.cn-name {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 700;
|
||||
color: var(--d-text);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
}
|
||||
.cn-txt {
|
||||
font-size: 0.84375rem;
|
||||
color: var(--d-text-3);
|
||||
}
|
||||
|
||||
@media (max-width: 48rem) {
|
||||
.meeting-canvas {
|
||||
height: calc(100vh - 3.25rem);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -108,6 +108,13 @@ const routes: RouteRecordRaw[] = [
|
||||
component: () => import('@/pages/relay/SchedulePage.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
// 화상회의 — LiveKit 기반(단계 1: 방 입장/연결). :room 으로 공유 링크 가능
|
||||
path: '/meeting/:room?',
|
||||
name: 'meeting',
|
||||
component: () => import('@/pages/relay/MeetingPage.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/:pathMatch(.*)*',
|
||||
name: 'not-found',
|
||||
|
||||
@@ -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) || '?'
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
// 화상회의 도메인 타입 — 백엔드 MeetingService 응답/DTO 와 1:1
|
||||
|
||||
// 회의 내 역할
|
||||
export type MeetingRole = 'host' | 'participant'
|
||||
|
||||
// 회의 방(API 원시) — 백엔드 MeetingRoomResponse 와 1:1
|
||||
export interface ApiMeetingRoom {
|
||||
id: string
|
||||
name: string
|
||||
host: { id: string; name: string; avatarUrl: string | null } | null
|
||||
scheduledAt: string | null // ISO, 예약 시각(없으면 null)
|
||||
restricted: boolean // 참여 제한(allow-list) 여부
|
||||
isHost: boolean // 현재 사용자가 진행자
|
||||
canJoin: boolean // 현재 사용자가 입장 가능
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
// 방 생성 요청
|
||||
export interface CreateMeetingRoomPayload {
|
||||
name: string
|
||||
scheduledAt?: string // ISO
|
||||
allowedUserIds?: string[] // 비우면 전체 공개
|
||||
}
|
||||
|
||||
// 회의실 채팅 메시지 — LiveKit 데이터 채널로 주고받는 인메모리 메시지(서버 저장 없음)
|
||||
export interface ChatMessage {
|
||||
id: string
|
||||
senderId: string // 보낸 사람 identity(사용자 ID)
|
||||
senderName: string // 보낸 사람 표시 이름
|
||||
senderAvatarUrl: string | null // 보낸 사람 프로필 이미지(참여자 metadata 에서 파생)
|
||||
text: string
|
||||
ts: number // 보낸 시각(epoch ms)
|
||||
self: boolean // 본인이 보낸 메시지 여부
|
||||
}
|
||||
|
||||
// 연결 품질 단계 — UI 막대(3/2/1)용으로 정규화한 값
|
||||
export type MeetingQuality = 'good' | 'medium' | 'poor'
|
||||
@@ -0,0 +1,136 @@
|
||||
# LiveKit 셀프호스팅 배포 가이드 (화상회의 단계 6)
|
||||
|
||||
운영에서 LiveKit(SFU)을 직접 호스팅하는 방법. **단일 노드 → 멀티 노드 클러스터** 순으로 설명한다.
|
||||
(간단히 가려면 운영도 LiveKit Cloud 를 그대로 쓸 수 있다 — 그 경우 `.env.production` 의 `LIVEKIT_*` 만 Cloud 값으로 두고 아래 셀프호스팅 절차는 생략.)
|
||||
|
||||
> 로컬 dev 에서 셀프호스팅이 막혔던 건 WSL2/Docker Desktop 네트워킹 제약 때문이며, **공인 IP 가 있는 실서버에서는 발생하지 않는다.**
|
||||
|
||||
---
|
||||
|
||||
## 1. 사전 준비
|
||||
|
||||
- 공인 IP 를 가진 리눅스 서버(도커 설치)
|
||||
- 도메인 + DNS: `livekit.<도메인>` A 레코드 → 서버 공인 IP
|
||||
- **리버스 프록시(TLS 종단)**: 기존 `proxy` 외부 네트워크의 프록시(Traefik/nginx 등)
|
||||
- 방화벽 개방:
|
||||
- `443/tcp` — 시그널링(wss, 프록시 경유)
|
||||
- `7881/tcp` — ICE/TCP 폴백
|
||||
- `7882/udp` — ICE/UDP(미디어)
|
||||
|
||||
---
|
||||
|
||||
## 2. 키 생성
|
||||
|
||||
API Key 는 임의 식별자, Secret 은 32자 이상 강력한 난수.
|
||||
|
||||
```bash
|
||||
echo "LIVEKIT_API_KEY=$(openssl rand -hex 8)"
|
||||
echo "LIVEKIT_API_SECRET=$(openssl rand -hex 32)"
|
||||
```
|
||||
|
||||
`.env.production` 에 채운다:
|
||||
|
||||
```dotenv
|
||||
LIVEKIT_API_KEY=<생성한 key>
|
||||
LIVEKIT_API_SECRET=<생성한 secret(32자+)>
|
||||
LIVEKIT_WS_URL=wss://livekit.<도메인> # 브라우저 접속(프론트 빌드에 주입)
|
||||
LIVEKIT_URL=http://livekit:7880 # 백엔드 → LiveKit 내부 API
|
||||
```
|
||||
|
||||
> `LIVEKIT_WS_URL` 은 프론트 빌드 시 `VITE_LIVEKIT_URL` 로 **굽혀진다**. 값 변경 시 프론트 재빌드 필요.
|
||||
|
||||
---
|
||||
|
||||
## 3. 기동 (단일 노드)
|
||||
|
||||
```bash
|
||||
docker compose --profile selfhost-livekit --env-file .env.production up -d --build
|
||||
```
|
||||
|
||||
- `selfhost-livekit` 프로파일을 켜야 LiveKit 컨테이너가 생성된다(미지정 시 미생성).
|
||||
- 설정: `livekit/livekit.prod.yaml`(키는 `LIVEKIT_KEYS` env 로 주입), `use_external_ip: true` 로 공인 IP 자동 광고.
|
||||
|
||||
---
|
||||
|
||||
## 4. 리버스 프록시 라우팅 (시그널링 wss)
|
||||
|
||||
`wss://livekit.<도메인>` → 컨테이너 `livekit:7880` 으로 라우팅(WebSocket 업그레이드 + TLS 종단).
|
||||
미디어(7881/7882)는 프록시를 거치지 않는다 — 직접 공인 IP 로 통신.
|
||||
|
||||
**Traefik(라벨) 예:**
|
||||
|
||||
```yaml
|
||||
# docker-compose 의 livekit 서비스에 labels 추가(운영 프록시 구성에 맞게):
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.livekit.rule=Host(`livekit.<도메인>`)"
|
||||
- "traefik.http.routers.livekit.entrypoints=websecure"
|
||||
- "traefik.http.routers.livekit.tls.certresolver=<resolver>"
|
||||
- "traefik.http.services.livekit.loadbalancer.server.port=7880"
|
||||
```
|
||||
|
||||
**nginx 예:**
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name livekit.<도메인>;
|
||||
# ssl_certificate ... ;
|
||||
location / {
|
||||
proxy_pass http://livekit:7880;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_read_timeout 600s;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 검증
|
||||
|
||||
1. 브라우저에서 회의 입장(두 기기/네트워크에서 영상 확인)
|
||||
2. 부하 테스트:
|
||||
```bash
|
||||
LIVEKIT_URL=wss://livekit.<도메인> \
|
||||
LIVEKIT_API_KEY=<key> LIVEKIT_API_SECRET=<secret> \
|
||||
./livekit/load-test.sh full
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 멀티 노드 클러스터링 (수평 확장)
|
||||
|
||||
대규모(예: 160명 다수 방)에서 노드 간 트래픽을 분산하려면 Redis 로 LiveKit 노드들을 묶는다.
|
||||
|
||||
1. **설정에 redis 블록 추가** — 비밀번호가 들어가므로 커밋 파일이 아닌 사본에 둔다.
|
||||
```bash
|
||||
cp livekit/livekit.prod.yaml livekit/livekit.prod.local.yaml # gitignore 대상
|
||||
```
|
||||
`livekit.prod.local.yaml` 에 추가:
|
||||
```yaml
|
||||
redis:
|
||||
address: redis:6379
|
||||
db: 1 # 앱 캐시(db 0)와 분리
|
||||
password: <REDIS_PASSWORD> # 루트 .env 의 REDIS_PASSWORD
|
||||
```
|
||||
compose 의 livekit 볼륨을 이 사본으로 교체(override 파일 권장).
|
||||
|
||||
2. **노드 확장**
|
||||
- 단일 호스트에서 replica 만 늘리는 것은 **진짜 분산이 아니다**(같은 머신·NIC).
|
||||
- 실제 분산 = **여러 호스트** 각각 LiveKit 실행 + **공유 Redis** + 시그널링은 프록시가 여러 노드로 LB. RTC 는 각 노드의 공인 IP 로 직접.
|
||||
- LiveKit 이 방을 노드에 분배(room→node)하고, 한 방이 여러 노드에 걸치면 **캐스케이딩**으로 노드 간 미디어를 릴레이한다.
|
||||
- Simulcast/SVC 로 수신측 상황에 맞는 화질 레이어만 전달(기본 활성).
|
||||
|
||||
3. 부하 분산 검증: `./livekit/load-test.sh full` 로 다수 참여자를 투입하고, 노드별 참여자/대역폭 분포를 모니터링.
|
||||
|
||||
---
|
||||
|
||||
## 7. 운영 팁
|
||||
|
||||
- **UDP 수신 버퍼 상향**(권장): `sysctl -w net.core.rmem_max=16777216 net.core.wmem_max=16777216` (영구화는 `/etc/sysctl.conf`). 미설정 시 LiveKit 이 경고 로그를 남긴다.
|
||||
- **모니터링**: LiveKit Prometheus 메트릭(`/metrics`) + Grafana — 노드별 참여자/트랙/CPU/대역폭.
|
||||
- **리소스**: compose 의 `deploy.resources.limits` 를 트래픽에 맞게 조정(기본 2 CPU / 2GB).
|
||||
- `.gitignore` 에 `livekit/*.local.yaml` 추가(비밀번호 포함 사본 커밋 방지).
|
||||
@@ -0,0 +1,100 @@
|
||||
# 화상회의 부하 테스트 가이드 (단계 5: 확장 검증)
|
||||
|
||||
`lk load-test`(livekit-cli)로 가상 참여자를 대량 접속시켜 **SFU 부하·미디어 품질**을 측정한다.
|
||||
러너: [`livekit/load-test.sh`](./load-test.sh)
|
||||
|
||||
---
|
||||
|
||||
## 1. 목적
|
||||
|
||||
- 다수 참여자 접속 시 **품질(지연·패킷 손실·대역폭)** 이 유지되는지 검증
|
||||
- (셀프호스팅 클러스터) 여러 SFU 노드로 **미디어 트래픽이 분산**되는지 검증
|
||||
- 문서 목표: 최대 **160명** 단일 방, 동시 표시 ~50명, Simulcast/SVC 로 수신측 화질 차등
|
||||
|
||||
---
|
||||
|
||||
## 2. 사전 준비
|
||||
|
||||
- Docker 설치 (러너가 `livekit/livekit-cli` 이미지를 사용)
|
||||
- 자격증명: 루트 `.env.${APP_ENV:-development}` 의 `LIVEKIT_URL` / `LIVEKIT_API_KEY` / `LIVEKIT_API_SECRET`
|
||||
- 러너가 자동으로 읽어 컨테이너에 주입한다.
|
||||
|
||||
---
|
||||
|
||||
## 3. 실행
|
||||
|
||||
```bash
|
||||
# 소규모(도구/연결 검증) — Cloud 에서도 안전
|
||||
./livekit/load-test.sh smoke # 2 pub + 3 sub, 15s, 저해상도
|
||||
|
||||
# 중간 규모
|
||||
./livekit/load-test.sh medium # 5 pub + 30 sub, 1m
|
||||
|
||||
# 대규모(문서 목표) — 반드시 셀프호스팅/전용 인스턴스에서!
|
||||
./livekit/load-test.sh full # 10 pub + 150 sub, 2m, 고해상도
|
||||
```
|
||||
|
||||
세부 조정(환경변수):
|
||||
|
||||
| 변수 | 의미 | 예 |
|
||||
|---|---|---|
|
||||
| `PUBLISHERS` | 영상 발행 가상 참여자 수 | `10` |
|
||||
| `SUBSCRIBERS` | 구독 전용 가상 참여자 수 | `150` |
|
||||
| `DURATION` | 지속 시간 | `2m`, `30s` |
|
||||
| `RESOLUTION` | 발행 화질 | `low` / `medium` / `high` |
|
||||
| `LAYOUT` | 구독 레이아웃(구독 수 모사) | `speaker` / `3x3` / `4x4` / `5x5` |
|
||||
| `SIMULCAST` | Simulcast 발행 | `on`(기본) / `off` |
|
||||
| `ROOM` | 방 이름 | `loadtest-full` |
|
||||
| `NUM_PER_SECOND` | 초당 입장 가속 | `5` |
|
||||
|
||||
```bash
|
||||
# 예) 160명 규모, 진행자 1명 고해상도 + 159명 구독, 3분
|
||||
PUBLISHERS=1 SUBSCRIBERS=159 RESOLUTION=high DURATION=3m ./livekit/load-test.sh full
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 결과 읽는 법
|
||||
|
||||
러너는 두 표를 출력한다.
|
||||
|
||||
- **Track loading**: 구독자별 각 트랙의 수신 패킷 수, 비트레이트, 패킷 손실
|
||||
- **Subscriber summaries**: 구독자별 `Tracks N/N`(구독 성공 비율), 총 비트레이트, 총 패킷 손실, 에러
|
||||
|
||||
**합격 기준(목표치)**
|
||||
- `Tracks` 가 모두 `N/N` (요청 트랙 전부 구독)
|
||||
- `Pkt. Loss` 가 낮게 유지 (목표 < 2~3%)
|
||||
- `Error` = 0
|
||||
- 참여자 수를 늘려도 위 지표가 급격히 악화되지 않을 것
|
||||
|
||||
> smoke 예시 결과: 6/6 트랙, 패킷 손실 0%, ~119kbps/구독자 — 정상.
|
||||
|
||||
---
|
||||
|
||||
## 5. 어디서 실행하나 (중요)
|
||||
|
||||
| 대상 | 권장 |
|
||||
|---|---|
|
||||
| **LiveKit Cloud** (현재 dev) | `smoke` 만. 대량 부하는 동시접속 쿼터·약관 위반 소지 → 금지 |
|
||||
| **셀프호스팅 단일 노드** (단계 6) | `medium`~`full` 로 단일 노드 한계 측정 |
|
||||
| **셀프호스팅 클러스터** (단계 6) | `full` 이상으로 **부하 분산(노드 간 트래픽 분배)** 검증 |
|
||||
|
||||
- 부하 **분산** 검증은 SFU 노드가 2개 이상인 클러스터 구성에서만 의미가 있다(단일 노드는 분산 대상이 없음).
|
||||
|
||||
---
|
||||
|
||||
## 6. 서버 측 모니터링 (셀프호스팅)
|
||||
|
||||
부하 중 서버 지표를 함께 본다.
|
||||
|
||||
- LiveKit Prometheus 메트릭(`/metrics`) + Grafana: 노드별 참여자/트랙 수, CPU, 송수신 대역폭
|
||||
- 호스트: CPU, 네트워크 대역폭, UDP 버퍼
|
||||
- 노드별 참여자 분포(클러스터링/캐스케이딩이 고르게 분산하는지)
|
||||
|
||||
---
|
||||
|
||||
## 7. 한계 / 참고
|
||||
|
||||
- `lk load-test` 의 발행 미디어는 합성 트래픽이라 실제 카메라 화질과 100% 동일하지는 않다(상대적 부하 비교용으로 충분).
|
||||
- 클라이언트 측 다운링크(시청자 회선) 부하는 별도 검증 필요.
|
||||
- 본 단계는 **검증 도구·방법론**이며, 실제 대규모 검증은 단계 6(셀프호스팅 배포) 환경에서 수행한다.
|
||||
@@ -0,0 +1,34 @@
|
||||
# LiveKit 셀프호스팅 운영 설정 (단계 6)
|
||||
# - 단일 노드 기준. 멀티 노드 클러스터링은 아래 redis 블록을 활성화한다(livekit/DEPLOY.md 참고).
|
||||
# - API 키/시크릿은 환경변수 LIVEKIT_KEYS 로 주입한다(이 파일에 비밀값을 두지 않는다).
|
||||
# - 시그널링(7880)은 리버스 프록시에서 TLS 종단 후 라우팅한다(wss://livekit.<도메인>).
|
||||
# - 미디어(RTC)는 프록시를 거치지 않고 서버 공인 IP 로 직접 통신한다.
|
||||
|
||||
# 시그널링 / HTTP API (리버스 프록시 뒤)
|
||||
port: 7880
|
||||
|
||||
rtc:
|
||||
# ICE/TCP 폴백 포트 (UDP 차단 환경 대비)
|
||||
tcp_port: 7881
|
||||
# ICE/UDP 단일 mux 포트 (방화벽 단순화 — 이 포트만 열면 됨)
|
||||
udp_port: 7882
|
||||
# 운영: 서버의 공인 IP 를 ICE 후보로 자동 광고
|
||||
use_external_ip: true
|
||||
|
||||
logging:
|
||||
level: info
|
||||
|
||||
# 내장 TURN 은 기본 비활성. 엄격한 방화벽 뒤 클라이언트를 지원하려면
|
||||
# DEPLOY.md 의 TURN/TLS 설정을 참고해 활성화한다.
|
||||
turn:
|
||||
enabled: false
|
||||
|
||||
# ── 멀티 노드 클러스터링(수평 확장) ──────────────────────────────
|
||||
# 2대 이상의 LiveKit 노드를 Redis 로 묶어 트래픽을 분산한다.
|
||||
# Redis 비밀번호가 들어가므로(비밀값) 이 블록은 커밋 파일에 두지 않고,
|
||||
# 배포 시 이 파일을 복사한 사본(gitignore)에 채워 마운트한다. (livekit/DEPLOY.md 참고)
|
||||
#
|
||||
# redis:
|
||||
# address: redis:6379
|
||||
# db: 1 # 앱 캐시(db 0)와 분리
|
||||
# password: <REDIS_PASSWORD>
|
||||
@@ -0,0 +1,20 @@
|
||||
# LiveKit 셀프호스팅 설정 (개발용 / 로컬 단일 노드)
|
||||
# - 미디어(WebRTC) 전송 계층을 담당하는 SFU. 애플리케이션은 토큰만 발급한다.
|
||||
# - API 키/시크릿은 환경변수 LIVEKIT_KEYS 로 주입한다(여기에 두지 않음).
|
||||
# - 로컬 개발에서는 노드 IP 를 127.0.0.1 로 광고해야 브라우저가 ICE 후보에 접속할 수 있다.
|
||||
# (docker-compose 의 command 에서 --node-ip 127.0.0.1 지정)
|
||||
|
||||
# 시그널링 / HTTP API 포트
|
||||
port: 7880
|
||||
|
||||
rtc:
|
||||
# ICE/TCP 포트 — Docker Desktop 에서 가장 안정적인 경로(브라우저가 연결을 거는 단일 스트림)
|
||||
tcp_port: 7881
|
||||
# ICE/UDP 단일 포트(매핑됨). udp_port:0 은 50000~60000 레인지를 켜버려(미매핑) NIC flapping 을
|
||||
# 유발하므로 반드시 매핑된 단일 포트를 명시한다.
|
||||
udp_port: 7882
|
||||
# 외부 IP 자동 탐지를 끄고 --node-ip 로 지정한 127.0.0.1 을 후보로 사용
|
||||
use_external_ip: false
|
||||
|
||||
logging:
|
||||
level: info
|
||||
Executable
+85
@@ -0,0 +1,85 @@
|
||||
#!/usr/bin/env bash
|
||||
# =====================================================================
|
||||
# LiveKit 부하 테스트 러너 (화상회의 단계 5: 확장 검증)
|
||||
# ---------------------------------------------------------------------
|
||||
# livekit-cli(lk load-test)로 가상 publisher/subscriber 를 대량 접속시켜
|
||||
# SFU 의 부하·미디어 품질(지연/패킷손실/대역폭)을 측정한다.
|
||||
#
|
||||
# 사용법:
|
||||
# ./livekit/load-test.sh [preset]
|
||||
# preset: smoke(기본) | medium | full
|
||||
#
|
||||
# 세부 조정(환경변수로 덮어쓰기):
|
||||
# ROOM, PUBLISHERS, SUBSCRIBERS, DURATION, RESOLUTION(low|medium|high),
|
||||
# LAYOUT(speaker|3x3|4x4|5x5), SIMULCAST(on|off), NUM_PER_SECOND
|
||||
#
|
||||
# 자격증명은 루트 .env.${APP_ENV:-development} 에서 읽는다
|
||||
# (LIVEKIT_URL / LIVEKIT_API_KEY / LIVEKIT_API_SECRET).
|
||||
#
|
||||
# ⚠️ 주의:
|
||||
# - 대규모(full=160참여자) 테스트는 반드시 "셀프호스팅 LiveKit(단계 6)" 또는
|
||||
# 전용 테스트 인스턴스를 대상으로 실행하세요.
|
||||
# - LiveKit Cloud 무료/개발 티어에 대한 대량 부하는 동시 접속 쿼터 초과 및
|
||||
# 이용약관 위반 소지가 있습니다. Cloud 에서는 smoke(소규모)로 도구 검증만 하세요.
|
||||
# - 부하 분산(클러스터링) 검증은 SFU 노드가 2개 이상인 셀프호스팅 구성에서만 의미가 있습니다.
|
||||
# =====================================================================
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
ENV_FILE="${ENV_FILE:-$ROOT_DIR/.env.${APP_ENV:-development}}"
|
||||
PRESET="${1:-smoke}"
|
||||
|
||||
# 프리셋별 기본값(환경변수가 있으면 그것을 우선)
|
||||
case "$PRESET" in
|
||||
smoke)
|
||||
PUBLISHERS="${PUBLISHERS:-2}"; SUBSCRIBERS="${SUBSCRIBERS:-3}"
|
||||
DURATION="${DURATION:-15s}"; RESOLUTION="${RESOLUTION:-low}"; LAYOUT="${LAYOUT:-3x3}" ;;
|
||||
medium)
|
||||
PUBLISHERS="${PUBLISHERS:-5}"; SUBSCRIBERS="${SUBSCRIBERS:-30}"
|
||||
DURATION="${DURATION:-1m}"; RESOLUTION="${RESOLUTION:-medium}"; LAYOUT="${LAYOUT:-5x5}" ;;
|
||||
full)
|
||||
# 문서 목표: 최대 160명(진행자 소수 발행 + 다수 구독), 동시 표시 ~50명
|
||||
PUBLISHERS="${PUBLISHERS:-10}"; SUBSCRIBERS="${SUBSCRIBERS:-150}"
|
||||
DURATION="${DURATION:-2m}"; RESOLUTION="${RESOLUTION:-high}"; LAYOUT="${LAYOUT:-speaker}" ;;
|
||||
*)
|
||||
echo "알 수 없는 preset: $PRESET (smoke|medium|full)" >&2; exit 1 ;;
|
||||
esac
|
||||
|
||||
ROOM="${ROOM:-loadtest-$PRESET}"
|
||||
NUM_PER_SECOND="${NUM_PER_SECOND:-5}"
|
||||
SIMULCAST="${SIMULCAST:-on}"
|
||||
SIMULCAST_FLAG=()
|
||||
[ "$SIMULCAST" = "off" ] && SIMULCAST_FLAG=(--no-simulcast)
|
||||
|
||||
# .env 에서 필요한 값만 읽는다(CRLF 제거). 파일이 없으면 기존 환경변수 사용.
|
||||
read_env() {
|
||||
if [ -f "$ENV_FILE" ]; then
|
||||
grep -E "^$1=" "$ENV_FILE" | tail -1 | cut -d= -f2- | tr -d '\r'
|
||||
fi
|
||||
}
|
||||
export LIVEKIT_URL="${LIVEKIT_URL:-$(read_env LIVEKIT_URL)}"
|
||||
export LIVEKIT_API_KEY="${LIVEKIT_API_KEY:-$(read_env LIVEKIT_API_KEY)}"
|
||||
export LIVEKIT_API_SECRET="${LIVEKIT_API_SECRET:-$(read_env LIVEKIT_API_SECRET)}"
|
||||
|
||||
if [ -z "${LIVEKIT_URL:-}" ] || [ -z "${LIVEKIT_API_KEY:-}" ] || [ -z "${LIVEKIT_API_SECRET:-}" ]; then
|
||||
echo "LIVEKIT_URL / LIVEKIT_API_KEY / LIVEKIT_API_SECRET 를 찾을 수 없습니다 ($ENV_FILE)." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "▶ LiveKit load-test [$PRESET]"
|
||||
echo " URL=$LIVEKIT_URL ROOM=$ROOM"
|
||||
echo " publishers=$PUBLISHERS subscribers=$SUBSCRIBERS duration=$DURATION resolution=$RESOLUTION layout=$LAYOUT simulcast=$SIMULCAST"
|
||||
echo
|
||||
|
||||
# livekit-cli(lk)는 $LIVEKIT_URL/$LIVEKIT_API_KEY/$LIVEKIT_API_SECRET 를 자동으로 읽는다.
|
||||
docker run --rm \
|
||||
-e LIVEKIT_URL -e LIVEKIT_API_KEY -e LIVEKIT_API_SECRET \
|
||||
livekit/livekit-cli load-test \
|
||||
--room "$ROOM" \
|
||||
--duration "$DURATION" \
|
||||
--publishers "$PUBLISHERS" \
|
||||
--subscribers "$SUBSCRIBERS" \
|
||||
--video-resolution "$RESOLUTION" \
|
||||
--layout "$LAYOUT" \
|
||||
--num-per-second "$NUM_PER_SECOND" \
|
||||
"${SIMULCAST_FLAG[@]}"
|
||||
Reference in New Issue
Block a user