first commit
This commit is contained in:
commit
69b647145f
58 files changed
+15796
No files matched your search
@@ -0,0 +1,100 @@
|
||||
<script setup lang="ts">
|
||||
import type { RoomAdmin } from '~/types/room'
|
||||
|
||||
const rooms = ref<RoomAdmin[]>([])
|
||||
const name = ref('')
|
||||
const password = ref('')
|
||||
const error = ref<string | null>(null)
|
||||
const loading = ref(false)
|
||||
const creating = ref(false)
|
||||
const authed = ref(false)
|
||||
|
||||
async function ensureAuth() {
|
||||
try {
|
||||
await $fetch('/api/auth/me')
|
||||
authed.value = true
|
||||
} catch {
|
||||
await navigateTo('/admin/login')
|
||||
}
|
||||
}
|
||||
|
||||
async function loadRooms() {
|
||||
loading.value = true
|
||||
try {
|
||||
rooms.value = await $fetch<RoomAdmin[]>('/api/rooms')
|
||||
} catch (e) {
|
||||
error.value = '방 목록을 불러오지 못했습니다.'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function createRoom() {
|
||||
error.value = null
|
||||
creating.value = true
|
||||
try {
|
||||
await $fetch('/api/rooms', {
|
||||
method: 'POST',
|
||||
body: { name: name.value, password: password.value },
|
||||
})
|
||||
name.value = ''
|
||||
password.value = ''
|
||||
await loadRooms()
|
||||
} catch (e: unknown) {
|
||||
error.value = '방 생성에 실패했습니다.'
|
||||
} finally {
|
||||
creating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteRoom(id: string) {
|
||||
if (!confirm('이 방을 삭제하면 접속 중인 사용자가 모두 퇴장합니다. 계속할까요?')) return
|
||||
await $fetch(`/api/rooms/${id}`, { method: 'DELETE' })
|
||||
await loadRooms()
|
||||
}
|
||||
|
||||
async function copyUrl(id: string) {
|
||||
const url = `${window.location.origin}/room/${id}`
|
||||
await navigator.clipboard.writeText(url)
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
await $fetch('/api/auth/logout', { method: 'POST' })
|
||||
await navigateTo('/admin/login')
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await ensureAuth()
|
||||
if (authed.value) await loadRooms()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main v-if="authed" class="mx-auto max-w-5xl space-y-6 px-4 py-8">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<p class="text-xs uppercase tracking-widest text-[var(--accent)]">Sejong TV</p>
|
||||
<h1 class="text-2xl font-semibold">관리자</h1>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<NuxtLink to="/" class="btn-ghost">홈</NuxtLink>
|
||||
<button type="button" class="btn-ghost" @click="logout">로그아웃</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form class="panel grid gap-3 p-4 md:grid-cols-[1fr_1fr_auto]" @submit.prevent="createRoom">
|
||||
<input v-model="name" class="input" placeholder="방 이름" required />
|
||||
<input v-model="password" class="input" type="password" placeholder="방 비밀번호 (4자 이상)" required minlength="4" />
|
||||
<button type="submit" class="btn-primary" :disabled="creating">방 생성</button>
|
||||
<p v-if="error" class="md:col-span-3 text-sm text-[var(--danger)]">{{ error }}</p>
|
||||
</form>
|
||||
|
||||
<AdminRoomTable
|
||||
:rooms="rooms"
|
||||
:loading="loading"
|
||||
@delete="deleteRoom"
|
||||
@copy="copyUrl"
|
||||
@refresh="loadRooms"
|
||||
/>
|
||||
</main>
|
||||
</template>
|
||||
@@ -0,0 +1,59 @@
|
||||
<script setup lang="ts">
|
||||
definePageMeta({ layout: 'default' })
|
||||
|
||||
const username = ref('admin')
|
||||
const password = ref('')
|
||||
const error = ref<string | null>(null)
|
||||
const loading = ref(false)
|
||||
|
||||
async function login() {
|
||||
error.value = null
|
||||
loading.value = true
|
||||
try {
|
||||
await $fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
body: { username: username.value, password: password.value },
|
||||
})
|
||||
await navigateTo('/admin')
|
||||
} catch (e: unknown) {
|
||||
error.value = '로그인에 실패했습니다.'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
await $fetch('/api/auth/me')
|
||||
await navigateTo('/admin')
|
||||
} catch {
|
||||
// not logged in
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="mx-auto flex min-h-screen max-w-md flex-col justify-center px-6">
|
||||
<form class="panel space-y-4 p-6" @submit.prevent="login">
|
||||
<div>
|
||||
<p class="text-xs uppercase tracking-widest text-[var(--text-muted)]">Admin</p>
|
||||
<h1 class="mt-1 text-2xl font-semibold">관리자 로그인</h1>
|
||||
</div>
|
||||
<label class="block space-y-1.5">
|
||||
<span class="text-sm text-[var(--text-muted)]">아이디</span>
|
||||
<input v-model="username" class="input" required />
|
||||
</label>
|
||||
<label class="block space-y-1.5">
|
||||
<span class="text-sm text-[var(--text-muted)]">비밀번호</span>
|
||||
<input v-model="password" class="input" type="password" required />
|
||||
</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>
|
||||
<NuxtLink to="/" class="block text-center text-sm text-[var(--text-muted)] hover:underline">
|
||||
홈으로
|
||||
</NuxtLink>
|
||||
</form>
|
||||
</main>
|
||||
</template>
|
||||
@@ -0,0 +1,36 @@
|
||||
<script setup lang="ts">
|
||||
const roomCode = ref('')
|
||||
|
||||
function goRoom() {
|
||||
const id = roomCode.value.trim()
|
||||
if (!id) return
|
||||
navigateTo(`/room/${id}`)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="mx-auto flex min-h-screen max-w-3xl flex-col justify-center px-6 py-16">
|
||||
<p class="text-sm uppercase tracking-[0.2em] text-[var(--accent)]">Sejong TV</p>
|
||||
<h1 class="mt-3 text-4xl font-semibold tracking-tight md:text-5xl">
|
||||
멀티 스크린<br />화면 공유
|
||||
</h1>
|
||||
<p class="mt-4 max-w-xl text-[var(--text-muted)]">
|
||||
관리자가 만든 방 URL로 입장해 여러 화면을 동시에 시청하고, 스트림별로 볼륨을 조절하세요.
|
||||
</p>
|
||||
|
||||
<form class="mt-10 flex flex-col gap-3 sm:flex-row" @submit.prevent="goRoom">
|
||||
<input
|
||||
v-model="roomCode"
|
||||
class="input sm:flex-1"
|
||||
placeholder="방 ID 입력"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<button type="submit" class="btn-primary">방 입장</button>
|
||||
</form>
|
||||
|
||||
<p class="mt-8 text-sm text-[var(--text-muted)]">
|
||||
관리자이신가요?
|
||||
<NuxtLink to="/admin" class="text-[var(--accent)] hover:underline">관리자 페이지</NuxtLink>
|
||||
</p>
|
||||
</main>
|
||||
</template>
|
||||
@@ -0,0 +1,351 @@
|
||||
<script setup lang="ts">
|
||||
import type { ActiveStream, RoomState } from '~/types/room'
|
||||
|
||||
const route = useRoute()
|
||||
const config = useRuntimeConfig()
|
||||
const roomId = computed(() => String(route.params.roomId || ''))
|
||||
|
||||
const store = useRoomStore()
|
||||
const { clientId } = useClientId()
|
||||
const { nickname } = useNickname()
|
||||
const { accessToken, requestAccess, clearAccess } = useRoomAccess(roomId)
|
||||
const { socket, connect, disconnect } = useSocket()
|
||||
const { unlockAudio } = useAudioUnlock()
|
||||
const {
|
||||
localStream,
|
||||
streamId: localStreamId,
|
||||
isSharing,
|
||||
startShare,
|
||||
stopShare,
|
||||
} = useScreenShare()
|
||||
|
||||
const roomMeta = ref<{ id: string; name: string } | null>(null)
|
||||
const showJoin = ref(true)
|
||||
const needPassword = ref(true)
|
||||
const joinLoading = ref(false)
|
||||
const joinError = ref<string | null>(null)
|
||||
const shareError = ref<string | null>(null)
|
||||
const localStartedAt = ref<number | null>(null)
|
||||
const handlersBound = ref(false)
|
||||
|
||||
const {
|
||||
remoteStreams,
|
||||
createOfferAsViewer,
|
||||
closePeer,
|
||||
closeAll,
|
||||
bindSocketHandlers,
|
||||
} = useWebRTC({
|
||||
socket,
|
||||
clientId,
|
||||
getLocalStream: () => localStream.value,
|
||||
})
|
||||
|
||||
const maxMembers = computed(() => config.public.maxRoomMembers || 8)
|
||||
|
||||
const watchingStreams = computed(() =>
|
||||
store.activeStreams.filter((s) => store.watchingStreamIds.includes(s.streamId)),
|
||||
)
|
||||
|
||||
const viewerNamesByStream = computed(() => {
|
||||
const out: Record<string, string[]> = {}
|
||||
for (const [streamId, viewerIds] of Object.entries(store.viewersOfStream)) {
|
||||
out[streamId] = viewerIds.map(
|
||||
(id) => store.participants.find((p) => p.clientId === id)?.nickname || id.slice(0, 6),
|
||||
)
|
||||
}
|
||||
return out
|
||||
})
|
||||
|
||||
const localPreview = computed(() => {
|
||||
if (!localStream.value || !localStreamId.value) return null
|
||||
return {
|
||||
streamId: localStreamId.value,
|
||||
stream: localStream.value,
|
||||
title: nickname.value || '나',
|
||||
startedAt: localStartedAt.value || Date.now(),
|
||||
}
|
||||
})
|
||||
|
||||
async function loadMeta() {
|
||||
try {
|
||||
roomMeta.value = await $fetch(`/api/rooms/${roomId.value}`)
|
||||
store.roomName = roomMeta.value.name
|
||||
} catch {
|
||||
joinError.value = '존재하지 않는 방입니다.'
|
||||
}
|
||||
}
|
||||
|
||||
async function tryTokenJoin() {
|
||||
if (!accessToken.value) {
|
||||
needPassword.value = true
|
||||
return
|
||||
}
|
||||
try {
|
||||
const res = await requestAccess({
|
||||
clientId: clientId.value,
|
||||
accessToken: accessToken.value,
|
||||
})
|
||||
roomMeta.value = res.room
|
||||
store.roomName = res.room.name
|
||||
needPassword.value = false
|
||||
// still show dialog for nickname confirm, or auto-join
|
||||
if (nickname.value) {
|
||||
await enterRoom(nickname.value)
|
||||
}
|
||||
} catch {
|
||||
clearAccess()
|
||||
needPassword.value = true
|
||||
}
|
||||
}
|
||||
|
||||
function bindSocketOnce() {
|
||||
if (handlersBound.value || !socket.value) return
|
||||
handlersBound.value = true
|
||||
bindSocketHandlers()
|
||||
|
||||
socket.value.on('room:state', (state: RoomState) => {
|
||||
store.applyState(state)
|
||||
})
|
||||
|
||||
socket.value.on('room:force-close', ({ reason }) => {
|
||||
store.error = reason
|
||||
leaveRoom(false)
|
||||
alert(reason)
|
||||
navigateTo('/')
|
||||
})
|
||||
|
||||
socket.value.on('room:error', ({ error }) => {
|
||||
store.error = error
|
||||
})
|
||||
}
|
||||
|
||||
async function enterRoom(nick: string, password?: string) {
|
||||
joinLoading.value = true
|
||||
joinError.value = null
|
||||
try {
|
||||
if (needPassword.value || password) {
|
||||
await requestAccess({
|
||||
clientId: clientId.value,
|
||||
password,
|
||||
accessToken: accessToken.value || undefined,
|
||||
})
|
||||
} else if (accessToken.value) {
|
||||
await requestAccess({
|
||||
clientId: clientId.value,
|
||||
accessToken: accessToken.value,
|
||||
})
|
||||
}
|
||||
|
||||
connect()
|
||||
bindSocketOnce()
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const s = socket.value
|
||||
if (!s) {
|
||||
reject(new Error('Socket not connected'))
|
||||
return
|
||||
}
|
||||
const doJoin = () => {
|
||||
s.emit(
|
||||
'room:join',
|
||||
{
|
||||
roomId: roomId.value,
|
||||
clientId: clientId.value,
|
||||
nickname: nick,
|
||||
accessToken: accessToken.value,
|
||||
},
|
||||
(res) => {
|
||||
if (!res?.ok) {
|
||||
reject(new Error(res?.error || 'Join failed'))
|
||||
return
|
||||
}
|
||||
resolve()
|
||||
},
|
||||
)
|
||||
}
|
||||
if (s.connected) doJoin()
|
||||
else s.once('connect', doJoin)
|
||||
})
|
||||
|
||||
nickname.value = nick
|
||||
store.joined = true
|
||||
store.joinedAt = Date.now()
|
||||
showJoin.value = false
|
||||
needPassword.value = false
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : '입장 실패'
|
||||
joinError.value = msg
|
||||
if (msg.includes('Password') || msg.includes('password') || msg.includes('Invalid')) {
|
||||
needPassword.value = true
|
||||
clearAccess()
|
||||
}
|
||||
} finally {
|
||||
joinLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onJoinSubmit(payload: { nickname: string; password?: string }) {
|
||||
needPassword.value = Boolean(payload.password) || needPassword.value
|
||||
await enterRoom(payload.nickname, payload.password)
|
||||
}
|
||||
|
||||
async function toggleShare() {
|
||||
shareError.value = null
|
||||
if (isSharing.value) {
|
||||
const sid = localStreamId.value
|
||||
stopShare()
|
||||
if (sid) socket.value?.emit('stream:stop', { streamId: sid })
|
||||
localStartedAt.value = null
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await unlockAudio()
|
||||
const { streamId } = await startShare(clientId.value)
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
socket.value?.emit('stream:start', { streamId }, (res) => {
|
||||
if (!res?.ok) reject(new Error(res?.error || 'Stream start failed'))
|
||||
else resolve()
|
||||
})
|
||||
})
|
||||
localStartedAt.value = Date.now()
|
||||
store.ensureAudioDefaults(streamId)
|
||||
store.mutedMap = { ...store.mutedMap, [streamId]: true }
|
||||
} catch (e: unknown) {
|
||||
stopShare()
|
||||
shareError.value = e instanceof Error ? e.message : '화면 공유 실패'
|
||||
}
|
||||
}
|
||||
|
||||
async function watchStream(stream: ActiveStream) {
|
||||
await unlockAudio()
|
||||
store.ensureAudioDefaults(stream.streamId)
|
||||
store.setWatching(stream.streamId, true)
|
||||
if (!store.focusStreamId) store.focusStreamId = stream.streamId
|
||||
socket.value?.emit('watch:start', {
|
||||
streamId: stream.streamId,
|
||||
broadcasterId: stream.broadcasterId,
|
||||
})
|
||||
await createOfferAsViewer(stream.broadcasterId, stream.streamId)
|
||||
}
|
||||
|
||||
function unwatchStream(stream: ActiveStream) {
|
||||
store.setWatching(stream.streamId, false)
|
||||
socket.value?.emit('watch:stop', {
|
||||
streamId: stream.streamId,
|
||||
broadcasterId: stream.broadcasterId,
|
||||
})
|
||||
closePeer(stream.broadcasterId, stream.streamId)
|
||||
}
|
||||
|
||||
function onStopWatch(streamId: string) {
|
||||
if (localStreamId.value === streamId) {
|
||||
toggleShare()
|
||||
return
|
||||
}
|
||||
const stream = store.activeStreams.find((s) => s.streamId === streamId)
|
||||
if (stream) unwatchStream(stream)
|
||||
}
|
||||
|
||||
function leaveRoom(navigate = true) {
|
||||
if (isSharing.value) {
|
||||
const sid = localStreamId.value
|
||||
stopShare()
|
||||
if (sid) socket.value?.emit('stream:stop', { streamId: sid })
|
||||
}
|
||||
for (const sid of [...store.watchingStreamIds]) {
|
||||
const stream = store.activeStreams.find((s) => s.streamId === sid)
|
||||
if (stream) unwatchStream(stream)
|
||||
}
|
||||
socket.value?.emit('room:leave')
|
||||
closeAll()
|
||||
store.reset()
|
||||
disconnect()
|
||||
handlersBound.value = false
|
||||
showJoin.value = true
|
||||
if (navigate) navigateTo('/')
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadMeta()
|
||||
await tryTokenJoin()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
leaveRoom(false)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex h-screen flex-col">
|
||||
<RoomJoinDialog
|
||||
v-if="showJoin"
|
||||
:room-name="roomMeta?.name || store.roomName || roomId"
|
||||
:need-password="needPassword"
|
||||
:loading="joinLoading"
|
||||
:error="joinError"
|
||||
@submit="onJoinSubmit"
|
||||
/>
|
||||
|
||||
<template v-if="store.joined">
|
||||
<RoomHeader
|
||||
:room-name="store.roomName || roomMeta?.name || roomId"
|
||||
:nickname="nickname"
|
||||
:joined-at="store.joinedAt"
|
||||
:is-sharing="isSharing"
|
||||
:member-count="store.participants.length"
|
||||
:max-members="maxMembers"
|
||||
:layout-mode="store.layoutMode"
|
||||
@toggle-share="toggleShare"
|
||||
@toggle-layout="store.layoutMode = store.layoutMode === 'grid' ? 'focus' : 'grid'"
|
||||
@leave="leaveRoom()"
|
||||
/>
|
||||
|
||||
<p v-if="shareError" class="bg-[var(--danger)]/20 px-4 py-2 text-sm text-[var(--danger)]">
|
||||
{{ shareError }}
|
||||
</p>
|
||||
|
||||
<div class="grid min-h-0 flex-1 grid-cols-1 md:grid-cols-[260px_1fr]">
|
||||
<RoomParticipantList
|
||||
class="hidden md:flex"
|
||||
:participants="store.participants"
|
||||
:active-streams="store.activeStreams"
|
||||
:watch-map="store.watchMap"
|
||||
:my-client-id="clientId"
|
||||
:watching-stream-ids="store.watchingStreamIds"
|
||||
@watch="watchStream"
|
||||
@unwatch="unwatchStream"
|
||||
/>
|
||||
|
||||
<main class="min-h-0 overflow-auto p-3">
|
||||
<div class="mb-3 md:hidden">
|
||||
<RoomParticipantList
|
||||
:participants="store.participants"
|
||||
:active-streams="store.activeStreams"
|
||||
:watch-map="store.watchMap"
|
||||
:my-client-id="clientId"
|
||||
:watching-stream-ids="store.watchingStreamIds"
|
||||
@watch="watchStream"
|
||||
@unwatch="unwatchStream"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<RoomStreamGrid
|
||||
:streams="watchingStreams"
|
||||
:remote-streams="remoteStreams"
|
||||
:local-preview="localPreview"
|
||||
:layout-mode="store.layoutMode"
|
||||
:focus-stream-id="store.focusStreamId"
|
||||
:volume-map="store.volumeMap"
|
||||
:muted-map="store.mutedMap"
|
||||
:viewer-names-by-stream="viewerNamesByStream"
|
||||
@update:volume="(id, v) => (store.volumeMap = { ...store.volumeMap, [id]: v })"
|
||||
@update:muted="(id, v) => (store.mutedMap = { ...store.mutedMap, [id]: v })"
|
||||
@stop="onStopWatch"
|
||||
@focus="store.focusStreamId = $event"
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
Reference in new issue
Block a user