Merge branch 'feature/20250930_cl_GR_GALLERY' into feature/20250910-all

This commit is contained in:
clkim
2025-09-25 10:07:06 +09:00
48 changed files with 1853 additions and 354 deletions

View File

@@ -0,0 +1,28 @@
<script setup lang="ts">
import type { PageDataResourceGroup } from '#layers/types/api/pageData'
const props = defineProps<{ resourcesData: PageDataResourceGroup }>()
const emit = defineEmits<{
(e: 'click'): void
}>()
const bgStyles = getResponsiveSrc(props.resourcesData?.res_path, {
resourcesType: 'bg',
})
</script>
<template>
<button
v-if="resourcesData && bgStyles"
class="relative group bg-cover bg-center bg-no-repeat w-[66px] h-[66px] md:w-[100px] md:h-[100px]"
:class="getResponsiveClass()"
:style="bgStyles"
@click="emit('click')"
>
<span
class="absolute inset-0 m-[10px] bg-white opacity-0 group-hover:opacity-10 transition-opacity duration-300 ease-in-out rounded-[50%]"
/>
<span class="sr-only">Play</span>
</button>
</template>

View File

@@ -1,15 +1,17 @@
<script setup lang="ts">
import type {
ButtonSize,
ButtonConfig,
ButtonProps,
} from '#layers/types/components/button'
import type { GameDataKeyCodeCodes } from '#layers/types/api/gameData'
interface ButtonProps {
backgroundColor?: string
textColor?: string
icon?: string
disabled?: boolean
backgroundImage?: string
}
const props = withDefaults(defineProps<ButtonProps>(), {
size: 'medium',
backgroundColor: 'var(--primary)',
textColor: 'var(--text-primary)',
textColor: 'var(--alternative-02)',
icon: '',
disabled: false,
})
@@ -23,63 +25,47 @@ const PARSED_KEY_CODE_CODES_KEYS: (keyof GameDataKeyCodeCodes)[] = [
'alternative-02',
]
//
const BUTTON_CONFIGS: Record<ButtonSize, ButtonConfig> = {
large: {
padding: 'px-10',
height: 'h-16',
text: 'text-lg',
rounded: 'rounded-lg',
},
medium: {
padding: 'px-10',
height: 'h-14',
text: 'text-base',
rounded: 'rounded-lg',
},
small: {
padding: 'px-10',
height: 'h-12',
text: 'text-sm',
rounded: 'rounded-lg',
},
'extra-small': {
padding: 'px-6',
height: 'h-10',
text: 'text-sm',
rounded: 'rounded',
},
} as const
// CSS
const getColorValue = (color: string) =>
PARSED_KEY_CODE_CODES_KEYS.includes(color as keyof GameDataKeyCodeCodes)
? `var(--${color})`
: color
const currentConfig = computed(() => BUTTON_CONFIGS[props.size])
const buttonClasses = computed(() => [
'group relative inline-flex items-center justify-center font-medium border border-gray-600/30 overflow-hidden',
`${currentConfig.value.padding} ${currentConfig.value.height} ${currentConfig.value.text} ${currentConfig.value.rounded}`,
'btn-base group relative inline-flex items-center justify-center font-medium border border-gray-600/30 overflow-hidden',
props.disabled ? 'cursor-default' : 'cursor-pointer',
])
const buttonStyles = computed(() => ({
backgroundColor: getColorValue(props.backgroundColor),
color: getColorValue(props.textColor),
}))
const buttonStyles = computed(() => {
const styles: Record<string, string> = {
backgroundColor: getColorValue(props.backgroundColor),
color: getColorValue(props.textColor),
}
if (props.backgroundImage) {
styles.backgroundImage = `url(${props.backgroundImage})`
styles.backgroundSize = 'contain'
styles.backgroundPosition = 'center'
styles.backgroundRepeat = 'no-repeat'
}
return styles
})
const overlayClasses = computed(() => [
'absolute inset-0 -m-px transition-opacity duration-200',
props.disabled
? 'opacity-20 z-10'
: 'bg-white opacity-0 group-hover:opacity-20',
currentConfig.value.rounded,
])
const overlayDisabledStyles = computed(
() =>
props.disabled && {
backgroundColor: props.textColor,
}
)
const contentDisabledStyles = computed(() => props.disabled && { opacity: 0.2 })
</script>

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

