From ff18824176b1fa75df04eb23e53ab32eac6090cf Mon Sep 17 00:00:00 2001 From: zenn Date: Fri, 24 Jul 2026 11:11:17 +0900 Subject: [PATCH] =?UTF-8?q?=EC=84=A4=EC=A0=95=20=EB=82=B4=EB=B9=84?= =?UTF-8?q?=EA=B2=8C=EC=9D=B4=EC=85=98=20=EC=83=81=ED=83=9C=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- composables/useAdminSettingsNavigation.js | 164 ++++++++++++++++++++++ docs/map.md | 3 +- docs/update.md | 6 + package-lock.json | 4 +- package.json | 2 +- pages/admin/settings/index.vue | 150 ++------------------ 6 files changed, 189 insertions(+), 140 deletions(-) create mode 100644 composables/useAdminSettingsNavigation.js diff --git a/composables/useAdminSettingsNavigation.js b/composables/useAdminSettingsNavigation.js new file mode 100644 index 0000000..27f9d79 --- /dev/null +++ b/composables/useAdminSettingsNavigation.js @@ -0,0 +1,164 @@ +/** + * 관리자 설정 화면 좌측 내비 구역 정의 + * @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 + } +} diff --git a/docs/map.md b/docs/map.md index d34058e..417ccfe 100644 --- a/docs/map.md +++ b/docs/map.md @@ -21,6 +21,7 @@ | composables/createPostSummary.js | 게시물 요약·본문에서 목록·SEO용 짧은 설명 텍스트 생성, 본문 대체 요약의 표시용 마크다운·HTML 토큰 제거 | | composables/useAdminToast.js | 관리자 우측 상단 토스트(성공·오류·안내; 모달보다 위 z-index) | | composables/useImeSearchInput.js | 한글 등 IME 조합 중(`compositionupdate`)에도 검색어가 즉시 반영되는 검색 입력 처리기(표시값·`onInput`·`onCompositionEnd`, NFC 정규화·선택적 디바운스) | +| composables/useAdminSettingsNavigation.js | 관리자 설정 화면 내비게이션 검색·필터·활성 구역·스크롤 이동·스크롤 스파이 상태 | ## 공유 라이브러리(서버·클라이언트 공통) @@ -258,7 +259,7 @@ | pages/admin/tags/index.vue | 태그 관리(메인 태그 드래그 정렬 자동 저장·more vert 메뉴, 일반 태그 배지 more vert 메뉴·검색/정렬, 태그 추가 버튼), 액션 피드백 토스트 | | pages/admin/tags/new.vue | 태그 생성 | | pages/admin/tags/[id].vue | 태그 수정 | -| pages/admin/settings/index.vue | 사이트 설정 Ghost형 전체 화면, 카드별 필드만 PATCH하고 성공 필드만 폼에 반영하는 독립 저장, 단순 설정 섹션 공통 저장 헬퍼, 설정 검색 상태·활성 구역 계산·스크롤 이동 처리, **블로그 제목·설명** 편집 상태·저장 요청·스냅샷 복원 연결, **사이트 정보** 편집 상태·저장 요청·로고 업로드 처리 연결, **SNS 정보** 편집 상태·저장 요청 연결, **POST 설정** 편집 상태·저장 요청·스냅샷 복원 연결, **메인 화면** 편집 상태·저장 요청·커버 업로드 처리 연결, **브랜드** 컬러 정규화·편집 상태·저장 요청 연결, **어나운스 바** 편집 상태·저장 요청 연결, **스팸 필터** 편집 상태·저장 요청·값 정규화 연결, **사이트 용량** 데이터 조회·새로고침 이벤트 연결, **게시물 내보내기** 요청 body 생성·작업 등록·목록 갱신·작업 액션 연결, **게시물 가져오기** 파일 검증·작업 생성 요청·작업 센터 모달 연결, 진행 중 요청 버튼 잠금 | +| pages/admin/settings/index.vue | 사이트 설정 Ghost형 전체 화면, 카드별 필드만 PATCH하고 성공 필드만 폼에 반영하는 독립 저장, 단순 설정 섹션 공통 저장 헬퍼, 설정 내비게이션 상태 연결, **블로그 제목·설명** 편집 상태·저장 요청·스냅샷 복원 연결, **사이트 정보** 편집 상태·저장 요청·로고 업로드 처리 연결, **SNS 정보** 편집 상태·저장 요청 연결, **POST 설정** 편집 상태·저장 요청·스냅샷 복원 연결, **메인 화면** 편집 상태·저장 요청·커버 업로드 처리 연결, **브랜드** 컬러 정규화·편집 상태·저장 요청 연결, **어나운스 바** 편집 상태·저장 요청 연결, **스팸 필터** 편집 상태·저장 요청·값 정규화 연결, **사이트 용량** 데이터 조회·새로고침 이벤트 연결, **게시물 내보내기** 요청 body 생성·작업 등록·목록 갱신·작업 액션 연결, **게시물 가져오기** 파일 검증·작업 생성 요청·작업 센터 모달 연결, 진행 중 요청 버튼 잠금 | | components/admin/AdminSettingsNavigation.vue | 관리자 설정 좌측 내비게이션(설정 검색 입력·검색 결과 목록·모바일 구역 선택·구역 이동 이벤트) | | components/admin/AdminHomeCoverSettingsCard.vue | 관리자 설정 메인 화면 카드(라이트·다크 커버 미리보기·드롭존·파일 선택·오버레이 문구·저장 이벤트) | | components/admin/AdminBrandSettingsCard.vue | 관리자 설정 브랜드 컬러 카드(읽기 미리보기·색상 입력·텍스트 입력·예시 배지·저장 이벤트) | diff --git a/docs/update.md b/docs/update.md index c8a08f9..4d9d279 100644 --- a/docs/update.md +++ b/docs/update.md @@ -1,5 +1,11 @@ # 업데이트 이력 +## v1.11.33 + +- 관리자 설정 내비게이션 검색·필터·스크롤 스파이 상태를 `useAdminSettingsNavigation` composable로 분리. +- 설정 화면의 좌측 내비 구역 정의와 활성 구역 계산 로직을 페이지 컴포넌트에서 제거. +- 설정 화면은 composable에서 받은 상태와 이벤트를 내비게이션 컴포넌트에 연결하도록 정리. + ## v1.11.32 - 관리자 설정의 단순 섹션 저장 흐름을 `saveSimpleSettingsSection` 헬퍼로 정리. diff --git a/package-lock.json b/package-lock.json index 4b8e265..204c0cb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "sori.studio", - "version": "1.11.32", + "version": "1.11.33", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sori.studio", - "version": "1.11.32", + "version": "1.11.33", "hasInstallScript": true, "dependencies": { "@nuxtjs/tailwindcss": "^6.14.0", diff --git a/package.json b/package.json index 8f03e69..5a14de6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sori.studio", - "version": "1.11.32", + "version": "1.11.33", "private": true, "type": "module", "imports": { diff --git a/pages/admin/settings/index.vue b/pages/admin/settings/index.vue index de69af7..b60fdae 100644 --- a/pages/admin/settings/index.vue +++ b/pages/admin/settings/index.vue @@ -79,15 +79,19 @@ const postExportMaxFileSizeMb = ref(500) const postImportExportPanel = ref('') const errorMessage = ref('') const { toast, showToast } = useAdminToast() -const mainScrollRef = ref(null) -const navSearchQuery = ref('') const { - displayValue: navSearchInput, - onInput: onNavSearchInput, - onCompositionEnd: onNavSearchCompositionEnd -} = useImeSearchInput(navSearchQuery) -const activeSectionId = ref('admin-settings-section-title') -const scrollSpySuspended = ref(false) + mainScrollRef, + settingsNavGroups, + filteredSettingsNavGroups, + navSearchInput, + onNavSearchInput, + onNavSearchCompositionEnd, + activeSectionId, + onMainScroll, + refreshActiveSection, + scrollToSection, + cleanupSettingsNavigation +} = useAdminSettingsNavigation() const uploadFileCountSelectOptions = UPLOAD_FILE_COUNT_OPTIONS.map((option) => ({ value: option, @@ -213,7 +217,6 @@ const adsSnapshot = reactive({ adPostInArticleCode: '', adPostBottomCode: '' }) -let scrollSpyFrame = null let postExportRefreshTimer = null let postImportRefreshTimer = null @@ -635,129 +638,6 @@ const signupBlockedUsernamesText = computed({ } }) -/** - * 설정 화면 좌측 내비 구역 정의 - * @type {ReadonlyArray<{ heading: string, items: ReadonlyArray<{ id: string, label: string, keywords: string, iconId?: string }> }>} - */ -const settingsNavGroups = [ - { - 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 {typeof settingsNavGroups} 표시할 그룹 목록 - */ -const filteredSettingsNavGroups = computed(() => { - const q = navSearchQuery.value.trim().toLowerCase() - if (!q) { - return settingsNavGroups - } - - return settingsNavGroups - .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 = settingsNavGroups[0].items[0].id - const flatIds = settingsNavGroups.flatMap((g) => g.items.map((i) => i.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 -} - -/** - * 스크롤 스파이 핸들러 (rAF 디바운스) - * @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 {Promise} @@ -1935,7 +1815,7 @@ onMounted(() => { } }, 2000) nextTick(() => { - updateActiveSectionFromScroll() + refreshActiveSection() }) } }) @@ -1943,9 +1823,7 @@ onMounted(() => { onBeforeUnmount(() => { window.clearInterval(postExportRefreshTimer) window.clearInterval(postImportRefreshTimer) - if (scrollSpyFrame) { - cancelAnimationFrame(scrollSpyFrame) - } + cleanupSettingsNavigation() if (import.meta.client) { window.removeEventListener('keydown', onGlobalKeydown) }