first commit
CI / Type-check & Build (push) Failing after 5m50s
CI / Build & Push image (push) Skipped

This commit is contained in:
ysj7705 committed 2026-08-12 15:01:43 +09:00
commit 69b647145f
58 files changed
+15796

No files matched your search

+59
View File
@@ -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>
+108
View File
@@ -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>
+43
View File
@@ -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>
+99
View File
@@ -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>
+95
View File
@@ -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>