@@ -0,0 +1,63 @@
<script setup lang="ts">
interface Props {
title: string
description: string | number
imgPath: string
linkTarget?: string
url?: string
class?: string
}
const props = defineProps<Props>()
</script>
<template>
<div
v-if="props.title || props.description"
:class="`card-news ${props.class || ''}`"
>
<img
:src="props.imgPath"
:alt="props.title"
class="card-image"
loading="lazy"
/>
<div class="card-overlay">
<h3 v-if="props.title" class="card-title">
{{ props.title }}
</h3>
<p v-if="props.description" class="card-description">
{{ props.description }}
</p>
</div>
<a
v-if="props.url"
:href="props.url"
:target="props.linkTarget"
class="card-link"
/>
</div>
</template>
<style scoped>
.card-news {
@apply overflow-hidden relative flex items-center justify-center h-full rounded-lg
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-image {
@apply transition-transform duration-300 w-full h-full object-cover;
}
.card-overlay {
@apply absolute bottom-0 left-0 right-0 pt-[14px] px-[18px] pb-[16px] flex flex-col justify-end border-t border-white/10 bg-black/40 shadow-[0_-10px_10px_0_rgba(0,0,0,0.25)] backdrop-blur-[25px] md:pt-[20px] md:px-[26px] md:pb-[26px];
}
.card-title {
@apply text-[14px] leading-[20px] font-medium text-white md:text-lg md:leading-[26px];
}
.card-description {
@apply mt-[6px] text-[12px] leading-[18px] text-white/50 md:mt-1 md:text-[14px] md:leading-[24px];
}
.card-link {
@apply absolute top-0 left-0 w-full h-full z-[5];
}
</style>

View File

@@ -1,37 +1,87 @@
<script setup lang="ts">
interface ImageSource {
mobileSrc?: string
pcSrc?: string
}
import { getResponsiveSrc } from '#layers/utils/dataUtil'
import type { PageDataResourceGroup } from '#layers/types/api/pageData'
interface Props {
text?: string
imageSrc?: ImageSource
resourcesData?: PageDataResourceGroup
}
const props = defineProps<Props>()
// 텍스트 데이터 추출
// [TODO] txt 대신 text 사용
const displayText = computed(() => {
return props.resourcesData?.display?.txt || ''
})
// 이미지 소스 추출
const imageSrc = computed(() => {
return getResponsiveSrc(props.resourcesData?.res_path)
})
// 색상 코드 추출 (우선순위: color_code_txt > color_code)
const colorCode = computed(() => {
return (
props.resourcesData?.display?.color_code_txt ||
props.resourcesData?.display?.color_code
)
})
// 색상 이름 추출 (우선순위: color_name_txt > color_name)
const colorName = computed(() => {
return (
props.resourcesData?.display?.color_name_txt ||
props.resourcesData?.display?.color_name
)
})
// 색상 스타일 계산
const textStyles = computed(() => {
const styles: Record<string, string> = {}
if (colorName.value) {
styles.color = `var(--${colorName.value})`
} else if (colorCode.value) {
styles.color = colorCode.value
}
return styles
})
// HTML 콘텐츠 정리 (줄바꿈 처리)
const sanitizedContent = computed(() => {
return props.text?.replace(/\n/g, '<br/>') || ''
return displayText.value?.replace(/\n/g, '<br/>') || ''
})
// 이미지가 있는지 확인
const hasImage = computed(() => {
return imageSrc.value && (imageSrc.value.mobileSrc || imageSrc.value.pcSrc)
})
</script>
<template>
<template v-if="imageSrc && 'mobileSrc' in imageSrc">
<!-- 모바일 이미지 (sm 미만) -->
<!-- 이미지가 있는 경우 -->
<template v-if="hasImage">
<!-- 모바일 이미지 (md 미만) -->
<img
v-if="imageSrc.mobileSrc"
:src="imageSrc.mobileSrc"
:alt="text"
class="sm:hidden w-full h-full object-contain"
:alt="displayText"
class="md:hidden w-full h-full object-contain"
/>
<!-- PC 이미지 (sm 이상) -->
<!-- PC 이미지 (md 이상) -->
<img
v-if="imageSrc.pcSrc"
:src="imageSrc.pcSrc"
:alt="text"
class="hidden sm:block w-full h-full object-contain"
:alt="displayText"
class="hidden md:block w-full h-full object-contain"
/>
</template>
<span v-else-if="text" v-dompurify-html="sanitizedContent" />
<!-- 텍스트가 있는 경우 -->
<span
v-else-if="displayText"
v-dompurify-html="sanitizedContent"
:style="textStyles"
/>
</template>

View File

@@ -1,84 +1,72 @@
<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="embedUrl"
:src="embedUrl"
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">
import { getYouTubeEmbedUrl } from '#layers/utils/youtube'
interface Props {
isOpen: boolean
youtubeId: string
youtubeUrl: string
title?: string
description?: string
closeOnBackdrop?: boolean
}
interface Emits {
(e: 'close'): void
(e: 'update:isOpen', value: boolean): void
(e: 'closeButtonEvent'): void
}
const props = withDefaults(defineProps<Props>(), {
isOpen: false,
youtubeUrl: '',
title: '',
description: '',
closeOnBackdrop: true,
@@ -86,6 +74,10 @@ const props = withDefaults(defineProps<Props>(), {
const emit = defineEmits<Emits>()
const embedUrl = computed(() => {
return getYouTubeEmbedUrl(props.youtubeUrl)
})
// ESC 키로 모달 닫기
const handleKeydown = (event: KeyboardEvent) => {
if (event.key === 'Escape' && props.isOpen) {
@@ -102,8 +94,7 @@ const handleBackdropClick = () => {
// 모달 닫기 함수
const closeModal = () => {
emit('close')
emit('update:isOpen', false)
emit('closeButtonEvent')
}
// 키보드 이벤트 리스너 등록/해제
@@ -114,21 +105,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

@@ -0,0 +1,121 @@
<script setup lang="ts">
import { Splide } from '@splidejs/vue-splide'
import type { Splide as SplideType, ResponsiveOptions } from '@splidejs/splide'
import type { SlideItemSize } from '#layers/types/components/slide'
interface Props {
slideItemSize: SlideItemSize
type?: 'loop' | 'slide'
autoplay?: boolean | string
arrows?: boolean
pagination?: boolean
class?: string
}
const props = withDefaults(defineProps<Props>(), {
type: 'loop',
autoplay: false,
arrows: true,
pagination: true,
})
const options = computed((): ResponsiveOptions => {
return {
type: props.type,
focus: 'center',
autoWidth: true,
autoHeight: true,
speed: 400,
updateOnMove: true,
arrows: props.arrows,
pagination: props.pagination,
autoplay: props.autoplay,
classes: {
arrows: 'splide-arrows',
arrow: 'splide-arrow',
prev: 'arrow-prev',
next: 'arrow-next',
pagination: 'splide-pagination-bullets',
page: 'splide-pagination-bullet',
},
}
})
const style = computed(() => {
if (!props.slideItemSize) return {}
const { mo, pc } = props.slideItemSize
const scaleFactor = 1.1429
return {
// 모바일 기본값
'--banner-width-mo': `${mo.width}px`,
'--banner-height-mo': `${mo.height}px`,
'--banner-gap-mo': `${mo.gap}px`,
// 모바일 확대값
'--banner-width-mo-active': `${mo.width * scaleFactor}px`,
'--banner-height-mo-active': `${mo.height * scaleFactor}px`,
'--banner-width-mo-container': `${mo.width * scaleFactor + mo.gap}px`,
// PC 기본값
'--banner-width-pc': `${pc.width}px`,
'--banner-height-pc': `${pc.height}px`,
'--banner-gap-pc': `${pc.gap}px`,
// PC 확대값
'--banner-width-pc-active': `${pc.width * scaleFactor}px`,
'--banner-height-pc-active': `${pc.height * scaleFactor}px`,
'--banner-width-pc-container': `${pc.width * scaleFactor + pc.gap * 4}px`,
// PC arrow값
'--banner-arrow-pc': `${(pc.width * scaleFactor) / 2 + (pc.gap * 3) / 2}px`,
}
})
const handleSplideMounted = (splide: SplideType) => {
splide.refresh()
}
</script>
<template>
<div :class="`center-highlight ${props.class || ''}`" :style="style">
<Splide :options="options" @splide:mounted="handleSplideMounted">
<slot />
</Splide>
</div>
</template>
<style scoped>
.center-highlight {
@apply w-full;
}
.center-highlight: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);
}
.center-highlight:deep(.splide__slide.is-active) {
width: var(--banner-width-mo-container);
}
/* PC 스타일 */
@media (min-width: 1024px) {
.center-highlight: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.is-active) {
width: var(--banner-width-pc-container);
}
.center-highlight:deep(.splide-arrow) {
left: 50%;
transform: translate(-50%, -50%);
}
.center-highlight:deep(.arrow-prev) {
margin-left: calc(-1 * var(--banner-arrow-pc));
}
.center-highlight:deep(.arrow-next) {
margin-left: var(--banner-arrow-pc);
}
}
</style>

View File

@@ -0,0 +1,52 @@
<script setup lang="ts">
import { Splide } from '@splidejs/vue-splide'
import type { ResponsiveOptions } from '@splidejs/splide'
interface Props {
autoplay?: boolean | string
arrows?: boolean
pagination?: boolean
class?: string
}
const props = withDefaults(defineProps<Props>(), {
autoplay: false,
arrows: true,
pagination: true,
})
// 페이드 슬라이드 옵션
const fadeOptions = computed((): ResponsiveOptions => {
return {
type: 'fade',
rewind: true,
perPage: 1,
perMove: 1,
speed: 600,
updateOnMove: true,
autoplay: props.autoplay,
arrows: props.arrows,
pagination: props.pagination,
classes: {
arrows: 'splide-arrows type-full',
arrow: 'splide-arrow',
prev: 'arrow-prev',
next: 'arrow-next',
pagination: 'splide-pagination-bullets type-full',
page: 'splide-pagination-bullet',
},
}
})
</script>
<template>
<Splide :options="fadeOptions" class="h-full">
<slot />
</Splide>
</template>
<style scoped>
.splide:deep(.splide__track) {
@apply h-full;
}
</style>

View File

@@ -0,0 +1,162 @@
<script setup lang="ts">
import { Splide, SplideSlide } from '@splidejs/vue-splide'
import type { Splide as SplideType, Options } from '@splidejs/splide'
import type { PageDataResourceGroup } from '#layers/types/api/pageData'
interface Props {
slideItemList: { media: any; set_order: number }[]
videoPlay?: PageDataResourceGroup
arrows?: boolean
pagination?: boolean
class?: string
style?: Record<string, string>
}
const props = defineProps<Props>()
const mainRef = ref<InstanceType<typeof Splide> | null>(null)
const thumbsRef = ref<InstanceType<typeof Splide> | null>(null)
const isPlaying = ref<boolean>(false)
const mainOptions = computed<Options>(() => ({
type: 'fade',
rewind: true,
perPage: 1,
perMove: 1,
speed: 600,
arrows: false,
pagination: false,
}))
const thumbOptions = computed<Options>(() => ({
type: 'slide',
rewind: true,
// focus: 'center',
autoWidth: true,
perMove: 1,
arrows: true,
pagination: false,
isNavigation: true,
updateOnMove: true,
classes: {
arrows: 'splide-arrows',
arrow: 'splide-arrow',
prev: 'arrow-prev',
next: 'arrow-next',
},
}))
// 비디오 클릭 핸들러
const handleVideoClick = () => {
isPlaying.value = true
}
let mainInst: SplideType | null = null
let thumbsInst: SplideType | null = null
onMounted(() => {
mainInst = mainRef.value?.splide ?? null
thumbsInst = thumbsRef.value?.splide ?? null
if (mainInst && thumbsInst) {
mainInst.sync(thumbsInst)
}
})
onBeforeUnmount(() => {
mainInst?.destroy?.()
thumbsInst?.destroy?.()
})
</script>
<template>
<div :class="`thumbnail-carousel ${props.class || ''}`" :style="props.style">
<!-- 메인 슬라이드 -->
<Splide ref="mainRef" :options="mainOptions" class="main-splide">
<SplideSlide
v-for="(item, index) in props.slideItemList"
:key="item.set_order || index"
class="main-slide"
>
<template v-if="!isPlaying">
<img
:src="getMediaImgSrc(item.media)"
alt="main image"
class="slide-image"
/>
<AtomsButtonPlay
v-if="getMediaType(item.media) === 'video'"
:resources-data="videoPlay"
class="btn-play"
@click="handleVideoClick()"
/>
</template>
<template v-else>
<iframe
:src="getYouTubeEmbedUrl(getMediaText(item.media))"
class="absolute top-0 left-0 w-full h-full"
frameborder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowfullscreen
/>
</template>
</SplideSlide>
</Splide>
<!-- 썸네일 슬라이드 -->
<Splide ref="thumbsRef" :options="thumbOptions" class="thumbnail-splide">
<SplideSlide
v-for="(item, index) in props.slideItemList"
:key="item.set_order || index"
class="thumbnail-slide"
>
<img
:src="getMediaImgSrc(item.media)"
alt="thumbnail image"
class="slide-image"
/>
</SplideSlide>
</Splide>
</div>
</template>
<style scoped>
/* 비디오 iframe 전환 애니메이션 */
.thumbnail-carousel {
@apply w-full px-[20px] sm:px-[40px] md:max-w-[1024px];
}
.main-splide {
@apply overflow-hidden mx-auto rounded-lg border border-white/10 shadow-[0_4px_20px_0_rgba(0,0,0,0.5)];
}
.main-slide {
@apply aspect-[16/9];
}
.slide-image {
@apply w-full h-full object-cover;
}
.btn-play {
@apply absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2;
}
.thumbnail-splide {
@apply overflow-hidden flex justify-center w-screen mt-[20px] mx-[-20px] sm:mx-[-40px] md:w-auto md:mx-0 md:px-[120px] md:mt-[28px];
}
.thumbnail-splide:deep(.splide__track) {
@apply !px-[20px] sm:!px-[40px] md:!px-[0];
}
.thumbnail-slide {
@apply overflow-hidden relative w-[92px] h-[52px] mr-[12px] !border-none rounded-[4px] md:w-[128px] md:h-[72px] md:mr-[16px]
after:content-[''] after:absolute after:top-0 after:left-0 after:w-full after:h-full
after:border after:border-white/60 after:rounded-[4px];
}
.thumbnail-slide:hover,
.thumbnail-slide.is-active {
@apply after:border-[var(--primary)];
}
.thumbnail-splide:deep(.arrow-prev) {
@apply left-[48px];
}
.thumbnail-splide:deep(.arrow-next) {
@apply right-[48px];
}
</style>

View File

@@ -65,7 +65,7 @@ watchEffect(() => {
<component
:is="registry[template.template_code]?.component"
:components="template.components"
:page-ver-tmpl-seq="template.page_ver_tmpl_seq"
:page-ver-tmpl-seq="template.page_ver_tmpl_seq.toString()"
/>
</template>
</main>

View File

@@ -2,10 +2,14 @@
import { getResponsiveClass, getResponsiveSrc } from '#layers/utils/dataUtil'
import type { PageDataResourceGroup } from '#layers/types/api/pageData'
const props = defineProps<{
interface Props {
resourcesData: PageDataResourceGroup
gradientClass?: string
}>()
gradient?: boolean
}
const props = withDefaults(defineProps<Props>(), {
gradient: false,
})
const resPath = computed(() => {
return props.resourcesData?.res_path
@@ -37,10 +41,10 @@ const posterSrc = computed(() => {
<!-- 비디오 타입 -->
<template v-else-if="resourcesData?.group_type === 'video'">
<!-- 모바일 비디오 (sm 미만) -->
<!-- 모바일 비디오 (md 미만) -->
<video
v-if="videoSrc?.mobileSrc"
class="w-full h-full object-cover sm:hidden"
class="w-full h-full object-cover md:hidden"
:poster="posterSrc?.mobileSrc"
autoplay
muted
@@ -50,10 +54,10 @@ const posterSrc = computed(() => {
<source :src="videoSrc.mobileSrc" type="video/mp4" />
<source :src="videoSrc.mobileSrc" type="video/webm" />
</video>
<!-- PC 비디오 (sm 이상) -->
<!-- PC 비디오 (md 이상) -->
<video
v-if="videoSrc?.pcSrc"
class="w-full h-full object-cover hidden sm:block"
class="w-full h-full object-cover hidden md:block"
:poster="posterSrc?.pcSrc"
autoplay
muted
@@ -65,6 +69,10 @@ const posterSrc = computed(() => {
</video>
</template>
<div class="absolute inset-0" :class="gradientClass" />
<!-- 그라디언트 오버레이 (gradient가 true일 때만) -->
<div
v-if="props.gradient"
class="absolute bottom-0 left-0 right-0 h-[342px] md:h-[720px] bg-gradient-to-b from-[#100d0f]/0 to-[#100d0f]"
/>
</div>
</template>

View File

@@ -0,0 +1,77 @@
<script setup lang="ts">
import { SplideSlide } from '@splidejs/vue-splide'
import type { ListOperateGroupItem } from '#layers/types/api/resourcesData'
import type { SlideItemSize } from '#layers/types/components/slide'
interface BannerListProps {
slideItemList: ListOperateGroupItem[]
slideItemSize: SlideItemSize
arrows?: boolean
pagination?: boolean
class?: string
}
const props = withDefaults(defineProps<BannerListProps>(), {
arrows: true,
pagination: true,
})
const isMultipleItems = computed(() => {
return props.slideItemList.length > 1
})
</script>
<template>
<BlocksSlideCenterHighlight
:slide-item-size="props.slideItemSize"
:type="isMultipleItems ? 'loop' : 'slide'"
:arrows="isMultipleItems ? true : false"
:pagination="false"
class="mt-[36px] md:mt-[60px]"
>
<SplideSlide
v-for="(item, index) in props.slideItemList"
:key="index"
class="splide-slide"
>
<BlocksCardNews
:title="item.title"
:description="item.option01"
:img-path="getResolvedHost(item.img_path)"
:url="item.url"
:link-target="item.link_target"
class="news-center-highlight"
/>
</SplideSlide>
</BlocksSlideCenterHighlight>
</template>
<style scoped>
.center-highlight:deep(.splide__slide.is-active .news-center-highlight) {
width: var(--banner-width-mo-active);
height: var(--banner-height-mo-active);
}
.center-highlight:deep(.splide__slide.is-active .card-link) {
pointer-events: auto;
}
.news-center-highlight {
width: var(--banner-width-mo);
height: var(--banner-height-mo);
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
}
.center-highlight:deep(.splide__slide .card-link) {
pointer-events: none;
}
/* PC 스타일 */
@media (min-width: 1024px) {
.center-highlight:deep(.splide__slide.is-active .news-center-highlight) {
width: var(--banner-width-pc-active);
height: var(--banner-height-pc-active);
}
.news-center-highlight {
width: var(--banner-width-pc);
height: var(--banner-height-pc);
}
}
</style>

View File

@@ -3,19 +3,25 @@ import type { PageDataResourceGroup } from '#layers/types/api/pageData'
const props = defineProps<{
groupsData: PageDataResourceGroup[]
buttonType?: string
}>()
</script>
<template>
<template v-if="props.groupsData">
<div
v-if="props.groupsData"
class="flex flex-wrap justify-center gap-3 md:gap-4"
>
<AtomsButton
v-for="button in props.groupsData"
:key="button.group_code"
:button-type="props.buttonType"
class="size-extra-small md:size-medium"
:background-color="button.btn_info?.color_code_btn"
:text-color="button.btn_info?.color_code_txt"
:disabled="button.btn_info?.disabled"
>
{{ button.btn_info?.txt_btn_name }}
</AtomsButton>
</template>
</div>
</template>

View File

@@ -1,17 +1,13 @@
<script setup lang="ts">
import { getResponsiveSrc } from '#layers/utils/dataUtil'
import type { PageDataResourceGroup } from '#layers/types/api/pageData'
const props = defineProps<{
resourcesData: PageDataResourceGroup
}>()
const displayText = props.resourcesData?.display?.text
const imageSrc = getResponsiveSrc(props.resourcesData?.res_path)
</script>
<template>
<p>
<BlocksVisualContent :text="displayText" :image-src="imageSrc" />
<BlocksVisualContent :resources-data="props.resourcesData" />
</p>
</template>

View File

@@ -1,17 +1,13 @@
<script setup lang="ts">
import { getResponsiveSrc } from '#layers/utils/dataUtil'
import type { PageDataResourceGroup } from '#layers/types/api/pageData'
const props = defineProps<{
resourcesData: PageDataResourceGroup
}>()
const displayText = props.resourcesData?.display?.text
const imageSrc = getResponsiveSrc(props.resourcesData?.res_path)
</script>
<template>
<h2>
<BlocksVisualContent :text="displayText" :image-src="imageSrc" />
<BlocksVisualContent :resources-data="props.resourcesData" />
</h2>
</template>

View File

@@ -1,17 +1,13 @@
<script setup lang="ts">
import { getResponsiveSrc } from '#layers/utils/dataUtil'
import type { PageDataResourceGroup } from '#layers/types/api/pageData'
const props = defineProps<{
resourcesData: PageDataResourceGroup
}>()
const displayText = props.resourcesData?.display?.text
const imageSrc = getResponsiveSrc(props.resourcesData?.res_path)
</script>
<template>
<h3>
<BlocksVisualContent :text="displayText" :image-src="imageSrc" />
<BlocksVisualContent :resources-data="props.resourcesData" />
</h3>
</template>

View File

@@ -1,51 +1,26 @@
<script setup lang="ts">
import { getResponsiveSrc, getResponsiveClass } from '#layers/utils/dataUtil'
import type { PageDataResourceGroup } from '#layers/types/api/pageData'
const props = defineProps<{ resourcesData: PageDataResourceGroup, pageVerTmplSeq: number }>()
const props = defineProps<{
resourcesData: PageDataResourceGroup
pageVerTmplSeq: number
}>()
const { useAnalyticsLogData } = useAnalytics()
const logData = useAnalyticsLogData(props.resourcesData, props.pageVerTmplSeq)
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 youtubeUrl = props.resourcesData?.display?.text ?? ''
modalStore.handleOpenYoutube({ youtubeUrl })
}
</script>
<template>
<button
v-if="resourcesData"
v-analytics="logData"
class="bg-cover bg-center bg-no-repeat w-[66px] h-[66px] lg:w-[100px] lg:h-[100px]"
: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)"
<AtomsButtonPlay
:resources-data="resourcesData"
@click="handleVideoPlayClick"
/>
</template>