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

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

View File

@@ -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>

View File

@@ -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;

View File

@@ -3,4 +3,12 @@
body {
background-color: #000;
}
body.scroll-lock {
overflow: hidden;
}
button,
a {
outline: none;
}
}

View 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;
}
}

View 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;
}
}

View 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;
}
}

View File

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

View File

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

View File

@@ -19,6 +19,7 @@ withDefaults(defineProps<Props>(), {
:height="size"
viewBox="0 0 12 12"
:fill="color"
:class="className"
>
<path
d="M5.29499 7.715L2.39999 4.875C2.07499 4.555 2.29999 4 2.75999 4L9.23499 4C9.69499 4 9.91999 4.555 9.59499 4.875L6.69999 7.715C6.30999 8.095 5.68999 8.095 5.29999 7.715H5.29499Z"

View File

@@ -0,0 +1,29 @@
<script setup lang="ts">
interface Props {
size?: number | string
color?: string
className?: string
}
withDefaults(defineProps<Props>(), {
size: 32,
color: '#EBEBEB',
className: '',
})
</script>
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
:width="size"
:height="size"
viewBox="0 0 32 33"
fill="none"
:class="className"
>
<path
d="M26.2768 8.10939C26.7975 7.5887 26.7975 6.74448 26.2768 6.22378C25.7561 5.70308 24.9119 5.70308 24.3912 6.22378L16.0007 14.6143L7.61013 6.22378C7.08943 5.70308 6.24521 5.70308 5.72451 6.22378C5.20381 6.74448 5.20381 7.5887 5.72451 8.10939L14.115 16.4999L5.72451 24.8904C5.20381 25.4111 5.20381 26.2554 5.72451 26.7761C6.24521 27.2968 7.08943 27.2968 7.61013 26.7761L16.0007 18.3855L24.3912 26.7761C24.9119 27.2968 25.7561 27.2968 26.2768 26.7761C26.7975 26.2554 26.7975 25.4111 26.2768 24.8904L17.8863 16.4999L26.2768 8.10939Z"
:fill="color"
/>
</svg>
</template>

View File

@@ -19,6 +19,7 @@ withDefaults(defineProps<Props>(), {
:height="size"
viewBox="0 0 16 16"
:fill="color"
:class="className"
>
<path
d="M3.63636 3.33333C3.469 3.33333 3.33333 3.469 3.33333 3.63636L3.33333 12.3636C3.33333 12.531 3.469 12.6667 3.63636 12.6667H12.3636C12.531 12.6667 12.6667 12.531 12.6667 12.3636V9.93939C12.6667 9.5712 12.9651 9.27273 13.3333 9.27273C13.7015 9.27273 14 9.5712 14 9.93939V12.3636C14 13.2674 13.2674 14 12.3636 14H3.63636C2.73262 14 2 13.2674 2 12.3636L2 3.63636C2 2.73263 2.73262 2 3.63636 2L6.06061 2C6.4288 2 6.72727 2.29848 6.72727 2.66667C6.72727 3.03486 6.4288 3.33333 6.06061 3.33333H3.63636Z"

View File

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

View File

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

View File

@@ -1,84 +1,72 @@
<template>
<Teleport to="body">
<Transition
enter-active-class="transition duration-300 ease-out"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-active-class="transition duration-200 ease-in"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
<Transition
enter-active-class="transition duration-300 ease-out"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-active-class="transition duration-200 ease-in"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<div
v-if="isOpen"
class="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-75"
@click="handleBackdropClick"
>
<div
v-if="isOpen"
class="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-75"
@click="handleBackdropClick"
class="relative mx-4 my-4"
style="
width: min(896px, 90vw, calc((90vh - 2rem) * 16 / 9));
aspect-ratio: 16/9;
"
@click.stop
>
<Transition
enter-active-class="transition duration-300 ease-out"
enter-from-class="opacity-0 scale-95"
enter-to-class="opacity-100 scale-100"
leave-active-class="transition duration-200 ease-in"
leave-from-class="opacity-100 scale-100"
leave-to-class="opacity-0 scale-95"
>
<div v-if="isOpen" class="relative w-full max-w-4xl mx-4" @click.stop>
<!-- 헤더 -->
<div class="flex justify-end">
<button
class="p-1 text-white rounded-full transition-colors"
aria-label="모달 닫기"
@click="closeModal"
>
<svg
class="w-8 h-8"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
<!-- 헤더 -->
<div class="flex justify-end">
<button
class="p-1 text-white rounded-full transition-colors"
aria-label="모달 닫기"
@click="closeModal"
>
<AtomsIconsClose />
</button>
</div>
<!-- 유튜브 영상 컨테이너 -->
<div class="relative w-full" :style="{ paddingBottom: '56.25%' }">
<iframe
v-if="youtubeId"
:src="`https://www.youtube.com/embed/${youtubeId}?autoplay=1&rel=0`"
class="absolute top-0 left-0 w-full h-full"
frameborder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowfullscreen
title="YouTube video player"
/>
</div>
</div>
</Transition>
<!-- 유튜브 영상 컨테이너 -->
<div class="relative w-full h-full">
<iframe
v-if="embedUrl"
:src="embedUrl"
class="absolute top-0 left-0 w-full h-full"
frameborder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowfullscreen
title="YouTube video player"
/>
</div>
</div>
</Transition>
</Teleport>
</div>
</Transition>
</template>
<script setup lang="ts">
import { getYouTubeEmbedUrl } from '#layers/utils/youtube'
interface Props {
isOpen: boolean
youtubeId: string
youtubeUrl: string
title?: string
description?: string
closeOnBackdrop?: boolean
}
interface Emits {
(e: 'close'): void
(e: 'update:isOpen', value: boolean): void
(e: 'closeButtonEvent'): void
}
const props = withDefaults(defineProps<Props>(), {
isOpen: false,
youtubeUrl: '',
title: '',
description: '',
closeOnBackdrop: true,
@@ -86,6 +74,10 @@ const props = withDefaults(defineProps<Props>(), {
const emit = defineEmits<Emits>()
const embedUrl = computed(() => {
return getYouTubeEmbedUrl(props.youtubeUrl)
})
// ESC 키로 모달 닫기
const handleKeydown = (event: KeyboardEvent) => {
if (event.key === 'Escape' && props.isOpen) {
@@ -102,8 +94,7 @@ const handleBackdropClick = () => {
// 모달 닫기 함수
const closeModal = () => {
emit('close')
emit('update:isOpen', false)
emit('closeButtonEvent')
}
// 키보드 이벤트 리스너 등록/해제
@@ -114,21 +105,4 @@ onMounted(() => {
onUnmounted(() => {
document.removeEventListener('keydown', handleKeydown)
})
// 모달이 열릴 때 body 스크롤 방지
watch(
() => props.isOpen,
isOpen => {
if (isOpen) {
document.body.style.overflow = 'hidden'
} else {
document.body.style.overflow = ''
}
}
)
// 컴포넌트 언마운트 시 body 스크롤 복원
onUnmounted(() => {
document.body.style.overflow = ''
})
</script>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,51 +1,26 @@
<script setup lang="ts">
import { getResponsiveSrc, getResponsiveClass } from '#layers/utils/dataUtil'
import type { PageDataResourceGroup } from '#layers/types/api/pageData'
const props = defineProps<{ resourcesData: PageDataResourceGroup, pageVerTmplSeq: number }>()
const props = defineProps<{
resourcesData: PageDataResourceGroup
pageVerTmplSeq: number
}>()
const { useAnalyticsLogData } = useAnalytics()
const logData = useAnalyticsLogData(props.resourcesData, props.pageVerTmplSeq)
const bgStyles = getResponsiveSrc(props.resourcesData?.res_path, {
resourcesType: 'bg',
})
// YouTube 모달 상태 관리
const isYouTubeModalOpen = ref(false)
const youtubeVideoId = ref('')
// YouTube 모달 스토어 사용
const modalStore = useModalStore()
// 비디오 플레이 버튼 클릭 핸들러
const handleVideoPlayClick = () => {
// TODO: 실제 YouTube 비디오 ID를 설정해야 합니다
// 예시: 'dQw4w9WgXcQ' (Rick Astley - Never Gonna Give You Up)
youtubeVideoId.value = 'UKVsZYHxYTc' // 임시로 설정
isYouTubeModalOpen.value = true
}
// 모달 닫기 핸들러
const handleCloseModal = () => {
isYouTubeModalOpen.value = false
youtubeVideoId.value = ''
const youtubeUrl = props.resourcesData?.display?.text ?? ''
modalStore.handleOpenYoutube({ youtubeUrl })
}
</script>
<template>
<button
v-if="resourcesData"
v-analytics="logData"
class="bg-cover bg-center bg-no-repeat w-[66px] h-[66px] lg:w-[100px] lg:h-[100px]"
:class="getResponsiveClass()"
:style="bgStyles"
@click="handleVideoPlayClick()"
>
<span class="sr-only">videoPlay</span>
</button>
<!-- YouTube 모달 -->
<BlocksModalYouTube
:is-open="isYouTubeModalOpen"
:youtube-id="youtubeVideoId"
@close="handleCloseModal"
@update:is-open="(value: boolean) => (isYouTubeModalOpen = value)"
<AtomsButtonPlay
:resources-data="resourcesData"
@click="handleVideoPlayClick"
/>
</template>

View 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+
})
}

View 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 ''
}
}

View 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,
}
}

View File

@@ -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,

View File

@@ -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 },

View File

@@ -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) {

View File

@@ -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'
})

View 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,
}
})

View 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,
}
})

View 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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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 문자열
}

View 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
}

View 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'

View File

@@ -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
}

View 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
}

View File

@@ -0,0 +1,14 @@
/**
* BannerList 컴포넌트 타입 정의
*/
export interface SlideItemSizeObject {
width: number
height: number
gap: number
}
export interface SlideItemSize {
mo: SlideItemSizeObject
pc: SlideItemSizeObject
}

View File

@@ -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
View 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'
}

View File

@@ -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
View File

@@ -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))