From 07f1846199405287ed4a0597e165d0cb8cd79aa7 Mon Sep 17 00:00:00 2001 From: clkim Date: Tue, 16 Sep 2025 19:44:29 +0900 Subject: [PATCH] =?UTF-8?q?feat.=20=EC=83=89=EC=83=81=20=ED=82=A4=EC=BB=AC?= =?UTF-8?q?=EB=9F=AC=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- layers/assets/css/base/_theme.css | 27 ++--- layers/composables/useGameColors.ts | 160 +++++++++++++++++++++++++++ layers/composables/useNavOverflow.ts | 144 ++++++++++++++++++++++++ layers/plugins/game-colors.client.ts | 17 +++ tailwind.config.ts | 12 +- 5 files changed, 339 insertions(+), 21 deletions(-) create mode 100644 layers/composables/useGameColors.ts create mode 100644 layers/composables/useNavOverflow.ts create mode 100644 layers/plugins/game-colors.client.ts diff --git a/layers/assets/css/base/_theme.css b/layers/assets/css/base/_theme.css index b3324a0..5fb8ffd 100644 --- a/layers/assets/css/base/_theme.css +++ b/layers/assets/css/base/_theme.css @@ -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; } /* 커스텀 컴포넌트 스타일 */ diff --git a/layers/composables/useGameColors.ts b/layers/composables/useGameColors.ts new file mode 100644 index 0000000..42390d3 --- /dev/null +++ b/layers/composables/useGameColors.ts @@ -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, + } +} diff --git a/layers/composables/useNavOverflow.ts b/layers/composables/useNavOverflow.ts new file mode 100644 index 0000000..0c7eb14 --- /dev/null +++ b/layers/composables/useNavOverflow.ts @@ -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, + headerRef: Ref, + logoRef: Ref, + trailingRefs: Ref[], // 우측 버튼/아이콘 등 + moreButtonRef: Ref, // "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([]) + 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(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) => { + onMounted(() => { + measureAll(navEl.value) + observe(headerRef.value, navEl.value) + // 폰트/i18n 변경 같은 경우에 수동 호출할 수 있도록 measureAll 노출 + }) + } + + // 아이템 배열 변경 시 재측정 + watch(items, () => measureAll(currentNav.value), { deep: true }) + + // nav 엘리먼트를 외부에서 넘기기 위해 보조 ref + const currentNav = ref(null) + + return { + visibleItems, + overflowItems, + splitIndex, + measureAll, + currentNav, // 템플릿에서 ref 바인딩 + init, + } +} diff --git a/layers/plugins/game-colors.client.ts b/layers/plugins/game-colors.client.ts new file mode 100644 index 0000000..c806f71 --- /dev/null +++ b/layers/plugins/game-colors.client.ts @@ -0,0 +1,17 @@ +export default defineNuxtPlugin(() => { + const { setGameColors } = useGameColors() + + // 게임 데이터가 로드된 후 색상 설정 + const gameDataStore = useGameDataStore() + + // 게임 데이터가 변경될 때마다 색상 업데이트 + watch( + () => gameDataStore.gameData, + newGameData => { + if (newGameData) { + setGameColors() + } + }, + { immediate: true } + ) +}) diff --git a/tailwind.config.ts b/tailwind.config.ts index 9f6a979..89fefcf 100644 --- a/tailwind.config.ts +++ b/tailwind.config.ts @@ -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)', }, }, },