feat. 색상 키컬러 적용

This commit is contained in:
clkim
2025-09-16 19:44:29 +09:00
parent d6301350ae
commit 07f1846199
5 changed files with 339 additions and 21 deletions

View File

@@ -9,14 +9,12 @@
--foreground-reversal-40: rgba(0, 0, 0, 0.4);
--foreground-reversal-70: #666666; /* gray-700 */
--primary: #3b82f6;
--secondary: #64748b;
--surface: #f8fafc;
--textSecondary: #475569;
--accent: #f59e0b;
--border: #e2e8f0;
/* 게임별 동적 색상 기본값 (API 로드 실패 시 fallback) */
--game-primary: #3b82f6;
--game-alternative-01: #64748b;
--game-alternative-02: #64748b;
--game-text-primary: #1f2937;
--game-text-secondary: #6b7280;
}
/* 다크 테마 색상 */
@@ -30,13 +28,12 @@
--foreground-reversal-40: rgba(255, 255, 255, 0.4);
--foreground-reversal-70: #b2b2b2; /* gray-300 */
--primary: #60a5fa;
--secondary: #94a3b8;
--surface: #1e293b;
--textSecondary: #cbd5e1;
--accent: #fbbf24;
--border: #475569;
/* 다크 테마 게임별 동적 색상 기본값 (API 로드 실패 시 fallback) */
--game-primary: #60a5fa;
--game-alternative-01: #94a3b8;
--game-alternative-02: #94a3b8;
--game-text-primary: #f9fafb;
--game-text-secondary: #d1d5db;
}
/* 커스텀 컴포넌트 스타일 */

View File

@@ -0,0 +1,160 @@
import { useGameDataStore } from '#layers/stores/useGameDataStore'
import type { ParsedKeyColorCodes } from '#layers/types/api/gameData'
/**
* 게임별 색상 코드를 CSS 변수로 설정하는 컴포저블
*/
export const useGameColors = () => {
const gameDataStore = useGameDataStore()
/**
* CSS에 정의된 기본 색상으로 리셋
*/
const resetToDefaultColors = () => {
if (typeof document !== 'undefined') {
const root = document.documentElement
// CSS에 정의된 기본값으로 리셋 (브라우저가 자동으로 CSS 값을 사용하도록 함)
root.style.removeProperty('--game-primary')
root.style.removeProperty('--game-alternative-01')
root.style.removeProperty('--game-alternative-02')
root.style.removeProperty('--game-text-primary')
root.style.removeProperty('--game-text-secondary')
console.log('게임 색상을 CSS 기본값으로 리셋했습니다.')
}
}
/**
* 게임 색상 코드를 파싱하여 CSS 변수로 설정
*/
const setGameColors = () => {
if (!gameDataStore.gameData?.key_color_codes) {
console.warn(
'게임 색상 데이터가 없습니다. CSS에 정의된 기본 색상을 사용합니다.'
)
// CSS에 정의된 기본값을 명시적으로 설정 (혹시 모를 경우를 대비)
resetToDefaultColors()
return
}
try {
// key_color_codes가 문자열인 경우 파싱
const colorCodes =
typeof gameDataStore.gameData.key_color_codes === 'string'
? JSON.parse(gameDataStore.gameData.key_color_codes)
: gameDataStore.gameData.key_color_codes
const colors = colorCodes as ParsedKeyColorCodes
// CSS 변수 설정
if (typeof document !== 'undefined') {
const root = document.documentElement
// 색상 유효성 검사 함수
const isValidColor = (color: string): boolean => {
return /^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/.test(color)
}
// 기본 색상 설정 (유효성 검사 포함)
if (colors.primary && isValidColor(colors.primary)) {
root.style.setProperty('--game-primary', colors.primary)
console.log('게임 Primary 색상 설정:', colors.primary)
}
if (colors.secondary && isValidColor(colors.secondary)) {
root.style.setProperty('--game-secondary', colors.secondary)
console.log('게임 Secondary 색상 설정:', colors.secondary)
}
if (colors['text-primary'] && isValidColor(colors['text-primary'])) {
root.style.setProperty('--game-text-primary', colors['text-primary'])
console.log('게임 텍스트 Primary 색상 설정:', colors['text-primary'])
}
if (
colors['text-secondary'] &&
isValidColor(colors['text-secondary'])
) {
root.style.setProperty(
'--game-text-secondary',
colors['text-secondary']
)
console.log(
'게임 텍스트 Secondary 색상 설정:',
colors['text-secondary']
)
}
// alternative 색상들 설정
if (
colors['alternative-01'] &&
isValidColor(colors['alternative-01'])
) {
root.style.setProperty(
'--game-alternative-01',
colors['alternative-01']
)
console.log(
'게임 Alternative-01 색상 설정:',
colors['alternative-01']
)
}
if (
colors['alternative-02'] &&
isValidColor(colors['alternative-02'])
) {
root.style.setProperty(
'--game-alternative-02',
colors['alternative-02']
)
console.log(
'게임 Alternative-02 색상 설정:',
colors['alternative-02']
)
}
}
} catch (error) {
console.error('게임 색상 코드 파싱 오류:', error)
console.warn('CSS에 정의된 기본 색상을 사용합니다.')
resetToDefaultColors()
}
}
/**
* 게임 색상 코드를 반환
*/
const getGameColors = (): ParsedKeyColorCodes | null => {
if (!gameDataStore.gameData?.key_color_codes) {
return null
}
try {
const colorCodes =
typeof gameDataStore.gameData.key_color_codes === 'string'
? JSON.parse(gameDataStore.gameData.key_color_codes)
: gameDataStore.gameData.key_color_codes
return colorCodes as ParsedKeyColorCodes
} catch (error) {
console.error('게임 색상 코드 파싱 오류:', error)
return null
}
}
/**
* 특정 색상 코드를 반환
*/
const getColor = (colorKey: keyof ParsedKeyColorCodes): string | null => {
const colors = getGameColors()
return colors?.[colorKey] || null
}
return {
setGameColors,
getGameColors,
getColor,
resetToDefaultColors,
}
}

