Merge branch 'feature/20250930_cl_GR_GALLERY' into feature/20250910-all
This commit is contained in:
13
app/app.vue
13
app/app.vue
@@ -2,11 +2,14 @@
|
||||
import { useNuxtApp } from 'nuxt/app'
|
||||
import LoadingFull from '#layers/components/blocks/loading/Full.vue'
|
||||
import LoadingLocal from '#layers/components/blocks/loading/Local.vue'
|
||||
import BlocksModalYouTube from '#layers/components/blocks/modal/YouTube.vue'
|
||||
import type { GameDataMetaTag, GameDataValue } from '#layers/types/api/gameData'
|
||||
|
||||
const nuxtApp = useNuxtApp()
|
||||
|
||||
const gameDataStore = useGameDataStore()
|
||||
const modalStore = useModalStore()
|
||||
const { youtube, handleResetYoutube } = modalStore
|
||||
const { setGameData } = gameDataStore
|
||||
const { gameData } = storeToRefs(gameDataStore)
|
||||
|
||||
@@ -121,7 +124,15 @@ gtag('event', 'screen_view', {
|
||||
<h1 class="sr-only">{{ gameData?.game_name }}</h1>
|
||||
<NuxtPage />
|
||||
|
||||
<!-- 로딩 컴포넌트들 -->
|
||||
<!-- 공통 모달 컴포넌트 -->
|
||||
<BlocksModalYouTube
|
||||
:is-open="youtube.storeIsOpen"
|
||||
:youtube-url="youtube.storeYoutubeUrl"
|
||||
:class-name="youtube.storeClassName"
|
||||
@close-button-event="handleResetYoutube"
|
||||
/>
|
||||
|
||||
<!-- 로딩 컴포넌트 -->
|
||||
<LoadingFull />
|
||||
<LoadingLocal />
|
||||
</template>
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
@import './base/_theme.css';
|
||||
@import './base/_reset.css';
|
||||
|
||||
@import './components/_splide.css';
|
||||
@import './components/_button.css';
|
||||
@import './components/_layout.css';
|
||||
|
||||
@import '@splidejs/vue-splide/css';
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@@ -3,4 +3,12 @@
|
||||
body {
|
||||
background-color: #000;
|
||||
}
|
||||
body.scroll-lock {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
button,
|
||||
a {
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
44
layers/assets/css/components/_button.css
Normal file
44
layers/assets/css/components/_button.css
Normal file
@@ -0,0 +1,44 @@
|
||||
/* Button Size Classes */
|
||||
@layer components {
|
||||
.btn-base {
|
||||
@apply relative inline-flex items-center justify-center font-medium border border-gray-600/30 overflow-hidden;
|
||||
/* 기본 크기: size-medium */
|
||||
--btn-padding: theme('spacing.10');
|
||||
--btn-height: theme('spacing.14');
|
||||
--btn-text: theme('fontSize.base');
|
||||
--btn-radius: theme('borderRadius.lg');
|
||||
@apply px-10 h-14 text-base rounded-lg;
|
||||
}
|
||||
|
||||
.size-extra-small {
|
||||
--btn-padding: theme('spacing.6');
|
||||
--btn-height: theme('spacing.10');
|
||||
--btn-text: theme('fontSize.sm');
|
||||
--btn-radius: theme('borderRadius.DEFAULT');
|
||||
@apply px-6 h-10 text-sm rounded;
|
||||
}
|
||||
|
||||
.size-small {
|
||||
--btn-padding: theme('spacing.10');
|
||||
--btn-height: theme('spacing.12');
|
||||
--btn-text: theme('fontSize.sm');
|
||||
--btn-radius: theme('borderRadius.lg');
|
||||
@apply px-10 h-12 text-sm rounded-lg;
|
||||
}
|
||||
|
||||
.size-medium {
|
||||
--btn-padding: theme('spacing.10');
|
||||
--btn-height: theme('spacing.14');
|
||||
--btn-text: theme('fontSize.base');
|
||||
--btn-radius: theme('borderRadius.lg');
|
||||
@apply px-10 h-14 text-base rounded-lg;
|
||||
}
|
||||
|
||||
.size-large {
|
||||
--btn-padding: theme('spacing.10');
|
||||
--btn-height: theme('spacing.16');
|
||||
--btn-text: theme('fontSize.lg');
|
||||
--btn-radius: theme('borderRadius.lg');
|
||||
@apply px-10 h-16 text-lg rounded-lg;
|
||||
}
|
||||
}
|
||||
10
layers/assets/css/components/_layout.css
Normal file
10
layers/assets/css/components/_layout.css
Normal file
@@ -0,0 +1,10 @@
|
||||
/* Layout Utility Classes */
|
||||
@layer components {
|
||||
.section-container {
|
||||
@apply relative h-[640px] md:h-[1000px];
|
||||
}
|
||||
|
||||
.section-content {
|
||||
@apply relative h-full flex flex-col items-center justify-center gap-4 md:gap-5;
|
||||
}
|
||||
}
|
||||
102
layers/assets/css/components/_splide.css
Normal file
102
layers/assets/css/components/_splide.css
Normal file
@@ -0,0 +1,102 @@
|
||||
/* 페이지네이션 버튼 - 모바일 퍼스트 */
|
||||
.splide-pagination-bullets {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-top: 24px;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.splide-pagination-bullets.type-full {
|
||||
position: absolute;
|
||||
bottom: 32px;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.splide-pagination-bullet {
|
||||
position: relative;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: var(--primary);
|
||||
border-radius: 50%;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.splide-pagination-bullet:after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 50%;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.splide-pagination-bullet.is-active:after {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* 네비게이션 버튼 - 모바일 퍼스트 */
|
||||
.splide-arrow {
|
||||
display: none;
|
||||
/* position: absolute;
|
||||
top: 50%;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
transform: translateY(-50%);
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
cursor: pointer;
|
||||
z-index: 5;
|
||||
background-color: var(--primary); */
|
||||
}
|
||||
|
||||
/* .type-full .arrow-prev {
|
||||
left: 20px;
|
||||
}
|
||||
.type-full.arrow-next {
|
||||
right: 20px;
|
||||
} */
|
||||
|
||||
/* 데스크톱 스타일 */
|
||||
@media (min-width: 1024px) {
|
||||
.splide-pagination-bullets {
|
||||
gap: 24px;
|
||||
margin-top: 32px;
|
||||
}
|
||||
|
||||
.splide-pagination-bullets.position-absolute {
|
||||
bottom: 48px;
|
||||
}
|
||||
|
||||
.splide-pagination-bullet {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
.splide-arrow {
|
||||
display: block;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
background-color: var(--primary);
|
||||
transform: translateY(-50%);
|
||||
cursor: pointer;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.type-full .arrow-prev {
|
||||
left: 40px;
|
||||
}
|
||||
|
||||
.type-full .arrow-next {
|
||||
right: 40px;
|
||||
}
|
||||
}
|
||||
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>
|
||||
|
||||
11
layers/composables/useBreakpoints.ts
Normal file
11
layers/composables/useBreakpoints.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* 반응형 브레이크포인트 계산 헬퍼
|
||||
*/
|
||||
export const useResponsiveBreakpoints = () => {
|
||||
return useBreakpoints({
|
||||
xs: 360, // Mobile: 360px ~ 767px
|
||||
sm: 768, // Tablet: 768px ~ 1023px
|
||||
md: 1024, // PC: 1024px ~ 1439px
|
||||
lg: 1440, // Large PC: 1440px+
|
||||
})
|
||||
}
|
||||
35
layers/composables/useGetGameDomain.ts
Normal file
35
layers/composables/useGetGameDomain.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { getHeader, getRequestHost } from 'h3'
|
||||
import { useRequestEvent } from 'nuxt/app'
|
||||
|
||||
/**
|
||||
* 게임 도메인을 가져오는 컴포저블 함수
|
||||
* 서버와 클라이언트 환경에서 모두 동작
|
||||
* @returns 게임 도메인 문자열
|
||||
*/
|
||||
export const useGetGameDomain = (): string => {
|
||||
try {
|
||||
if (import.meta.client) {
|
||||
const host = window.location.host || ''
|
||||
return host.split(':')[0]
|
||||
}
|
||||
|
||||
const event = useRequestEvent()
|
||||
if (!event) {
|
||||
return ''
|
||||
}
|
||||
|
||||
// 미들웨어에서 설정한 gameDomain가 있다면 우선 사용
|
||||
if (event.context.gameDomain) {
|
||||
return event.context.gameDomain
|
||||
}
|
||||
|
||||
const host =
|
||||
(getHeader(event, 'host') || getRequestHost(event)).toString() || ''
|
||||
const cleanHost = host.split(':')[0]
|
||||
|
||||
return cleanHost || ''
|
||||
} catch (error) {
|
||||
console.error('useGetGameDomain error:', error)
|
||||
return ''
|
||||
}
|
||||
}
|
||||
41
layers/composables/useResourcesData.ts
Normal file
41
layers/composables/useResourcesData.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import type {
|
||||
GetResourcesDataParams,
|
||||
ResourcesDataResponse,
|
||||
ResourcesDataValue,
|
||||
} from '#layers/types/api/resourcesData'
|
||||
|
||||
export const useResourcesData = () => {
|
||||
const getResourcesData = async (
|
||||
params: GetResourcesDataParams
|
||||
): Promise<ResourcesDataValue | null> => {
|
||||
const { pageSeq, pageVer, pageVerTmplSeq, langCode, q, qc } = params
|
||||
|
||||
const config = useRuntimeConfig()
|
||||
const stoveApiBaseUrl = config.public.stoveApiUrl
|
||||
const apiUrl = `${stoveApiBaseUrl}/pub-comm/v1.0/template/resources`
|
||||
|
||||
const queryParams: Record<string, string> = {
|
||||
page_seq: pageSeq,
|
||||
page_ver: pageVer,
|
||||
page_ver_tmpl_seq: pageVerTmplSeq,
|
||||
lang_code: langCode,
|
||||
q: q || '',
|
||||
qc: qc || '',
|
||||
_t: Date.now().toString(), // 캐시 무효화를 위한 타임스탬프
|
||||
}
|
||||
|
||||
const response = (await commonFetch('GET', apiUrl, {
|
||||
query: queryParams,
|
||||
loading: true,
|
||||
})) as ResourcesDataResponse | null
|
||||
|
||||
if (response?.code === 0 && 'value' in response) {
|
||||
return response.value
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
getResourcesData,
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,13 @@
|
||||
import { commonFetch } from '#layers/utils/apiUtil'
|
||||
import { usePageDataStore } from '#layers/stores/usePageDataStore'
|
||||
import { useGetGameAlias } from '#layers/composables/useGetGameAlias'
|
||||
import { useGetGameDomain } from '#layers/composables/useGetGameDomain'
|
||||
import { usePathResolver } from '#layers/composables/usePathResolver'
|
||||
import type { PageDataResponse } from '#layers/types/api/pageData'
|
||||
|
||||
export default defineNuxtRouteMiddleware(async (to, _from) => {
|
||||
const config = useRuntimeConfig()
|
||||
const store = usePageDataStore()
|
||||
const gameAlias = useGetGameAlias()
|
||||
const gameDomain = useGetGameDomain()
|
||||
const { getPathAfterLanguage } = usePathResolver()
|
||||
const headers = useRequestHeaders()
|
||||
const langCode = ssrGetFinalLocale(to.path, headers)
|
||||
@@ -17,7 +17,7 @@ export default defineNuxtRouteMiddleware(async (to, _from) => {
|
||||
|
||||
try {
|
||||
const pageUrl = getPathAfterLanguage(to.path)
|
||||
console.log("🚀 ~ pageUrl:", pageUrl)
|
||||
console.log('🚀 ~ pageUrl:', pageUrl)
|
||||
|
||||
// pageUrl이 빈값이거나 null이면 /brand로 리다이렉트
|
||||
if (!pageUrl || pageUrl === '' || pageUrl === '/') {
|
||||
@@ -25,12 +25,12 @@ export default defineNuxtRouteMiddleware(async (to, _from) => {
|
||||
}
|
||||
|
||||
const queryParams: Record<string, string> = {
|
||||
game_domain: gameAlias,
|
||||
game_domain: gameDomain,
|
||||
lang_code: langCode,
|
||||
page_url: pageUrl,
|
||||
_t: Date.now().toString(), // 캐시 무효화를 위한 타임스탬프
|
||||
}
|
||||
console.log("🚀 ~ queryParams:", queryParams)
|
||||
console.log('🚀 ~ queryParams:', queryParams)
|
||||
|
||||
const response = (await commonFetch('GET', apiUrl, {
|
||||
query: queryParams,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import GrVisual01 from '#layers/templates/GrVisual01/index.vue'
|
||||
import GrVisual02 from '#layers/templates/GrVisual02/index.vue'
|
||||
import GrVisual03 from '#layers/templates/GrVisual03/index.vue'
|
||||
// import GrGallery01 from "#layers/templates/GrGallery01/index.vue";
|
||||
import GrGallery01 from '#layers/templates/GrGallery01/index.vue'
|
||||
// import GrGallery02 from "#layers/templates/GrGallery02/index.vue";
|
||||
// import GrGallery03 from "#layers/templates/GrGallery03/index.vue";
|
||||
// import GrBoard01 from "#layers/templates/GrBoard01/index.vue";
|
||||
@@ -14,7 +14,7 @@ export const templateRegistry = {
|
||||
GR_VISUAL_01: { component: GrVisual01 },
|
||||
GR_VISUAL_02: { component: GrVisual02 },
|
||||
GR_VISUAL_03: { component: GrVisual03 },
|
||||
// GR_GALLERY_01: { component: GrGallery01 },
|
||||
GR_GALLERY_01: { component: GrGallery01 },
|
||||
// GR_GALLERY_02: { component: GrGallery02 },
|
||||
// GR_GALLERY_03: { component: GrGallery03 },
|
||||
// GR_BOARD_01: { component: GrBoard01 },
|
||||
|
||||
@@ -24,14 +24,11 @@ export default defineEventHandler(async event => {
|
||||
const host =
|
||||
(getHeader(event, 'host') || getRequestHost(event)).toString() || ''
|
||||
const baseDomain = process.env.BASE_DOMAIN || '.onstove.com'
|
||||
const isGameAliasExtractable = host.includes(baseDomain)
|
||||
const isGameDomainExtractable = host.includes(baseDomain)
|
||||
|
||||
if (isGameAliasExtractable) {
|
||||
const gameAlias = host.split(':')[0]
|
||||
|
||||
if (gameAlias && gameAlias !== 'www') {
|
||||
event.context.gameAlias = gameAlias
|
||||
}
|
||||
if (isGameDomainExtractable) {
|
||||
const cleanHost = host.split(':')[0]
|
||||
event.context.gameDomain = cleanHost
|
||||
}
|
||||
|
||||
// gameData를 직접 가져와서 context에 저장 (API 호출 없이)
|
||||
@@ -40,8 +37,10 @@ export default defineEventHandler(async event => {
|
||||
const stoveApiBaseUrl = config.public.stoveApiUrl
|
||||
const apiUrl = `${stoveApiBaseUrl}/pub-comm/v1.0/template/game`
|
||||
|
||||
|
||||
const langCode = ssrGetFinalLocale(event?.node.req.url, event.node.req.headers)
|
||||
const langCode = ssrGetFinalLocale(
|
||||
event?.node.req.url,
|
||||
event.node.req.headers
|
||||
)
|
||||
|
||||
// URL의 첫 번째 path를 lang_code로 사용 (파비콘, API 경로 제외)
|
||||
// const pathSegments = url.pathname
|
||||
@@ -56,7 +55,7 @@ export default defineEventHandler(async event => {
|
||||
// const langCode = pathSegments[0] || 'ko'
|
||||
|
||||
const queryParams: Record<string, string> = {
|
||||
game_domain: event.context.gameAlias || '',
|
||||
game_domain: event.context.gameDomain || '',
|
||||
lang_code: langCode,
|
||||
}
|
||||
|
||||
@@ -65,7 +64,7 @@ export default defineEventHandler(async event => {
|
||||
})
|
||||
|
||||
const gaId = (response as any).value?.ga_code
|
||||
|
||||
|
||||
if (gaId) {
|
||||
// 환경변수에 동적 설정
|
||||
event.context.googleAnalyticsId = gaId
|
||||
@@ -74,7 +73,7 @@ export default defineEventHandler(async event => {
|
||||
// 타입 단언을 사용하여 response의 타입 오류를 해결
|
||||
const res = response as { code?: number; value?: unknown }
|
||||
|
||||
if (res?.code === 0 && 'value' in res) {
|
||||
if (res?.code === 0 && res && typeof res === 'object' && 'value' in res) {
|
||||
event.context.gameData = res.value
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -1,78 +1,88 @@
|
||||
// server/routes/robots.txt.ts
|
||||
type RobotsConfig = {
|
||||
userAgent?: string | string[]
|
||||
allow?: string[]
|
||||
disallow?: string[]
|
||||
sitemap?: string | string[]
|
||||
host?: string
|
||||
cache?: { sMaxAge?: number; staleWhileRevalidate?: number }
|
||||
}
|
||||
userAgent?: string | string[]
|
||||
allow?: string[]
|
||||
disallow?: string[]
|
||||
sitemap?: string | string[]
|
||||
host?: string
|
||||
cache?: { sMaxAge?: number; staleWhileRevalidate?: number }
|
||||
}
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const host =
|
||||
(getHeader(event, "host") || getRequestHost(event)).toString() || "";
|
||||
const baseDomain = process.env.BASE_DOMAIN || ".onstove.com";
|
||||
const isGameAliasExtractable = host.includes(baseDomain);
|
||||
export default defineEventHandler(async event => {
|
||||
const host =
|
||||
(getHeader(event, 'host') || getRequestHost(event)).toString() || ''
|
||||
const baseDomain = process.env.BASE_DOMAIN || '.onstove.com'
|
||||
const isGameAliasExtractable = host.includes(baseDomain)
|
||||
|
||||
let gameAlias = "";
|
||||
let gameAlias = ''
|
||||
if (isGameAliasExtractable) {
|
||||
gameAlias = host.split(".")[0];
|
||||
gameAlias = host.split('.')[0]
|
||||
}
|
||||
|
||||
// if (gameAlias && gameAlias !== "www") {
|
||||
// event.context.gameAlias = gameAlias;
|
||||
// }
|
||||
// }
|
||||
// robots 설정을 직접 가져오기 (미들웨어 context 사용)
|
||||
|
||||
let config: RobotsConfig;
|
||||
|
||||
try {
|
||||
|
||||
// robots 설정 추출
|
||||
config = {
|
||||
userAgent: "*",
|
||||
allow: ["/"],
|
||||
disallow: ["/error", "/inspection/", "/inspection/*", "/html/*"],
|
||||
sitemap: [`https://static-pubcomm.gate8.com/local/template/${gameAlias}/sitemap.xml`],
|
||||
host: `${gameAlias}.onstove.com`,
|
||||
cache: { sMaxAge: 300, staleWhileRevalidate: 600 }
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch robots config:', error);
|
||||
|
||||
// 에러 발생 시 기본값 반환
|
||||
config = {
|
||||
userAgent: "*",
|
||||
allow: ["/"],
|
||||
disallow: ["/error", "/inspection/", "/inspection/*", "/html/*"],
|
||||
cache: { sMaxAge: 300, staleWhileRevalidate: 600 }
|
||||
};
|
||||
// if (gameAlias && gameAlias !== "www") {
|
||||
// event.context.gameAlias = gameAlias;
|
||||
// }
|
||||
// }
|
||||
// robots 설정을 직접 가져오기 (미들웨어 context 사용)
|
||||
|
||||
let config: RobotsConfig
|
||||
|
||||
try {
|
||||
// robots 설정 추출
|
||||
config = {
|
||||
userAgent: '*',
|
||||
allow: ['/'],
|
||||
disallow: ['/error', '/inspection/', '/inspection/*', '/html/*'],
|
||||
sitemap: [
|
||||
`https://static-pubcomm.gate8.com/local/template/${gameAlias}/sitemap.xml`,
|
||||
],
|
||||
host: `${gameAlias}.onstove.com`,
|
||||
cache: { sMaxAge: 300, staleWhileRevalidate: 600 },
|
||||
}
|
||||
|
||||
setHeader(event, "Content-Type", "text/plain; charset=utf-8")
|
||||
|
||||
// 캐시 헤더 (CDN 친화)
|
||||
const sMax = config.cache?.sMaxAge ?? 300
|
||||
const swr = config.cache?.staleWhileRevalidate ?? 600
|
||||
setHeader(event, "Cache-Control", `public, s-maxage=${sMax}, stale-while-revalidate=${swr}`)
|
||||
|
||||
// 여러 user-agent 지원
|
||||
const agents = Array.isArray(config.userAgent) ? config.userAgent : [config.userAgent ?? "*"]
|
||||
|
||||
const lines: string[] = []
|
||||
for (const ua of agents) {
|
||||
lines.push(`User-agent: ${ua}`)
|
||||
for (const p of config.allow ?? []) lines.push(`Allow: ${p}`)
|
||||
for (const p of config.disallow ?? []) lines.push(`Disallow: ${p}`)
|
||||
lines.push("") // 블록 구분 공백
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch robots config:', error)
|
||||
|
||||
// 에러 발생 시 기본값 반환
|
||||
config = {
|
||||
userAgent: '*',
|
||||
allow: ['/'],
|
||||
disallow: ['/error', '/inspection/', '/inspection/*', '/html/*'],
|
||||
cache: { sMaxAge: 300, staleWhileRevalidate: 600 },
|
||||
}
|
||||
|
||||
const sitemaps = Array.isArray(config.sitemap) ? config.sitemap : (config.sitemap ? [config.sitemap] : [])
|
||||
for (const sm of sitemaps) lines.push(`Sitemap: ${sm}`)
|
||||
if (config.host) lines.push(`Host: ${config.host}`)
|
||||
|
||||
// 마지막 개행
|
||||
return lines.join("\n").trim() + "\n"
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
setHeader(event, 'Content-Type', 'text/plain; charset=utf-8')
|
||||
|
||||
// 캐시 헤더 (CDN 친화)
|
||||
const sMax = config.cache?.sMaxAge ?? 300
|
||||
const swr = config.cache?.staleWhileRevalidate ?? 600
|
||||
setHeader(
|
||||
event,
|
||||
'Cache-Control',
|
||||
`public, s-maxage=${sMax}, stale-while-revalidate=${swr}`
|
||||
)
|
||||
|
||||
// 여러 user-agent 지원
|
||||
const agents = Array.isArray(config.userAgent)
|
||||
? config.userAgent
|
||||
: [config.userAgent ?? '*']
|
||||
|
||||
const lines: string[] = []
|
||||
for (const ua of agents) {
|
||||
lines.push(`User-agent: ${ua}`)
|
||||
for (const p of config.allow ?? []) lines.push(`Allow: ${p}`)
|
||||
for (const p of config.disallow ?? []) lines.push(`Disallow: ${p}`)
|
||||
lines.push('') // 블록 구분 공백
|
||||
}
|
||||
|
||||
const sitemaps = Array.isArray(config.sitemap)
|
||||
? config.sitemap
|
||||
: config.sitemap
|
||||
? [config.sitemap]
|
||||
: []
|
||||
for (const sm of sitemaps) lines.push(`Sitemap: ${sm}`)
|
||||
if (config.host) lines.push(`Host: ${config.host}`)
|
||||
|
||||
// 마지막 개행
|
||||
return lines.join('\n').trim() + '\n'
|
||||
})
|
||||
|
||||
130
layers/stores/useModalStore.ts
Normal file
130
layers/stores/useModalStore.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import type {
|
||||
DialogParams,
|
||||
YoutubeParams,
|
||||
} from '#layers/types/components/modal'
|
||||
|
||||
const createModalState = () => ({
|
||||
storeIsOpen: ref(false),
|
||||
storeIsShowDimmed: ref(false),
|
||||
storeClassName: ref(''),
|
||||
storeIsOutsideClose: ref(true),
|
||||
storeContentText: ref(''),
|
||||
storeConfirmButtonText: ref(''),
|
||||
storeConfirmButtonEvent: ref(() => {}),
|
||||
storeCloseButtonEvent: ref(() => {}),
|
||||
})
|
||||
|
||||
const resetModalState = (type: ReturnType<typeof createModalState>) => {
|
||||
type.storeIsOpen.value = false
|
||||
type.storeIsShowDimmed.value = false
|
||||
type.storeClassName.value = ''
|
||||
type.storeIsOutsideClose.value = true
|
||||
type.storeContentText.value = ''
|
||||
type.storeConfirmButtonText.value = ''
|
||||
type.storeConfirmButtonEvent.value = () => {}
|
||||
type.storeCloseButtonEvent.value = () => {}
|
||||
}
|
||||
|
||||
export const useModalStore = defineStore('modalStore', () => {
|
||||
const scrollStore = useScrollStore()
|
||||
|
||||
// alert ------------------
|
||||
const alert = {
|
||||
...createModalState(),
|
||||
}
|
||||
|
||||
const handleOpenAlert = ({
|
||||
isShowDimmed = false,
|
||||
className = '',
|
||||
isOutsideClose = true,
|
||||
contentText,
|
||||
confirmButtonText = '',
|
||||
confirmButtonEvent,
|
||||
closeButtonEvent,
|
||||
}: DialogParams) => {
|
||||
alert.storeIsOpen.value = true
|
||||
alert.storeIsShowDimmed.value = isShowDimmed
|
||||
alert.storeClassName.value = className
|
||||
alert.storeContentText.value = contentText
|
||||
alert.storeConfirmButtonText.value = confirmButtonText
|
||||
alert.storeIsOutsideClose.value = isOutsideClose
|
||||
alert.storeConfirmButtonEvent.value = confirmButtonEvent ?? handleResetAlert
|
||||
alert.storeCloseButtonEvent.value = closeButtonEvent ?? handleResetAlert
|
||||
}
|
||||
|
||||
const handleResetAlert = () => {
|
||||
resetModalState(alert)
|
||||
}
|
||||
|
||||
// confirm ------------------
|
||||
const confirm = {
|
||||
...createModalState(),
|
||||
storeCancelButtonText: ref(''),
|
||||
storeCancelButtonEvent: ref(() => {}),
|
||||
}
|
||||
|
||||
const handleOpenConfirm = ({
|
||||
isShowDimmed = false,
|
||||
className = '',
|
||||
isOutsideClose = true,
|
||||
contentText,
|
||||
confirmButtonText = '',
|
||||
cancelButtonText = '',
|
||||
confirmButtonEvent,
|
||||
cancelButtonEvent,
|
||||
closeButtonEvent,
|
||||
}: DialogParams) => {
|
||||
confirm.storeIsOpen.value = true
|
||||
confirm.storeIsShowDimmed.value = isShowDimmed
|
||||
confirm.storeClassName.value = className
|
||||
confirm.storeContentText.value = contentText
|
||||
confirm.storeConfirmButtonText.value = confirmButtonText
|
||||
confirm.storeCancelButtonText.value = cancelButtonText
|
||||
confirm.storeIsOutsideClose.value = isOutsideClose
|
||||
confirm.storeConfirmButtonEvent.value =
|
||||
confirmButtonEvent ?? handleResetConfirm
|
||||
confirm.storeCancelButtonEvent.value =
|
||||
cancelButtonEvent ?? handleResetConfirm
|
||||
confirm.storeCloseButtonEvent.value = closeButtonEvent ?? handleResetConfirm
|
||||
}
|
||||
|
||||
const handleResetConfirm = () => {
|
||||
resetModalState(confirm)
|
||||
confirm.storeCancelButtonText.value = ''
|
||||
confirm.storeCancelButtonEvent.value = () => {}
|
||||
}
|
||||
|
||||
// youtube ------------------
|
||||
const youtube = {
|
||||
storeIsOpen: ref(false),
|
||||
storeYoutubeUrl: ref(''),
|
||||
storeClassName: ref(''),
|
||||
}
|
||||
|
||||
const handleOpenYoutube = ({ youtubeUrl, className = '' }: YoutubeParams) => {
|
||||
youtube.storeIsOpen.value = true
|
||||
youtube.storeYoutubeUrl.value = youtubeUrl
|
||||
youtube.storeClassName.value = className
|
||||
scrollStore.controlScrollLock(true)
|
||||
}
|
||||
|
||||
const handleResetYoutube = () => {
|
||||
youtube.storeIsOpen.value = false
|
||||
youtube.storeYoutubeUrl.value = ''
|
||||
youtube.storeClassName.value = ''
|
||||
scrollStore.controlScrollLock(false)
|
||||
}
|
||||
|
||||
return {
|
||||
alert,
|
||||
confirm,
|
||||
youtube,
|
||||
handleOpenAlert,
|
||||
handleOpenConfirm,
|
||||
handleResetAlert,
|
||||
handleResetConfirm,
|
||||
handleOpenYoutube,
|
||||
handleResetYoutube,
|
||||
}
|
||||
})
|
||||
36
layers/stores/useScrollStore.ts
Normal file
36
layers/stores/useScrollStore.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { useWindowScroll } from '@vueuse/core'
|
||||
|
||||
export const useScrollStore = defineStore('scrollStore', () => {
|
||||
const { x: windowX, y: windowY } = useWindowScroll({ behavior: 'smooth' })
|
||||
|
||||
const stoveGnbHeight = 48
|
||||
const scrollXValue = ref('0px')
|
||||
const isPassedStoveGnb = ref(false)
|
||||
|
||||
const updateScrollValue = () => {
|
||||
if (stoveGnbHeight <= windowY.value) {
|
||||
isPassedStoveGnb.value = true
|
||||
scrollXValue.value = `-${windowX.value}px`
|
||||
} else {
|
||||
isPassedStoveGnb.value = false
|
||||
scrollXValue.value = '0px'
|
||||
}
|
||||
}
|
||||
|
||||
const controlScrollLock = (state: boolean) => {
|
||||
if (state) {
|
||||
document.body.classList.add('scroll-lock')
|
||||
} else {
|
||||
document.body.classList.remove('scroll-lock')
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
scrollXValue,
|
||||
isPassedStoveGnb,
|
||||
|
||||
updateScrollValue,
|
||||
controlScrollLock,
|
||||
}
|
||||
})
|
||||
46
layers/templates/GrGallery01/index.vue
Normal file
46
layers/templates/GrGallery01/index.vue
Normal file
@@ -0,0 +1,46 @@
|
||||
<script setup lang="ts">
|
||||
import { getComponentGroup } from '#layers/utils/dataUtil'
|
||||
|
||||
interface Props {
|
||||
components: Record<string, any>
|
||||
pageVerTmplSeq: string
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const backgroundData = computed(() =>
|
||||
getComponentGroup(props.components, 'background')
|
||||
)
|
||||
const mainTitleData = computed(() =>
|
||||
getComponentGroup(props.components, 'mainTitle')
|
||||
)
|
||||
const slideThumbnailData = computed(() => props.components.group_sets)
|
||||
const videoPlayData = computed(() =>
|
||||
getComponentGroup(props.components, 'videoPlay')
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="section-container">
|
||||
<WidgetsBackground v-if="backgroundData" :resources-data="backgroundData" />
|
||||
<div class="section-content">
|
||||
<WidgetsMainTitle
|
||||
v-if="mainTitleData"
|
||||
:resources-data="mainTitleData"
|
||||
class="main-title"
|
||||
/>
|
||||
<BlocksSlideThumbnail
|
||||
:slide-item-list="slideThumbnailData"
|
||||
:video-play="videoPlayData"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.main-title {
|
||||
@apply text-center text-[16px] font-medium leading-[24px] tracking-[-0.48px] md:text-[24px] md:leading-[34px] md:tracking-[-0.72px];
|
||||
text-shadow: 0 2px 2px rgba(0, 0, 0, 0.6);
|
||||
font-family: 'Spoqa Han Sans Neo', sans-serif;
|
||||
}
|
||||
</style>
|
||||
@@ -1,37 +1,54 @@
|
||||
<script setup lang="ts">
|
||||
import { getComponentGroup, getComponentGroupAry } from '#layers/utils/dataUtil'
|
||||
|
||||
interface Props {
|
||||
components: Record<string, any>
|
||||
pageVerTmplSeq: number
|
||||
pageVerTmplSeq: string
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
|
||||
const backgroundData = computed(() =>
|
||||
getComponentGroup(props.components, 'background')
|
||||
)
|
||||
const mainTitleData = computed(() =>
|
||||
getComponentGroup(props.components, 'mainTitle')
|
||||
)
|
||||
const descriptionData = computed(() =>
|
||||
getComponentGroup(props.components, 'description')
|
||||
)
|
||||
const videoPlayData = computed(() =>
|
||||
getComponentGroup(props.components, 'videoPlay')
|
||||
)
|
||||
const buttonListData = computed(() =>
|
||||
getComponentGroupAry(props.components, 'buttonList')
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="relative h-[640px] lg:h-[1000px]">
|
||||
<section class="section-container">
|
||||
<WidgetsBackground
|
||||
v-if="props.components?.background"
|
||||
:resources-data="props.components?.background.groups[0]"
|
||||
gradient-class="bg-gradient-to-b from-[#100d0f]/0 to-[#100d0f]"
|
||||
v-if="backgroundData"
|
||||
:resources-data="backgroundData"
|
||||
:gradient="true"
|
||||
/>
|
||||
<div
|
||||
class="relative h-full flex flex-col items-center justify-center gap-4"
|
||||
>
|
||||
<div class="section-content">
|
||||
<WidgetsMainTitle
|
||||
v-if="props.components.mainTitle"
|
||||
:resources-data="props.components.mainTitle.groups[0]"
|
||||
class="w-[355px] lg:w-[944px]"
|
||||
v-if="mainTitleData"
|
||||
:resources-data="mainTitleData"
|
||||
class="w-[355px] md:w-[944px]"
|
||||
/>
|
||||
<WidgetsDescription
|
||||
v-if="props.components.description"
|
||||
:resources-data="props.components.description.groups[0]"
|
||||
v-if="descriptionData"
|
||||
:resources-data="descriptionData"
|
||||
class="w-[355px] md:w-[944px]"
|
||||
/>
|
||||
<WidgetsVideoPlay
|
||||
v-if="props.components.videoPlay"
|
||||
:resources-data="props.components.videoPlay.groups[0]"
|
||||
:page-ver-tmpl-seq="props.pageVerTmplSeq"
|
||||
<WidgetsVideoPlay v-if="videoPlayData" :resources-data="videoPlayData" />
|
||||
<WidgetsButtonList
|
||||
v-if="buttonListData.length > 0"
|
||||
:groups-data="buttonListData"
|
||||
button-type="market"
|
||||
class="mt-[28px] md:mt-[52px]"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -1,11 +1,109 @@
|
||||
<script setup lang="ts">
|
||||
import { getComponentGroup, getComponentGroupAry } from '#layers/utils/dataUtil'
|
||||
|
||||
interface Props {
|
||||
components: Record<string, any>
|
||||
pageVerTmplSeq: string
|
||||
}
|
||||
|
||||
const _props = defineProps<Props>()
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const pageDataStore = usePageDataStore()
|
||||
const { getResourcesData } = useResourcesData()
|
||||
|
||||
const { pageData } = storeToRefs(pageDataStore)
|
||||
|
||||
const backgroundData = computed(() =>
|
||||
getComponentGroup(props.components, 'background')
|
||||
)
|
||||
const mainTitleData = computed(() =>
|
||||
getComponentGroup(props.components, 'mainTitle')
|
||||
)
|
||||
const descriptionData = computed(() =>
|
||||
getComponentGroup(props.components, 'description')
|
||||
)
|
||||
const videoPlayData = computed(() =>
|
||||
getComponentGroup(props.components, 'videoPlay')
|
||||
)
|
||||
const buttonListData = computed(() =>
|
||||
getComponentGroupAry(props.components, 'buttonList')
|
||||
)
|
||||
|
||||
// 비동기 데이터 로딩
|
||||
const { data: resourcesData } = await useLazyAsyncData(
|
||||
'gr-visual-02-resources',
|
||||
async () => {
|
||||
if (!pageData.value?.page_seq || !pageData.value?.page_ver) {
|
||||
return null
|
||||
}
|
||||
|
||||
return await getResourcesData({
|
||||
pageSeq: pageData.value.page_seq,
|
||||
pageVer: pageData.value.page_ver,
|
||||
pageVerTmplSeq: props.pageVerTmplSeq,
|
||||
langCode: 'ko',
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
// 배너 리스트 데이터 추출
|
||||
const bannerListData = computed(() => {
|
||||
const operateComponents = resourcesData.value?.operate_components
|
||||
|
||||
if (!operateComponents) {
|
||||
return []
|
||||
}
|
||||
|
||||
const firstKey = Object.keys(operateComponents)[0]
|
||||
return operateComponents[firstKey]?.list_operate_groups || []
|
||||
})
|
||||
|
||||
const bannerSize = {
|
||||
mo: {
|
||||
width: 293,
|
||||
height: 185,
|
||||
gap: 12,
|
||||
},
|
||||
pc: {
|
||||
width: 455,
|
||||
height: 287,
|
||||
gap: 32,
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="template-section" />
|
||||
<section class="pt-[140px] pb-[80px] md:pt-[200px] md:pb-[120px]">
|
||||
<WidgetsBackground
|
||||
v-if="backgroundData"
|
||||
:resources-data="backgroundData"
|
||||
:gradient="true"
|
||||
/>
|
||||
<div class="section-content">
|
||||
<WidgetsMainTitle
|
||||
v-if="mainTitleData"
|
||||
:resources-data="mainTitleData"
|
||||
class="w-[355px] md:w-[944px]"
|
||||
/>
|
||||
<WidgetsDescription
|
||||
v-if="descriptionData"
|
||||
:resources-data="descriptionData"
|
||||
class="w-[355px] md:w-[944px]"
|
||||
/>
|
||||
<WidgetsVideoPlay v-if="videoPlayData" :resources-data="videoPlayData" />
|
||||
<WidgetsButtonList
|
||||
v-if="buttonListData.length > 0"
|
||||
:groups-data="buttonListData"
|
||||
class="mt-[48px] md:mt-[72px]"
|
||||
/>
|
||||
<WidgetsBannerList
|
||||
v-if="bannerListData.length > 0"
|
||||
:slide-item-list="bannerListData"
|
||||
:slide-item-size="bannerSize"
|
||||
:arrows="true"
|
||||
:pagination="false"
|
||||
class="mt-[36px] md:mt-[60px]"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -1,39 +1,59 @@
|
||||
<script setup lang="ts">
|
||||
import { SplideSlide } from '@splidejs/vue-splide'
|
||||
import {
|
||||
hasComponentGroup,
|
||||
getComponentGroup,
|
||||
getComponentGroupAry,
|
||||
} from '#layers/utils/dataUtil'
|
||||
|
||||
interface Props {
|
||||
components: Record<string, any>
|
||||
pageVerTmplSeq: string
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="relative h-[640px] lg:h-[1000px]">
|
||||
<WidgetsBackground
|
||||
v-if="props.components?.background"
|
||||
:resources-data="props.components?.background.groups[0]"
|
||||
/>
|
||||
<div
|
||||
class="relative h-full flex flex-col items-center justify-center gap-4"
|
||||
<section class="section-container">
|
||||
<BlocksSlideFade
|
||||
v-if="props.components?.group_sets"
|
||||
:arrows="true"
|
||||
:pagination="true"
|
||||
class="h-full"
|
||||
>
|
||||
<WidgetsSubTitle
|
||||
v-if="props.components.subTitle"
|
||||
:resources-data="props.components.subTitle.groups[0]"
|
||||
class="text-[24px] font-[500] text-[#ffffff] leading-[34px]"
|
||||
/>
|
||||
<WidgetsMainTitle
|
||||
v-if="props.components.mainTitle"
|
||||
:resources-data="props.components.cardMainTitle.groups[0]"
|
||||
class="text-[50px] font-[700] text-[#c7a28b] leading-[70px]"
|
||||
/>
|
||||
<WidgetsDescription
|
||||
v-if="props.components.description"
|
||||
:resources-data="props.components.description.groups[0]"
|
||||
class="text-[20px] font-[500] text-white/70 leading-[30px]"
|
||||
/>
|
||||
<WidgetsButtonList
|
||||
v-if="props.components.buttonList"
|
||||
:groups-data="props.components.buttonList"
|
||||
/>
|
||||
</div>
|
||||
<SplideSlide
|
||||
v-for="(item, index) in props.components.group_sets"
|
||||
:key="index"
|
||||
>
|
||||
<WidgetsBackground
|
||||
v-if="hasComponentGroup(item, 'background')"
|
||||
:resources-data="getComponentGroup(item, 'background')"
|
||||
/>
|
||||
<div
|
||||
class="relative h-full flex flex-col items-center justify-center gap-[14px] text-center md:gap-5"
|
||||
>
|
||||
<WidgetsSubTitle
|
||||
v-if="hasComponentGroup(item, 'subTitle')"
|
||||
:resources-data="getComponentGroup(item, 'subTitle')"
|
||||
class="line-clamp-2 text-[16px] font-[500] leading-[24px] md:line-clamp-1 md:text-[24px] md:leading-[34px]"
|
||||
/>
|
||||
<WidgetsMainTitle
|
||||
v-if="hasComponentGroup(item, 'mainTitle')"
|
||||
:resources-data="getComponentGroup(item, 'mainTitle')"
|
||||
class="line-clamp-3 text-[24px] font-[700] leading-[34px] md:text-[50px] md:leading-[70px]"
|
||||
/>
|
||||
<WidgetsDescription
|
||||
v-if="hasComponentGroup(item, 'description')"
|
||||
:resources-data="getComponentGroup(item, 'description')"
|
||||
class="line-clamp-3 text-[15px] font-[400] leading-[24px] md:text-[20px] md:leading-[30px]"
|
||||
/>
|
||||
<WidgetsButtonList
|
||||
v-if="hasComponentGroup(item, 'buttonList')"
|
||||
:groups-data="getComponentGroupAry(item, 'buttonList')"
|
||||
/>
|
||||
</div>
|
||||
</SplideSlide>
|
||||
</BlocksSlideFade>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -23,11 +23,11 @@ export interface PageDataResponse {
|
||||
|
||||
// API 응답의 value 객체 타입
|
||||
export interface PageDataValue {
|
||||
page_seq: number
|
||||
page_seq: string
|
||||
page_type: number
|
||||
page_name: string
|
||||
page_name_en: string
|
||||
page_ver: number
|
||||
page_ver: string
|
||||
meta_tag_type: number
|
||||
fit_page_height: boolean
|
||||
use_top_btn: boolean
|
||||
@@ -74,6 +74,13 @@ export interface PageDataResourceGroup {
|
||||
}
|
||||
display?: {
|
||||
text: string
|
||||
txt: string
|
||||
color_code_btn?: string
|
||||
color_name_btn?: string
|
||||
color_code_txt?: string
|
||||
color_name_txt?: string
|
||||
color_code?: string
|
||||
color_name?: string
|
||||
}
|
||||
tracking: string // JSON 문자열
|
||||
}
|
||||
|
||||
58
layers/types/api/resourcesData.ts
Normal file
58
layers/types/api/resourcesData.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Resources Data API 타입 정의
|
||||
*/
|
||||
|
||||
// 리스트 운영 그룹 아이템
|
||||
export interface ListOperateGroupItem {
|
||||
seq: number
|
||||
title: string
|
||||
img_path: string
|
||||
url: string
|
||||
link_target: string
|
||||
display_status: number
|
||||
option01: number
|
||||
option02: number
|
||||
option03: string
|
||||
}
|
||||
|
||||
// 플래그 운영 그룹 아이템
|
||||
export interface FlagOperateGroupItem {
|
||||
seq: number
|
||||
flag_type: number
|
||||
option01: number
|
||||
option02: number
|
||||
option03: string
|
||||
}
|
||||
|
||||
// 운영 컴포넌트 그룹
|
||||
export interface OperateComponentGroup {
|
||||
list_operate_groups: ListOperateGroupItem[]
|
||||
flag_operate_groups: FlagOperateGroupItem[]
|
||||
}
|
||||
|
||||
// 운영 컴포넌트 목록 (동적 키)
|
||||
export interface OperateComponents {
|
||||
[key: string]: OperateComponentGroup
|
||||
}
|
||||
|
||||
// Resources Data 응답 값
|
||||
export interface ResourcesDataValue {
|
||||
operate_components: OperateComponents
|
||||
}
|
||||
|
||||
// Resources Data API 응답
|
||||
export interface ResourcesDataResponse {
|
||||
code: number
|
||||
message: string
|
||||
value: ResourcesDataValue
|
||||
}
|
||||
|
||||
// getResourcesData 함수 파라미터
|
||||
export interface GetResourcesDataParams {
|
||||
pageSeq: string
|
||||
pageVer: string
|
||||
pageVerTmplSeq: string
|
||||
langCode: string
|
||||
q?: string
|
||||
qc?: string
|
||||
}
|
||||
16
layers/types/components/banner.ts
Normal file
16
layers/types/components/banner.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* BannerList 컴포넌트 타입 정의
|
||||
*/
|
||||
|
||||
export interface BannerSizeItem {
|
||||
width: number
|
||||
height: number
|
||||
gap: number
|
||||
}
|
||||
|
||||
export interface BannerSize {
|
||||
mo: BannerSizeItem
|
||||
pc: BannerSizeItem
|
||||
}
|
||||
|
||||
export type BannerMode = 'auto' | 'fixed'
|
||||
@@ -8,12 +8,3 @@ export interface ButtonConfig {
|
||||
text: string
|
||||
rounded: string
|
||||
}
|
||||
|
||||
// Button 컴포넌트 Props 인터페이스
|
||||
export interface ButtonProps {
|
||||
size?: ButtonSize
|
||||
backgroundColor?: string
|
||||
textColor?: string
|
||||
icon?: string
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
16
layers/types/components/modal.ts
Normal file
16
layers/types/components/modal.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export interface DialogParams {
|
||||
isShowDimmed?: boolean
|
||||
className?: string
|
||||
isOutsideClose?: boolean
|
||||
contentText: string
|
||||
confirmButtonText?: string
|
||||
cancelButtonText?: string
|
||||
confirmButtonEvent?: () => void
|
||||
cancelButtonEvent?: () => void
|
||||
closeButtonEvent?: () => void
|
||||
}
|
||||
|
||||
export interface YoutubeParams {
|
||||
youtubeUrl: string
|
||||
className?: string
|
||||
}
|
||||
14
layers/types/components/slide.ts
Normal file
14
layers/types/components/slide.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* BannerList 컴포넌트 타입 정의
|
||||
*/
|
||||
|
||||
export interface SlideItemSizeObject {
|
||||
width: number
|
||||
height: number
|
||||
gap: number
|
||||
}
|
||||
|
||||
export interface SlideItemSize {
|
||||
mo: SlideItemSizeObject
|
||||
pc: SlideItemSizeObject
|
||||
}
|
||||
@@ -1,16 +1,87 @@
|
||||
import type {
|
||||
PageDataValue,
|
||||
PageDataResourceGroupResPath,
|
||||
PageDataComponent,
|
||||
} from '#layers/types/api/pageData'
|
||||
|
||||
// 레이아웃 타입 리턴하는 함수
|
||||
// ============================================================================
|
||||
// 페이지 데이터 관련 유틸리티
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 페이지 데이터를 기반으로 레이아웃 타입을 결정합니다.
|
||||
* @param pageData 페이지 데이터
|
||||
* @returns 레이아웃 타입 ('default' | 'promotion')
|
||||
*/
|
||||
export const getLayoutType = (
|
||||
pageData: PageDataValue | null
|
||||
): 'default' | 'promotion' => {
|
||||
return pageData?.page_type === 1 ? 'default' : 'promotion'
|
||||
}
|
||||
|
||||
// [TODO] 환경변수 처리 수정
|
||||
// ============================================================================
|
||||
// 컴포넌트 데이터 접근 관련 유틸리티
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 그룹의 첫 번째 데이터를 반환합니다.
|
||||
* @param source props.components 또는 group 객체
|
||||
* @returns 첫 번째 그룹 데이터 또는 null
|
||||
*/
|
||||
export const getFirstGroup = (source: any) => {
|
||||
if (!source) return null
|
||||
return source.groups?.[0] || null
|
||||
}
|
||||
|
||||
/**
|
||||
* 컴포넌트 그룹에 데이터가 존재하는지 확인합니다.
|
||||
* @param source props.components 또는 group 객체
|
||||
* @param componentName 컴포넌트 이름
|
||||
* @returns 데이터 존재 여부
|
||||
*/
|
||||
export const hasComponentGroup = (
|
||||
source: any,
|
||||
componentName: string
|
||||
): boolean => {
|
||||
if (!source) return false
|
||||
|
||||
const component = source[componentName] as PageDataComponent
|
||||
return component?.groups && component.groups.length > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* 컴포넌트 그룹의 첫 번째 데이터를 반환합니다.
|
||||
* @param source props.components 또는 group 객체
|
||||
* @param componentName 컴포넌트 이름
|
||||
* @returns 첫 번째 그룹 데이터 또는 null
|
||||
*/
|
||||
export const getComponentGroup = (source: any, componentName: string) => {
|
||||
if (!source) return null
|
||||
|
||||
return getFirstGroup(source[componentName])
|
||||
}
|
||||
|
||||
/**
|
||||
* 컴포넌트 그룹의 모든 데이터를 반환합니다.
|
||||
* @param source props.components 또는 group 객체
|
||||
* @param componentName 컴포넌트 이름
|
||||
* @returns 그룹 배열 데이터
|
||||
*/
|
||||
export const getComponentGroupAry = (source: any, componentName: string) => {
|
||||
if (!source) return []
|
||||
|
||||
return source[componentName]?.groups || []
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 리소스/이미지 처리 관련 유틸리티
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 이미지 경로를 완전한 호스트 URL로 변환합니다.
|
||||
* @param path 이미지 경로
|
||||
* @returns 완전한 이미지 URL
|
||||
*/
|
||||
export const getResolvedHost = (path: string): string => {
|
||||
const config = useRuntimeConfig()
|
||||
// const isDev = process.env.NODE_ENV === "development";
|
||||
@@ -21,12 +92,12 @@ export const getResolvedHost = (path: string): string => {
|
||||
return `${rootPath}${path}`
|
||||
}
|
||||
|
||||
// 반응형 클래스 리턴하는 함수
|
||||
export const getResponsiveClass = () => {
|
||||
return ['bg-[image:var(--mobile-bg)]', 'sm:bg-[image:var(--pc-bg)]']
|
||||
}
|
||||
|
||||
// 통합된 반응형 리소스 함수
|
||||
/**
|
||||
* 반응형 리소스(이미지/비디오)를 처리하여 PC/모바일 버전을 반환합니다.
|
||||
* @param pathArray 리소스 경로 배열
|
||||
* @param options 리소스 타입 옵션
|
||||
* @returns 반응형 리소스 객체 또는 null
|
||||
*/
|
||||
export const getResponsiveSrc = (
|
||||
pathArray: PageDataResourceGroupResPath,
|
||||
options: {
|
||||
@@ -58,3 +129,15 @@ export const getResponsiveSrc = (
|
||||
pcSrc: resolvedImages.pc,
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 스타일링 관련 유틸리티
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 반응형 배경 이미지를 위한 CSS 클래스를 반환합니다.
|
||||
* @returns 반응형 배경 클래스 배열
|
||||
*/
|
||||
export const getResponsiveClass = () => {
|
||||
return ['bg-[image:var(--mobile-bg)]', 'md:bg-[image:var(--pc-bg)]']
|
||||
}
|
||||
|
||||
149
layers/utils/youtube.ts
Normal file
149
layers/utils/youtube.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
// ============================================================================
|
||||
// 유튜브 관련 유틸리티
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 유튜브 URL에서 비디오 ID를 추출합니다.
|
||||
* @param url - 유튜브 URL (watch, embed, youtu.be 등 다양한 형태 지원)
|
||||
* @returns 비디오 ID 또는 빈 문자열
|
||||
*/
|
||||
export const getYouTubeVideoId = (url: string): string => {
|
||||
if (!url) return ''
|
||||
|
||||
// 다양한 유튜브 URL 패턴 지원
|
||||
const patterns = [
|
||||
/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/|youtube\.com\/shorts\/)([^&\n?#]+)/,
|
||||
/youtube\.com\/watch\?.*v=([^&\n?#]+)/,
|
||||
/youtube\.com\/embed\/([^&\n?#]+)/,
|
||||
/youtube\.com\/shorts\/([^&\n?#]+)/,
|
||||
]
|
||||
|
||||
for (const pattern of patterns) {
|
||||
const match = url.match(pattern)
|
||||
if (match && match[1]) {
|
||||
return match[1]
|
||||
}
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 유튜브 임베드 URL을 생성합니다.
|
||||
* @param url - 유튜브 URL
|
||||
* @param autoplay - 자동재생 여부
|
||||
* @param rel - 관련 비디오 표시 여부
|
||||
* @returns 임베드 URL
|
||||
*/
|
||||
/** [TODO] 임베드 형태로 넘어오도록 데이터 수정 후 이부분 사용 필요 없음 */
|
||||
export const getYouTubeEmbedUrl = (
|
||||
url: string,
|
||||
autoplay: boolean = false,
|
||||
rel: boolean = false
|
||||
): string => {
|
||||
const videoId = getYouTubeVideoId(url)
|
||||
if (!videoId) return ''
|
||||
|
||||
const params = new URLSearchParams()
|
||||
if (autoplay) params.append('autoplay', '1')
|
||||
if (!rel) params.append('rel', '0')
|
||||
|
||||
const queryString = params.toString()
|
||||
return `https://www.youtube.com/embed/${videoId}${queryString ? `?${queryString}` : ''}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 유튜브 비디오 ID로부터 썸네일 URL을 생성합니다.
|
||||
* @param videoId - 유튜브 비디오 ID
|
||||
* @param quality - 썸네일 품질 ('default', 'medium', 'high', 'standard', 'maxres')
|
||||
* @returns 썸네일 URL
|
||||
*/
|
||||
export const getYouTubeThumbnail = (
|
||||
videoId: string,
|
||||
quality: 'default' | 'medium' | 'high' | 'standard' | 'maxres' = 'high'
|
||||
): string => {
|
||||
if (!videoId) return ''
|
||||
|
||||
const qualityMap = {
|
||||
default: 'default',
|
||||
medium: 'mqdefault',
|
||||
high: 'hqdefault',
|
||||
standard: 'sddefault',
|
||||
maxres: 'maxresdefault',
|
||||
}
|
||||
|
||||
return `https://img.youtube.com/vi/${videoId}/${qualityMap[quality]}.jpg`
|
||||
}
|
||||
|
||||
/**
|
||||
* 유튜브 URL에서 직접 썸네일 URL을 추출합니다.
|
||||
* @param url - 유튜브 URL
|
||||
* @param quality - 썸네일 품질
|
||||
* @returns 썸네일 URL
|
||||
*/
|
||||
export const getYouTubeThumbnailFromUrl = (
|
||||
url: string,
|
||||
quality: 'default' | 'medium' | 'high' | 'standard' | 'maxres' = 'high'
|
||||
): string => {
|
||||
const videoId = getYouTubeVideoId(url)
|
||||
return getYouTubeThumbnail(videoId, quality)
|
||||
}
|
||||
|
||||
/**
|
||||
* 미디어 text(src)를 추출합니다.
|
||||
* @param source - 미디어 소스 객체
|
||||
* @returns 미디어 text(src)
|
||||
*/
|
||||
export const getMediaText = (source: Record<string, any>): string => {
|
||||
if (!source) return ''
|
||||
const resource = source.groups?.[0]
|
||||
const mediaUrl = resource?.display?.text
|
||||
return mediaUrl || ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 미디어 이미지를 추출합니다. (유튜브인 경우 썸네일)
|
||||
* @param source - 미디어 소스 객체
|
||||
* @param quality - 썸네일 품질
|
||||
* @returns 미디어 이미지 소스
|
||||
*/
|
||||
export const getMediaImgSrc = (
|
||||
source: Record<string, any>,
|
||||
quality: 'default' | 'medium' | 'high' | 'standard' | 'maxres' = 'high'
|
||||
): string => {
|
||||
if (!source) return ''
|
||||
|
||||
const resource = source.groups?.[0]
|
||||
const mediaType = resource?.group_type
|
||||
const mediaUrl = resource?.display?.text
|
||||
|
||||
if (mediaType === 'video' && mediaUrl) {
|
||||
const videoId = getYouTubeVideoId(mediaUrl)
|
||||
const thumbnailUrl = getYouTubeThumbnail(videoId, quality)
|
||||
return thumbnailUrl
|
||||
}
|
||||
|
||||
return mediaUrl || ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 미디어 타입을 확인합니다.
|
||||
* @param source - 미디어 소스 객체
|
||||
* @returns 미디어 타입 ('video' | 'image' | '')
|
||||
*/
|
||||
export const getMediaType = (source: Record<string, any>): string => {
|
||||
if (!source) return ''
|
||||
|
||||
const resource = source.groups?.[0]
|
||||
const mediaType = resource?.group_type
|
||||
return mediaType || ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 비디오 아이템인지 확인합니다.
|
||||
* @param source - 미디어 소스 객체
|
||||
* @returns 비디오 여부
|
||||
*/
|
||||
export const isVideoItem = (source: Record<string, any>): boolean => {
|
||||
return getMediaType(source) === 'video'
|
||||
}
|
||||
@@ -24,6 +24,8 @@
|
||||
"@nuxtjs/device": "^3.2.4",
|
||||
"@nuxtjs/i18n": "^10.0.6",
|
||||
"@pinia/nuxt": "^0.6.1",
|
||||
"@splidejs/splide": "^4.1.4",
|
||||
"@splidejs/vue-splide": "^0.6.12",
|
||||
"@vueuse/core": "^13.6.0",
|
||||
"@vueuse/nuxt": "^13.6.0",
|
||||
"h3": "^1.15.4",
|
||||
|
||||
18
pnpm-lock.yaml
generated
18
pnpm-lock.yaml
generated
@@ -23,6 +23,12 @@ importers:
|
||||
'@pinia/nuxt':
|
||||
specifier: ^0.6.1
|
||||
version: 0.6.1(magicast@0.3.5)(typescript@5.9.2)(vue@3.5.21(typescript@5.9.2))
|
||||
'@splidejs/splide':
|
||||
specifier: ^4.1.4
|
||||
version: 4.1.4
|
||||
'@splidejs/vue-splide':
|
||||
specifier: ^0.6.12
|
||||
version: 0.6.12
|
||||
'@vueuse/core':
|
||||
specifier: ^13.6.0
|
||||
version: 13.9.0(vue@3.5.21(typescript@5.9.2))
|
||||
@@ -1495,6 +1501,12 @@ packages:
|
||||
'@speed-highlight/core@1.2.7':
|
||||
resolution: {integrity: sha512-0dxmVj4gxg3Jg879kvFS/msl4s9F3T9UXC1InxgOf7t5NvcPD97u/WTA5vL/IxWHMn7qSxBozqrnnE2wvl1m8g==}
|
||||
|
||||
'@splidejs/splide@4.1.4':
|
||||
resolution: {integrity: sha512-5I30evTJcAJQXt6vJ26g2xEkG+l1nXcpEw4xpKh0/FWQ8ozmAeTbtniVtVmz2sH1Es3vgfC4SS8B2X4o5JMptA==}
|
||||
|
||||
'@splidejs/vue-splide@0.6.12':
|
||||
resolution: {integrity: sha512-eQb8pnGMN8Tr0FVaQo1PUMZlMHl8fSqHNXPTx79eeE2dkZqbsvq6jRzXoT9ZF7hFkxdOEmB6qYNp93SUwV684g==}
|
||||
|
||||
'@stylistic/eslint-plugin@5.3.1':
|
||||
resolution: {integrity: sha512-Ykums1VYonM0TgkD0VteVq9mrlO2FhF48MDJnPyv3MktIB2ydtuhlO0AfWm7xnW1kyf5bjOqA6xc7JjviuVTxg==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
@@ -6234,6 +6246,12 @@ snapshots:
|
||||
|
||||
'@speed-highlight/core@1.2.7': {}
|
||||
|
||||
'@splidejs/splide@4.1.4': {}
|
||||
|
||||
'@splidejs/vue-splide@0.6.12':
|
||||
dependencies:
|
||||
'@splidejs/splide': 4.1.4
|
||||
|
||||
'@stylistic/eslint-plugin@5.3.1(eslint@9.35.0(jiti@2.5.1))':
|
||||
dependencies:
|
||||
'@eslint-community/eslint-utils': 4.8.0(eslint@9.35.0(jiti@2.5.1))
|
||||
|
||||
Reference in New Issue
Block a user