Merge branch 'feature/20250930_cl_GR_GALLERY' into feature/20250910-all
This commit is contained in:
28
layers/components/atoms/Button/Play.vue
Normal file
28
layers/components/atoms/Button/Play.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
29
layers/components/atoms/icons/Close.vue
Normal file
29
layers/components/atoms/icons/Close.vue
Normal 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>
|
||||
@@ -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"
|
||||
|
||||
63
layers/components/blocks/CardNews.vue
Normal file
63
layers/components/blocks/CardNews.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
121
layers/components/blocks/slide/CenterHighlight.vue
Normal file
121
layers/components/blocks/slide/CenterHighlight.vue
Normal 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>
|
||||
52
layers/components/blocks/slide/Fade.vue
Normal file
52
layers/components/blocks/slide/Fade.vue
Normal 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>
|
||||
162
layers/components/blocks/slide/Thumbnail.vue
Normal file
162
layers/components/blocks/slide/Thumbnail.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
77
layers/components/widgets/BannerList.vue
Normal file
77
layers/components/widgets/BannerList.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user