feat. videoPlay 컴포넌트 제작

This commit is contained in:
clkim
2025-09-18 15:18:53 +09:00
parent 0acc3b3eb8
commit 1a4c6b684d
10 changed files with 282 additions and 103 deletions

View File

@@ -2,11 +2,14 @@
import { useNuxtApp } from 'nuxt/app'
import LoadingFull from '#layers/components/blocks/loading/Full.vue'
import LoadingLocal from '#layers/components/blocks/loading/Local.vue'
import BlocksModalYouTube from '#layers/components/blocks/modal/YouTube.vue'
import type { GameDataMetaTag, GameDataValue } from '#layers/types/api/gameData'
const nuxtApp = useNuxtApp()
const gameDataStore = useGameDataStore()
const modalStore = useModalStore()
const { youtube, handleResetYoutube } = modalStore
const { setGameData } = gameDataStore
const { gameData } = storeToRefs(gameDataStore)
@@ -114,7 +117,15 @@ if (serverGameData) {
<h1 class="sr-only">{{ gameData?.game_name }}</h1>
<NuxtPage />
<!-- 로딩 컴포넌트 -->
<!-- 공통 모달 컴포넌트 -->
<BlocksModalYouTube
:is-open="youtube.storeIsOpen"
:youtube-id="youtube.storeYoutubeId"
:class-name="youtube.storeClassName"
@close-button-event="handleResetYoutube"
/>
<!-- 로딩 컴포넌트 -->
<LoadingFull />
<LoadingLocal />
</template>

View File

@@ -3,4 +3,12 @@
body {
background-color: #000;
}
body.scroll-lock {
overflow: hidden;
}
button,
a {
outline: none;
}
}

View File

@@ -9,7 +9,7 @@ import type { GameDataKeyCodeCodes } from '#layers/types/api/gameData'
const props = withDefaults(defineProps<ButtonProps>(), {
size: 'medium',
backgroundColor: 'var(--primary)',
textColor: 'var(--text-primary)',
textColor: 'var(--alternative-02)',
icon: '',
disabled: false,
})

View File

