fix. 버튼 컴포넌트 링크 적용, 코드 리팩토링

This commit is contained in:
clkim
2025-09-25 18:28:02 +09:00
parent 0ef8c5bdf5
commit acea3418e3
10 changed files with 247 additions and 235 deletions

View File

@@ -1,44 +1,34 @@
/* Button Size Classes */ /* Button Size Classes */
@layer components { @layer components {
.btn-base { .btn-base {
@apply relative inline-flex items-center justify-center font-medium border border-gray-600/30 overflow-hidden; @apply overflow-hidden relative inline-flex items-center justify-center font-medium cursor-pointer
before:content-[''] before:absolute before:top-0 before:left-0 before:w-full before:h-full before:border before:border-white/10 before:rounded-lg
after:content-[''] after:absolute after:top-0 after:left-0 after:w-full after:h-full after:bg-white after:rounded-lg after:transition-opacity after:duration-300 after:ease-in-out after:opacity-0
/* 기본 크기: size-medium */ /* 기본 크기: size-medium */
--btn-padding: theme('spacing.10'); px-10 h-14 text-base rounded-lg;
--btn-height: theme('spacing.14');
--btn-text: theme('fontSize.base');
--btn-radius: theme('borderRadius.lg');
@apply px-10 h-14 text-base rounded-lg;
} }
.btn-base:hover {
.size-extra-small { @apply after:opacity-20;
--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;
} }
.btn-base:disabled {
.size-small { @apply cursor-default
--btn-padding: theme('spacing.10'); after:bg-[var(--text-color)] after:opacity-20 after:z-[2];
--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 { .size-large {
--btn-padding: theme('spacing.10'); @apply px-10 h-16 text-lg;
--btn-height: theme('spacing.16'); }
--btn-text: theme('fontSize.lg');
--btn-radius: theme('borderRadius.lg'); .size-medium {
@apply px-10 h-16 text-lg rounded-lg; @apply px-10 h-14 text-base;
}
.size-small {
@apply px-10 h-12 text-sm;
}
.size-extra-small {
@apply before:rounded after:rounded
px-6 h-10 text-sm rounded;
} }
} }

View File

@@ -1,45 +1,34 @@
<script setup lang="ts"> <script setup lang="ts">
import type { GameDataKeyCodeCodes } from '#layers/types/api/gameData' import type { ButtonType } from '#layers/types/components/button'
interface ButtonProps { interface ButtonProps {
backgroundColor?: string type?: ButtonType
textColor?: string
icon?: string icon?: string
disabled?: boolean target?: '_self' | '_blank'
href?: string
rel?: string
backgroundColor?: string
backgroundImage?: string backgroundImage?: string
textColor?: string
disabled?: boolean
class?: string
} }
const props = withDefaults(defineProps<ButtonProps>(), { const props = withDefaults(defineProps<ButtonProps>(), {
type: 'action',
backgroundColor: 'var(--primary)', backgroundColor: 'var(--primary)',
textColor: 'var(--alternative-02)', textColor: 'var(--alternative-02)',
icon: '',
disabled: false, disabled: false,
}) })
// 색상 코드 키 목록 key_code_codes const buttonClasses = computed(() =>
const PARSED_KEY_CODE_CODES_KEYS: (keyof GameDataKeyCodeCodes)[] = [ ['btn-base group', props.class].filter(Boolean)
'primary', )
'text-primary',
'text-secondary',
'alternative-01',
'alternative-02',
]
// 색상 값을 CSS 변수로 변환하는 헬퍼 함수
const getColorValue = (color: string) =>
PARSED_KEY_CODE_CODES_KEYS.includes(color as keyof GameDataKeyCodeCodes)
? `var(--${color})`
: color
const buttonClasses = computed(() => [
'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(() => { const buttonStyles = computed(() => {
const styles: Record<string, string> = { const styles: Record<string, string> = {
backgroundColor: getColorValue(props.backgroundColor), backgroundColor: props.backgroundColor,
color: getColorValue(props.textColor), color: props.textColor,
'--text-color': props.textColor,
} }
if (props.backgroundImage) { if (props.backgroundImage) {
@@ -51,40 +40,50 @@ const buttonStyles = computed(() => {
return styles return styles
}) })
const componentTag = computed((): string => {
switch (props.type) {
case 'download':
case 'external':
return 'a'
case 'internal':
return 'AtomsLocaleLink'
default:
return 'button'
}
})
const componentProps = computed(() => {
const baseProps = { disabled: props.disabled }
const overlayClasses = computed(() => [ if (props.type === 'external') {
'absolute inset-0 -m-px transition-opacity duration-200', return {
props.disabled ...baseProps,
? 'opacity-20 z-10' href: props.href,
: 'bg-white opacity-0 group-hover:opacity-20', target: props.target,
]) rel: props.rel,
const overlayDisabledStyles = computed(
() =>
props.disabled && {
backgroundColor: props.textColor,
} }
) }
const contentDisabledStyles = computed(() => props.disabled && { opacity: 0.2 }) if (props.type === 'internal') {
return {
...baseProps,
to: props.href,
}
}
return baseProps
})
</script> </script>
<template> <template>
<button <component
:is="componentTag"
v-bind="componentProps"
:class="buttonClasses" :class="buttonClasses"
:style="buttonStyles" :style="buttonStyles"
:disabled="props.disabled"
> >
<!-- 호버 효과 / Disabled 오버레이 --> <span class="relative flex items-center gap-2 z-[1]">
<span :class="overlayClasses" :style="overlayDisabledStyles" />
<!-- 버튼 내용 -->
<span
class="relative flex items-center gap-2"
:style="contentDisabledStyles"
>
<slot /> <slot />
<span v-if="props.icon" class="flex-shrink-0" v-html="props.icon" /> <span v-if="props.icon" class="flex-shrink-0" v-html="props.icon" />
</span> </span>
</button> </component>
</template> </template>

View File

@@ -9,19 +9,29 @@ const props = withDefaults(defineProps<Props>(), {
target: '', target: '',
class: '', class: '',
}) })
const componentTag = computed(() => {
return props.target === '_blank' ? 'a' : 'AtomsLocaleLink'
})
const componentProps = computed(() => {
if (props.target === '_blank') {
return {
href: props.to,
target: props.target,
class: props.class,
}
}
return {
to: props.to,
class: props.class,
}
})
</script> </script>
<template> <template>
<a <component :is="componentTag" v-bind="{ ...$attrs, ...componentProps }">
v-if="props.target === '_blank'"
v-bind="$attrs"
:href="props.to"
:target="props.target"
:class="props.class"
>
<slot /> <slot />
</a> </component>
<AtomsLocaleLink v-else v-bind="$attrs" :to="props.to" :class="props.class">
<slot />
</AtomsLocaleLink>
</template> </template>

View File

@@ -1,5 +1,4 @@
<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'
interface Props { interface Props {
@@ -13,39 +12,14 @@ const props = defineProps<Props>()
const displayText = computed(() => { const displayText = computed(() => {
return props.resourcesData?.display?.txt || '' return props.resourcesData?.display?.txt || ''
}) })
// 이미지 소스 추출
const imageSrc = computed(() => { const imageSrc = computed(() => {
return getResponsiveSrc(props.resourcesData?.res_path) 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(() => { const colorName = computed(() => {
return ( return props.resourcesData?.display?.color_name
props.resourcesData?.display?.color_name_txt ||
props.resourcesData?.display?.color_name
)
}) })
const colorCode = computed(() => {
// 색상 스타일 계산 return props.resourcesData?.display?.color_code
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 콘텐츠 정리 (줄바꿈 처리) // HTML 콘텐츠 정리 (줄바꿈 처리)
@@ -82,7 +56,7 @@ const hasImage = computed(() => {
<span <span
v-else-if="displayText" v-else-if="displayText"
v-dompurify-html="sanitizedContent" v-dompurify-html="sanitizedContent"
:style="textStyles" :style="{ color: getColorCode({ colorName, colorCode }) }"
class="block" class="block"
/> />
</template> </template>

View File

@@ -1,5 +1,4 @@
<script setup lang="ts"> <script setup lang="ts">
import { getResponsiveClass, getResponsiveSrc } from '#layers/utils/dataUtil'
import type { PageDataResourceGroup } from '#layers/types/api/pageData' import type { PageDataResourceGroup } from '#layers/types/api/pageData'
interface Props { interface Props {

View File

@@ -1,25 +1,69 @@
<script setup lang="ts"> <script setup lang="ts">
import type { PageDataResourceGroup } from '#layers/types/api/pageData' import type {
PageDataResourceGroup,
PageDataResourceGroupBtnInfo,
} from '#layers/types/api/pageData'
import type { ButtonType } from '#layers/types/components/button'
const props = defineProps<{ interface ButtonListProps {
resourcesData: PageDataResourceGroup[] resourcesData: PageDataResourceGroup[]
buttonType?: string }
}>()
const props = defineProps<ButtonListProps>()
// 상수 정의
const BUTTON_TYPE_MAP = {
URL: {
_self: 'internal' as const,
_blank: 'external' as const,
},
DOWNLOAD: 'download' as const,
} as const
const DEFAULT_BUTTON_TYPE: ButtonType = 'action'
const getButtonType = (btnInfo: PageDataResourceGroupBtnInfo): ButtonType => {
const btnType = btnInfo?.detail?.btn_type
const btnTarget = btnInfo?.detail?.action?.link_target
if (btnType === 'URL' && btnTarget) {
return BUTTON_TYPE_MAP.URL[btnTarget] || DEFAULT_BUTTON_TYPE
}
if (btnType === 'DOWNLOAD') {
return BUTTON_TYPE_MAP.DOWNLOAD
}
return DEFAULT_BUTTON_TYPE
}
const getButtonProps = (button: PageDataResourceGroup) => ({
type: getButtonType(button.btn_info),
target: button.btn_info?.detail?.action?.link_target,
href: button.btn_info?.detail?.action?.url,
rel: button.btn_info?.detail?.action?.rel,
backgroundColor: getColorCode({
colorName: button.btn_info?.color_name_btn,
colorCode: button.btn_info?.color_code_btn,
}),
textColor: getColorCode({
colorName: button.btn_info?.color_name_txt,
colorCode: button.btn_info?.color_code_txt,
}),
disabled: button.btn_info?.disabled,
text: button.btn_info?.txt_btn_name,
})
</script> </script>
<template> <template>
<div <div
v-if="props.resourcesData" v-if="props.resourcesData?.length"
class="flex flex-wrap justify-center gap-3 md:gap-4" class="flex flex-wrap justify-center gap-3 md:gap-4"
> >
<AtomsButton <AtomsButton
v-for="button in props.resourcesData" v-for="(button, index) in props.resourcesData"
:key="button.group_code" :key="`${button.group_code}-${index}`"
:button-type="props.buttonType" v-bind="getButtonProps(button)"
class="size-extra-small md:size-medium" 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 }} {{ button.btn_info?.txt_btn_name }}
</AtomsButton> </AtomsButton>

View File

@@ -60,25 +60,25 @@ export interface PageDataResourceGroupResPath {
path_pc?: string path_pc?: string
} }
export interface PageDataResourceGroupBtnInfo {
color_code_btn: string
color_name_btn: string
color_code_txt: string
color_name_txt: string
disabled: boolean
txt_btn_name: string
detail: Record<string, any>
}
// 리소스 그룹 타입 // 리소스 그룹 타입
export interface PageDataResourceGroup { export interface PageDataResourceGroup {
group_type?: string group_type?: string
group_code?: string group_code?: string
res_path?: PageDataResourceGroupResPath res_path?: PageDataResourceGroupResPath
btn_info?: { btn_info?: PageDataResourceGroupBtnInfo
color_code_btn: string
color_code_txt: string
disabled: boolean
txt_btn_name: string
detail: Record<string, any>
}
display?: { display?: {
text: string text: string
txt: string txt: string
color_code_btn?: string
color_name_btn?: string
color_code_txt?: string
color_name_txt?: string
color_code?: string color_code?: string
color_name?: string color_name?: string
} }

View File

@@ -1,10 +1,2 @@
// 버튼 크기 타입 export type ButtonType = 'internal' | 'external' | 'download' | 'action'
export type ButtonSize = 'large' | 'medium' | 'small' | 'extra-small' export type ButtonSize = 'large' | 'medium' | 'small' | 'extra-small'
// 버튼 설정 인터페이스
export interface ButtonConfig {
padding: string
height: string
text: string
rounded: string
}

View File

@@ -1,13 +1,8 @@
import type { import type {
PageDataValue, PageDataValue,
PageDataResourceGroupResPath,
PageDataComponent, PageDataComponent,
} from '#layers/types/api/pageData' } from '#layers/types/api/pageData'
// ============================================================================
// 페이지 데이터 관련 유틸리티
// ============================================================================
/** /**
* 페이지 데이터를 기반으로 레이아웃 타입을 결정합니다. * 페이지 데이터를 기반으로 레이아웃 타입을 결정합니다.
* @param pageData 페이지 데이터 * @param pageData 페이지 데이터
@@ -19,10 +14,6 @@ export const getLayoutType = (
return pageData?.page_type === 1 ? 'default' : 'promotion' return pageData?.page_type === 1 ? 'default' : 'promotion'
} }
// ============================================================================
// 컴포넌트 데이터 접근 관련 유틸리티
// ============================================================================
/** /**
* 그룹의 첫 번째 데이터를 반환합니다. * 그룹의 첫 번째 데이터를 반환합니다.
* @param source props.components 또는 group 객체 * @param source props.components 또는 group 객체
@@ -72,72 +63,3 @@ export const getComponentGroupAry = (source: any, componentName: string) => {
return source[componentName]?.groups || [] 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";
// const rootPath = isDev ? "/images" : `${config.public.staticUrl}`;
const rootPath = config.public.staticUrl
return `${rootPath}${path}`
}
/**
* 반응형 리소스(이미지/비디오)를 처리하여 PC/모바일 버전을 반환합니다.
* @param pathArray 리소스 경로 배열
* @param options 리소스 타입 옵션
* @returns 반응형 리소스 객체 또는 null
*/
export const getResponsiveSrc = (
pathArray: PageDataResourceGroupResPath,
options: {
resourcesType?: 'image' | 'bg' | 'video'
} = {}
) => {
const { resourcesType = 'image' } = options
const pcField = resourcesType === 'video' ? 'path_vid_pc' : 'path_pc'
const mobileField = resourcesType === 'video' ? 'path_vid_mo' : 'path_mo'
if (!pathArray?.[mobileField]) {
return null
}
const resolvedImages = {
pc: getResolvedHost(pathArray[pcField] || pathArray[mobileField]),
mobile: getResolvedHost(pathArray[mobileField]),
}
if (resourcesType === 'bg') {
return {
'--pc-bg': `url(${resolvedImages.pc})`,
'--mobile-bg': `url(${resolvedImages.mobile})`,
}
}
return {
mobileSrc: resolvedImages.mobile,
pcSrc: resolvedImages.pc,
}
}
// ============================================================================
// 스타일링 관련 유틸리티
// ============================================================================
/**
* 반응형 배경 이미지를 위한 CSS 클래스를 반환합니다.
* @returns 반응형 배경 클래스 배열
*/
export const getResponsiveClass = () => {
return ['bg-[image:var(--mobile-bg)]', 'md:bg-[image:var(--pc-bg)]']
}

82
layers/utils/styleUtil.ts Normal file
View File

@@ -0,0 +1,82 @@
import type { PageDataResourceGroupResPath } from '#layers/types/api/pageData'
/**
* 이미지 경로를 완전한 호스트 URL로 변환합니다.
* @param path 이미지 경로
* @returns 완전한 이미지 URL
*/
export const getResolvedHost = (path: string): string => {
const config = useRuntimeConfig()
// const isDev = process.env.NODE_ENV === "development";
// const rootPath = isDev ? "/images" : `${config.public.staticUrl}`;
const rootPath = config.public.staticUrl
return `${rootPath}${path}`
}
/**
* 반응형 리소스(이미지/비디오)를 처리하여 PC/모바일 버전을 반환합니다.
* @param pathArray 리소스 경로 배열
* @param options 리소스 타입 옵션
* @returns 반응형 리소스 객체 또는 null
*/
export const getResponsiveSrc = (
pathArray: PageDataResourceGroupResPath,
options: {
resourcesType?: 'image' | 'bg' | 'video'
} = {}
) => {
const { resourcesType = 'image' } = options
const pcField = resourcesType === 'video' ? 'path_vid_pc' : 'path_pc'
const mobileField = resourcesType === 'video' ? 'path_vid_mo' : 'path_mo'
if (!pathArray?.[mobileField]) {
return null
}
const resolvedImages = {
pc: getResolvedHost(pathArray[pcField] || pathArray[mobileField]),
mobile: getResolvedHost(pathArray[mobileField]),
}
if (resourcesType === 'bg') {
return {
'--pc-bg': `url(${resolvedImages.pc})`,
'--mobile-bg': `url(${resolvedImages.mobile})`,
}
}
return {
mobileSrc: resolvedImages.mobile,
pcSrc: resolvedImages.pc,
}
}
/**
* 반응형 배경 이미지를 위한 CSS 클래스를 반환합니다.
* @returns 반응형 배경 클래스 배열
*/
export const getResponsiveClass = () => {
return ['bg-[image:var(--mobile-bg)]', 'md:bg-[image:var(--pc-bg)]']
}
/**
* 색상값을 반환합니다.
* @param colorName 색상 이름
* @param colorCode 색상 코드
* @returns 색상 값
*/
export const getColorCode = ({
colorName,
colorCode,
}: {
colorName: string
colorCode: string
}) => {
if (colorName) {
return `var(--${colorName})`
} else if (colorCode) {
return colorCode
}
}