View File

@@ -0,0 +1,144 @@
// composables/useHeaderNavOverflow.ts
import { ref, computed, onMounted, nextTick, watch, type Ref } from 'vue'
import { useResizeObserver } from '@vueuse/core'
type NavItem = { id: string | number; label: string; href?: string }
interface Options {
itemSelector?: string // nav 내부 개별 아이템 선택자
headerGap?: number // header 수평 gap(px)
navGap?: number // nav 아이템 간 gap(px)
}
export function useHeaderNavOverflow(
items: Ref<NavItem[]>,
headerRef: Ref<HTMLElement | null>,
logoRef: Ref<HTMLElement | null>,
trailingRefs: Ref<HTMLElement | null>[], // 우측 버튼/아이콘 등
moreButtonRef: Ref<HTMLElement | null>, // "More" 버튼 참조 (실측용)
options: Options = {}
) {
const itemSelector = options.itemSelector ?? '[data-nav-item]'
const headerGap = options.headerGap ?? 0
const navGap = options.navGap ?? 0
const containerWidth = ref(0)
const logoWidth = ref(0)
const trailingWidth = ref(0)
const moreBtnWidth = ref(0) // 오버플로우 발생 시 필요한 "More" 버튼 너비
const itemWidths = ref<number[]>([])
const splitIndex = ref(items.value.length)
const measureStatic = () => {
const header = headerRef.value
if (!header) return
containerWidth.value = header.clientWidth
// 로고/트레일링(우측 버튼들) 폭 합
logoWidth.value = Math.ceil(
logoRef.value?.getBoundingClientRect().width ?? 0
)
trailingWidth.value = trailingRefs.reduce((sum, r) => {
const w = Math.ceil(r.value?.getBoundingClientRect().width ?? 0)
return sum + w
}, 0)
// "More" 버튼 실측 (display:none이면 0이므로, visibility:hidden 상태로 자리 차지 측정 추천)
moreBtnWidth.value = Math.ceil(
moreButtonRef.value?.getBoundingClientRect().width ?? 0
)
}
const measureItems = (navEl: HTMLElement | null) => {
if (!navEl) return
const nodes = Array.from(navEl.querySelectorAll<HTMLElement>(itemSelector))
itemWidths.value = nodes.map(n =>
Math.ceil(n.getBoundingClientRect().width)
)
}
const computeSplit = (navEl: HTMLElement | null) => {
if (!navEl) return
const available =
containerWidth.value - logoWidth.value - trailingWidth.value - headerGap // header 자체 gap 여유분(필요 시)
if (available <= 0) {
splitIndex.value = 0
return
}
// 1차: 모두 넣을 수 있는지 시도
let sum = 0
let idx = items.value.length
for (let i = 0; i < items.value.length; i++) {
const w = itemWidths.value[i] ?? 0
const extra = i === 0 ? 0 : navGap
if (sum + w + extra <= available) {
sum += w + extra
} else {
idx = i
break
}
}
// 오버플로우가 생기면 "More" 버튼 자리도 고려해서 재계산
if (idx < items.value.length && moreBtnWidth.value > 0) {
const available2 = available - moreBtnWidth.value - navGap // more와의 간격
let sum2 = 0
let idx2 = 0
for (let i = 0; i < items.value.length; i++) {
const w = itemWidths.value[i] ?? 0
const extra = i === 0 ? 0 : navGap
if (sum2 + w + extra <= available2) {
sum2 += w + extra
idx2 = i + 1
} else {
break
}
}
splitIndex.value = idx2
} else {
splitIndex.value = idx
}
}
const visibleItems = computed(() => items.value.slice(0, splitIndex.value))
const overflowItems = computed(() => items.value.slice(splitIndex.value))
const measureAll = async (navEl: HTMLElement | null) => {
await nextTick()
measureStatic()
measureItems(navEl)
computeSplit(navEl)
}
const observe = (el: HTMLElement | null, navEl: HTMLElement | null) => {
if (!el) return
useResizeObserver(el, () => {
containerWidth.value = el.clientWidth
computeSplit(navEl)
})
}
const init = (navEl: Ref<HTMLElement | null>) => {
onMounted(() => {
measureAll(navEl.value)
observe(headerRef.value, navEl.value)
// 폰트/i18n 변경 같은 경우에 수동 호출할 수 있도록 measureAll 노출
})
}
// 아이템 배열 변경 시 재측정
watch(items, () => measureAll(currentNav.value), { deep: true })
// nav 엘리먼트를 외부에서 넘기기 위해 보조 ref
const currentNav = ref<HTMLElement | null>(null)
return {
visibleItems,
overflowItems,
splitIndex,
measureAll,
currentNav, // 템플릿에서 ref 바인딩
init,
}
}

