Merge branch 'feature/202501107-all' into feature/20251201-gil_qa
This commit is contained in:
@@ -126,7 +126,7 @@ let stopWatch: (() => void) | null = null
|
||||
onMounted(() => {
|
||||
if (!import.meta.client) return
|
||||
|
||||
useEventListener('scroll', scrollStore.updateScrollValue)
|
||||
useEventListener('scroll', scrollStore.updateScrollValue, { passive: true })
|
||||
|
||||
stopWatch = watch(
|
||||
scrollGnbPosition,
|
||||
|
||||
@@ -47,4 +47,8 @@ const componentProps = computed(() => {
|
||||
after:content-[''] after:absolute after:top-0 after:left-0 after:w-full after:h-full after:bg-white after:rounded-full after:opacity-0 after:transition-all after:duration-300 after:ease-in-out
|
||||
hover:after:opacity-10;
|
||||
}
|
||||
|
||||
.btn-circle:deep(svg) {
|
||||
@apply w-[20px] h-[20px] md:w-[24px] md:h-[24px];
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,92 +1,128 @@
|
||||
<script setup lang="ts">
|
||||
import type { ClassType } from '#layers/types/Common'
|
||||
|
||||
interface Props {
|
||||
src: string
|
||||
type?: 'mp4' | 'webm'
|
||||
poster?: string
|
||||
play?: boolean
|
||||
autoplay?: boolean
|
||||
muted?: boolean
|
||||
loop?: boolean
|
||||
playsinline?: boolean
|
||||
bordered?: boolean
|
||||
class?: ClassType
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
type: 'mp4',
|
||||
play: false,
|
||||
play: true,
|
||||
muted: true,
|
||||
loop: true,
|
||||
playsinline: true,
|
||||
bordered: true,
|
||||
})
|
||||
|
||||
const videoRef = ref<HTMLVideoElement | null>(null)
|
||||
let pauseTimeoutId: ReturnType<typeof setTimeout> | null = null
|
||||
const pauseTimer = ref<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
// autoplay prop 변경 시 재생/정지 제어
|
||||
watch(
|
||||
() => props.play,
|
||||
shouldPlay => {
|
||||
if (!videoRef.value) return
|
||||
|
||||
// 이전 타이머 정리
|
||||
if (pauseTimeoutId) {
|
||||
clearTimeout(pauseTimeoutId)
|
||||
pauseTimeoutId = null
|
||||
}
|
||||
|
||||
if (shouldPlay) {
|
||||
videoRef.value.play().catch(err => {
|
||||
console.warn('Video play failed:', err)
|
||||
})
|
||||
} else {
|
||||
pauseTimeoutId = setTimeout(() => {
|
||||
if (videoRef.value) {
|
||||
videoRef.value.pause()
|
||||
videoRef.value.currentTime = 0
|
||||
}
|
||||
pauseTimeoutId = null
|
||||
}, 200)
|
||||
}
|
||||
const clearPauseTimer = () => {
|
||||
if (pauseTimer.value) {
|
||||
clearTimeout(pauseTimer.value)
|
||||
pauseTimer.value = null
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
// 타이머 정리
|
||||
if (pauseTimeoutId) {
|
||||
clearTimeout(pauseTimeoutId)
|
||||
pauseTimeoutId = null
|
||||
const playVideo = async () => {
|
||||
if (!videoRef.value) return
|
||||
|
||||
clearPauseTimer()
|
||||
|
||||
try {
|
||||
await videoRef.value.play()
|
||||
} catch (e) {
|
||||
console.warn('Video play failed:', e)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const pauseVideo = () => {
|
||||
if (!videoRef.value) return
|
||||
|
||||
clearPauseTimer()
|
||||
|
||||
pauseTimer.value = setTimeout(() => {
|
||||
if (videoRef.value) {
|
||||
videoRef.value.pause()
|
||||
videoRef.value.currentTime = 0
|
||||
}
|
||||
pauseTimer.value = null
|
||||
}, 500)
|
||||
}
|
||||
|
||||
const waitForCanPlay = () =>
|
||||
new Promise<void>(resolve => {
|
||||
if (!videoRef.value) return resolve()
|
||||
videoRef.value.addEventListener('canplay', () => resolve(), { once: true })
|
||||
})
|
||||
|
||||
const reloadVideo = async () => {
|
||||
if (!videoRef.value) return
|
||||
|
||||
clearPauseTimer()
|
||||
videoRef.value.currentTime = 0
|
||||
videoRef.value.load()
|
||||
|
||||
if (props.play) {
|
||||
await waitForCanPlay()
|
||||
await playVideo()
|
||||
}
|
||||
}
|
||||
|
||||
const syncPlayState = (shouldPlay: boolean) => {
|
||||
if (shouldPlay) {
|
||||
void playVideo()
|
||||
} else {
|
||||
pauseVideo()
|
||||
}
|
||||
}
|
||||
|
||||
// src 변경 시 비디오 다시 로드
|
||||
watch(
|
||||
() => props.src,
|
||||
() => {
|
||||
if (!videoRef.value) return
|
||||
|
||||
// 비디오 시간 초기화 및 새 소스 로드
|
||||
videoRef.value.currentTime = 0
|
||||
videoRef.value.load()
|
||||
|
||||
// 재생 중이었다면 다시 재생
|
||||
if (props.play) {
|
||||
nextTick(() => {
|
||||
videoRef.value?.play().catch(err => {
|
||||
console.warn('Video play failed:', err)
|
||||
})
|
||||
})
|
||||
}
|
||||
void reloadVideo()
|
||||
}
|
||||
)
|
||||
|
||||
// play 상태 변경 시 재생/일시정지
|
||||
watch(
|
||||
() => props.play,
|
||||
shouldPlay => {
|
||||
syncPlayState(!!shouldPlay)
|
||||
}
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
nextTick(() => {
|
||||
if (props.play) {
|
||||
playVideo()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
clearPauseTimer()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="props.src" :class="['video-box', props.class]">
|
||||
<div
|
||||
v-if="props.src"
|
||||
:class="['video-box', { 'has-bordered': props.bordered }, props.class]"
|
||||
>
|
||||
<video
|
||||
ref="videoRef"
|
||||
:autoplay="props.autoplay"
|
||||
:poster="props.poster"
|
||||
:muted="props.muted"
|
||||
:loop="props.loop"
|
||||
:playsinline="props.playsinline"
|
||||
playsinline
|
||||
>
|
||||
<source :src="props.src" :type="`video/${props.type}`" />
|
||||
</video>
|
||||
@@ -95,7 +131,10 @@ watch(
|
||||
|
||||
<style scoped>
|
||||
.video-box {
|
||||
@apply overflow-hidden relative rounded-[4px] md:rounded-[8px]
|
||||
@apply overflow-hidden;
|
||||
}
|
||||
.video-box.has-bordered {
|
||||
@apply relative rounded-[4px] md:rounded-[8px]
|
||||
after:content-[''] after:absolute after:top-0 after:left-0 after:w-full after:h-full after:border after:border-white after:opacity-10 after:rounded-[4px] after:md:rounded-[8px];
|
||||
}
|
||||
.video-box video {
|
||||
|
||||
@@ -33,7 +33,14 @@ const handleLinkClick = (title: string) => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="['card-news', { 'no-image': isNoImage }, props.class]">
|
||||
<div
|
||||
:class="[
|
||||
'card-news',
|
||||
{ 'no-image': isNoImage },
|
||||
{ 'is-clickable': props.url },
|
||||
props.class,
|
||||
]"
|
||||
>
|
||||
<img
|
||||
v-if="props.imgPath"
|
||||
:src="props.imgPath"
|
||||
@@ -68,6 +75,10 @@ const handleLinkClick = (title: string) => {
|
||||
after:content-[''] after:absolute after:top-0 after:left-0 after:w-full after:h-full
|
||||
after:border after:border-white/10 after:rounded-lg;
|
||||
}
|
||||
.card-news.is-clickable:hover .card-image,
|
||||
.card-news.is-clickable:hover .card-stove {
|
||||
@apply scale-110;
|
||||
}
|
||||
.card-image {
|
||||
@apply transition-transform duration-300 w-full h-full object-cover;
|
||||
}
|
||||
@@ -85,7 +96,7 @@ const handleLinkClick = (title: string) => {
|
||||
}
|
||||
|
||||
.card-stove {
|
||||
@apply relative w-full h-full bg-[#333333];
|
||||
@apply relative w-full h-full bg-[#333333] transition-transform duration-300;
|
||||
}
|
||||
.card-stove svg {
|
||||
@apply absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2;
|
||||
|
||||
@@ -16,7 +16,7 @@ const stoveGnbData = gameData.value?.stove_gnb_json
|
||||
const languageCodes = computed(() => {
|
||||
if (Array.isArray(availableLocales)) {
|
||||
return availableLocales.map(
|
||||
(localeCode: any) => localeCode.code || localeCode
|
||||
(localeCode: any) => localeCode.code ?? localeCode
|
||||
)
|
||||
}
|
||||
return [locale]
|
||||
@@ -32,19 +32,19 @@ const loadGnb = (locale: string) => {
|
||||
widget: {
|
||||
gameListAndService: false,
|
||||
languageSelect: false,
|
||||
notification: false,
|
||||
notification: stoveGnbData?.notify_icon_visible ?? true,
|
||||
stoveDownload: false,
|
||||
},
|
||||
global: {
|
||||
userGds: true,
|
||||
defaultSelectedLanguage: locale || 'en',
|
||||
defaultSelectedLanguage: locale ?? 'en',
|
||||
languageCoverages: languageCodes.value,
|
||||
},
|
||||
loginMethod: {
|
||||
params: {
|
||||
inflow_path: stoveInflowPath,
|
||||
game_no: stoveGameNo,
|
||||
show_play_button: stoveGnbData?.stove_install_button_visible || 'Y',
|
||||
show_play_button: stoveGnbData?.stove_install_button_visible ?? 'Y',
|
||||
},
|
||||
redirectCurrentPage: true,
|
||||
windowTitle: undefined,
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import type { GameDataValue } from '#layers/types/api/gameData'
|
||||
let mountedInstance: any = null
|
||||
const runtimeConfig = useRuntimeConfig()
|
||||
const baseDomain = `${runtimeConfig.public.baseDomain}`
|
||||
|
||||
onMounted(() => {
|
||||
const gameDataStore = useGameDataStore()
|
||||
const gameData = gameDataStore.gameData as GameDataValue
|
||||
const langCodes = gameData?.lang_codes
|
||||
const defaultLangCode = gameData?.default_lang_code
|
||||
const stoveGnbData = gameData?.stove_gnb_json
|
||||
const designTheme = gameData?.design_theme
|
||||
const { gameData } = storeToRefs(gameDataStore)
|
||||
const langCodes = gameData.value?.lang_codes
|
||||
const defaultLangCode = gameData.value?.default_lang_code
|
||||
const stoveGnbData = gameData.value?.stove_gnb_json
|
||||
const designTheme = gameData.value?.design_theme
|
||||
|
||||
const currentDomain =
|
||||
window.location.protocol + '//' + window.location.hostname
|
||||
@@ -22,8 +22,8 @@ onMounted(() => {
|
||||
mobile: '',
|
||||
},
|
||||
widget: {
|
||||
notification: stoveGnbData?.notify_icon_visible || true,
|
||||
stoveDownload: stoveGnbData?.stove_install_button_visible || true,
|
||||
notification: stoveGnbData?.notify_icon_visible ?? true,
|
||||
stoveDownload: stoveGnbData?.stove_install_button_visible ?? true,
|
||||
languageSelect: false,
|
||||
themeSelect: false,
|
||||
stoveMenu: {
|
||||
@@ -33,7 +33,7 @@ onMounted(() => {
|
||||
},
|
||||
global: {
|
||||
languageCoverages: langCodes,
|
||||
defaultSelectedLanguage: defaultLangCode || 'en',
|
||||
defaultSelectedLanguage: defaultLangCode ?? 'en',
|
||||
},
|
||||
loginMethod: {
|
||||
redirectCurrentPage: true,
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { getYouTubeEmbedUrl } from '@/layers/utils/youtubeUtil'
|
||||
import { getYouTubeUrl } from '@/layers/utils/youtubeUtil'
|
||||
|
||||
const modalStore = useModalStore()
|
||||
const scrollStore = useScrollStore()
|
||||
|
||||
const { youtube } = modalStore
|
||||
|
||||
const embedUrl = computed(() => {
|
||||
return getYouTubeEmbedUrl(youtube.storeYoutubeUrl)
|
||||
const youtubeUrl = computed(() => {
|
||||
return getYouTubeUrl(youtube.storeYoutubeUrl)
|
||||
})
|
||||
|
||||
const handleClose = () => {
|
||||
@@ -60,8 +60,8 @@ onUnmounted(() => {
|
||||
<!-- 유튜브 영상 컨테이너 -->
|
||||
<div class="relative w-full h-full">
|
||||
<iframe
|
||||
v-if="embedUrl"
|
||||
:src="embedUrl"
|
||||
v-if="youtubeUrl"
|
||||
:src="youtubeUrl"
|
||||
class="absolute top-0 left-0 w-full h-full"
|
||||
frameborder="0"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
|
||||
@@ -23,6 +23,8 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
|
||||
const emit = defineEmits(['mounted', 'move', 'arrowClick'])
|
||||
|
||||
const splideIndex = defineModel<number>('index', { required: false })
|
||||
|
||||
// Splide 화살표 로직을 위한 composable 사용
|
||||
const { addArrowClickListeners } = useSplideArrow()
|
||||
|
||||
@@ -41,6 +43,7 @@ const options = computed((): ResponsiveOptions => {
|
||||
pagination: props.pagination && isMultipleItems.value,
|
||||
autoplay: props.autoplay,
|
||||
interval: props.interval,
|
||||
flickPower: 400,
|
||||
classes: {
|
||||
arrows: 'splide-arrows',
|
||||
arrow: 'splide-arrow',
|
||||
@@ -89,6 +92,10 @@ const handleSplideMounted = (splide: SplideType) => {
|
||||
emit('mounted', splide)
|
||||
splide.refresh()
|
||||
|
||||
if (splideIndex.value !== undefined) {
|
||||
splideIndex.value = splide.index
|
||||
}
|
||||
|
||||
// 화살표 버튼 클릭 이벤트 리스너 추가
|
||||
nextTick(() => {
|
||||
addArrowClickListeners(splide, (direction, targetIndex) => {
|
||||
@@ -104,6 +111,10 @@ const handleMove = (
|
||||
destIndex: number
|
||||
) => {
|
||||
emit('move', splide, newIndex, oldIndex, destIndex)
|
||||
|
||||
if (splideIndex.value !== undefined) {
|
||||
splideIndex.value = newIndex
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -131,10 +142,10 @@ const handleMove = (
|
||||
transition: transform 0.6s ease-out;
|
||||
opacity: 0.3;
|
||||
}
|
||||
.center-focus:deep(.splide__slide.is-active) {
|
||||
.center-focus:deep(.splide__slide.is-active:not(.splide__slide--clone)) {
|
||||
width: var(--banner-width-mo-container);
|
||||
}
|
||||
.center-focus:deep(.splide__slide.is-active),
|
||||
.center-focus:deep(.splide__slide.is-active:not(.splide__slide--clone)),
|
||||
.center-focus:deep(.splide__slide.is-next),
|
||||
.center-focus:deep(.splide__slide.is-prev) {
|
||||
opacity: 1;
|
||||
@@ -146,7 +157,8 @@ const handleMove = (
|
||||
background-color: #191919;
|
||||
transition: all 0.6s ease-out;
|
||||
}
|
||||
.center-focus:deep(.splide__slide.is-active) .slide-inner {
|
||||
.center-focus:deep(.splide__slide.is-active:not(.splide__slide--clone))
|
||||
.slide-inner {
|
||||
width: var(--banner-width-mo-active);
|
||||
height: var(--banner-height-mo-active);
|
||||
}
|
||||
@@ -155,7 +167,9 @@ const handleMove = (
|
||||
opacity: 0.5;
|
||||
transition: all 0.6s ease-out;
|
||||
}
|
||||
.center-focus:deep(.splide__slide.is-active) .slide-inner .inner-content {
|
||||
.center-focus:deep(.splide__slide.is-active:not(.splide__slide--clone))
|
||||
.slide-inner
|
||||
.inner-content {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@@ -170,10 +184,11 @@ const handleMove = (
|
||||
width: var(--banner-width-pc);
|
||||
height: var(--banner-height-pc);
|
||||
}
|
||||
.center-focus:deep(.splide__slide.is-active) {
|
||||
.center-focus:deep(.splide__slide.is-active:not(.splide__slide--clone)) {
|
||||
width: var(--banner-width-pc-container);
|
||||
}
|
||||
.center-focus:deep(.splide__slide.is-active) .slide-inner {
|
||||
.center-focus:deep(.splide__slide.is-active:not(.splide__slide--clone))
|
||||
.slide-inner {
|
||||
width: var(--banner-width-pc-active);
|
||||
height: var(--banner-height-pc-active);
|
||||
}
|
||||
|
||||
@@ -22,6 +22,8 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
|
||||
const emit = defineEmits(['mounted', 'move', 'arrowClick'])
|
||||
|
||||
const splideIndex = defineModel<number>('index', { required: false })
|
||||
|
||||
// Splide 화살표 로직을 위한 composable 사용
|
||||
const { addArrowClickListeners } = useSplideArrow()
|
||||
|
||||
@@ -40,6 +42,7 @@ const options = computed((): ResponsiveOptions => {
|
||||
pagination: props.pagination && isMultipleItems.value,
|
||||
autoplay: props.autoplay,
|
||||
interval: props.interval,
|
||||
flickPower: 400,
|
||||
classes: {
|
||||
arrows: 'splide-arrows',
|
||||
arrow: 'splide-arrow',
|
||||
@@ -91,6 +94,10 @@ const handleSplideMounted = (splide: SplideType) => {
|
||||
emit('mounted', splide)
|
||||
splide.refresh()
|
||||
|
||||
if (splideIndex.value !== undefined) {
|
||||
splideIndex.value = splide.index
|
||||
}
|
||||
|
||||
// 화살표 버튼 클릭 이벤트 리스너 추가
|
||||
nextTick(() => {
|
||||
addArrowClickListeners(splide, (direction, targetIndex) => {
|
||||
@@ -106,6 +113,10 @@ const handleMove = (
|
||||
destIndex: number
|
||||
) => {
|
||||
emit('move', splide, newIndex, oldIndex, destIndex)
|
||||
|
||||
if (splideIndex.value !== undefined) {
|
||||
splideIndex.value = newIndex
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -136,10 +147,11 @@ const handleMove = (
|
||||
height: var(--banner-height-mo);
|
||||
transition: all 0.6s ease-out;
|
||||
}
|
||||
.center-highlight:deep(.splide__slide.is-active) {
|
||||
.center-highlight:deep(.splide__slide.is-active:not(.splide__slide--clone)) {
|
||||
width: var(--banner-width-mo-container);
|
||||
}
|
||||
.center-highlight:deep(.splide__slide.is-active) .slide-inner {
|
||||
.center-highlight:deep(.splide__slide.is-active:not(.splide__slide--clone))
|
||||
.slide-inner {
|
||||
width: var(--banner-width-mo-active);
|
||||
height: var(--banner-height-mo-active);
|
||||
}
|
||||
@@ -155,10 +167,11 @@ const handleMove = (
|
||||
width: var(--banner-width-pc);
|
||||
height: var(--banner-height-pc);
|
||||
}
|
||||
.center-highlight:deep(.splide__slide.is-active) {
|
||||
.center-highlight:deep(.splide__slide.is-active:not(.splide__slide--clone)) {
|
||||
width: var(--banner-width-pc-container);
|
||||
}
|
||||
.center-highlight:deep(.splide__slide.is-active) .slide-inner {
|
||||
.center-highlight:deep(.splide__slide.is-active:not(.splide__slide--clone))
|
||||
.slide-inner {
|
||||
width: var(--banner-width-pc-active);
|
||||
height: var(--banner-height-pc-active);
|
||||
}
|
||||
|
||||
@@ -31,6 +31,8 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
|
||||
const emit = defineEmits(['mounted', 'move', 'arrowClick'])
|
||||
|
||||
const splideIndex = defineModel<number>('index', { required: false })
|
||||
|
||||
// Splide 화살표 로직을 위한 composable 사용
|
||||
const { addArrowClickListeners } = useSplideArrow()
|
||||
|
||||
@@ -56,6 +58,7 @@ const options = computed((): ResponsiveOptions => {
|
||||
arrows: props.arrows,
|
||||
pagination: props.pagination,
|
||||
destroy: props.destroy,
|
||||
flickPower: 400,
|
||||
classes: {
|
||||
arrows: 'splide-arrows',
|
||||
arrow: 'splide-arrow',
|
||||
@@ -71,6 +74,10 @@ const options = computed((): ResponsiveOptions => {
|
||||
const handleSplideMounted = (splide: SplideType) => {
|
||||
emit('mounted', splide)
|
||||
|
||||
if (splideIndex.value !== undefined) {
|
||||
splideIndex.value = splide.index
|
||||
}
|
||||
|
||||
// 화살표 버튼 클릭 이벤트 리스너 추가
|
||||
nextTick(() => {
|
||||
addArrowClickListeners(splide, (direction, targetIndex) => {
|
||||
@@ -86,6 +93,10 @@ const handleMove = (
|
||||
destIndex: number
|
||||
) => {
|
||||
emit('move', splide, newIndex, oldIndex, destIndex)
|
||||
|
||||
if (splideIndex.value !== undefined) {
|
||||
splideIndex.value = newIndex
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -24,6 +24,8 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
|
||||
const emit = defineEmits(['mounted', 'move', 'arrowClick'])
|
||||
|
||||
const splideIndex = defineModel<number>('index', { required: false })
|
||||
|
||||
const splideRef = ref()
|
||||
// Splide 화살표 로직을 위한 composable 사용
|
||||
const { addArrowClickListeners } = useSplideArrow()
|
||||
@@ -44,6 +46,7 @@ const options = computed((): ResponsiveOptions => {
|
||||
pauseOnFocus: false,
|
||||
arrows: props.arrows,
|
||||
pagination: props.pagination,
|
||||
flickPower: 400,
|
||||
classes: {
|
||||
arrows: 'splide-arrows type-full',
|
||||
arrow: 'splide-arrow',
|
||||
@@ -55,13 +58,13 @@ const options = computed((): ResponsiveOptions => {
|
||||
}
|
||||
})
|
||||
|
||||
defineExpose({
|
||||
splide: computed(() => splideRef.value?.splide),
|
||||
})
|
||||
|
||||
const handleSplideMounted = (splide: SplideType) => {
|
||||
emit('mounted', splide)
|
||||
|
||||
if (splideIndex.value !== undefined) {
|
||||
splideIndex.value = splide.index
|
||||
}
|
||||
|
||||
// 화살표 버튼 클릭 이벤트 리스너 추가
|
||||
nextTick(() => {
|
||||
addArrowClickListeners(splide, (direction, targetIndex) => {
|
||||
@@ -77,7 +80,15 @@ const handleMove = (
|
||||
destIndex: number
|
||||
) => {
|
||||
emit('move', splide, newIndex, oldIndex, destIndex)
|
||||
|
||||
if (splideIndex.value !== undefined) {
|
||||
splideIndex.value = newIndex
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
splide: computed(() => splideRef.value?.splide),
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -12,17 +12,17 @@ interface Props {
|
||||
paginationData?: PageDataResourceGroups
|
||||
arrows?: boolean
|
||||
variant?: 'default' | 'media'
|
||||
drag?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
arrows: true,
|
||||
variant: 'default',
|
||||
drag: true,
|
||||
})
|
||||
|
||||
const emit = defineEmits(['mounted', 'move', 'arrowClick'])
|
||||
|
||||
const splideIndex = defineModel<number>('index', { required: false })
|
||||
|
||||
// Splide 화살표 로직을 위한 composable 사용
|
||||
const { addArrowClickListeners } = useSplideArrow()
|
||||
|
||||
@@ -47,7 +47,7 @@ const mainOptions = computed<Options>(() => ({
|
||||
easing: 'ease-in-out',
|
||||
arrows: false,
|
||||
pagination: false,
|
||||
drag: props.drag,
|
||||
drag: false,
|
||||
}))
|
||||
|
||||
const thumbOptions = computed<Options>(() => ({
|
||||
@@ -59,6 +59,7 @@ const thumbOptions = computed<Options>(() => ({
|
||||
pagination: false,
|
||||
isNavigation: true,
|
||||
updateOnMove: true,
|
||||
flickPower: 400,
|
||||
classes: {
|
||||
arrows: 'splide-arrows',
|
||||
arrow: 'splide-arrow',
|
||||
@@ -89,6 +90,14 @@ const getThumbnailSrc = (item: PageDataResourceGroup) => {
|
||||
return getResourceSrc(item)
|
||||
}
|
||||
|
||||
const handleSplideMounted = (splide: SplideType) => {
|
||||
emit('mounted', splide)
|
||||
|
||||
if (splideIndex.value !== undefined) {
|
||||
splideIndex.value = splide.index
|
||||
}
|
||||
}
|
||||
|
||||
const handleMove = (
|
||||
splide: SplideType,
|
||||
newIndex: number,
|
||||
@@ -96,10 +105,10 @@ const handleMove = (
|
||||
destIndex: number
|
||||
) => {
|
||||
emit('move', splide, newIndex, oldIndex, destIndex)
|
||||
}
|
||||
|
||||
const handleSplideMounted = (splide: SplideType) => {
|
||||
emit('mounted', splide)
|
||||
if (splideIndex.value !== undefined) {
|
||||
splideIndex.value = newIndex
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
@@ -142,8 +151,8 @@ onBeforeUnmount(() => {
|
||||
ref="mainRef"
|
||||
:options="mainOptions"
|
||||
class="main-splide"
|
||||
@splide:move="handleMove"
|
||||
@splide:mounted="handleSplideMounted"
|
||||
@splide:move="handleMove"
|
||||
>
|
||||
<slot />
|
||||
</Splide>
|
||||
|
||||
@@ -57,7 +57,7 @@ onMounted(async () => {
|
||||
class="btn-control"
|
||||
@click="toggleEventNavigation"
|
||||
>
|
||||
<AtomsIconsArrowRightLine size="24" color="#ffffff" />
|
||||
<AtomsIconsArrowRightLine color="#ffffff" />
|
||||
</AtomsButtonCircle>
|
||||
<ul class="navigation-list">
|
||||
<li v-for="item in eventNavigationList" :key="item.banner_seq">
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { useThrottleFn } from '@vueuse/core'
|
||||
import { useGameDataStore } from '#layers/stores/useGameDataStore'
|
||||
import type {
|
||||
GameDataMenu,
|
||||
@@ -40,6 +41,13 @@ const officialItemWidths = ref<number[]>([])
|
||||
const overflowNam = ref<number>(0)
|
||||
|
||||
const gnbData = computed(() => gameData.value?.gnb)
|
||||
const hasGnbMenus = computed(() => {
|
||||
const menus = gnbData.value?.menus
|
||||
if (!menus) return false
|
||||
if (typeof menus !== 'object') return false
|
||||
return Object.keys(menus).length > 0
|
||||
})
|
||||
|
||||
const gnb1depthButtonData = computed(
|
||||
() => gnbData.value?.buttons[0]?.button_json as GameDataResourceGroup
|
||||
)
|
||||
@@ -81,6 +89,7 @@ const isNavItemActive = (gnbItem: GameDataMenu): boolean => {
|
||||
|
||||
// navAreaRef의 넓이를 구하는 함수
|
||||
const calculateNavWidth = () => {
|
||||
if (!import.meta.client) return
|
||||
if (!navAreaRef.value || !gnbData.value) return 0
|
||||
|
||||
const navAreaWidth = navAreaRef.value.offsetWidth
|
||||
@@ -89,6 +98,7 @@ const calculateNavWidth = () => {
|
||||
|
||||
// official 자식들의 넓이를 구하는 함수
|
||||
const calculateOfficialItemWidths = () => {
|
||||
if (!import.meta.client) return
|
||||
if (!navAreaRef.value) return
|
||||
|
||||
const officialItems = navAreaRef.value.querySelectorAll('.official .nav-item')
|
||||
@@ -107,6 +117,7 @@ const calculateOfficialItemWidths = () => {
|
||||
|
||||
// 오버플로우 계산 함수
|
||||
const calculateOverflow = () => {
|
||||
if (!import.meta.client) return
|
||||
if (!navAreaRef.value || !startRef.value) return
|
||||
|
||||
if (breakpoints.value.isMobile) {
|
||||
@@ -137,6 +148,11 @@ const calculateOverflow = () => {
|
||||
}
|
||||
}
|
||||
|
||||
// 100ms마다 최대 1회 실행
|
||||
const throttledCalculateOverflow = useThrottleFn(() => {
|
||||
calculateOverflow()
|
||||
}, 100)
|
||||
|
||||
const handleMenuOpen = () => {
|
||||
isMenuOpen.value = true
|
||||
scrollStore.controlScrollLock(true)
|
||||
@@ -200,26 +216,26 @@ const handleStartClick = () => {
|
||||
window.open(url, '_blank')
|
||||
}
|
||||
|
||||
watchEffect(() => {
|
||||
if (!startWidth.value) return // 0, null, undefined면 스킵
|
||||
calculateOverflow()
|
||||
})
|
||||
|
||||
// 화면 크기 변경 시 오버플로우 재계산
|
||||
watch(width, () => {
|
||||
calculateOverflow()
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
overflowNam.value = 0
|
||||
isMounted.value = true
|
||||
|
||||
// 초기 계산 시도
|
||||
// 초기 계산
|
||||
nextTick(() => {
|
||||
calculateNavWidth()
|
||||
calculateOfficialItemWidths()
|
||||
calculateOverflow()
|
||||
})
|
||||
|
||||
watchEffect(() => {
|
||||
if (!startWidth.value) return
|
||||
throttledCalculateOverflow()
|
||||
})
|
||||
|
||||
// 화면 크기 변경 시 오버플로우 재계산
|
||||
watch(width, () => {
|
||||
throttledCalculateOverflow()
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -256,89 +272,45 @@ onMounted(() => {
|
||||
</AtomsLocaleLink>
|
||||
</div>
|
||||
<nav :class="['nav-list', { 'is-mounted': isMounted }]">
|
||||
<div v-if="gnbData?.menus" class="official custom-theme-scrollbar">
|
||||
<div
|
||||
v-for="(gnbItem, key) in gnbData?.menus"
|
||||
:key="key"
|
||||
class="nav-item"
|
||||
:class="{
|
||||
'is-hidden':
|
||||
overflowNam > 0 &&
|
||||
Number(key) >=
|
||||
Object.keys(gnbData?.menus).length - overflowNam,
|
||||
}"
|
||||
>
|
||||
<AtomsLocaleLink
|
||||
:to="isNotClickable(gnbItem) ? '#' : gnbItem.url_path"
|
||||
:target="gnbItem.link_target"
|
||||
:class="[
|
||||
'nav-1depth',
|
||||
{ 'has-link': !isNotClickable(gnbItem) },
|
||||
{ active: isNavItemActive(gnbItem) },
|
||||
]"
|
||||
@click="handleMenuClose"
|
||||
>
|
||||
<span>{{ gnbItem.menu_name }}</span>
|
||||
<AtomsIconsWebLinkLine
|
||||
v-if="gnbItem.link_target === '_blank'"
|
||||
/>
|
||||
<AtomsIconsArrowDownFill
|
||||
v-if="has2depthButton(gnbItem)"
|
||||
class="hidden md:block"
|
||||
/>
|
||||
<AtomsIconsArrowRightLine
|
||||
v-if="!has2depthButton(gnbItem)"
|
||||
class="ml-auto md:hidden"
|
||||
/>
|
||||
</AtomsLocaleLink>
|
||||
<Transition name="fade">
|
||||
<div v-if="has2depthButton(gnbItem)" class="nav-2depth">
|
||||
<ul>
|
||||
<li
|
||||
v-for="child in gnbItem.children"
|
||||
:key="child.menu_name"
|
||||
>
|
||||
<AtomsLocaleLink
|
||||
:to="child.url_path"
|
||||
:target="child.link_target"
|
||||
@click="handleMenuClose"
|
||||
>
|
||||
<span>{{ child.menu_name }}</span>
|
||||
<AtomsIconsWebLinkLine
|
||||
v-if="child.link_target === '_blank'"
|
||||
/>
|
||||
</AtomsLocaleLink>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="gnbData?.menus && overflowNam > 0" class="more">
|
||||
<button class="btn-more">
|
||||
<AtomsIconsOptionHorizontalFill class="mx-auto" />
|
||||
<span class="sr-only">more</span>
|
||||
</button>
|
||||
<div class="more-list">
|
||||
<div class="list-inner">
|
||||
<div
|
||||
v-for="(gnbItem, key) in gnbData?.menus"
|
||||
:key="key"
|
||||
:class="{
|
||||
hidden:
|
||||
Number(key) <
|
||||
<template v-if="hasGnbMenus">
|
||||
<div class="official custom-theme-scrollbar">
|
||||
<div
|
||||
v-for="(gnbItem, key) in gnbData?.menus"
|
||||
:key="key"
|
||||
class="nav-item"
|
||||
:class="{
|
||||
'is-hidden':
|
||||
isMounted &&
|
||||
overflowNam > 0 &&
|
||||
Number(key) >=
|
||||
Object.keys(gnbData?.menus).length - overflowNam,
|
||||
}"
|
||||
}"
|
||||
>
|
||||
<AtomsLocaleLink
|
||||
:to="isNotClickable(gnbItem) ? '#' : gnbItem.url_path"
|
||||
:target="gnbItem.link_target"
|
||||
:class="[
|
||||
'nav-1depth',
|
||||
{ 'has-link': !isNotClickable(gnbItem) },
|
||||
{ active: isNavItemActive(gnbItem) },
|
||||
]"
|
||||
@click="handleMenuClose"
|
||||
>
|
||||
<AtomsLocaleLink
|
||||
:to="gnbItem.url_path"
|
||||
:target="gnbItem.link_target"
|
||||
:class="`${isNavItemActive(gnbItem) ? 'active' : ''}`"
|
||||
@click="handleMenuClose"
|
||||
>
|
||||
<span>{{ gnbItem.menu_name }}</span>
|
||||
</AtomsLocaleLink>
|
||||
<div v-if="gnbItem.children">
|
||||
<span>{{ gnbItem.menu_name }}</span>
|
||||
<AtomsIconsWebLinkLine
|
||||
v-if="gnbItem.link_target === '_blank'"
|
||||
/>
|
||||
<AtomsIconsArrowDownFill
|
||||
v-if="has2depthButton(gnbItem)"
|
||||
class="hidden md:block"
|
||||
/>
|
||||
<AtomsIconsArrowRightLine
|
||||
v-if="!has2depthButton(gnbItem)"
|
||||
class="ml-auto md:hidden"
|
||||
/>
|
||||
</AtomsLocaleLink>
|
||||
<Transition name="fade">
|
||||
<div v-if="has2depthButton(gnbItem)" class="nav-2depth">
|
||||
<ul>
|
||||
<li
|
||||
v-for="child in gnbItem.children"
|
||||
@@ -347,6 +319,7 @@ onMounted(() => {
|
||||
<AtomsLocaleLink
|
||||
:to="child.url_path"
|
||||
:target="child.link_target"
|
||||
@click="handleMenuClose"
|
||||
>
|
||||
<span>{{ child.menu_name }}</span>
|
||||
<AtomsIconsWebLinkLine
|
||||
@@ -356,16 +329,62 @@ onMounted(() => {
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="isMounted && overflowNam > 0" class="more">
|
||||
<button class="btn-more">
|
||||
<AtomsIconsOptionHorizontalFill class="mx-auto" />
|
||||
<span class="sr-only">more</span>
|
||||
</button>
|
||||
<div class="more-list">
|
||||
<div class="list-inner">
|
||||
<div
|
||||
v-for="(gnbItem, key) in gnbData?.menus"
|
||||
:key="key"
|
||||
:class="{
|
||||
hidden:
|
||||
Number(key) <
|
||||
Object.keys(gnbData?.menus).length - overflowNam,
|
||||
}"
|
||||
>
|
||||
<AtomsLocaleLink
|
||||
:to="gnbItem.url_path"
|
||||
:target="gnbItem.link_target"
|
||||
:class="`${isNavItemActive(gnbItem) ? 'active' : ''}`"
|
||||
@click="handleMenuClose"
|
||||
>
|
||||
<span>{{ gnbItem.menu_name }}</span>
|
||||
</AtomsLocaleLink>
|
||||
<div v-if="gnbItem.children">
|
||||
<ul>
|
||||
<li
|
||||
v-for="child in gnbItem.children"
|
||||
:key="child.menu_name"
|
||||
>
|
||||
<AtomsLocaleLink
|
||||
:to="child.url_path"
|
||||
:target="child.link_target"
|
||||
>
|
||||
<span>{{ child.menu_name }}</span>
|
||||
<AtomsIconsWebLinkLine
|
||||
v-if="child.link_target === '_blank'"
|
||||
/>
|
||||
</AtomsLocaleLink>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div v-if="gameData?.event_banner" class="event">
|
||||
<div class="nav-item">
|
||||
<AtomsLocaleLink
|
||||
:to="gameData.event_banner?.page_url"
|
||||
:to="gameData?.event_banner?.page_url"
|
||||
:target="
|
||||
gameData.event_banner?.link_type === 1 ? '_self' : '_blank'
|
||||
gameData?.event_banner?.link_type === 1 ? '_self' : '_blank'
|
||||
"
|
||||
class="nav-1depth text-gradient-pink"
|
||||
@click="handleMenuClose"
|
||||
@@ -560,11 +579,14 @@ onMounted(() => {
|
||||
@apply px-6;
|
||||
}
|
||||
|
||||
.event {
|
||||
@apply relative pr-1 md:ml-[64px] md:pr-0
|
||||
.official ~ .event {
|
||||
@apply md:ml-[64px]
|
||||
before:content-[''] before:block before:h-px before:mb-2 before:mx-3 before:bg-theme-foreground-reversal-8 md:before:hidden
|
||||
after:content-[''] after:absolute md:after:top-[50%] md:after:left-[-32px] md:after:w-[1px] md:after:h-[16px] md:after:bg-theme-foreground-gray-750 md:after:translate-y-[-50%];
|
||||
}
|
||||
.event {
|
||||
@apply relative pr-1 md:pr-0;
|
||||
}
|
||||
|
||||
.is-hidden {
|
||||
@apply hidden;
|
||||
|
||||
@@ -4,20 +4,20 @@ import type { PageDataResourceGroup } from '#layers/types/api/pageData'
|
||||
interface Props {
|
||||
resourcesData: PageDataResourceGroup
|
||||
size?: 'contain' | 'cover'
|
||||
videoPlay?: boolean
|
||||
gradient?: string
|
||||
dimmed?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
size: 'cover',
|
||||
videoPlay: true,
|
||||
gradient: '',
|
||||
dimmed: false,
|
||||
})
|
||||
|
||||
const { getCurrentSrc } = useResponsiveSrc()
|
||||
|
||||
const videoRef = ref<HTMLVideoElement | null>(null)
|
||||
|
||||
const resourcesSrc = computed(() => {
|
||||
return getCurrentSrc(props.resourcesData)
|
||||
})
|
||||
@@ -32,28 +32,6 @@ const gradientClasses = computed(() => [
|
||||
'absolute bottom-[-2px] left-[-2px] right-[-2px]',
|
||||
props.gradient,
|
||||
])
|
||||
|
||||
// 비디오를 처음부터 재생하는 메서드
|
||||
const restartVideo = () => {
|
||||
if (!videoRef.value) return
|
||||
|
||||
videoRef.value.currentTime = 0
|
||||
videoRef.value.play().catch(err => {
|
||||
console.warn('Video play failed:', err)
|
||||
})
|
||||
}
|
||||
|
||||
// src 변경 시 비디오 다시 로드
|
||||
watch(resourcesSrc, () => {
|
||||
if (!videoRef.value) return
|
||||
|
||||
videoRef.value.currentTime = 0
|
||||
videoRef.value.load()
|
||||
})
|
||||
|
||||
defineExpose({
|
||||
restartVideo,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -69,18 +47,14 @@ defineExpose({
|
||||
/>
|
||||
|
||||
<!-- 비디오 타입 -->
|
||||
<video
|
||||
<AtomsVideo
|
||||
v-else-if="isTypeVideo(resourcesData?.resource_type)"
|
||||
ref="videoRef"
|
||||
class="w-full h-full object-cover"
|
||||
:src="resourcesSrc"
|
||||
:poster="posterSrc"
|
||||
autoplay
|
||||
muted
|
||||
loop
|
||||
playsinline
|
||||
>
|
||||
<source :src="resourcesSrc" type="video/mp4" />
|
||||
</video>
|
||||
:play="videoPlay"
|
||||
:bordered="false"
|
||||
class="w-full h-full object-cover"
|
||||
/>
|
||||
|
||||
<i v-if="dimmed" class="absolute inset-0 bg-black/50" />
|
||||
<i v-if="gradient" :class="gradientClasses" />
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
const { isShowCheckLauncher, isShowDownloadLauncher, downloadLauncher } =
|
||||
useCheckGameStart()
|
||||
const { tm } = useI18n()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -13,34 +14,19 @@ const { isShowCheckLauncher, isShowDownloadLauncher, downloadLauncher } =
|
||||
close-class="absolute top-[16px] right-[24px]"
|
||||
>
|
||||
<span class="ico-loading"></span>
|
||||
<!-- [TODO] i18n 적용 -->
|
||||
<!-- <p class="text-check">{{ tm('Common_Message_Check_Client').txt }}</p> -->
|
||||
<p class="text-check">pc 클라이언트 실행 중...</p>
|
||||
<p class="text-check">{{ tm('Alert_Client_Running') }}</p>
|
||||
<Transition name="fade">
|
||||
<div v-if="isShowDownloadLauncher" class="client-area">
|
||||
<!-- <p
|
||||
v-dompurify-html="tm('Common_Message_Download_Client').txt"
|
||||
class="text-info"
|
||||
></p>
|
||||
<button type="button" class="btn-download" @click="downloadLauncher">
|
||||
{{ tm('Common_Message_Install').txt }}
|
||||
</button>
|
||||
<p
|
||||
v-dompurify-html="tm('Common_Message_Download_Close').txt"
|
||||
class="text-tip"
|
||||
></p> -->
|
||||
<p class="text-info">
|
||||
PC 클라이언트가 실행되지 않나요?
|
||||
<br />
|
||||
다운로드 전이라면 다운로드 후 진행해주세요
|
||||
</p>
|
||||
<p
|
||||
v-dompurify-html="tm('Alert_Client_Not_Running')"
|
||||
class="text-info"
|
||||
></p>
|
||||
|
||||
<AtomsButtonVariant class="max-w-[300px]" @click="downloadLauncher">
|
||||
다운로드
|
||||
{{ tm('Text_Download') }}
|
||||
</AtomsButtonVariant>
|
||||
<p
|
||||
v-dompurify-html="
|
||||
'*PC 클라이언트가 정상 실행되었다면 팝업을 닫아 주세요.'
|
||||
"
|
||||
v-dompurify-html="tm('Alert_Client_Run_Normally')"
|
||||
class="text-tip"
|
||||
></p>
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { globalDateFormat } from '@seed-next/date'
|
||||
import { PREREGIST_ERROR_CODE } from '#layers/composables/usePreregist'
|
||||
|
||||
interface Props {
|
||||
preregistCode?: string
|
||||
preregistCode: string
|
||||
tm?: (key: string) => string
|
||||
}
|
||||
|
||||
@@ -109,14 +109,6 @@ const showErrorModal = (code: number): void => {
|
||||
* 토큰 및 사전등록 여부 검증
|
||||
*/
|
||||
const checkValidation = async (): Promise<boolean> => {
|
||||
if (!props.preregistCode) {
|
||||
if (import.meta.dev) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('[Preregist] preregistCode is required')
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
// 토큰 검증
|
||||
const accessToken = csrGetAccessToken()
|
||||
@@ -260,11 +252,18 @@ defineExpose({
|
||||
<!-- Step 1: Terms Agreement -->
|
||||
<div v-if="currentStep === 1" class="flex flex-col h-full">
|
||||
<div class="flex gap-5 px-5 pt-5 pb-[12px] md:px-10 md:pt-6 md:pb-[16px]">
|
||||
<h4
|
||||
class="flex-1 text-xl font-bold leading-[30px] tracking-[-0.6px] text-[#ebebeb] md:text-2xl md:leading-[34px] md:tracking-[-0.72px]"
|
||||
>
|
||||
{{ tm('Preregist_Modal_Title01') }}
|
||||
</h4>
|
||||
<div class="flex-1">
|
||||
<h4
|
||||
class="text-xl font-bold leading-[30px] tracking-[-0.6px] text-[#ebebeb] md:text-2xl md:leading-[34px] md:tracking-[-0.72px]"
|
||||
>
|
||||
{{ tm('Preregist_Modal_Title01') }}
|
||||
</h4>
|
||||
<p
|
||||
class="mt-2 text-[13px] leading-[22px] tracking-[-0.325px] text-[#b2b2b2] md:text-[15px] md:leading-6 md:tracking-[-0.45px]"
|
||||
>
|
||||
{{ tm('Preregist_Modal_Info') }}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
class="flex h-[30px] items-center gap-1 text-base leading-6 tracking-[-0.48px] md:h-[34px]"
|
||||
>
|
||||
@@ -339,11 +338,13 @@ defineExpose({
|
||||
<!-- Step 2: Success -->
|
||||
<div v-if="currentStep === 2" class="flex flex-1 flex-col h-full">
|
||||
<div class="flex gap-5 px-5 pb-10 pt-5 md:px-10 md:pb-12 md:pt-6">
|
||||
<h4
|
||||
class="flex-1 text-xl font-bold leading-[30px] tracking-[-0.6px] text-[#ebebeb] md:text-2xl md:leading-[34px] md:tracking-[-0.72px]"
|
||||
>
|
||||
{{ tm('Preregist_Modal_Title02') }}
|
||||
</h4>
|
||||
<div class="flex-1">
|
||||
<h4
|
||||
class="text-xl font-bold leading-[30px] tracking-[-0.6px] text-[#ebebeb] md:text-2xl md:leading-[34px] md:tracking-[-0.72px]"
|
||||
>
|
||||
{{ tm('Preregist_Modal_Title02') }}
|
||||
</h4>
|
||||
</div>
|
||||
<div
|
||||
class="flex h-[30px] items-center gap-1 text-base leading-6 tracking-[-0.48px] md:h-[34px]"
|
||||
>
|
||||
|
||||
@@ -45,6 +45,10 @@ export const useAnalyticsLogDataDirect = (
|
||||
}
|
||||
|
||||
// resourcesData가 객체인 경우 (기존 로직)
|
||||
if (!resourcesData || !resourcesData.tracking) {
|
||||
return {} as AnalyticsDetailType
|
||||
}
|
||||
|
||||
const pageDataTrack = resourcesData.tracking
|
||||
|
||||
const logData = {
|
||||
@@ -96,7 +100,7 @@ const findValueFromOption = (target: string, { options = {} }: any) => {
|
||||
*/
|
||||
const sendGA = (analytics: AnalyticsDetailType, { options = {} }: any) => {
|
||||
if (!import.meta.client) return
|
||||
|
||||
|
||||
try {
|
||||
const { gtag } = useGtag()
|
||||
|
||||
@@ -129,7 +133,7 @@ const sendSA = (
|
||||
{ mcode = '', options = {} }: any
|
||||
) => {
|
||||
if (!import.meta.client) return
|
||||
|
||||
|
||||
const gameDataStore = useGameDataStore()
|
||||
const { gameData } = storeToRefs(gameDataStore)
|
||||
|
||||
@@ -254,7 +258,7 @@ const sendLog = (locale: string, analytics: AnalyticsDetailType) => {
|
||||
*/
|
||||
const sendGAEventOnly = (gaEventName: string) => {
|
||||
if (!import.meta.client) return
|
||||
|
||||
|
||||
try {
|
||||
const { gtag } = useGtag()
|
||||
|
||||
@@ -274,7 +278,7 @@ const sendGAEventOnly = (gaEventName: string) => {
|
||||
*/
|
||||
const sendMetaPixel = (fbEventName: string) => {
|
||||
if (!import.meta.client) return
|
||||
|
||||
|
||||
try {
|
||||
const { $fbq } = useNuxtApp()
|
||||
if (typeof $fbq === 'function') {
|
||||
@@ -293,7 +297,7 @@ const sendMetaPixel = (fbEventName: string) => {
|
||||
*/
|
||||
const sendTwitterPixel = (twEventName: string) => {
|
||||
if (!import.meta.client) return
|
||||
|
||||
|
||||
try {
|
||||
twq('event', twEventName, {})
|
||||
} catch (e) {
|
||||
@@ -309,7 +313,7 @@ const sendTwitterPixel = (twEventName: string) => {
|
||||
*/
|
||||
const sendTiktokPixel = (ttEventName: string) => {
|
||||
if (!import.meta.client) return
|
||||
|
||||
|
||||
try {
|
||||
ttq.track(ttEventName)
|
||||
} catch (e) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { GameDataRequest, GameDataValue } from '#layers/types/api/gameData'
|
||||
import type { GameDataValue } from '#layers/types/api/gameData'
|
||||
|
||||
export default defineNuxtRouteMiddleware(async (to, _from) => {
|
||||
const nuxtApp = useNuxtApp()
|
||||
@@ -59,12 +59,11 @@ export default defineNuxtRouteMiddleware(async (to, _from) => {
|
||||
to.path.includes('/error') ||
|
||||
to.path.includes('/inspection')
|
||||
) {
|
||||
console.log("🚀 ~ init.route.global error 페이지는 API 호출하지 않음")
|
||||
console.log('🚀 ~ init.route.global error 페이지는 API 호출하지 않음')
|
||||
showError(
|
||||
createError({
|
||||
statusCode: 500,
|
||||
statusMessage:
|
||||
'Internal Server Error',
|
||||
statusMessage: 'Internal Server Error',
|
||||
fatal: false, // 즉시 에러 페이지로
|
||||
data: { reason: 'post-not-found' },
|
||||
})
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
getComponentContainer,
|
||||
} from '#layers/utils/dataUtil'
|
||||
import { getYouTubeThumbnail } from '#layers/utils/youtubeUtil'
|
||||
import type { Splide as SplideType } from '@splidejs/splide'
|
||||
import type { PageDataTemplateComponents } from '#layers/types/api/pageData'
|
||||
import type { OperateGroupItem } from '#layers/types/api/operateResources'
|
||||
|
||||
@@ -41,6 +40,8 @@ const { tm, locale } = useI18n({
|
||||
messages: Object(resultGetMultilingual?.value?.multilingual),
|
||||
})
|
||||
|
||||
const recommendHover = ref(false)
|
||||
|
||||
const backgroundData = computed(() =>
|
||||
getComponentGroup(props.components, 'background')
|
||||
)
|
||||
@@ -82,7 +83,8 @@ const ITEMS_PER_PAGE = {
|
||||
MOBILE: 8,
|
||||
DESKTOP: 12,
|
||||
} as const
|
||||
const currentRecommendedIndex = ref(1)
|
||||
|
||||
const currentRecommendedIndex = ref<number | null>(null)
|
||||
const currentRecentPage = ref(1)
|
||||
|
||||
// 추천 영상 (option01 === 1)
|
||||
@@ -108,10 +110,6 @@ const hasMore = computed(
|
||||
() => visibleVideos.value.length < recentVideos.value.length
|
||||
)
|
||||
|
||||
const handleSplideMove = (_splide: SplideType, newIndex: number) => {
|
||||
currentRecommendedIndex.value = newIndex + 1
|
||||
}
|
||||
|
||||
const handleVideoClick = (url: string) => {
|
||||
modalStore.handleOpenYoutube({ youtubeUrl: url })
|
||||
}
|
||||
@@ -140,19 +138,19 @@ const handleLoadMoreRecent = () => {
|
||||
class="relative content-static bg-[#fff] rounded-[12px] md:rounded-[16px]"
|
||||
>
|
||||
<BlocksSlideFade
|
||||
v-model:index="currentRecommendedIndex"
|
||||
:autoplay="recommendedVideos.length > 1"
|
||||
:interval="3000"
|
||||
:arrows="recommendedVideos.length > 1"
|
||||
:pagination="false"
|
||||
:drag="false"
|
||||
@move="handleSplideMove"
|
||||
>
|
||||
<SplideSlide
|
||||
v-for="(item, index) in recommendedVideos"
|
||||
:key="`recommended-${item.url}-${index}`"
|
||||
>
|
||||
<div
|
||||
class="overflow-hidden relative aspect-[16/9] flex-shrink-0 w-full rounded-[4px] cursor-pointer sm:w-[60.3%] sm:rounded-[8px] md:w-[56%] lg:w-[710px] lg:rounded-[12px]"
|
||||
class="overflow-hidden relative aspect-[16/9] flex-shrink-0 w-full rounded-[4px] cursor-pointer group sm:w-[60.3%] sm:rounded-[8px] md:w-[56%] lg:w-[710px] lg:rounded-[12px]"
|
||||
@click="handleVideoClick(item.url)"
|
||||
>
|
||||
<img
|
||||
@@ -160,7 +158,10 @@ const handleLoadMoreRecent = () => {
|
||||
:alt="item.title || 'Video thumbnail'"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
class="w-full h-full object-cover"
|
||||
:class="[
|
||||
'w-full h-full object-cover group-hover:scale-110 transition-transform duration-300',
|
||||
{ 'is-hover': recommendHover },
|
||||
]"
|
||||
/>
|
||||
<AtomsIconsPlayRoundFill
|
||||
class="drop-shadow-[0_0_6px_rgba(0,0,0,0.25)] absolute bottom-[14px] right-[14px] w-[20px] h-[20px] sm:bottom-[18px] sm:right-[18px] md:bottom-[23px] md:right-[23px] md:w-[33px] md:h-[33px]"
|
||||
@@ -169,6 +170,8 @@ const handleLoadMoreRecent = () => {
|
||||
<div
|
||||
class="w-full mx-[8px] pb-[20px] border-b border-[rgba(0,0,0,0.08)] cursor-pointer sm:mx-[0] sm:pt-[12px] sm:pr-[16px] sm:pb-[0] sm:border-none md:pt-[20px] md:pr-[28px] lg:pt-[40px] lg:pr-[48px]"
|
||||
@click="handleVideoClick(item.url)"
|
||||
@mouseenter="recommendHover = true"
|
||||
@mouseleave="recommendHover = false"
|
||||
>
|
||||
<h4
|
||||
class="h-[52px] text-[18px] font-[700] leading-[26px] tracking-[-0.54px] text-[#1F1F1F] line-clamp-2 sm:h-[auto] sm:text-[20px] sm:leading-[30px] sm:tracking-[-0.6px] sm:line-clamp-3 md:text-[32px] md:leading-[44px] md:tracking-[-0.96px]"
|
||||
@@ -185,7 +188,7 @@ const handleLoadMoreRecent = () => {
|
||||
</BlocksSlideFade>
|
||||
<div v-if="recommendedVideos.length > 1" class="splide-pagination">
|
||||
<span class="font-[700] text-[#1F1F1F]">
|
||||
{{ currentRecommendedIndex }}
|
||||
{{ currentRecommendedIndex + 1 }}
|
||||
</span>
|
||||
/
|
||||
<span>{{ recommendedVideos.length }}</span>
|
||||
@@ -199,7 +202,7 @@ const handleLoadMoreRecent = () => {
|
||||
<li
|
||||
v-for="(item, index) in visibleVideos"
|
||||
:key="`recent-${item.url}-${index}`"
|
||||
class="p-3 rounded-[12px] bg-white md:p-4 md:rounded-[16px] lg:p-5 cursor-pointer"
|
||||
class="p-3 rounded-[12px] bg-white md:p-4 md:rounded-[16px] lg:p-5 group cursor-pointer"
|
||||
@click="handleVideoClick(item.url)"
|
||||
>
|
||||
<div
|
||||
@@ -210,7 +213,7 @@ const handleLoadMoreRecent = () => {
|
||||
:alt="item.title || 'Video thumbnail'"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
class="w-full h-full object-cover"
|
||||
class="w-full h-full object-cover group-hover:scale-110 transition-transform duration-300"
|
||||
/>
|
||||
<AtomsIconsPlayRoundFill
|
||||
class="drop-shadow-[0_0_6px_rgba(0,0,0,0.25)] absolute bottom-[14px] right-[14px] w-[20px] h-[20px]"
|
||||
@@ -278,4 +281,7 @@ const handleLoadMoreRecent = () => {
|
||||
md:bottom-[47px] md:left-[calc(56%+167px)] md:right-[unset] md:text-[18px] md:leading-[26px] md:tracking-[-0.54px]
|
||||
lg:bottom-[71px] lg:left-[918px];
|
||||
}
|
||||
.is-hover {
|
||||
@apply scale-110;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
getComponentContainer,
|
||||
getComponentGroupAry,
|
||||
} from '#layers/utils/dataUtil'
|
||||
import type { Splide as SplideType } from '@splidejs/splide'
|
||||
import type { PageDataTemplateComponents } from '#layers/types/api/pageData'
|
||||
|
||||
interface Props {
|
||||
@@ -30,14 +29,6 @@ const goToSlide = (index: number) => {
|
||||
splide.go(index)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSplideMounted = (splide: SplideType) => {
|
||||
currentSlideIndex.value = splide.index
|
||||
}
|
||||
|
||||
const handleSplideMove = (_splide: SplideType, newIndex: number) => {
|
||||
currentSlideIndex.value = newIndex
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -45,17 +36,17 @@ const handleSplideMove = (_splide: SplideType, newIndex: number) => {
|
||||
<BlocksSlideFade
|
||||
v-if="slideData"
|
||||
ref="splideRef"
|
||||
v-model:index="currentSlideIndex"
|
||||
:autoplay="true"
|
||||
:arrows="false"
|
||||
:pagination="false"
|
||||
class="h-full"
|
||||
@move="handleSplideMove"
|
||||
@mounted="handleSplideMounted"
|
||||
>
|
||||
<SplideSlide v-for="(item, index) in slideData" :key="index">
|
||||
<WidgetsBackground
|
||||
v-if="hasComponentGroup(item, 'background')"
|
||||
:resources-data="getComponentGroup(item, 'background')"
|
||||
:video-play="currentSlideIndex === index"
|
||||
/>
|
||||
<div class="content-standard">
|
||||
<WidgetsMainTitle
|
||||
|
||||
@@ -13,6 +13,8 @@ interface Props {
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const currentSlideIndex = ref<number | null>(null)
|
||||
|
||||
const slideData = computed(() => {
|
||||
return getComponentContainer(props.components, 'group_sets', { maxLength: 7 })
|
||||
})
|
||||
@@ -29,6 +31,7 @@ const paginationData = computed(() => {
|
||||
<template>
|
||||
<section class="section-standard">
|
||||
<BlocksSlideThumbnail
|
||||
v-model:index="currentSlideIndex"
|
||||
:thumbnail-data="thumbnailData"
|
||||
:pagination-data="paginationData"
|
||||
>
|
||||
@@ -36,6 +39,7 @@ const paginationData = computed(() => {
|
||||
<WidgetsBackground
|
||||
v-if="hasComponentGroup(item, 'background')"
|
||||
:resources-data="getComponentGroup(item, 'background')"
|
||||
:video-play="currentSlideIndex === index"
|
||||
/>
|
||||
<div class="content-standard">
|
||||
<WidgetsMainTitle
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
getComponentGroup,
|
||||
hasComponentGroup,
|
||||
} from '#layers/utils/dataUtil'
|
||||
import type { Splide as SplideType } from '@splidejs/splide'
|
||||
import type {
|
||||
PageDataTemplateComponents,
|
||||
PageDataTemplateComponent,
|
||||
@@ -21,7 +20,7 @@ const props = defineProps<Props>()
|
||||
|
||||
const { getCurrentSrc } = useResponsiveSrc()
|
||||
|
||||
const currentSlideIndex = ref<number>(0)
|
||||
const currentSlideIndex = ref<number | null>(null)
|
||||
|
||||
const slideData = computed(() => {
|
||||
return getComponentContainer(props.components, 'group_sets', {
|
||||
@@ -37,27 +36,24 @@ const paginationData = computed(() => {
|
||||
return getComponentGroupAry(props.components, 'pagination')
|
||||
})
|
||||
|
||||
const videoSrc = (item: PageDataTemplateComponent) => {
|
||||
const getVideoSrc = (item: PageDataTemplateComponent) => {
|
||||
const videoData = getComponentGroup(item, 'video')
|
||||
return getCurrentSrc(videoData)
|
||||
}
|
||||
|
||||
const handleSplideMove = (_splide: SplideType, newIndex: number) => {
|
||||
currentSlideIndex.value = newIndex
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="section-standard">
|
||||
<BlocksSlideThumbnail
|
||||
v-model:index="currentSlideIndex"
|
||||
:thumbnail-data="thumbnailData"
|
||||
:pagination-data="paginationData"
|
||||
@move="handleSplideMove"
|
||||
>
|
||||
<SplideSlide v-for="(item, index) in slideData" :key="index">
|
||||
<WidgetsBackground
|
||||
v-if="hasComponentGroup(item, 'background')"
|
||||
:resources-data="getComponentGroup(item, 'background')"
|
||||
:video-play="currentSlideIndex === index"
|
||||
/>
|
||||
<WidgetsBackground
|
||||
v-if="hasComponentGroup(item, 'foreground')"
|
||||
@@ -69,18 +65,18 @@ const handleSplideMove = (_splide: SplideType, newIndex: number) => {
|
||||
<WidgetsSubTitle
|
||||
v-if="hasComponentGroup(item, 'category')"
|
||||
:resources-data="getComponentGroup(item, 'category')"
|
||||
class="title-xs max-w-[540px] mb-2 line-clamp-1 text-left md:mb-5"
|
||||
class="title-xs max-w-[540px] mb-2 text-left md:mb-5"
|
||||
/>
|
||||
<WidgetsMainTitle
|
||||
v-if="hasComponentGroup(item, 'mainTitle')"
|
||||
:resources-data="getComponentGroup(item, 'mainTitle')"
|
||||
class="title-lg max-w-[540px] line-clamp-1 text-left"
|
||||
class="title-lg max-w-[540px] text-left"
|
||||
/>
|
||||
<WidgetsSubTitle
|
||||
v-if="hasComponentGroup(item, 'subTitle')"
|
||||
:resources-data="getComponentGroup(item, 'subTitle')"
|
||||
tag="p"
|
||||
class="title-md max-w-[540px] mt-1 line-clamp-1 text-left"
|
||||
class="title-md max-w-[540px] mt-1 text-left"
|
||||
/>
|
||||
<WidgetsDescription
|
||||
v-if="hasComponentGroup(item, 'description')"
|
||||
@@ -89,12 +85,8 @@ const handleSplideMove = (_splide: SplideType, newIndex: number) => {
|
||||
/>
|
||||
<AtomsVideo
|
||||
v-if="hasComponentGroup(item, 'video')"
|
||||
:src="videoSrc(item)"
|
||||
:src="getVideoSrc(item)"
|
||||
:play="currentSlideIndex === index"
|
||||
autoplay
|
||||
muted
|
||||
loop
|
||||
playsinline
|
||||
class="aspect-[16/10] w-[258px] mt-8 md:w-[496px] md:mt-10"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
getComponentGroup,
|
||||
isTypeVideo,
|
||||
} from '#layers/utils/dataUtil'
|
||||
import { getYouTubeUrl, getYouTubeThumbnail } from '#layers/utils/youtubeUtil'
|
||||
import type { PageDataTemplateComponents } from '#layers/types/api/pageData'
|
||||
|
||||
interface Props {
|
||||
@@ -18,7 +19,6 @@ const props = defineProps<Props>()
|
||||
const { locale } = useI18n()
|
||||
const { sendLog, useAnalyticsLogDataDirect } = useAnalytics()
|
||||
|
||||
const slideThumbnailRef = ref<any>(null)
|
||||
const playingSlideIndex = ref<number | null>(null)
|
||||
let stopVideoTimeoutId: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
@@ -44,27 +44,27 @@ const handleVideoClick = (index: number) => {
|
||||
playingSlideIndex.value = index
|
||||
|
||||
const group = getComponentGroup(props.components, 'videoPlay')
|
||||
const base = group?.tracking?.click_item || ''
|
||||
if (!group || !group.tracking) return
|
||||
|
||||
const base = group.tracking.click_item || ''
|
||||
const next = base
|
||||
? base.replace(/(^.*_)(\d+)$/, `$1${index}`) === base
|
||||
? `${base}_${index}`
|
||||
: base.replace(/(^.*_)(\d+)$/, `$1${index}`)
|
||||
: `${index}`
|
||||
|
||||
const sendingGroup = group
|
||||
? { ...group, tracking: { ...group.tracking, click_item: next } }
|
||||
: group
|
||||
const sendingGroup = {
|
||||
...group,
|
||||
tracking: { ...group.tracking, click_item: next },
|
||||
}
|
||||
|
||||
sendLog(
|
||||
locale.value,
|
||||
useAnalyticsLogDataDirect(
|
||||
(sendingGroup as any) || getComponentGroup(props.components, 'videoPlay'),
|
||||
props.pageVerTmplSeq
|
||||
)
|
||||
useAnalyticsLogDataDirect(sendingGroup, props.pageVerTmplSeq)
|
||||
)
|
||||
}
|
||||
|
||||
const stopVideo = () => {
|
||||
const handleSplideMove = () => {
|
||||
// 이전 타이머 정리
|
||||
if (stopVideoTimeoutId) {
|
||||
clearTimeout(stopVideoTimeoutId)
|
||||
@@ -78,7 +78,7 @@ const stopVideo = () => {
|
||||
}, 600)
|
||||
}
|
||||
|
||||
const onArrowClick = (direction, targetIndex) => {
|
||||
const onArrowClick = (direction, _targetIndex) => {
|
||||
const arrowGroupAry = getComponentGroupAry(props.components, 'arrow')
|
||||
const logTracking = arrowGroupAry?.[direction === 'prev' ? 0 : 1]
|
||||
sendLog(locale.value, useAnalyticsLogDataDirect(logTracking, 1))
|
||||
@@ -103,14 +103,13 @@ onBeforeUnmount(() => {
|
||||
class="title-md max-w-[944px]"
|
||||
/>
|
||||
<BlocksSlideThumbnail
|
||||
ref="slideThumbnailRef"
|
||||
:thumbnail-data="slideData"
|
||||
variant="media"
|
||||
class="mt-[24px] md:mt-[32px]"
|
||||
:pagination-data="paginationData"
|
||||
:arrows="slideData.length > 5"
|
||||
@move="stopVideo"
|
||||
@arrow-click="onArrowClick"
|
||||
@move="handleSplideMove"
|
||||
>
|
||||
<SplideSlide
|
||||
v-for="(item, index) in slideData"
|
||||
@@ -133,7 +132,7 @@ onBeforeUnmount(() => {
|
||||
<transition name="fade">
|
||||
<iframe
|
||||
v-if="playingSlideIndex === index"
|
||||
:src="getYouTubeEmbedUrl(item.display?.text, true)"
|
||||
:src="getYouTubeUrl(item.display?.text)"
|
||||
class="video-iframe"
|
||||
frameborder="0"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
|
||||
@@ -18,6 +18,8 @@ const props = defineProps<Props>()
|
||||
const { locale } = useI18n()
|
||||
const { sendLog, useAnalyticsLogDataDirect } = useAnalytics()
|
||||
|
||||
const currentSlideIndex = ref<number | null>(null)
|
||||
|
||||
const slideData = computed(() => {
|
||||
return getComponentContainer(props.components, 'group_sets')
|
||||
})
|
||||
@@ -36,6 +38,7 @@ const onArrowClick = direction => {
|
||||
<section class="section-standard">
|
||||
<BlocksSlideFade
|
||||
v-if="slideData"
|
||||
v-model:index="currentSlideIndex"
|
||||
:arrows="slideData.length > 1"
|
||||
:pagination="slideData.length > 1"
|
||||
class="h-full"
|
||||
@@ -46,6 +49,7 @@ const onArrowClick = direction => {
|
||||
<WidgetsBackground
|
||||
v-if="hasComponentGroup(item, 'background')"
|
||||
:resources-data="getComponentGroup(item, 'background')"
|
||||
:video-play="currentSlideIndex === index"
|
||||
/>
|
||||
<div class="content-standard gap-3 md:gap-5">
|
||||
<WidgetsSubTitle
|
||||
|
||||
@@ -10,7 +10,7 @@ import { csrFormatJWT } from '#layers/utils/formatUtil'
|
||||
*/
|
||||
export const csrGoStoveLogin = () => {
|
||||
if (!import.meta.client) return
|
||||
|
||||
|
||||
const runtimeConfig = useRuntimeConfig()
|
||||
const gameDataStore = useGameDataStore()
|
||||
|
||||
|
||||
@@ -30,27 +30,48 @@ export const getYouTubeId = (url: string): string => {
|
||||
}
|
||||
|
||||
/**
|
||||
* 유튜브 임베드 URL을 생성합니다.
|
||||
* 유튜브 URL을 반환합니다.
|
||||
* @param url - 유튜브 URL
|
||||
* @param autoplay - 자동재생 여부
|
||||
* @param rel - 관련 비디오 표시 여부
|
||||
* @returns 임베드 URL
|
||||
*/
|
||||
/** [TODO] 임베드 형태로 넘어오도록 데이터 수정 후 이부분 사용 필요 없음 */
|
||||
export const getYouTubeEmbedUrl = (
|
||||
export const getYouTubeUrl = (
|
||||
url: string,
|
||||
autoplay: boolean = false,
|
||||
autoplay: boolean = true,
|
||||
rel: boolean = false
|
||||
): string => {
|
||||
const videoId = getYouTubeId(url)
|
||||
if (!videoId) return ''
|
||||
if (!url) return ''
|
||||
|
||||
const params = new URLSearchParams()
|
||||
if (autoplay) params.append('autoplay', '1')
|
||||
if (!rel) params.append('rel', '0')
|
||||
try {
|
||||
const urlObj = new URL(url)
|
||||
const params = new URLSearchParams(urlObj.searchParams)
|
||||
|
||||
const queryString = params.toString()
|
||||
return `https://www.youtube.com/embed/${videoId}${queryString ? `?${queryString}` : ''}`
|
||||
// 기본 파라미터 보장
|
||||
if (!params.has('autoplay')) {
|
||||
params.set('autoplay', autoplay ? '1' : '0')
|
||||
}
|
||||
if (!params.has('rel')) {
|
||||
params.set('rel', rel ? '1' : '0')
|
||||
}
|
||||
|
||||
const baseUrl = (() => {
|
||||
const isEmbedUrl = urlObj.pathname.startsWith('/embed/')
|
||||
if (isEmbedUrl) {
|
||||
return `${urlObj.origin}${urlObj.pathname}`
|
||||
}
|
||||
|
||||
// 일반 URL이면 id 추출 후 embed URL로 변환
|
||||
const youtubeId = getYouTubeId(url)
|
||||
if (!youtubeId) return ''
|
||||
return `https://www.youtube.com/embed/${youtubeId}`
|
||||
})()
|
||||
|
||||
const queryString = params.toString()
|
||||
return queryString ? `${baseUrl}?${queryString}` : baseUrl
|
||||
} catch {
|
||||
return url
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -112,5 +112,10 @@ export default defineNuxtConfig({
|
||||
allowedHosts: true,
|
||||
},
|
||||
base: '/',
|
||||
// 프로덕션에서 hydration mismatch 상세 로그 활성화 (디버깅 후 제거 권장)
|
||||
define: {
|
||||
__VUE_PROD_HYDRATION_MISMATCH_DETAILS__: true,
|
||||
},
|
||||
// 제거
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user