feat. 색상 키컬러 적용
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
/* 커스텀 컴포넌트 스타일 */
|
||||
|
||||
160
layers/composables/useGameColors.ts
Normal file
160
layers/composables/useGameColors.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
144
layers/composables/useNavOverflow.ts
Normal file
144
layers/composables/useNavOverflow.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
17
layers/plugins/game-colors.client.ts
Normal file
17
layers/plugins/game-colors.client.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
export default defineNuxtPlugin(() => {
|
||||
const { setGameColors } = useGameColors()
|
||||
|
||||
// 게임 데이터가 로드된 후 색상 설정
|
||||
const gameDataStore = useGameDataStore()
|
||||
|
||||
// 게임 데이터가 변경될 때마다 색상 업데이트
|
||||
watch(
|
||||
() => gameDataStore.gameData,
|
||||
newGameData => {
|
||||
if (newGameData) {
|
||||
setGameColors()
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
})
|
||||
@@ -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)',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user