first commit
This commit is contained in:
commit
69b647145f
58 files changed
+15796
No files matched your search
@@ -0,0 +1,66 @@
|
||||
<script setup lang="ts">
|
||||
import type { RoomAdmin } from '~/types/room'
|
||||
|
||||
defineProps<{
|
||||
rooms: RoomAdmin[]
|
||||
loading?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
delete: [id: string]
|
||||
copy: [id: string]
|
||||
refresh: []
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="panel overflow-hidden">
|
||||
<div class="flex items-center justify-between border-b border-[var(--border)] px-4 py-3">
|
||||
<h2 class="font-semibold">방 목록</h2>
|
||||
<button type="button" class="btn-ghost !py-1 text-xs" @click="emit('refresh')">새로고침</button>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full min-w-[640px] text-left text-sm">
|
||||
<thead class="bg-[var(--bg-elevated)] text-[var(--text-muted)]">
|
||||
<tr>
|
||||
<th class="px-4 py-2 font-medium">이름</th>
|
||||
<th class="px-4 py-2 font-medium">ID</th>
|
||||
<th class="px-4 py-2 font-medium">인원</th>
|
||||
<th class="px-4 py-2 font-medium">방송</th>
|
||||
<th class="px-4 py-2 font-medium">생성</th>
|
||||
<th class="px-4 py-2 font-medium">동작</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="room in rooms" :key="room.id" class="border-t border-[var(--border)]">
|
||||
<td class="px-4 py-3">{{ room.name }}</td>
|
||||
<td class="px-4 py-3 font-mono text-xs text-[var(--text-muted)]">{{ room.id }}</td>
|
||||
<td class="px-4 py-3">{{ room.memberCount ?? 0 }}</td>
|
||||
<td class="px-4 py-3">{{ room.streamCount ?? 0 }}</td>
|
||||
<td class="px-4 py-3 text-[var(--text-muted)]">
|
||||
{{ room.createdAt ? new Date(room.createdAt).toLocaleString() : '-' }}
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button type="button" class="btn-ghost !py-1 text-xs" @click="emit('copy', room.id)">
|
||||
URL 복사
|
||||
</button>
|
||||
<NuxtLink :to="`/room/${room.id}`" class="btn-ghost !py-1 text-xs" target="_blank">
|
||||
열기
|
||||
</NuxtLink>
|
||||
<button type="button" class="btn-danger !py-1 text-xs" @click="emit('delete', room.id)">
|
||||
삭제
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="!rooms.length && !loading">
|
||||
<td colspan="6" class="px-4 py-8 text-center text-[var(--text-muted)]">
|
||||
생성된 방이 없습니다.
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,59 @@
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
roomName: string
|
||||
needPassword: boolean
|
||||
loading?: boolean
|
||||
error?: string | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
submit: [payload: { nickname: string; password?: string }]
|
||||
}>()
|
||||
|
||||
const { nickname } = useNickname()
|
||||
const password = ref('')
|
||||
const localNick = ref(nickname.value || '')
|
||||
|
||||
function onSubmit() {
|
||||
if (!localNick.value.trim()) return
|
||||
nickname.value = localNick.value.trim()
|
||||
emit('submit', {
|
||||
nickname: localNick.value.trim(),
|
||||
password: props.needPassword ? password.value : undefined,
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm">
|
||||
<form class="panel w-full max-w-md space-y-4 p-6 shadow-xl" @submit.prevent="onSubmit">
|
||||
<div>
|
||||
<p class="text-xs uppercase tracking-widest text-[var(--text-muted)]">방 입장</p>
|
||||
<h2 class="mt-1 text-xl font-semibold">{{ roomName || 'Room' }}</h2>
|
||||
</div>
|
||||
|
||||
<label class="block space-y-1.5">
|
||||
<span class="text-sm text-[var(--text-muted)]">닉네임</span>
|
||||
<input v-model="localNick" class="input" maxlength="32" required placeholder="표시할 이름" />
|
||||
</label>
|
||||
|
||||
<label v-if="needPassword" class="block space-y-1.5">
|
||||
<span class="text-sm text-[var(--text-muted)]">방 비밀번호</span>
|
||||
<input
|
||||
v-model="password"
|
||||
class="input"
|
||||
type="password"
|
||||
required
|
||||
placeholder="관리자가 설정한 비밀번호"
|
||||
autocomplete="current-password"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<p v-if="error" class="text-sm text-[var(--danger)]">{{ error }}</p>
|
||||
|
||||
<button type="submit" class="btn-primary w-full" :disabled="loading">
|
||||
{{ loading ? '입장 중…' : '입장' }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,108 @@
|
||||
<script setup lang="ts">
|
||||
import type { ActiveStream, Participant, WatchMap } from '~/types/room'
|
||||
|
||||
const props = defineProps<{
|
||||
participants: Participant[]
|
||||
activeStreams: ActiveStream[]
|
||||
watchMap: WatchMap
|
||||
myClientId: string
|
||||
watchingStreamIds: string[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
watch: [stream: ActiveStream]
|
||||
unwatch: [stream: ActiveStream]
|
||||
}>()
|
||||
|
||||
function nicknameOf(clientId: string) {
|
||||
return props.participants.find((p) => p.clientId === clientId)?.nickname || clientId.slice(0, 6)
|
||||
}
|
||||
|
||||
function viewersFor(streamId: string) {
|
||||
const ids: string[] = []
|
||||
for (const [viewerId, streams] of Object.entries(props.watchMap)) {
|
||||
if (streams.includes(streamId)) ids.push(viewerId)
|
||||
}
|
||||
return ids.map(nicknameOf)
|
||||
}
|
||||
|
||||
function isWatching(streamId: string) {
|
||||
return props.watchingStreamIds.includes(streamId)
|
||||
}
|
||||
|
||||
function watchingSummary(clientId: string) {
|
||||
const streams = props.watchMap[clientId] || []
|
||||
if (!streams.length) return '시청 없음'
|
||||
return streams
|
||||
.map((sid) => {
|
||||
const s = props.activeStreams.find((a) => a.streamId === sid)
|
||||
return s?.broadcasterNickname || 'stream'
|
||||
})
|
||||
.join(', ')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside class="flex h-full flex-col gap-4 border-r border-[var(--border)] bg-[var(--bg-elevated)]/50 p-3">
|
||||
<section>
|
||||
<h3 class="mb-2 text-xs font-semibold uppercase tracking-wider text-[var(--text-muted)]">
|
||||
참여자 ({{ participants.length }})
|
||||
</h3>
|
||||
<ul class="space-y-2">
|
||||
<li
|
||||
v-for="p in participants"
|
||||
:key="p.clientId"
|
||||
class="rounded-md border border-[var(--border)] bg-[var(--bg-panel)] px-2.5 py-2 text-sm"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="font-medium">
|
||||
{{ p.nickname }}
|
||||
<span v-if="p.clientId === myClientId" class="text-[var(--text-muted)]">(나)</span>
|
||||
</span>
|
||||
<span
|
||||
v-if="p.isStreaming"
|
||||
class="rounded bg-[var(--accent-soft)] px-1.5 py-0.5 text-[10px] text-[var(--accent)]"
|
||||
>
|
||||
LIVE
|
||||
</span>
|
||||
</div>
|
||||
<p class="mt-1 text-[11px] text-[var(--text-muted)]">
|
||||
시청: {{ watchingSummary(p.clientId) }}
|
||||
</p>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="flex-1 overflow-auto">
|
||||
<h3 class="mb-2 text-xs font-semibold uppercase tracking-wider text-[var(--text-muted)]">
|
||||
방송 중 ({{ activeStreams.length }})
|
||||
</h3>
|
||||
<ul class="space-y-2">
|
||||
<li
|
||||
v-for="s in activeStreams"
|
||||
:key="s.streamId"
|
||||
class="rounded-md border border-[var(--border)] bg-[var(--bg-panel)] px-2.5 py-2 text-sm"
|
||||
>
|
||||
<p class="font-medium">{{ s.broadcasterNickname }}</p>
|
||||
<p class="mt-0.5 text-[11px] text-[var(--text-muted)]">
|
||||
시청자: {{ viewersFor(s.streamId).join(', ') || '없음' }}
|
||||
</p>
|
||||
<div class="mt-2">
|
||||
<button
|
||||
v-if="s.broadcasterId !== myClientId"
|
||||
type="button"
|
||||
class="btn-ghost w-full !py-1 text-xs"
|
||||
@click="isWatching(s.streamId) ? emit('unwatch', s) : emit('watch', s)"
|
||||
>
|
||||
{{ isWatching(s.streamId) ? '시청 종료' : '시청' }}
|
||||
</button>
|
||||
<p v-else class="text-[11px] text-[var(--text-muted)]">내 화면</p>
|
||||
</div>
|
||||
</li>
|
||||
<li v-if="!activeStreams.length" class="text-xs text-[var(--text-muted)]">
|
||||
아직 공유 중인 화면이 없습니다.
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</aside>
|
||||
</template>
|
||||
@@ -0,0 +1,43 @@
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
roomName: string
|
||||
nickname: string
|
||||
joinedAt: number | null
|
||||
isSharing: boolean
|
||||
memberCount: number
|
||||
maxMembers: number
|
||||
layoutMode: 'grid' | 'focus'
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
toggleShare: []
|
||||
toggleLayout: []
|
||||
leave: []
|
||||
}>()
|
||||
|
||||
const { formatted } = useRoomTimer(() => props.joinedAt)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="flex flex-wrap items-center gap-3 border-b border-[var(--border)] bg-[var(--bg-elevated)]/80 px-4 py-3 backdrop-blur">
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="truncate text-sm font-semibold">{{ roomName }}</p>
|
||||
<p class="text-xs text-[var(--text-muted)]">
|
||||
{{ nickname }} · 입장 {{ formatted }} · {{ memberCount }}/{{ maxMembers }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<button class="btn-ghost" type="button" @click="emit('toggleLayout')">
|
||||
{{ layoutMode === 'grid' ? '포커스' : '그리드' }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
:class="isSharing ? 'btn-danger' : 'btn-primary'"
|
||||
@click="emit('toggleShare')"
|
||||
>
|
||||
{{ isSharing ? '공유 중지' : '화면 공유' }}
|
||||
</button>
|
||||
<button class="btn-ghost" type="button" @click="emit('leave')">나가기</button>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
@@ -0,0 +1,99 @@
|
||||
<script setup lang="ts">
|
||||
import type { ActiveStream } from '~/types/room'
|
||||
|
||||
const props = defineProps<{
|
||||
streams: ActiveStream[]
|
||||
remoteStreams: Record<string, MediaStream>
|
||||
localPreview?: { streamId: string; stream: MediaStream; title: string; startedAt: number } | null
|
||||
layoutMode: 'grid' | 'focus'
|
||||
focusStreamId: string | null
|
||||
volumeMap: Record<string, number>
|
||||
mutedMap: Record<string, boolean>
|
||||
viewerNamesByStream: Record<string, string[]>
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:volume': [streamId: string, value: number]
|
||||
'update:muted': [streamId: string, value: boolean]
|
||||
stop: [streamId: string]
|
||||
focus: [streamId: string]
|
||||
}>()
|
||||
|
||||
const cards = computed(() => {
|
||||
const list: Array<{
|
||||
streamId: string
|
||||
title: string
|
||||
mediaStream: MediaStream | null
|
||||
startedAt: number
|
||||
isLocal?: boolean
|
||||
}> = []
|
||||
|
||||
if (props.localPreview) {
|
||||
list.push({
|
||||
streamId: props.localPreview.streamId,
|
||||
title: props.localPreview.title,
|
||||
mediaStream: props.localPreview.stream,
|
||||
startedAt: props.localPreview.startedAt,
|
||||
isLocal: true,
|
||||
})
|
||||
}
|
||||
|
||||
for (const s of props.streams) {
|
||||
if (props.localPreview?.streamId === s.streamId) continue
|
||||
list.push({
|
||||
streamId: s.streamId,
|
||||
title: s.broadcasterNickname,
|
||||
mediaStream: props.remoteStreams[s.streamId] || null,
|
||||
startedAt: s.startedAt,
|
||||
})
|
||||
}
|
||||
|
||||
if (props.layoutMode === 'focus' && props.focusStreamId) {
|
||||
return [...list].sort((a, b) => {
|
||||
if (a.streamId === props.focusStreamId) return -1
|
||||
if (b.streamId === props.focusStreamId) return 1
|
||||
return 0
|
||||
})
|
||||
}
|
||||
return list
|
||||
})
|
||||
|
||||
const gridClass = computed(() => {
|
||||
if (props.layoutMode === 'focus') {
|
||||
return 'grid grid-cols-1 gap-3 lg:grid-cols-4'
|
||||
}
|
||||
const n = cards.value.length
|
||||
if (n <= 1) return 'grid grid-cols-1 gap-3'
|
||||
if (n === 2) return 'grid grid-cols-1 gap-3 md:grid-cols-2'
|
||||
if (n <= 4) return 'grid grid-cols-1 gap-3 md:grid-cols-2'
|
||||
return 'grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3'
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="gridClass">
|
||||
<RoomVideoPlayerCard
|
||||
v-for="(card, index) in cards"
|
||||
:key="card.streamId"
|
||||
:class="layoutMode === 'focus' && index === 0 ? 'lg:col-span-3 lg:row-span-2' : ''"
|
||||
:stream-id="card.streamId"
|
||||
:title="card.isLocal ? `${card.title} (미리보기)` : card.title"
|
||||
:media-stream="card.mediaStream"
|
||||
:started-at="card.startedAt"
|
||||
:viewer-names="viewerNamesByStream[card.streamId] || []"
|
||||
:volume="volumeMap[card.streamId] ?? 1"
|
||||
:muted="mutedMap[card.streamId] ?? false"
|
||||
:focused="focusStreamId === card.streamId"
|
||||
@update:volume="emit('update:volume', card.streamId, $event)"
|
||||
@update:muted="emit('update:muted', card.streamId, $event)"
|
||||
@stop="emit('stop', card.streamId)"
|
||||
@focus="emit('focus', card.streamId)"
|
||||
/>
|
||||
<div
|
||||
v-if="!cards.length"
|
||||
class="flex min-h-[240px] items-center justify-center rounded-lg border border-dashed border-[var(--border)] text-sm text-[var(--text-muted)]"
|
||||
>
|
||||
시청할 화면을 왼쪽에서 선택하세요.
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,95 @@
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
streamId: string
|
||||
title: string
|
||||
mediaStream: MediaStream | null
|
||||
startedAt: number
|
||||
viewerNames: string[]
|
||||
volume: number
|
||||
muted: boolean
|
||||
focused?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:volume': [value: number]
|
||||
'update:muted': [value: boolean]
|
||||
stop: []
|
||||
focus: []
|
||||
}>()
|
||||
|
||||
const videoRef = ref<HTMLVideoElement | null>(null)
|
||||
const { formatted } = useRoomTimer(() => props.startedAt)
|
||||
|
||||
watch(
|
||||
() => props.mediaStream,
|
||||
(stream) => {
|
||||
const el = videoRef.value
|
||||
if (!el) return
|
||||
el.srcObject = stream
|
||||
el.play().catch(() => {})
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
() => [props.volume, props.muted] as const,
|
||||
([volume, muted]) => {
|
||||
const el = videoRef.value
|
||||
if (!el) return
|
||||
el.volume = volume
|
||||
el.muted = muted
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
const el = videoRef.value
|
||||
if (el && props.mediaStream) {
|
||||
el.srcObject = props.mediaStream
|
||||
el.volume = props.volume
|
||||
el.muted = props.muted
|
||||
el.play().catch(() => {})
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<article
|
||||
class="flex flex-col overflow-hidden rounded-lg border border-[var(--border)] bg-black/40"
|
||||
:class="focused ? 'ring-2 ring-[var(--accent)]' : ''"
|
||||
>
|
||||
<div class="relative aspect-video bg-black" @dblclick="emit('focus')">
|
||||
<video ref="videoRef" class="h-full w-full object-contain" autoplay playsinline />
|
||||
<div class="pointer-events-none absolute left-2 top-2 rounded bg-black/60 px-2 py-1 text-xs">
|
||||
{{ title }} · {{ formatted }}
|
||||
</div>
|
||||
<div
|
||||
v-if="viewerNames.length"
|
||||
class="pointer-events-none absolute bottom-2 left-2 max-w-[80%] truncate rounded bg-black/60 px-2 py-1 text-[11px] text-[var(--text-muted)]"
|
||||
>
|
||||
시청: {{ viewerNames.join(', ') }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 border-t border-[var(--border)] bg-[var(--bg-panel)] px-3 py-2">
|
||||
<button
|
||||
type="button"
|
||||
class="btn-ghost !px-2 !py-1 text-xs"
|
||||
@click="emit('update:muted', !muted)"
|
||||
>
|
||||
{{ muted ? '음소거됨' : '소리' }}
|
||||
</button>
|
||||
<input
|
||||
class="flex-1 accent-[var(--accent)]"
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.01"
|
||||
:value="volume"
|
||||
:disabled="muted"
|
||||
@input="emit('update:volume', Number(($event.target as HTMLInputElement).value))"
|
||||
/>
|
||||
<span class="w-10 text-right text-xs text-[var(--text-muted)]">{{ Math.round(volume * 100) }}%</span>
|
||||
<button type="button" class="btn-ghost !px-2 !py-1 text-xs" @click="emit('stop')">종료</button>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
Reference in new issue
Block a user