View File

@@ -0,0 +1,17 @@
export default defineNuxtPlugin(() => {
const { setGameColors } = useGameColors()
// 게임 데이터가 로드된 후 색상 설정
const gameDataStore = useGameDataStore()
// 게임 데이터가 변경될 때마다 색상 업데이트
watch(
() => gameDataStore.gameData,
newGameData => {
if (newGameData) {
setGameColors()
}
},
{ immediate: true }
)
})

View File

@@ -21,12 +21,12 @@ export default {
'theme-foreground-reversal-40': 'var(--foreground-reversal-40)',
'theme-foreground-reversal-70': 'var(--foreground-reversal-70)',
// "theme-primary": "var(--light-primary)",
// "theme-secondary": "var(--light-secondary)",
// "theme-surface": "var(--light-surface)",
// "theme-text-secondary": "var(--light-textSecondary)",
// "theme-accent": "var(--light-accent)",
// "theme-border": "var(--light-border)",
// 게임별 동적 색상 (CSS 변수로 설정)
'game-primary': 'var(--game-primary)',
'game-alternative-01': 'var(--game-alternative-01)',
'game-alternative-02': 'var(--game-alternative-02)',
'game-text-primary': 'var(--game-text-primary)',
'game-text-secondary': 'var(--game-text-secondary)',
},
},
},