@@ -19,6 +19,7 @@ withDefaults(defineProps<Props>(), {
:height="size"
viewBox="0 0 12 12"
:fill="color"
:class="className"
>
<path
d="M5.29499 7.715L2.39999 4.875C2.07499 4.555 2.29999 4 2.75999 4L9.23499 4C9.69499 4 9.91999 4.555 9.59499 4.875L6.69999 7.715C6.30999 8.095 5.68999 8.095 5.29999 7.715H5.29499Z"

View File

@@ -0,0 +1,29 @@
<script setup lang="ts">
interface Props {
size?: number | string
color?: string
className?: string
}
withDefaults(defineProps<Props>(), {
size: 32,
color: '#EBEBEB',
className: '',
})
</script>
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
:width="size"
:height="size"
viewBox="0 0 32 33"
fill="none"
:class="className"
>
<path
d="M26.2768 8.10939C26.7975 7.5887 26.7975 6.74448 26.2768 6.22378C25.7561 5.70308 24.9119 5.70308 24.3912 6.22378L16.0007 14.6143L7.61013 6.22378C7.08943 5.70308 6.24521 5.70308 5.72451 6.22378C5.20381 6.74448 5.20381 7.5887 5.72451 8.10939L14.115 16.4999L5.72451 24.8904C5.20381 25.4111 5.20381 26.2554 5.72451 26.7761C6.24521 27.2968 7.08943 27.2968 7.61013 26.7761L16.0007 18.3855L24.3912 26.7761C24.9119 27.2968 25.7561 27.2968 26.2768 26.7761C26.7975 26.2554 26.7975 25.4111 26.2768 24.8904L17.8863 16.4999L26.2768 8.10939Z"
:fill="color"
/>
</svg>
</template>

View File

@@ -19,6 +19,7 @@ withDefaults(defineProps<Props>(), {
:height="size"
viewBox="0 0 16 16"
:fill="color"
:class="className"
>
<path
d="M3.63636 3.33333C3.469 3.33333 3.33333 3.469 3.33333 3.63636L3.33333 12.3636C3.33333 12.531 3.469 12.6667 3.63636 12.6667H12.3636C12.531 12.6667 12.6667 12.531 12.6667 12.3636V9.93939C12.6667 9.5712 12.9651 9.27273 13.3333 9.27273C13.7015 9.27273 14 9.5712 14 9.93939V12.3636C14 13.2674 13.2674 14 12.3636 14H3.63636C2.73262 14 2 13.2674 2 12.3636L2 3.63636C2 2.73263 2.73262 2 3.63636 2L6.06061 2C6.4288 2 6.72727 2.29848 6.72727 2.66667C6.72727 3.03486 6.4288 3.33333 6.06061 3.33333H3.63636Z"

View File

@@ -1,67 +1,52 @@
<template>
<Teleport to="body">
<Transition
enter-active-class="transition duration-300 ease-out"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-active-class="transition duration-200 ease-in"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
<Transition
enter-active-class="transition duration-300 ease-out"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-active-class="transition duration-200 ease-in"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<div
v-if="isOpen"
class="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-75"
@click="handleBackdropClick"
>
<div
v-if="isOpen"
class="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-75"
@click="handleBackdropClick"
class="relative mx-4 my-4"
style="
width: min(896px, 90vw, calc((90vh - 2rem) * 16 / 9));
aspect-ratio: 16/9;
"
@click.stop
>
<Transition
enter-active-class="transition duration-300 ease-out"
enter-from-class="opacity-0 scale-95"
enter-to-class="opacity-100 scale-100"
leave-active-class="transition duration-200 ease-in"
leave-from-class="opacity-100 scale-100"
leave-to-class="opacity-0 scale-95"
>
<div v-if="isOpen" class="relative w-full max-w-4xl mx-4" @click.stop>
<!-- 헤더 -->
<div class="flex justify-end">
<button
class="p-1 text-white rounded-full transition-colors"
aria-label="모달 닫기"
@click="closeModal"
>
<svg
class="w-8 h-8"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
<!-- 헤더 -->
<div class="flex justify-end">
<button
class="p-1 text-white rounded-full transition-colors"
aria-label="모달 닫기"
@click="closeModal"
>
<AtomsIconsClose />
</button>
</div>
<!-- 유튜브 영상 컨테이너 -->
<div class="relative w-full" :style="{ paddingBottom: '56.25%' }">
<iframe
v-if="youtubeId"
:src="`https://www.youtube.com/embed/${youtubeId}?autoplay=1&rel=0`"
class="absolute top-0 left-0 w-full h-full"
frameborder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowfullscreen
title="YouTube video player"
/>
</div>
</div>
</Transition>
<!-- 유튜브 영상 컨테이너 -->
<div class="relative w-full h-full">
<iframe
v-if="youtubeId"
:src="`https://www.youtube.com/embed/${youtubeId}?autoplay=1&rel=0`"
class="absolute top-0 left-0 w-full h-full"
frameborder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowfullscreen
title="YouTube video player"
/>
</div>
</div>
</Transition>
</Teleport>
</div>
</Transition>
</template>
<script setup lang="ts">
@@ -74,11 +59,12 @@ interface Props {
}
interface Emits {
(e: 'close'): void
(e: 'update:isOpen', value: boolean): void
(e: 'closeButtonEvent'): void
}
const props = withDefaults(defineProps<Props>(), {
isOpen: false,
youtubeId: '',
title: '',
description: '',
closeOnBackdrop: true,
@@ -102,8 +88,7 @@ const handleBackdropClick = () => {
// 모달 닫기 함수
const closeModal = () => {
emit('close')
emit('update:isOpen', false)
emit('closeButtonEvent')
}
// 키보드 이벤트 리스너 등록/해제
@@ -114,21 +99,4 @@ onMounted(() => {
onUnmounted(() => {
document.removeEventListener('keydown', handleKeydown)
})
// 모달이 열릴 때 body 스크롤 방지
watch(
() => props.isOpen,
isOpen => {
if (isOpen) {
document.body.style.overflow = 'hidden'
} else {
document.body.style.overflow = ''
}
}
)
// 컴포넌트 언마운트 시 body 스크롤 복원
onUnmounted(() => {
document.body.style.overflow = ''
})
</script>

View File

@@ -8,41 +8,24 @@ const bgStyles = getResponsiveSrc(props.resourcesData?.res_path, {
resourcesType: 'bg',
})
// YouTube 모달 상태 관리
const isYouTubeModalOpen = ref(false)
const youtubeVideoId = ref('')
// YouTube 모달 스토어 사용
const modalStore = useModalStore()
// 비디오 플레이 버튼 클릭 핸들러
const handleVideoPlayClick = () => {
// TODO: 실제 YouTube 비디오 ID를 설정해야 합니다
// 예시: 'dQw4w9WgXcQ' (Rick Astley - Never Gonna Give You Up)
youtubeVideoId.value = 'UKVsZYHxYTc' // 임시로 설정
isYouTubeModalOpen.value = true
}
// 모달 닫기 핸들러
const handleCloseModal = () => {
isYouTubeModalOpen.value = false
youtubeVideoId.value = ''
const youtubeId = props.resourcesData?.display?.text ?? ''
modalStore.handleOpenYoutube({ youtubeId })
}
</script>
<template>
<button
v-if="resourcesData"
v-if="resourcesData && bgStyles"
class="bg-cover bg-center bg-no-repeat w-[66px] h-[66px] lg:w-[100px] lg:h-[100px]"
:class="getResponsiveClass"
:class="getResponsiveClass()"
:style="bgStyles"
@click="handleVideoPlayClick"
>
<span class="sr-only">videoPlay</span>
</button>
<!-- YouTube 모달 -->
<BlocksModalYouTube
:is-open="isYouTubeModalOpen"
:youtube-id="youtubeVideoId"
@close="handleCloseModal"
@update:is-open="(value: boolean) => (isYouTubeModalOpen = value)"
/>
</template>

View File

@@ -0,0 +1,142 @@
import { defineStore } from 'pinia'
interface DialogParams {
isShowDimmed?: boolean
className?: string
isOutsideClose?: boolean
contentText: string
confirmButtonText?: string
cancelButtonText?: string
confirmButtonEvent?: () => void
cancelButtonEvent?: () => void
closeButtonEvent?: () => void
}
interface YoutubeParams {
youtubeId: string
className?: string
}
const createModalState = () => ({
storeIsOpen: ref(false),
storeIsShowDimmed: ref(false),
storeClassName: ref(''),
storeIsOutsideClose: ref(true),
storeContentText: ref(''),
storeConfirmButtonText: ref(''),
storeConfirmButtonEvent: ref(() => {}),
storeCloseButtonEvent: ref(() => {}),
})
const resetModalState = (type: ReturnType<typeof createModalState>) => {
type.storeIsOpen.value = false
type.storeIsShowDimmed.value = false
type.storeClassName.value = ''
type.storeIsOutsideClose.value = true
type.storeContentText.value = ''
type.storeConfirmButtonText.value = ''
type.storeConfirmButtonEvent.value = () => {}
type.storeCloseButtonEvent.value = () => {}
}
export const useModalStore = defineStore('modalStore', () => {
const scrollStore = useScrollStore()
// alert ------------------
const alert = {
...createModalState(),
}
const handleOpenAlert = ({
isShowDimmed = false,
className = '',
isOutsideClose = true,
contentText,
confirmButtonText = '',
confirmButtonEvent,
closeButtonEvent,
}: DialogParams) => {
alert.storeIsOpen.value = true
alert.storeIsShowDimmed.value = isShowDimmed
alert.storeClassName.value = className
alert.storeContentText.value = contentText
alert.storeConfirmButtonText.value = confirmButtonText
alert.storeIsOutsideClose.value = isOutsideClose
alert.storeConfirmButtonEvent.value = confirmButtonEvent ?? handleResetAlert
alert.storeCloseButtonEvent.value = closeButtonEvent ?? handleResetAlert
}
const handleResetAlert = () => {
resetModalState(alert)
}
// confirm ------------------
const confirm = {
...createModalState(),
storeCancelButtonText: ref(''),
storeCancelButtonEvent: ref(() => {}),
}
const handleOpenConfirm = ({
isShowDimmed = false,
className = '',
isOutsideClose = true,
contentText,
confirmButtonText = '',
cancelButtonText = '',
confirmButtonEvent,
cancelButtonEvent,
closeButtonEvent,
}: DialogParams) => {
confirm.storeIsOpen.value = true
confirm.storeIsShowDimmed.value = isShowDimmed
confirm.storeClassName.value = className
confirm.storeContentText.value = contentText
confirm.storeConfirmButtonText.value = confirmButtonText
confirm.storeCancelButtonText.value = cancelButtonText
confirm.storeIsOutsideClose.value = isOutsideClose
confirm.storeConfirmButtonEvent.value =
confirmButtonEvent ?? handleResetConfirm
confirm.storeCancelButtonEvent.value =
cancelButtonEvent ?? handleResetConfirm
confirm.storeCloseButtonEvent.value = closeButtonEvent ?? handleResetConfirm
}
const handleResetConfirm = () => {
resetModalState(confirm)
confirm.storeCancelButtonText.value = ''
confirm.storeCancelButtonEvent.value = () => {}
}
// youtube ------------------
const youtube = {
storeIsOpen: ref(false),
storeYoutubeId: ref(''),
storeClassName: ref(''),
}
const handleOpenYoutube = ({ youtubeId, className = '' }: YoutubeParams) => {
youtube.storeIsOpen.value = true
youtube.storeYoutubeId.value = youtubeId
youtube.storeClassName.value = className
scrollStore.controlScrollLock(true)
}
const handleResetYoutube = () => {
youtube.storeIsOpen.value = false
youtube.storeYoutubeId.value = ''
youtube.storeClassName.value = ''
scrollStore.controlScrollLock(false)
}
return {
alert,
confirm,
youtube,
handleOpenAlert,
handleOpenConfirm,
handleResetAlert,
handleResetConfirm,
handleOpenYoutube,
handleResetYoutube,
}
})

View File

@@ -0,0 +1,36 @@
import { defineStore } from 'pinia'
import { useWindowScroll } from '@vueuse/core'
export const useScrollStore = defineStore('scrollStore', () => {
const { x: windowX, y: windowY } = useWindowScroll({ behavior: 'smooth' })
const stoveGnbHeight = 48
const scrollXValue = ref('0px')
const isPassedStoveGnb = ref(false)
const updateScrollValue = () => {
if (stoveGnbHeight <= windowY.value) {
isPassedStoveGnb.value = true
scrollXValue.value = `-${windowX.value}px`
} else {
isPassedStoveGnb.value = false
scrollXValue.value = '0px'
}
}
const controlScrollLock = (state: boolean) => {
if (state) {
document.body.classList.add('scroll-lock')
} else {
document.body.classList.remove('scroll-lock')
}
}
return {
scrollXValue,
isPassedStoveGnb,
updateScrollValue,
controlScrollLock,
}
})