feat. GR_VISUAL_01 컴포넌트 제작

This commit is contained in:
clkim
2025-09-18 20:08:41 +09:00
parent 6952670da3
commit 1667e0f22b
10 changed files with 209 additions and 113 deletions

View File

@@ -1,37 +1,87 @@
<script setup lang="ts"> <script setup lang="ts">
interface ImageSource { import { getResponsiveSrc } from '#layers/utils/dataUtil'
mobileSrc?: string import type { PageDataResourceGroup } from '#layers/types/api/pageData'
pcSrc?: string
}
interface Props { interface Props {
text?: string resourcesData?: PageDataResourceGroup
imageSrc?: ImageSource
} }
const props = defineProps<Props>() 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(() => { 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> </script>
<template> <template>
<template v-if="imageSrc && 'mobileSrc' in imageSrc"> <!-- 이미지가 있는 경우 -->
<template v-if="hasImage">
<!-- 모바일 이미지 (sm 미만) --> <!-- 모바일 이미지 (sm 미만) -->
<img <img
v-if="imageSrc.mobileSrc" v-if="imageSrc.mobileSrc"
:src="imageSrc.mobileSrc" :src="imageSrc.mobileSrc"
:alt="text" :alt="displayText"
class="sm:hidden w-full h-full object-contain" class="sm:hidden w-full h-full object-contain"
/> />
<!-- PC 이미지 (sm 이상) --> <!-- PC 이미지 (sm 이상) -->
<img <img
v-if="imageSrc.pcSrc" v-if="imageSrc.pcSrc"
:src="imageSrc.pcSrc" :src="imageSrc.pcSrc"
:alt="text" :alt="displayText"
class="hidden sm:block w-full h-full object-contain" class="hidden sm:block w-full h-full object-contain"
/> />
</template> </template>
<span v-else-if="text" v-dompurify-html="sanitizedContent" />
<!-- 텍스트가 있는 경우 -->
<span
v-else-if="displayText"
v-dompurify-html="sanitizedContent"
:style="textStyles"
/>
</template> </template>

View File

@@ -2,10 +2,14 @@
import { getResponsiveClass, getResponsiveSrc } from '#layers/utils/dataUtil' import { getResponsiveClass, getResponsiveSrc } from '#layers/utils/dataUtil'
import type { PageDataResourceGroup } from '#layers/types/api/pageData' import type { PageDataResourceGroup } from '#layers/types/api/pageData'
const props = defineProps<{ interface Props {
resourcesData: PageDataResourceGroup resourcesData: PageDataResourceGroup
gradientClass?: string gradient?: boolean
}>() }
const props = withDefaults(defineProps<Props>(), {
gradient: false,
})
const resPath = computed(() => { const resPath = computed(() => {
return props.resourcesData?.res_path return props.resourcesData?.res_path
@@ -37,10 +41,10 @@ const posterSrc = computed(() => {
<!-- 비디오 타입 --> <!-- 비디오 타입 -->
<template v-else-if="resourcesData?.group_type === 'video'"> <template v-else-if="resourcesData?.group_type === 'video'">
<!-- 모바일 비디오 (sm 미만) --> <!-- 모바일 비디오 (md 미만) -->
<video <video
v-if="videoSrc?.mobileSrc" 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" :poster="posterSrc?.mobileSrc"
autoplay autoplay
muted muted
@@ -50,10 +54,10 @@ const posterSrc = computed(() => {
<source :src="videoSrc.mobileSrc" type="video/mp4" /> <source :src="videoSrc.mobileSrc" type="video/mp4" />
<source :src="videoSrc.mobileSrc" type="video/webm" /> <source :src="videoSrc.mobileSrc" type="video/webm" />
</video> </video>
<!-- PC 비디오 (sm 이상) --> <!-- PC 비디오 (md 이상) -->
<video <video
v-if="videoSrc?.pcSrc" 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" :poster="posterSrc?.pcSrc"
autoplay autoplay
muted muted
@@ -65,6 +69,17 @@ const posterSrc = computed(() => {
</video> </video>
</template> </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]"
style="
background: linear-gradient(
180deg,
rgba(16, 13, 15, 0) 0%,
#100d0f 30%
);
"
/>
</div> </div>
</template> </template>

View File

@@ -1,21 +1,32 @@
<script setup lang="ts"> <script setup lang="ts">
import type { PageDataResourceGroup } from '#layers/types/api/pageData' import type { PageDataResourceGroup } from '#layers/types/api/pageData'
import type { ButtonSize } from '#layers/types/components/button'
const props = defineProps<{ const props = defineProps<{
groupsData: PageDataResourceGroup[] groupsData: PageDataResourceGroup[]
}>() }>()
const breakpoints = useResponsiveBreakpoints()
const buttonSize = computed<ButtonSize>(() => {
return breakpoints.md.value ? 'medium' : 'extra-small'
})
</script> </script>
<template> <template>
<template v-if="props.groupsData"> <div
v-if="props.groupsData"
class="flex flex-wrap justify-center gap-3 sm:gap-4"
>
<AtomsButton <AtomsButton
v-for="button in props.groupsData" v-for="button in props.groupsData"
:key="button.group_code" :key="button.group_code"
:size="buttonSize"
:background-color="button.btn_info?.color_code_btn" :background-color="button.btn_info?.color_code_btn"
:text-color="button.btn_info?.color_code_txt" :text-color="button.btn_info?.color_code_txt"
:disabled="button.btn_info?.disabled" :disabled="button.btn_info?.disabled"
> >
{{ button.btn_info?.txt_btn_name }} {{ button.btn_info?.txt_btn_name }}
</AtomsButton> </AtomsButton>
</template> </div>
</template> </template>

View File

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

View File

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

View File

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

View File

@@ -21,11 +21,14 @@ const handleVideoPlayClick = () => {
<template> <template>
<button <button
v-if="resourcesData && bgStyles" v-if="resourcesData && bgStyles"
class="bg-cover bg-center bg-no-repeat w-[66px] h-[66px] lg:w-[100px] lg:h-[100px]" class="relative group bg-cover bg-center bg-no-repeat w-[66px] h-[66px] sm:w-[100px] sm:h-[100px]"
:class="getResponsiveClass()" :class="getResponsiveClass()"
:style="bgStyles" :style="bgStyles"
@click="handleVideoPlayClick" @click="handleVideoPlayClick"
> >
<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">videoPlay</span> <span class="sr-only">videoPlay</span>
</button> </button>
</template> </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

@@ -6,73 +6,83 @@ type RobotsConfig = {
sitemap?: string | string[] sitemap?: string | string[]
host?: string host?: string
cache?: { sMaxAge?: number; staleWhileRevalidate?: number } cache?: { sMaxAge?: number; staleWhileRevalidate?: number }
} }
export default defineEventHandler(async (event) => { export default defineEventHandler(async event => {
const host = const host =
(getHeader(event, "host") || getRequestHost(event)).toString() || ""; (getHeader(event, 'host') || getRequestHost(event)).toString() || ''
const baseDomain = process.env.BASE_DOMAIN || ".onstove.com"; const baseDomain = process.env.BASE_DOMAIN || '.onstove.com'
const isGameAliasExtractable = host.includes(baseDomain); const isGameAliasExtractable = host.includes(baseDomain)
let gameAlias = ""; let gameAlias = ''
if (isGameAliasExtractable) { if (isGameAliasExtractable) {
gameAlias = host.split(".")[0]; gameAlias = host.split('.')[0]
} }
// if (gameAlias && gameAlias !== "www") { // if (gameAlias && gameAlias !== "www") {
// event.context.gameAlias = gameAlias; // event.context.gameAlias = gameAlias;
// } // }
// } // }
// robots 설정을 직접 가져오기 (미들웨어 context 사용) // robots 설정을 직접 가져오기 (미들웨어 context 사용)
let config: RobotsConfig; let config: RobotsConfig
try { try {
// robots 설정 추출 // robots 설정 추출
config = { config = {
userAgent: "*", userAgent: '*',
allow: ["/"], allow: ['/'],
disallow: ["/error", "/inspection/", "/inspection/*", "/html/*"], disallow: ['/error', '/inspection/', '/inspection/*', '/html/*'],
sitemap: [`https://static-pubcomm.gate8.com/local/template/${gameAlias}/sitemap.xml`], sitemap: [
`https://static-pubcomm.gate8.com/local/template/${gameAlias}/sitemap.xml`,
],
host: `${gameAlias}.onstove.com`, host: `${gameAlias}.onstove.com`,
cache: { sMaxAge: 300, staleWhileRevalidate: 600 } cache: { sMaxAge: 300, staleWhileRevalidate: 600 },
}; }
} catch (error) { } catch (error) {
console.error('Failed to fetch robots config:', error); console.error('Failed to fetch robots config:', error)
// 에러 발생 시 기본값 반환 // 에러 발생 시 기본값 반환
config = { config = {
userAgent: "*", userAgent: '*',
allow: ["/"], allow: ['/'],
disallow: ["/error", "/inspection/", "/inspection/*", "/html/*"], disallow: ['/error', '/inspection/', '/inspection/*', '/html/*'],
cache: { sMaxAge: 300, staleWhileRevalidate: 600 } cache: { sMaxAge: 300, staleWhileRevalidate: 600 },
}; }
} }
setHeader(event, "Content-Type", "text/plain; charset=utf-8") setHeader(event, 'Content-Type', 'text/plain; charset=utf-8')
// 캐시 헤더 (CDN 친화) // 캐시 헤더 (CDN 친화)
const sMax = config.cache?.sMaxAge ?? 300 const sMax = config.cache?.sMaxAge ?? 300
const swr = config.cache?.staleWhileRevalidate ?? 600 const swr = config.cache?.staleWhileRevalidate ?? 600
setHeader(event, "Cache-Control", `public, s-maxage=${sMax}, stale-while-revalidate=${swr}`) setHeader(
event,
'Cache-Control',
`public, s-maxage=${sMax}, stale-while-revalidate=${swr}`
)
// 여러 user-agent 지원 // 여러 user-agent 지원
const agents = Array.isArray(config.userAgent) ? config.userAgent : [config.userAgent ?? "*"] const agents = Array.isArray(config.userAgent)
? config.userAgent
: [config.userAgent ?? '*']
const lines: string[] = [] const lines: string[] = []
for (const ua of agents) { for (const ua of agents) {
lines.push(`User-agent: ${ua}`) lines.push(`User-agent: ${ua}`)
for (const p of config.allow ?? []) lines.push(`Allow: ${p}`) for (const p of config.allow ?? []) lines.push(`Allow: ${p}`)
for (const p of config.disallow ?? []) lines.push(`Disallow: ${p}`) for (const p of config.disallow ?? []) lines.push(`Disallow: ${p}`)
lines.push("") // 블록 구분 공백 lines.push('') // 블록 구분 공백
} }
const sitemaps = Array.isArray(config.sitemap) ? config.sitemap : (config.sitemap ? [config.sitemap] : []) const sitemaps = Array.isArray(config.sitemap)
? config.sitemap
: config.sitemap
? [config.sitemap]
: []
for (const sm of sitemaps) lines.push(`Sitemap: ${sm}`) for (const sm of sitemaps) lines.push(`Sitemap: ${sm}`)
if (config.host) lines.push(`Host: ${config.host}`) if (config.host) lines.push(`Host: ${config.host}`)
// 마지막 개행 // 마지막 개행
return lines.join("\n").trim() + "\n" return lines.join('\n').trim() + '\n'
}) })

View File

@@ -7,28 +7,36 @@ const props = defineProps<Props>()
</script> </script>
<template> <template>
<section class="relative h-[640px] lg:h-[1000px]"> <section class="relative h-[640px] md:h-[1000px]">
<WidgetsBackground <WidgetsBackground
v-if="props.components?.background" v-if="props.components?.background"
:resources-data="props.components?.background.groups[0]" :resources-data="props.components?.background.groups[0]"
gradient-class="bg-gradient-to-b from-[#100d0f]/0 to-[#100d0f]" :gradient="true"
/> />
<div <div
class="relative h-full flex flex-col items-center justify-center gap-4" class="relative h-full flex flex-col items-center justify-center gap-4 md:gap-5"
> >
<WidgetsMainTitle <WidgetsMainTitle
v-if="props.components.mainTitle" v-if="props.components.mainTitle && props.components.mainTitle.groups"
:resources-data="props.components.mainTitle.groups[0]" :resources-data="props.components.mainTitle.groups[0]"
class="w-[355px] lg:w-[944px]" class="w-[355px] md:w-[944px]"
/> />
<WidgetsDescription <WidgetsDescription
v-if="props.components.description" v-if="
props.components.description && props.components.description.groups
"
:resources-data="props.components.description.groups[0]" :resources-data="props.components.description.groups[0]"
class="w-[355px] md:w-[944px]"
/> />
<WidgetsVideoPlay <WidgetsVideoPlay
v-if="props.components.videoPlay" v-if="props.components.videoPlay && props.components.videoPlay.groups"
:resources-data="props.components.videoPlay.groups[0]" :resources-data="props.components.videoPlay.groups[0]"
/> />
<WidgetsButtonList
v-if="props.components.buttonList && props.components.buttonList.groups"
:groups-data="props.components.buttonList.groups"
class="mt-[48px] md:mt-[72px]"
/>
</div> </div>
</section> </section>
</template> </template>