/** * 관리자 설정 화면 좌측 내비 구역 정의 * @type {ReadonlyArray<{ heading: string, items: ReadonlyArray<{ id: string, label: string, keywords: string, iconId?: string }> }>} */ const ADMIN_SETTINGS_NAV_GROUPS = [ { heading: '일반', items: [ { id: 'admin-settings-section-title', label: '블로그 제목·설명', keywords: 'title description site name', iconId: 'title-desc' }, { id: 'admin-settings-section-misc', label: '사이트 정보', keywords: 'logo url copyright favicon site info', iconId: 'site-info' }, { id: 'admin-settings-section-social', label: 'SNS 정보', keywords: 'social follow sns x github instagram youtube rss', iconId: 'social' } ] }, { heading: '게시물', items: [ { id: 'admin-settings-section-post', label: 'POST 설정', keywords: 'post updated date display 수정일', iconId: 'post-settings' } ] }, { heading: '사이트', items: [ { id: 'admin-settings-section-home-cover', label: '메인 화면', keywords: 'home cover hero banner image', iconId: 'home-cover' }, { id: 'admin-settings-section-brand', label: '브랜드', keywords: 'brand design accent color point 포인트 컬러', iconId: 'brand' }, { id: 'admin-settings-section-announcement', label: '어나운스 바', keywords: 'announcement banner notice', iconId: 'announcement' }, { id: 'admin-settings-section-site-code', label: '사이트 코드', keywords: 'ads ads.txt head footer script code adsense', iconId: 'site-code' }, { id: 'admin-settings-section-ads', label: 'Ads', keywords: 'ads ad slot advertisement adsense 광고 애드센스', iconId: 'ads' } ] }, { heading: '콘텐츠·안전', items: [ { id: 'admin-settings-section-backup-summary', label: '사이트 용량', keywords: 'storage media count volume 용량 통계 사이트', iconId: 'post-export' }, { id: 'admin-settings-section-export', label: '게시물 내보내기', keywords: 'export backup 내보내기', iconId: 'post-export' }, { id: 'admin-settings-section-import', label: '게시물 가져오기', keywords: 'import restore 가져오기', iconId: 'post-import' }, { id: 'admin-settings-section-spam', label: '스팸 필터', keywords: 'spam moderation comments', iconId: 'spam' } ] } ] /** * 관리자 설정 내비게이션 검색과 스크롤 스파이 상태를 만든다. * @returns {{ mainScrollRef: import('vue').Ref, settingsNavGroups: typeof ADMIN_SETTINGS_NAV_GROUPS, filteredSettingsNavGroups: import('vue').ComputedRef, navSearchInput: import('vue').Ref, onNavSearchInput: (event: Event) => void, onNavSearchCompositionEnd: (event: Event) => void, activeSectionId: import('vue').Ref, onMainScroll: () => void, refreshActiveSection: () => void, scrollToSection: (sectionId: string) => void, cleanupSettingsNavigation: () => void }} */ export const useAdminSettingsNavigation = () => { const mainScrollRef = ref(null) const navSearchQuery = ref('') const { displayValue: navSearchInput, onInput: onNavSearchInput, onCompositionEnd: onNavSearchCompositionEnd } = useImeSearchInput(navSearchQuery) const activeSectionId = ref(ADMIN_SETTINGS_NAV_GROUPS[0].items[0].id) const scrollSpySuspended = ref(false) let scrollSpyFrame = null /** * 검색어에 맞춰 내비 그룹을 필터링한다. * @returns {typeof ADMIN_SETTINGS_NAV_GROUPS} 표시할 그룹 목록 */ const filteredSettingsNavGroups = computed(() => { const q = navSearchQuery.value.trim().toLowerCase() if (!q) { return ADMIN_SETTINGS_NAV_GROUPS } return ADMIN_SETTINGS_NAV_GROUPS .map((group) => { const items = group.items.filter((item) => { const hay = `${item.label} ${item.keywords}`.toLowerCase() return hay.includes(q) }) return { ...group, items } }) .filter((group) => group.items.length > 0) }) /** * 본문 스크롤 위치에 맞춰 활성 섹션 id를 갱신한다. * @returns {void} */ const updateActiveSectionFromScroll = () => { const root = mainScrollRef.value if (!root || scrollSpySuspended.value) { return } const marker = root.getBoundingClientRect().top + 56 let nextId = ADMIN_SETTINGS_NAV_GROUPS[0].items[0].id const flatIds = ADMIN_SETTINGS_NAV_GROUPS.flatMap((group) => group.items.map((item) => item.id)) for (const id of flatIds) { const el = document.getElementById(id) if (!el) { continue } const top = el.getBoundingClientRect().top if (top <= marker) { nextId = id } } activeSectionId.value = nextId } /** * 스크롤 스파이 핸들러를 실행한다. * @returns {void} */ const onMainScroll = () => { if (scrollSpyFrame) { cancelAnimationFrame(scrollSpyFrame) } scrollSpyFrame = requestAnimationFrame(() => { scrollSpyFrame = null updateActiveSectionFromScroll() }) } /** * 해당 섹션으로 부드럽게 스크롤한다. * @param {string} sectionId - 섹션 요소 id * @returns {void} */ const scrollToSection = (sectionId) => { const el = document.getElementById(sectionId) if (!el) { return } scrollSpySuspended.value = true activeSectionId.value = sectionId el.scrollIntoView({ behavior: 'smooth', block: 'start' }) window.setTimeout(() => { scrollSpySuspended.value = false updateActiveSectionFromScroll() }, 520) } /** * 예약된 스크롤 스파이 프레임을 정리한다. * @returns {void} */ const cleanupSettingsNavigation = () => { if (scrollSpyFrame) { cancelAnimationFrame(scrollSpyFrame) scrollSpyFrame = null } } return { mainScrollRef, settingsNavGroups: ADMIN_SETTINGS_NAV_GROUPS, filteredSettingsNavGroups, navSearchInput, onNavSearchInput, onNavSearchCompositionEnd, activeSectionId, onMainScroll, refreshActiveSection: updateActiveSectionFromScroll, scrollToSection, cleanupSettingsNavigation } }