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

+100
View File
@@ -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>
+59
View File
@@ -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>
+36
View File
@@ -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>
+351
View File
@@ -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>