Merge branch 'feature/20250910-all' into feature/20251001-gil

This commit is contained in:
“hyeonggkim”
2025-10-24 15:07:07 +09:00
47 changed files with 982 additions and 659 deletions

View File

@@ -0,0 +1,62 @@
<script setup lang="ts">
import { getResolvedHost } from '#layers/utils/styleUtil'
import type { PageDataResourceGroup } from '#layers/types/api/pageData'
interface Props {
resourcesData?: PageDataResourceGroup
objectFit?: 'contain' | 'cover'
alt?: string
}
const props = withDefaults(defineProps<Props>(), {
objectFit: 'contain',
})
const imagePaths = computed(() => {
if (!props.resourcesData?.res_path) return null
const pcPath =
props.resourcesData.res_path.path_pc ?? props.resourcesData.res_path.path_mo
const moPath =
props.resourcesData.res_path.path_mo ?? props.resourcesData.res_path.path_pc
return {
pc: pcPath ? getResolvedHost(pcPath) : '',
mo: moPath ? getResolvedHost(moPath) : '',
}
})
</script>
<template>
<picture v-if="imagePaths">
<source media="(min-width: 1024px)" :srcset="imagePaths.pc" />
<source media="(max-width: 1023px)" :srcset="imagePaths.mo" />
<img
:src="imagePaths.pc"
:alt="alt"
:class="`w-full h-full object-${objectFit}`"
loading="lazy"
/>
</picture>
</template>
<style scoped>
/* 이미지 깨짐 시 보더 및 아이콘 제거 */
img {
border: none;
outline: none;
}
/* 깨진 이미지 아이콘과 alt 텍스트 숨김 */
img::before,
img::after {
display: none;
}
/* alt 텍스트 영역 숨김 */
img[alt] {
text-indent: -9999px;
overflow: hidden;
display: block;
}
</style>

View File

@@ -0,0 +1,86 @@
<script setup lang="ts">
interface Props {
src: string
type?: 'mp4' | 'webm'
play?: boolean
autoplay?: boolean
muted?: boolean
loop?: boolean
playsinline?: boolean
class?: ClassType
}
const props = withDefaults(defineProps<Props>(), {
type: 'mp4',
play: false,
muted: true,
loop: true,
playsinline: true,
})
const videoRef = ref<HTMLVideoElement | null>(null)
// autoplay prop 변경 시 재생/정지 제어
watch(
() => props.play,
shouldPlay => {
if (!videoRef.value) return
if (shouldPlay) {
videoRef.value.play().catch(err => {
console.warn('Video play failed:', err)
})
} else {
setTimeout(() => {
videoRef.value.pause()
videoRef.value.currentTime = 0
}, 200)
}
}
)
// 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)
})
})
}
}
)
</script>
<template>
<div v-if="props.src" :class="['video-box', props.class]">
<video
ref="videoRef"
:autoplay="props.autoplay"
:muted="props.muted"
:loop="props.loop"
:playsinline="props.playsinline"
>
<source :src="props.src" :type="`video/${props.type}`" />
</video>
</div>
</template>
<style scoped>
.video-box {
@apply overflow-hidden 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 {
@apply absolute top-0 left-0 w-full h-full object-cover;
}
</style>

View File

@@ -10,28 +10,25 @@ interface Props {
}
const props = defineProps<Props>()
const {locale} = useI18n()
const { locale } = useI18n()
const { sendLog, useAnalyticsLogDataDirect } = useAnalytics()
const handleLinkClick = (title) => {
const handleLinkClick = (title: string) => {
const trackingData = {
tracking: {
click_item: title,
action_type: 'click',
click_sarea: ''
}
click_sarea: '',
},
}
sendLog(locale.value, useAnalyticsLogDataDirect(trackingData, 1))
}
</script>
<template>
<div
v-if="props.title || props.description"
:class="`card-news ${props.class || ''}`"
@click="handleLinkClick(props.title)"
>
<img
:src="props.imgPath"
@@ -52,6 +49,7 @@ const handleLinkClick = (title) => {
:href="props.url"
:target="props.linkTarget"
class="card-link"
@click="handleLinkClick(props.title)"
/>
</div>
</template>

View File

@@ -1,39 +0,0 @@
<script setup lang="ts">
import type { ClassType } from '#layers/types/Common'
interface Props {
to: string
target?: string
class?: ClassType
}
const props = withDefaults(defineProps<Props>(), {
target: '',
class: '',
})
const componentTag = computed(() => {
return props.target === '_blank' ? 'a' : 'AtomsLocaleLink'
})
const componentProps = computed(() => {
if (props.target === '_blank') {
return {
href: props.to,
target: props.target,
class: props.class,
}
}
return {
to: props.to,
class: props.class,
}
})
</script>
<template>
<component :is="componentTag" v-bind="{ ...componentProps }">
<slot />
</component>
</template>

View File

@@ -9,7 +9,7 @@ const { gameData } = useGameDataStore()
const stoveInflowPath = runtimeConfig.public.stoveInflowPath
const stoveGameNo = runtimeConfig.public.stoveGameNo
const gnbData = gameData?.stove_gnb
const gnbData = gameData?.stove_gnb_json
const languageCodes = computed(() => {
if (Array.isArray(availableLocales)) {

View File

@@ -1,5 +1,7 @@
<script setup lang="ts">
import type { PageDataResourceGroup } from '#layers/types/api/pageData'
import { getResolvedHost, getColorCode } from '#layers/utils/styleUtil'
import { isTypeImage, isTypeText } from '#layers/utils/dataUtil'
interface Props {
resourcesData?: PageDataResourceGroup
@@ -11,27 +13,28 @@ const props = withDefaults(defineProps<Props>(), {
objectFit: 'contain',
})
const breakpoints = useResponsiveBreakpointsReliable()
const imagePaths = computed(() => {
if (!props.resourcesData?.res_path) return null
const pcPath =
props.resourcesData.res_path.path_pc ?? props.resourcesData.res_path.path_mo
const moPath =
props.resourcesData.res_path.path_mo ?? props.resourcesData.res_path.path_pc
return {
pc: pcPath ? getResolvedHost(pcPath) : '',
mo: moPath ? getResolvedHost(moPath) : '',
}
})
const displayText = computed(() => {
return props.resourcesData?.display?.text || 'image'
})
const imageSrc = computed(() => {
return getResponsiveSrc(props.resourcesData?.res_path)
})
const colorName = computed(() => {
return props.resourcesData?.display?.color_name
})
const colorCode = computed(() => {
return props.resourcesData?.display?.color_code
})
const currentImageSrc = computed(() => {
if (!imageSrc.value) return ''
return breakpoints.value.isMobile
? imageSrc.value.mobileSrc || ''
: imageSrc.value.pcSrc || ''
})
// HTML 콘텐츠 정리 (줄바꿈 처리)
const sanitizedContent = computed(() => {
@@ -41,13 +44,16 @@ const sanitizedContent = computed(() => {
<template>
<!-- 이미지 -->
<img
v-if="isTypeImage(resourcesData?.resource_type) && currentImageSrc"
:src="currentImageSrc"
:alt="alt || displayText"
:class="`w-full h-full object-${objectFit}`"
loading="lazy"
/>
<picture v-if="isTypeImage(resourcesData?.resource_type) && imagePaths">
<source :srcset="imagePaths.pc" media="(min-width: 1024px)" />
<source :srcset="imagePaths.mo" media="(max-width: 1023px)" />
<img
:src="imagePaths.pc"
:alt="alt || displayText"
:class="`w-full h-full object-${objectFit}`"
loading="lazy"
/>
</picture>
<!-- 텍스트 -->
<span
v-else-if="isTypeText(resourcesData?.resource_type)"

View File

@@ -6,40 +6,27 @@ const { fullLoading } = storeToRefs(loadingStore)
</script>
<template>
<Transition
enter-active-class="transition-opacity duration-300 ease-in-out"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-active-class="transition-opacity duration-300 ease-in-out"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<div
v-if="fullLoading"
class="fixed inset-0 bg-black/80 flex items-center justify-center z-[9999]"
>
<!-- 메인 스피너 -->
<div class="relative w-16 h-16">
<!-- 외부 -->
<div
class="absolute inset-0 border-4 border-transparent border-t-blue-500 rounded-full animate-spin"
/>
<!-- 중간 -->
<div
class="absolute inset-1 border-4 border-transparent border-t-purple-500 rounded-full animate-spin"
style="animation-delay: -0.3s"
/>
<!-- 내부 -->
<div
class="absolute inset-2 border-4 border-transparent border-t-cyan-500 rounded-full animate-spin"
style="animation-delay: -0.6s"
/>
<!-- 중심 -->
<div
class="absolute inset-3 border-4 border-transparent border-t-emerald-500 rounded-full animate-spin"
style="animation-delay: -0.9s"
/>
</div>
<Transition name="fade">
<div v-if="fullLoading" class="spinner-container">
<div class="spinner-line"></div>
</div>
</Transition>
</template>
<style scoped>
.spinner-container {
@apply fixed inset-0 bg-black/90 flex items-center justify-center z-[900];
}
.spinner {
@apply w-[80px] h-[80px] bg-cover bg-center bg-no-repeat bg-[url('/images/common/publisning_template_loader_black.png')];
}
[data-theme='light'] {
.spinner-container {
@apply bg-white/90;
}
.spinner {
@apply bg-[url('/images/common/publisning_template_loader_white.png')];
}
}
</style>

View File

@@ -14,42 +14,29 @@ const canTeleport = (localId: string) => {
<template>
<template v-for="[localId, loadingInfo] in localLoadings" :key="localId">
<Teleport v-if="canTeleport(localId)" :to="`#${localId}`">
<Transition
enter-active-class="transition-opacity duration-300 ease-in-out"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-active-class="transition-opacity duration-300 ease-in-out"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<div
v-if="loadingInfo.active"
class="fixed inset-0 bg-black/80 flex items-center justify-center z-[9999]"
>
<!-- 메인 스피너 -->
<div class="relative w-16 h-16">
<!-- 외부 -->
<div
class="absolute inset-0 border-4 border-transparent border-t-blue-500 rounded-full animate-spin"
/>
<!-- 중간 -->
<div
class="absolute inset-1 border-4 border-transparent border-t-purple-500 rounded-full animate-spin"
style="animation-delay: -0.3s"
/>
<!-- 내부 -->
<div
class="absolute inset-2 border-4 border-transparent border-t-cyan-500 rounded-full animate-spin"
style="animation-delay: -0.6s"
/>
<!-- 중심 -->
<div
class="absolute inset-3 border-4 border-transparent border-t-emerald-500 rounded-full animate-spin"
style="animation-delay: -0.9s"
/>
</div>
<Transition name="fade">
<div v-if="loadingInfo.active" class="spinner-container">
<div class="spinner-line"></div>
</div>
</Transition>
</Teleport>
</template>
</template>
<style scoped>
.spinner-container {
@apply fixed inset-0 bg-black/90 flex items-center justify-center z-[900];
}
.spinner {
@apply w-[80px] h-[80px] bg-cover bg-center bg-no-repeat bg-[url('/images/common/publisning_template_loader_black.png')];
}
[data-theme='light'] {
.spinner-container {
@apply bg-white/90;
}
.spinner {
@apply bg-[url('/images/common/publisning_template_loader_white.png')];
}
}
</style>

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { getYouTubeEmbedUrl } from '#layers/utils/youtube'
import { getYouTubeEmbedUrl } from '@/layers/utils/youtubeUtil'
interface Props {
youtubeUrl: string

View File

@@ -7,7 +7,7 @@ import { useSplideArrow } from '#layers/composables/useSplideArrow'
interface Props {
slideItemSize: SlideItemSize
slideItemLength?: number
autoplay?: boolean | string
autoplay?: boolean
arrows?: boolean
pagination?: boolean
class?: string
@@ -19,14 +19,12 @@ const props = withDefaults(defineProps<Props>(), {
pagination: true,
})
const emit = defineEmits(['mounted', 'move', 'moved', 'arrowClick'])
const emit = defineEmits(['mounted', 'move', 'arrowClick'])
// Splide 화살표 로직을 위한 composable 사용
const { addArrowClickListeners } = useSplideArrow()
const isMultipleItems = computed(() => {
return props.slideItemLength > 1
})
const isMultipleItems = computed(() => (props.slideItemLength ?? 0) > 1)
const options = computed((): ResponsiveOptions => {
return {
@@ -34,7 +32,7 @@ const options = computed((): ResponsiveOptions => {
focus: 'center',
autoWidth: true,
autoHeight: true,
speed: 400,
speed: 500,
updateOnMove: true,
arrows: props.arrows && isMultipleItems.value,
pagination: props.pagination && isMultipleItems.value,
@@ -86,7 +84,7 @@ const style = computed(() => {
const handleSplideMounted = (splide: SplideType) => {
emit('mounted', splide)
splide.refresh()
// 화살표 버튼 클릭 이벤트 리스너 추가
nextTick(() => {
addArrowClickListeners(splide, (direction, targetIndex) => {
@@ -103,27 +101,14 @@ const handleMove = (
) => {
emit('move', splide, newIndex, oldIndex, destIndex)
}
const handleMoved = (
splide: SplideType,
newIndex: number,
oldIndex: number,
destIndex: number
) => {
emit('moved', splide, newIndex, oldIndex, destIndex)
}
</script>
<template>
<div :class="`center-highlight ${props.class || ''}`" :style="style">
<div :class="`center-focus ${props.class || ''}`" :style="style">
<Splide
:options="options"
@splide:mounted="handleSplideMounted"
@splide:move="handleMove"
@splide:moved="handleMoved"
>
<slot />
</Splide>
@@ -131,58 +116,58 @@ const handleMoved = (
</template>
<style scoped>
.center-highlight {
.center-focus {
@apply w-full;
}
.center-highlight:deep(.splide__slide) {
.center-focus:deep(.splide__slide) {
@apply flex items-center justify-center;
width: var(--banner-width-mo);
height: var(--banner-height-mo-active);
margin-right: var(--banner-gap-mo);
opacity: 0.5;
}
.center-highlight:deep(.splide__slide) .slide-inner {
.center-focus:deep(.splide__slide) .slide-inner {
width: var(--banner-width-mo);
height: var(--banner-height-mo);
opacity: 0.5;
}
.center-highlight:deep(.splide__slide.is-active) {
.center-focus:deep(.splide__slide.is-active) {
width: var(--banner-width-mo-container);
opacity: 1;
}
.center-highlight:deep(.splide__slide.is-active) .slide-inner {
.center-focus:deep(.splide__slide.is-active) .slide-inner {
width: var(--banner-width-mo-active);
height: var(--banner-height-mo-active);
opacity: 1;
transition: all 0.45s cubic-bezier(0.4, 0, 0.2, 1);
}
.center-highlight:deep(.splide__slide.is-next),
.center-highlight:deep(.splide__slide.is-prev) {
.center-focus:deep(.splide__slide.is-next),
.center-focus:deep(.splide__slide.is-prev) {
opacity: 1;
}
/* PC 스타일 */
@media (min-width: 1024px) {
.center-highlight:deep(.splide__slide) {
.center-focus:deep(.splide__slide) {
width: var(--banner-width-pc);
height: var(--banner-height-pc-active);
margin-right: var(--banner-gap-pc);
}
.center-highlight:deep(.splide__slide) .slide-inner {
.center-focus:deep(.splide__slide) .slide-inner {
width: var(--banner-width-pc);
height: var(--banner-height-pc);
}
.center-highlight:deep(.splide__slide.is-active) {
.center-focus:deep(.splide__slide.is-active) {
width: var(--banner-width-pc-container);
}
.center-highlight:deep(.splide__slide.is-active) .slide-inner {
.center-focus:deep(.splide__slide.is-active) .slide-inner {
width: var(--banner-width-pc-active);
height: var(--banner-height-pc-active);
}
.center-highlight:deep(.arrow-prev) {
.center-focus:deep(.arrow-prev) {
left: 28px;
}
.center-highlight:deep(.arrow-next) {
.center-focus:deep(.arrow-next) {
right: 28px;
}
}

View File

@@ -6,7 +6,7 @@ import type { SlideItemSize } from '#layers/types/components/slide'
interface Props {
slideItemSize: SlideItemSize
slideItemLength?: number
autoplay?: boolean | string
autoplay?: boolean
arrows?: boolean
pagination?: boolean
class?: string
@@ -18,14 +18,12 @@ const props = withDefaults(defineProps<Props>(), {
pagination: true,
})
const emit = defineEmits(['mounted', 'move', 'moved', 'arrowClick'])
const emit = defineEmits(['mounted', 'move', 'arrowClick'])
// Splide 화살표 로직을 위한 composable 사용
const { addArrowClickListeners } = useSplideArrow()
const isMultipleItems = computed(() => {
return props.slideItemLength > 1
})
const isMultipleItems = computed(() => (props.slideItemLength ?? 0) > 1)
const options = computed((): ResponsiveOptions => {
return {
@@ -33,7 +31,7 @@ const options = computed((): ResponsiveOptions => {
focus: 'center',
autoWidth: true,
autoHeight: true,
speed: 400,
speed: 500,
updateOnMove: true,
arrows: props.arrows && isMultipleItems.value,
pagination: props.pagination && isMultipleItems.value,
@@ -88,7 +86,7 @@ const style = computed(() => {
const handleSplideMounted = (splide: SplideType) => {
emit('mounted', splide)
splide.refresh()
// 화살표 버튼 클릭 이벤트 리스너 추가
nextTick(() => {
addArrowClickListeners(splide, (direction, targetIndex) => {
@@ -105,15 +103,6 @@ const handleMove = (
) => {
emit('move', splide, newIndex, oldIndex, destIndex)
}
const handleMoved = (
splide: SplideType,
newIndex: number,
oldIndex: number,
destIndex: number
) => {
emit('moved', splide, newIndex, oldIndex, destIndex)
}
</script>
<template>
@@ -122,7 +111,6 @@ const handleMoved = (
:options="options"
@splide:mounted="handleSplideMounted"
@splide:move="handleMove"
@splide:moved="handleMoved"
>
<slot />
</Splide>

View File

@@ -0,0 +1,86 @@
<script setup lang="ts">
import { Splide } from '@splidejs/vue-splide'
import type { Splide as SplideType, ResponsiveOptions } from '@splidejs/splide'
import type { PageDataResourceGroups } from '#layers/types/api/pageData'
interface Props {
type?: ResponsiveOptions['type']
slideItemLength?: number
autoplay?: boolean
arrows?: boolean
pagination?: boolean
paginationData?: PageDataResourceGroups
breakpoints?: ResponsiveOptions['breakpoints']
}
const props = withDefaults(defineProps<Props>(), {
autoplay: false,
arrows: true,
pagination: true,
})
const emit = defineEmits(['mounted', 'move', 'arrowClick'])
// Splide 화살표 로직을 위한 composable 사용
const { addArrowClickListeners } = useSplideArrow()
const isMultipleItems = computed(() => (props.slideItemLength ?? 0) > 1)
const resolvedType = computed<ResponsiveOptions['type']>(() => {
if (props.type) return props.type
return isMultipleItems.value ? 'loop' : 'slide'
})
const options = computed((): ResponsiveOptions => {
return {
type: resolvedType.value,
autoWidth: true,
autoHeight: true,
speed: 500,
updateOnMove: true,
autoplay: props.autoplay,
arrows: props.arrows && isMultipleItems.value,
pagination: props.pagination && isMultipleItems.value,
classes: {
arrows: 'splide-arrows',
arrow: 'splide-arrow',
prev: 'arrow-prev',
next: 'arrow-next',
pagination: 'splide-pagination-bullets',
page: 'splide-pagination-bullet',
},
...(props.breakpoints ? { breakpoints: props.breakpoints } : {}),
}
})
const handleSplideMounted = (splide: SplideType) => {
emit('mounted', splide)
// 화살표 버튼 클릭 이벤트 리스너 추가
nextTick(() => {
addArrowClickListeners(splide, (direction, targetIndex) => {
emit('arrowClick', direction, targetIndex)
})
})
}
const handleMove = (
splide: SplideType,
newIndex: number,
oldIndex: number,
destIndex: number
) => {
emit('move', splide, newIndex, oldIndex, destIndex)
}
</script>
<template>
<Splide
:options="options"
class="w-full"
:style="getPaginationClass(props.paginationData)"
@splide:mounted="handleSplideMounted"
@splide:move="handleMove"
>
<slot />
</Splide>
</template>

View File

@@ -1,13 +1,15 @@
<script setup lang="ts">
import { Splide } from '@splidejs/vue-splide'
import type { Splide as SplideType, ResponsiveOptions } from '@splidejs/splide'
import { useSplideArrow } from '#layers/composables/useSplideArrow'
import { getPaginationClass } from '#layers/utils/styleUtil'
import type { Splide as SplideType, ResponsiveOptions } from '@splidejs/splide'
import type { PageDataResourceGroups } from '#layers/types/api/pageData'
interface Props {
autoplay?: boolean | string
autoplay?: boolean
arrows?: boolean
pagination?: boolean
class?: string
paginationData?: PageDataResourceGroups
}
const props = withDefaults(defineProps<Props>(), {
@@ -16,7 +18,7 @@ const props = withDefaults(defineProps<Props>(), {
pagination: true,
})
const emit = defineEmits(['mounted', 'move', 'moved', 'arrowClick'])
const emit = defineEmits(['mounted', 'move', 'arrowClick'])
const splideRef = ref()
// Splide 화살표 로직을 위한 composable 사용
@@ -28,7 +30,7 @@ const options = computed((): ResponsiveOptions => {
rewind: true,
perPage: 1,
perMove: 1,
speed: 600,
speed: 500,
updateOnMove: true,
autoplay: props.autoplay,
pauseOnHover: false,
@@ -52,7 +54,6 @@ defineExpose({
const handleSplideMounted = (splide: SplideType) => {
emit('mounted', splide)
splide.refresh()
// 화살표 버튼 클릭 이벤트 리스너 추가
nextTick(() => {
@@ -70,16 +71,6 @@ const handleMove = (
) => {
emit('move', splide, newIndex, oldIndex, destIndex)
}
const handleMoved = (
splide: SplideType,
newIndex: number,
oldIndex: number,
destIndex: number
) => {
emit('moved', splide, newIndex, oldIndex, destIndex)
}
</script>
<template>
@@ -87,9 +78,9 @@ const handleMoved = (
ref="splideRef"
:options="options"
class="h-full"
:style="getPaginationClass(props.paginationData)"
@splide:mounted="handleSplideMounted"
@splide:move="handleMove"
@splide:moved="handleMoved"
>
<slot />
</Splide>

View File

@@ -19,7 +19,7 @@ const props = withDefaults(defineProps<Props>(), {
drag: true,
})
const emit = defineEmits(['arrowClick'])
const emit = defineEmits(['mounted', 'move', 'arrowClick'])
// Splide 화살표 로직을 위한 composable 사용
const { addArrowClickListeners } = useSplideArrow()
@@ -32,8 +32,6 @@ defineExpose({
thumbsInst: computed(() => thumbsInst),
})
const breakpoints = useResponsiveBreakpointsReliable()
const mainRef = ref<InstanceType<typeof Splide> | null>(null)
const thumbsRef = ref<InstanceType<typeof Splide> | null>(null)
@@ -42,7 +40,7 @@ const mainOptions = computed<Options>(() => ({
rewind: true,
perPage: 1,
perMove: 1,
speed: 600,
speed: 500,
arrows: false,
pagination: false,
drag: props.drag,
@@ -72,17 +70,23 @@ const getThumbnailSrc = (item: PageDataTemplateComponentSet) => {
return mediaComponent ? getMediaImgSrc(mediaComponent) : ''
}
const pagenaviThumbnailComponent = getComponentGroup(
item,
'pagenaviThumbnail'
)
const pagenaviThumbnailSrc = getResponsiveSrc(
pagenaviThumbnailComponent?.res_path
)
const thumbnailComponent = getComponentGroup(item, 'pagenaviThumbnail')
const thumbnailPath = getDeviceSrc(thumbnailComponent?.res_path)
return breakpoints.value.isMobile
? pagenaviThumbnailSrc?.mobileSrc
: pagenaviThumbnailSrc?.pcSrc || ''
return thumbnailPath?.pcSrc
}
const handleMove = (
splide: SplideType,
newIndex: number,
oldIndex: number,
destIndex: number
) => {
emit('move', splide, newIndex, oldIndex, destIndex)
}
const handleSplideMounted = (splide: SplideType) => {
emit('mounted', splide)
}
onMounted(() => {
@@ -109,7 +113,13 @@ onBeforeUnmount(() => {
<template>
<div :class="['thumbnail-carousel', $attrs?.class, `thumbnail-${variant}`]">
<!-- 메인 슬라이드 -->
<Splide ref="mainRef" :options="mainOptions" class="main-splide">
<Splide
ref="mainRef"
:options="mainOptions"
class="main-splide"
@splide:move="handleMove"
@splide:mounted="handleSplideMounted"
>
<slot />
</Splide>
<!-- 썸네일 슬라이드 -->
@@ -118,7 +128,7 @@ onBeforeUnmount(() => {
ref="thumbsRef"
:options="thumbOptions"
class="thumbnail-splide"
:style="getPaginationClass(paginationData, { type: 'thumbnail' })"
:style="getPaginationClass(paginationData)"
>
<SplideSlide
v-for="(item, index) in props.slideData"

View File

@@ -201,7 +201,7 @@ onBeforeUnmount(() => {
Object.keys(gnbData?.menus).length - overflowNam,
}"
>
<BlocksHybridLink
<AtomsLocaleLink
:to="gnbItem.url_path"
:target="gnbItem.link_target"
:class="['nav-1depth', { active: isNavItemActive(gnbItem) }]"
@@ -211,7 +211,7 @@ onBeforeUnmount(() => {
v-if="gnbItem.children"
class="hidden md:block"
/>
</BlocksHybridLink>
</AtomsLocaleLink>
<Transition name="fade">
<div v-if="gnbItem.children" class="nav-2depth">
<ul>
@@ -219,7 +219,7 @@ onBeforeUnmount(() => {
v-for="child in gnbItem.children"
:key="child.menu_name"
>
<BlocksHybridLink
<AtomsLocaleLink
:to="child.url_path"
:target="child.link_target"
>
@@ -227,7 +227,7 @@ onBeforeUnmount(() => {
<AtomsIconsWebLinkLine
v-if="child.link_target === '_blank'"
/>
</BlocksHybridLink>
</AtomsLocaleLink>
</li>
</ul>
</div>
@@ -250,20 +250,20 @@ onBeforeUnmount(() => {
Object.keys(gnbData?.menus).length - overflowNam,
}"
>
<BlocksHybridLink
<AtomsLocaleLink
:to="gnbItem.url_path"
:target="gnbItem.link_target"
:class="`${isNavItemActive(gnbItem) ? 'active' : ''}`"
>
<span>{{ gnbItem.menu_name }}</span>
</BlocksHybridLink>
</AtomsLocaleLink>
<div v-if="gnbItem.children">
<ul>
<li
v-for="child in gnbItem.children"
:key="child.menu_name"
>
<BlocksHybridLink
<AtomsLocaleLink
:to="child.url_path"
:target="child.link_target"
>
@@ -271,7 +271,7 @@ onBeforeUnmount(() => {
<AtomsIconsWebLinkLine
v-if="child.link_target === '_blank'"
/>
</BlocksHybridLink>
</AtomsLocaleLink>
</li>
</ul>
</div>
@@ -279,17 +279,19 @@ onBeforeUnmount(() => {
</div>
</div>
</div>
<div class="event">
<div v-if="gameData?.event_banner" class="event">
<div class="nav-item">
<BlocksHybridLink
:to="'/event'"
:target="'_self'"
<AtomsLocaleLink
:to="gameData.event_banner?.page_url"
:target="
gameData.event_banner?.link_type === 1 ? '_self' : '_blank'
"
class="nav-1depth text-gradient-pink"
>
<AtomsIconsStarFill />
<span>이벤트</span>
<AtomsIconsStarFill />
</BlocksHybridLink>
</AtomsLocaleLink>
</div>
</div>
</nav>

View File

@@ -22,7 +22,8 @@ const shouldShowMetaTag = computed(() => props.pageData.meta_tag_type === 2)
// 템플릿 표시 여부 확인
const isTemplateVisible = (template: PageDataTemplate): boolean => {
return Boolean(
template?.components && Object.keys(template.components).length > 0
template?.page_ver_tmpl_json &&
Object.keys(template?.page_ver_tmpl_json).length > 0
)
}
@@ -34,14 +35,14 @@ const visibleTemplates = computed(() =>
// SEO 메타 태그 설정
const setupSeoMeta = (metaTag: PageDataMetaTag) => {
useSeoMeta({
title: metaTag.page_title ?? '',
description: metaTag.page_desc ?? '',
ogTitle: metaTag.og_title ?? '',
ogDescription: metaTag.og_desc ?? '',
ogImage: metaTag.og_image ?? '',
twitterTitle: metaTag.x_title ?? '',
twitterImage: metaTag.x_image ?? '',
twitterDescription: metaTag.x_desc ?? '',
title: metaTag?.page_title ?? '',
description: metaTag?.page_desc ?? '',
ogTitle: metaTag?.og_title ?? '',
ogDescription: metaTag?.og_desc ?? '',
ogImage: metaTag?.og_image ?? '',
twitterTitle: metaTag?.x_title ?? '',
twitterImage: metaTag?.x_image ?? '',
twitterDescription: metaTag?.x_desc ?? '',
})
}
@@ -52,8 +53,8 @@ onMounted(() => {
// 메타 태그 설정 감시
watchEffect(() => {
if (shouldShowMetaTag.value && props.pageData.meta_tag) {
setupSeoMeta(props.pageData.meta_tag)
if (shouldShowMetaTag.value && props.pageData?.meta_tag_json) {
setupSeoMeta(props.pageData?.meta_tag_json)
}
})
@@ -67,8 +68,8 @@ watchEffect(() => {
>
<component
:is="getTemplateComponent(template.template_code)"
:components="template.components"
:page-ver-tmpl-seq="template.page_ver_tmpl_seq.toString()"
:components="template.page_ver_tmpl_json"
:page-ver-tmpl-seq="template.page_ver_tmpl_seq"
/>
</template>
</main>

View File

@@ -4,72 +4,70 @@ import type { PageDataResourceGroup } from '#layers/types/api/pageData'
interface Props {
resourcesData: PageDataResourceGroup
gradient?: string
size?: 'contain' | 'cover'
}
const props = withDefaults(defineProps<Props>(), {
gradient: '',
size: 'cover',
})
const breakpoints = useResponsiveBreakpointsReliable()
const { getCurrentSrc } = useResponsiveSrc()
const resPath = computed(() => {
return props.resourcesData?.res_path
})
const bgStyles = computed(() => {
return getResponsiveSrc(resPath.value, {
resourcesType: 'bg',
})
const videoRef = ref<HTMLVideoElement | null>(null)
const resPath = computed(() => props.resourcesData?.res_path)
const imageSrc = computed(() => {
return getCurrentSrc(resPath.value)
})
const videoSrc = computed(() => {
return getResponsiveSrc(resPath.value, {
resourcesType: 'video',
})
return getCurrentSrc(resPath.value, { resourcesType: 'video' })
})
const posterSrc = computed(() => {
return getResponsiveSrc(resPath.value)
return getCurrentSrc(resPath.value)
})
const currentVideoSrc = computed(() => {
if (!videoSrc.value) return ''
return breakpoints.value.isMobile
? videoSrc.value.mobileSrc
: videoSrc.value.pcSrc
})
const currentPosterSrc = computed(() => {
if (!posterSrc.value) return ''
return breakpoints.value.isMobile
? posterSrc.value.mobileSrc
: posterSrc.value.pcSrc
const imageClasses = computed(() => [
`w-full h-full bg-center bg-no-repeat`,
props.size === 'contain' ? 'bg-contain' : 'bg-cover',
])
const gradientClasses = computed(() => [
'absolute bottom-0 left-0 right-0',
props.gradient,
])
// src 변경 시 비디오 다시 로드
watch(videoSrc, () => {
if (!videoRef.value) return
videoRef.value.currentTime = 0
videoRef.value.load()
})
</script>
<template>
<div class="absolute inset-0 w-full h-full">
<!-- 이미지 타입-->
<!-- 이미지 타입 -->
<div
v-if="isTypeImage(resourcesData?.resource_type)"
class="w-full h-full bg-cover bg-center bg-no-repeat"
:class="getResponsiveClass()"
:style="bgStyles"
v-if="isTypeImage(resourcesData?.resource_type) && imageSrc"
:class="imageClasses"
:style="{ backgroundImage: `url(${imageSrc})` }"
/>
<!-- 비디오 타입 -->
<video
v-else-if="isTypeVideo(resourcesData?.resource_type) && currentVideoSrc"
v-else-if="isTypeVideo(resourcesData?.resource_type) && videoSrc"
ref="videoRef"
class="w-full h-full object-cover"
:poster="currentPosterSrc"
:poster="posterSrc"
autoplay
muted
loop
playsinline
>
<source :src="currentVideoSrc" type="video/mp4" />
<source :src="currentVideoSrc" type="video/webm" />
<source :src="videoSrc" type="video/mp4" />
</video>
<!-- 그라디언트 오버레이 (gradient가 true일 때만) -->
<div
v-if="props.gradient"
:class="`absolute bottom-0 left-0 right-0 ${props.gradient}`"
/>
<!-- 그라디언트 오버레이 -->
<div v-if="gradient" :class="gradientClasses" />
</div>
</template>

View File

@@ -1,47 +0,0 @@
<script setup lang="ts">
import { SplideSlide } from '@splidejs/vue-splide'
import type { OperateGroupItem } from '#layers/types/api/resourcesData'
import type { SlideItemSize } from '#layers/types/components/slide'
interface BannerListProps {
resourcesData: OperateGroupItem[]
slideItemSize: SlideItemSize
arrows?: boolean
pagination?: boolean
class?: string
}
const props = withDefaults(defineProps<BannerListProps>(), {
arrows: true,
pagination: true,
})
</script>
<template>
<BlocksSlideCenterHighlight
:slide-item-size="props.slideItemSize"
:slide-item-length="props.resourcesData.length"
:pagination="false"
class="mt-[36px] md:mt-[60px]"
>
<SplideSlide v-for="(item, index) in props.resourcesData" :key="index">
<BlocksCardNews
:title="item.title"
:description="item.option01"
:img-path="getResolvedHost(item.img_path)"
:url="item.url"
:link-target="item.link_target"
class="slide-inner"
/>
</SplideSlide>
</BlocksSlideCenterHighlight>
</template>
<style scoped>
.center-highlight:deep(.splide__slide.is-active .card-link) {
pointer-events: auto;
}
.center-highlight:deep(.splide__slide .card-link) {
pointer-events: none;
}
</style>

View File

@@ -13,6 +13,9 @@ interface ButtonListProps {
const props = defineProps<ButtonListProps>()
const { gameData } = useGameDataStore()
const { locale } = useI18n()
const { sendLog, useAnalyticsLogDataDirect } = useAnalytics()
const BUTTON_TYPE_MAP = {
URL: {
_self: 'internal' as const,
@@ -54,15 +57,18 @@ const getButtonBackgroundImage = (
return ''
}
const { locale } = useI18n()
const { sendLog, useAnalyticsLogDataDirect } = useAnalytics()
const handleButtonClick = (btnInfo: PageDataResourceGroupBtnInfo, index: any) => {
sendLog(locale.value, useAnalyticsLogDataDirect(props.resourcesData[index], props.pageVerTmplSeq))
const handleButtonClick = (
btnInfo: PageDataResourceGroupBtnInfo,
index: any
) => {
sendLog(
locale.value,
useAnalyticsLogDataDirect(props.resourcesData[index], props.pageVerTmplSeq)
)
const marketType = btnInfo?.detail?.market_type
if (marketType) {
const url = gameData?.market[marketType]?.url
const url = gameData?.market_json[marketType]?.url
window.open(url, '_blank')
return
}
@@ -100,7 +106,7 @@ const handleButtonClick = (btnInfo: PageDataResourceGroupBtnInfo, index: any) =>
}"
@click="handleButtonClick(button.btn_info, index)"
>
{{ button.btn_info?.txt_btn_name }}
{{ button.btn_info?.txt_btn_name }}
</AtomsButton>
</div>
</template>

View File

@@ -7,7 +7,13 @@ const props = defineProps<{
</script>
<template>
<p>
<p class="description">
<BlocksVisualContent :resources-data="props.resourcesData" />
</p>
</template>
<style scoped>
.description {
@apply line-clamp-4 text-[15px] font-[400] leading-[24px] drop-shadow-[0_2px_2px_rgba(0,0,0,0.6)] md:line-clamp-3 md:text-[20px] md:leading-[30px];
}
</style>

View File

@@ -1,13 +1,18 @@
<script setup lang="ts">
import type { PageDataResourceGroup } from '#layers/types/api/pageData'
const props = defineProps<{
interface Props {
resourcesData: PageDataResourceGroup
}>()
tag?: 'h3' | 'h4' | 'p'
}
const props = withDefaults(defineProps<Props>(), {
tag: 'h3',
})
</script>
<template>
<h3>
<component :is="props.tag">
<BlocksVisualContent :resources-data="props.resourcesData" />
</h3>
</component>
</template>

View File

@@ -6,19 +6,19 @@ const props = defineProps<{
pageVerTmplSeq: number
}>()
// YouTube 모달 스토어 사용
const modalStore = useModalStore()
const {locale} = useI18n()
const { locale } = useI18n()
const { sendLog, useAnalyticsLogDataDirect } = useAnalytics()
// 비디오 플레이 버튼 클릭 핸들러
const handleVideoPlayClick = () => {
const youtubeUrl = props.resourcesData?.display?.text ?? ''
modalStore.handleOpenYoutube({ youtubeUrl })
sendLog(locale.value, useAnalyticsLogDataDirect(props.resourcesData, props.pageVerTmplSeq))
sendLog(
locale.value,
useAnalyticsLogDataDirect(props.resourcesData, props.pageVerTmplSeq)
)
}
</script>