Files
sori.studio/pages/admin/navigation/index.vue
zenn 00999404a4 페이지 섹션·공개 목록 설정 누적 변경 일괄 반영
페이지 섹션 관리와 홈 Pages 진열장 연동, 공개 목록 표시개수 설정 관련 누적 로컬 변경을 기능 단위로 정리해 반영한다.
문서 이력과 패키지 버전을 v1.9.23으로 맞춰 코드와 문서 상태를 동기화한다.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-30 18:48:42 +09:00

2519 lines
86 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup>
import { buildNavigationEditorTree, flattenNavigationEditorWrappers } from '~/lib/navigation-editor-tree.js'
import { getRecommendedSiteIconUrl } from '~/lib/recommended-site-icon-url.js'
import {
composeNavigationUrl,
navigationUrlPrefixOptions,
normalizeNavigationUrl,
splitNavigationUrl
} from '~/lib/navigation-url.js'
definePageMeta({
layout: 'admin'
})
const saving = ref(false)
const activeTab = ref('primary')
const { toast, showToast, clearToast } = useAdminToast()
const { openMenuId, closeMenu } = useAdminRowMenu()
const { data: navigationItems } = await useFetch('/admin/api/navigation', {
default: () => []
})
const { data: pageSections, refresh: refreshPageSections } = await useFetch('/admin/api/page-sections', {
default: () => []
})
const { data: adminPages, refresh: refreshAdminPages } = await useFetch('/admin/api/pages', {
default: () => []
})
/**
* 항목 id로 레코드를 찾는다.
* @param {string} id - 항목 id
* @returns {Object|undefined}
*/
const getNavItem = (id) => items.value.find((i) => String(i.id) === String(id))
const items = ref((Array.isArray(navigationItems.value) ? navigationItems.value : []).map((item) => ({
...item,
descriptionText: item.descriptionText || '',
thumbnailUrl: item.thumbnailUrl || '',
cachedFaviconUrl: item.cachedFaviconUrl || '',
faviconCachedAt: item.faviconCachedAt || '',
parentId: item.parentId ?? null,
isFolder: Boolean(item.isFolder),
isVisible: true
})))
/** 파비콘 수동 갱신 중인 추천 사이트 ID */
const faviconRefreshingIds = ref(new Set())
/** 편집 모달 대상 추천 사이트 ID */
const recommendedEditId = ref('')
/** 추천 사이트 추가 중인 임시 항목 ID */
const recommendedCreatingId = ref('')
/** 편집 모달 입력값 */
const recommendedEditDraft = ref(null)
/** 추천 사이트 편집 모달 — 미저장 닫기 확인 */
const recommendedDiscardModalOpen = ref(false)
/** 추천 사이트 편집 모달 — 삭제 확인 */
const recommendedDeleteModalOpen = ref(false)
const navDraggingId = ref('')
const navDragOverId = ref('')
/** 'before' | 'into' | 'after' — 행 위·중·아래 드롭 구역 */
const navDragOverZone = ref('')
const footerDraggingId = ref('')
const footerDragOverId = ref('')
const recommendedDraggingId = ref('')
const recommendedDragOverId = ref('')
/** 'before' | 'after' — 카드 위·아래 드롭 구역 */
const recommendedDragOverZone = ref('')
const navCreateModalType = ref('')
const navCreateDraft = reactive({
label: '',
urlPrefix: '',
urlBody: ''
})
const pageSectionCreateModalOpen = ref(false)
const pageSectionCreateName = ref('')
const savingPageSectionId = ref('')
const deletingPageSectionId = ref('')
const savingPageSections = ref(false)
const pageSectionMessage = ref('')
const pageSectionBaseline = ref('')
const pageSectionDraggingId = ref('')
const pageSectionDragOverId = ref('')
const pageOrderModalSection = ref(null)
const pageOrderDraftPages = ref([])
const pageOrderRemovedPageIds = ref(new Set())
const pageOrderDraggingId = ref('')
const pageOrderDragOverId = ref('')
const savingPageOrder = ref(false)
/**
* 네비게이션 항목을 저장 API와 동일한 형태로 직렬화한다.
* @param {Array<Object>} list - 항목 목록
* @returns {string} 비교용 JSON 문자열
*/
const serializeNavigationItems = (list) => JSON.stringify(
[...list]
.sort((a, b) => {
if (a.location !== b.location) {
return String(a.location).localeCompare(String(b.location))
}
return (a.sortOrder || 0) - (b.sortOrder || 0)
})
.map((item) => ({
id: String(item.id || '').trim(),
label: String(item.label || '').trim(),
url: String(item.url || '').trim(),
descriptionText: String(item.descriptionText || '').trim(),
thumbnailUrl: String(item.thumbnailUrl || '').trim(),
location: item.location,
sortOrder: Number(item.sortOrder || 0),
parentId: item.parentId ? String(item.parentId).trim() : null
}))
)
/** 서버에서 불러온 네비게이션 직렬화 스냅샷 */
const navigationBaseline = ref(serializeNavigationItems(items.value))
/**
* 현재 편집본이 서버 스냅샷과 다른지 여부
* @returns {boolean} 변경 여부
*/
const isNavigationDirty = computed(() => serializeNavigationItems(items.value) !== navigationBaseline.value)
/**
* 페이지 섹션 목록을 저장 비교용 문자열로 직렬화한다.
* @param {Array<Object>} sections - 페이지 섹션 목록
* @returns {string} 직렬화 문자열
*/
const serializePageSections = (sections) => JSON.stringify(
[...sections]
.map((section, index) => ({
id: String(section.id || ''),
name: String(section.name || '').trim(),
sortOrder: Number(section.sortOrder ?? index * 10)
}))
)
pageSectionBaseline.value = serializePageSections(pageSections.value)
/** 페이지 섹션 변경 여부 */
const isPageSectionDirty = computed(() => serializePageSections(pageSections.value) !== pageSectionBaseline.value)
const primaryTree = computed(() => buildNavigationEditorTree(items.value, 'primary'))
const primaryRows = computed(() => flattenNavigationEditorWrappers(primaryTree.value))
/**
* 상단 메뉴 평면 행에 표시할 개요 번호(예: 1, 2, 2.1, 2.2, 3)
* @returns {string[]}
*/
const primaryOutlineLabels = computed(() => {
const rows = primaryRows.value
const labels = []
/** @type {number[]} */
const counters = []
for (const row of rows) {
const d = row.depth
counters.length = d + 1
counters[d] = (counters[d] || 0) + 1
labels.push(counters.slice(0, d + 1).join('.'))
}
return labels
})
const footerItemsSorted = computed(() =>
items.value
.filter((item) => item.location === 'footer' && !item.parentId)
.sort((a, b) => (a.sortOrder || 0) - (b.sortOrder || 0))
)
const recommendedItemsSorted = computed(() =>
items.value
.filter((item) => item.location === 'recommended' && !item.parentId)
.sort((a, b) => (a.sortOrder || 0) - (b.sortOrder || 0))
)
/**
* 페이지 섹션 사용 개수 라벨 생성
* @param {Object} section - 페이지 섹션
* @returns {string} 사용 개수 라벨
*/
const getPageSectionCountLabel = (section) => `${new Intl.NumberFormat('ko-KR').format(Number(section.pageCount || 0))}개 페이지`
/**
* 섹션에 속한 페이지 목록을 정렬해 반환한다.
* @param {Object} section - 페이지 섹션
* @returns {Array<Object>} 페이지 목록
*/
const getPagesBySection = (section) => {
const sectionName = String(section?.name || '').trim()
if (!sectionName) {
return []
}
return (Array.isArray(adminPages.value) ? adminPages.value : [])
.filter((page) => Boolean(page.isListed) && String(page.listingSection || '').trim() === sectionName)
.sort((a, b) => {
const featuredDiff = Number(Boolean(b.isFeatured)) - Number(Boolean(a.isFeatured))
if (featuredDiff !== 0) {
return featuredDiff
}
const sortDiff = Number(a.listingSortOrder || 0) - Number(b.listingSortOrder || 0)
if (sortDiff !== 0) {
return sortDiff
}
return String(a.title || '').localeCompare(String(b.title || ''), 'ko-KR')
})
}
/**
* 페이지 섹션 추가 모달을 연다.
* @returns {void}
*/
const openPageSectionCreateModal = () => {
pageSectionCreateName.value = ''
pageSectionCreateModalOpen.value = true
pageSectionMessage.value = ''
}
/**
* 페이지 섹션 추가 모달을 닫는다.
* @returns {void}
*/
const closePageSectionCreateModal = () => {
pageSectionCreateModalOpen.value = false
pageSectionCreateName.value = ''
}
/**
* 페이지 섹션을 추가한다.
* @returns {Promise<void>}
*/
const addPageSection = async () => {
const name = pageSectionCreateName.value.trim()
if (!name) {
return
}
savingPageSectionId.value = 'new'
pageSectionMessage.value = ''
try {
await $fetch('/admin/api/page-sections', {
method: 'POST',
body: { name }
})
closePageSectionCreateModal()
await refreshPageSections()
pageSectionBaseline.value = serializePageSections(pageSections.value)
} catch (error) {
pageSectionMessage.value = error?.data?.message || '페이지 섹션을 추가하지 못했습니다.'
} finally {
savingPageSectionId.value = ''
}
}
/**
* 페이지 섹션 변경사항을 저장한다.
* @returns {Promise<void>}
*/
const savePageSections = async () => {
if (savingPageSections.value || !isPageSectionDirty.value) {
return
}
const trimmedSections = pageSections.value.map((section) => ({
...section,
name: String(section.name || '').trim()
}))
if (trimmedSections.some((section) => !section.name)) {
pageSectionMessage.value = '페이지 섹션 이름을 입력해 주세요.'
return
}
const duplicatedName = trimmedSections.find((section, index) =>
trimmedSections.findIndex((candidate) => candidate.name === section.name) !== index
)
if (duplicatedName) {
pageSectionMessage.value = '같은 이름의 페이지 섹션이 있습니다.'
return
}
savingPageSections.value = true
pageSectionMessage.value = ''
try {
const baselineSections = JSON.parse(pageSectionBaseline.value || '[]')
const baselineMap = new Map(baselineSections.map((section) => [String(section.id), section]))
for (const section of trimmedSections) {
const previous = baselineMap.get(String(section.id))
if (previous && previous.name !== section.name) {
await $fetch(`/admin/api/page-sections/${section.id}`, {
method: 'PUT',
body: { name: section.name }
})
}
}
pageSections.value = await $fetch('/admin/api/page-sections/reorder', {
method: 'PUT',
body: {
sectionIds: trimmedSections.map((section) => section.id)
}
})
await Promise.all([refreshPageSections(), refreshAdminPages()])
pageSectionBaseline.value = serializePageSections(pageSections.value)
showToast('success', '페이지 섹션을 저장했습니다.')
} catch (error) {
pageSectionMessage.value = error?.data?.message || '페이지 섹션을 저장하지 못했습니다.'
} finally {
savingPageSections.value = false
}
}
/**
* 페이지 섹션을 삭제한다.
* @param {Object} section - 페이지 섹션
* @returns {Promise<void>}
*/
const deletePageSection = async (section) => {
if (Number(section.pageCount || 0) > 0) {
pageSectionMessage.value = '사용 중인 페이지 섹션은 삭제할 수 없습니다.'
return
}
deletingPageSectionId.value = section.id
pageSectionMessage.value = ''
try {
await $fetch(`/admin/api/page-sections/${section.id}`, {
method: 'DELETE'
})
await refreshPageSections()
pageSectionBaseline.value = serializePageSections(pageSections.value)
} catch (error) {
pageSectionMessage.value = error?.data?.message || '페이지 섹션을 삭제하지 못했습니다.'
} finally {
deletingPageSectionId.value = ''
}
}
/**
* 페이지 섹션 드래그를 시작한다.
* @param {DragEvent} event - 드래그 이벤트
* @param {string} id - 섹션 ID
* @returns {void}
*/
const onPageSectionDragStart = (event, id) => {
if (shouldBlockNavRowDrag(event)) {
event.preventDefault()
return
}
if (!event.dataTransfer) {
return
}
pageSectionDraggingId.value = id
event.dataTransfer.effectAllowed = 'move'
}
/**
* 페이지 섹션 드래그 오버를 처리한다.
* @param {DragEvent} event - 드래그 이벤트
* @param {string} id - 대상 섹션 ID
* @returns {void}
*/
const onPageSectionDragOver = (event, id) => {
event.preventDefault()
if (!pageSectionDraggingId.value) {
return
}
pageSectionDragOverId.value = id
}
/**
* 페이지 섹션 순서를 변경한다.
* @param {DragEvent} event - 드롭 이벤트
* @param {string} targetId - 대상 섹션 ID
* @returns {void}
*/
const onPageSectionDrop = (event, targetId) => {
event.preventDefault()
const sourceId = pageSectionDraggingId.value
if (!sourceId || String(sourceId) === String(targetId)) {
pageSectionDraggingId.value = ''
pageSectionDragOverId.value = ''
return
}
const nextSections = [...pageSections.value]
const sourceIndex = nextSections.findIndex((section) => String(section.id) === String(sourceId))
const targetIndex = nextSections.findIndex((section) => String(section.id) === String(targetId))
if (sourceIndex < 0 || targetIndex < 0) {
return
}
const [movedSection] = nextSections.splice(sourceIndex, 1)
nextSections.splice(targetIndex, 0, movedSection)
nextSections.forEach((section, index) => {
section.sortOrder = index * 10
})
pageSections.value = nextSections
pageSectionDraggingId.value = ''
pageSectionDragOverId.value = ''
}
/**
* 페이지 섹션 드래그 상태를 초기화한다.
* @returns {void}
*/
const onPageSectionDragEnd = () => {
pageSectionDraggingId.value = ''
pageSectionDragOverId.value = ''
}
/**
* 페이지 섹션 행 하이라이트 클래스를 반환한다.
* @param {string} id - 섹션 ID
* @returns {string} 클래스 문자열
*/
const pageSectionRowClass = (id) => {
const parts = ['cursor-move']
if (pageSectionDragOverId.value === id) {
parts.push('bg-[#f9f9f7]')
}
if (pageSectionDraggingId.value === id) {
parts.push('opacity-50')
}
return parts.join(' ')
}
/**
* 페이지 순서 모달 목록을 공개 홈 정렬 규칙에 맞춘다.
* @param {Array<Object>} pages - 페이지 목록
* @returns {Array<Object>} 추천 우선 정렬된 페이지 목록
*/
const normalizePageOrderDraftPages = (pages) => [
...pages.filter((page) => Boolean(page.isFeatured)),
...pages.filter((page) => !page.isFeatured)
]
/**
* 섹션 페이지 순서 변경 모달을 연다.
* @param {Object} section - 페이지 섹션
* @returns {void}
*/
const openPageOrderModal = (section) => {
pageOrderModalSection.value = section
pageOrderDraftPages.value = normalizePageOrderDraftPages(getPagesBySection(section))
pageOrderRemovedPageIds.value = new Set()
pageOrderDraggingId.value = ''
pageOrderDragOverId.value = ''
pageSectionMessage.value = ''
}
/**
* 섹션 페이지 순서 변경 모달을 닫는다.
* @returns {void}
*/
const closePageOrderModal = () => {
pageOrderModalSection.value = null
pageOrderDraftPages.value = []
pageOrderRemovedPageIds.value = new Set()
pageOrderDraggingId.value = ''
pageOrderDragOverId.value = ''
}
/**
* 섹션 페이지 드래그를 시작한다.
* @param {DragEvent} event - 드래그 이벤트
* @param {string} id - 페이지 ID
* @returns {void}
*/
const onPageOrderDragStart = (event, id) => {
pageOrderDraggingId.value = id
event.dataTransfer.effectAllowed = 'move'
event.dataTransfer.setData('text/plain', id)
}
/**
* 섹션 페이지 드래그 오버를 처리한다.
* @param {DragEvent} event - 드래그 이벤트
* @param {string} id - 대상 페이지 ID
* @returns {void}
*/
const onPageOrderDragOver = (event, id) => {
if (!pageOrderDraggingId.value) {
return
}
event.preventDefault()
pageOrderDragOverId.value = id
}
/**
* 섹션 페이지 순서를 변경한다.
* @param {DragEvent} event - 드롭 이벤트
* @param {string} targetId - 대상 페이지 ID
* @returns {void}
*/
const onPageOrderDrop = (event, targetId) => {
event.preventDefault()
const sourceId = pageOrderDraggingId.value
if (!sourceId || sourceId === targetId) {
pageOrderDraggingId.value = ''
pageOrderDragOverId.value = ''
return
}
const nextPages = [...pageOrderDraftPages.value]
const sourceIndex = nextPages.findIndex((page) => String(page.id) === String(sourceId))
const targetIndex = nextPages.findIndex((page) => String(page.id) === String(targetId))
if (sourceIndex < 0 || targetIndex < 0) {
return
}
const [movedPage] = nextPages.splice(sourceIndex, 1)
nextPages.splice(targetIndex, 0, movedPage)
pageOrderDraftPages.value = normalizePageOrderDraftPages(nextPages)
pageOrderDraggingId.value = ''
pageOrderDragOverId.value = ''
}
/**
* 섹션 페이지 드래그 상태를 초기화한다.
* @returns {void}
*/
const onPageOrderDragEnd = () => {
pageOrderDraggingId.value = ''
pageOrderDragOverId.value = ''
}
/**
* 페이지 순서 행 하이라이트 클래스를 반환한다.
* @param {string} id - 페이지 ID
* @returns {string} 클래스 문자열
*/
const pageOrderRowClass = (id) => {
const parts = ['cursor-move']
if (pageOrderDragOverId.value === id) {
parts.push('ring-2 ring-inset ring-blue-500')
}
if (pageOrderDraggingId.value === id) {
parts.push('opacity-50')
}
return parts.join(' ')
}
/** 섹션에서 제거 예정인 페이지 개수 */
const pageOrderRemovedCount = computed(() => pageOrderRemovedPageIds.value.size)
/**
* 페이지 순서 모달 카드의 썸네일 URL을 반환한다.
* @param {Object} page - 관리자 페이지
* @returns {string} 썸네일 URL
*/
const getPageOrderCardImage = (page) => String(page?.featuredImage || page?.listingImage || '').trim()
/**
* 페이지를 현재 섹션에서 제거 목록에 추가한다.
* @param {Object} page - 제거할 페이지
* @returns {void}
*/
const removePageFromOrderSection = (page) => {
const pageId = String(page?.id || '').trim()
if (!pageId) {
return
}
pageOrderDraftPages.value = normalizePageOrderDraftPages(pageOrderDraftPages.value.filter((item) => String(item.id) !== pageId))
pageOrderRemovedPageIds.value = new Set([...pageOrderRemovedPageIds.value, pageId])
}
/**
* 섹션 페이지 순서를 저장한다.
* @returns {Promise<void>}
*/
const savePageOrder = async () => {
if (!pageOrderModalSection.value || savingPageOrder.value) {
return
}
savingPageOrder.value = true
pageSectionMessage.value = ''
try {
await $fetch(`/admin/api/page-sections/${pageOrderModalSection.value.id}/pages/reorder`, {
method: 'PUT',
body: {
pageIds: normalizePageOrderDraftPages(pageOrderDraftPages.value).map((page) => page.id),
removedPageIds: [...pageOrderRemovedPageIds.value]
}
})
await Promise.all([refreshAdminPages(), refreshPageSections()])
closePageOrderModal()
} catch (error) {
pageSectionMessage.value = error?.data?.message || '페이지 순서를 저장하지 못했습니다.'
} finally {
savingPageOrder.value = false
}
}
/**
* 추천 사이트에 현재 적용될 아이콘 URL을 반환한다.
* @param {Object} item - 추천 사이트 항목
* @returns {string} 아이콘 URL
*/
const getRecommendedIconPreviewUrl = (item) => getRecommendedSiteIconUrl(item, 64)
/**
* 추천 사이트 캐시 갱신 시각만 반환한다.
* @param {Object} item - 추천 사이트 항목
* @returns {string} 갱신 시각 문구
*/
const getRecommendedFaviconCachedAtLabel = (item) => {
const raw = String(item?.faviconCachedAt || '').trim()
if (!raw) {
return '캐시 없음'
}
const date = new Date(raw)
if (Number.isNaN(date.getTime())) {
return '캐시 없음'
}
return date.toLocaleString('ko-KR')
}
/**
* 공개 Recommended와 동일한 보조 문구(설명 또는 URL)를 반환한다.
* @param {Object} item - 추천 사이트 항목
* @returns {string} 보조 문구
*/
const getRecommendedCardSubtitle = (item) => {
return String(item?.descriptionText || '').trim() || String(item?.url || '').trim()
}
/**
* 모달·카드 미리보기용 보조 문구를 반환한다.
* @param {Object} item - 추천 사이트 항목
* @returns {string} 설명 또는 URL
*/
const getRecommendedPreviewSubtitle = (item) => {
return String(item?.descriptionText || '').trim() || String(item?.url || '').trim()
}
/**
* 추천 사이트 편집 draft에서 URL을 만든다.
* @param {Object|null} draft - 편집 draft
* @returns {string} URL
*/
const getRecommendedDraftUrl = (draft) => {
if (!draft) {
return ''
}
return normalizeNavigationUrl(composeNavigationUrl(draft.urlPrefix, draft.urlBody))
}
/**
* URL 접두사에 맞는 입력 placeholder를 반환한다.
* @param {string} prefix - URL 접두사
* @returns {string} placeholder
*/
const getNavigationUrlBodyPlaceholder = (prefix) => {
if (!prefix) {
return '/pages/clock'
}
if (prefix === 'http://') {
return 'example.com'
}
return 'uniare.sori.studio'
}
/**
* 편집 중인 추천 사이트 항목을 반환한다.
* @returns {Object|null} 추천 사이트 항목
*/
const recommendedEditItem = computed(() => {
if (!recommendedEditId.value) {
return null
}
return getNavItem(recommendedEditId.value) || null
})
/**
* 편집 모달 미리보기용 추천 사이트 항목
* @returns {Object|null} draft가 반영된 항목
*/
const recommendedEditPreviewItem = computed(() => {
const item = recommendedEditItem.value
const draft = recommendedEditDraft.value
if (!item || !draft) {
return null
}
return {
...item,
label: draft.label,
url: getRecommendedDraftUrl(draft),
descriptionText: draft.descriptionText,
thumbnailUrl: draft.thumbnailUrl
}
})
/**
* 추천 사이트 편집 모달을 연다.
* @param {Object} item - 추천 사이트 항목
* @returns {void}
*/
const openRecommendedEdit = (item) => {
closeMenu()
const { prefix, body } = splitNavigationUrl(item.url)
recommendedEditId.value = String(item.id)
recommendedEditDraft.value = {
label: String(item.label || ''),
urlPrefix: prefix,
urlBody: body,
descriptionText: String(item.descriptionText || ''),
thumbnailUrl: String(item.thumbnailUrl || '')
}
}
/**
* 추천 사이트 편집 draft가 원본과 다른지 여부
* @returns {boolean} 변경 여부
*/
const isRecommendedEditDirty = computed(() => {
const item = recommendedEditItem.value
const draft = recommendedEditDraft.value
if (!item || !draft) {
return false
}
const { prefix, body } = splitNavigationUrl(item.url)
return (
String(draft.label || '').trim() !== String(item.label || '').trim()
|| draft.urlPrefix !== prefix
|| String(draft.urlBody || '').trim() !== String(body || '').trim()
|| String(draft.descriptionText || '').trim() !== String(item.descriptionText || '').trim()
|| String(draft.thumbnailUrl || '').trim() !== String(item.thumbnailUrl || '').trim()
)
})
/**
* 추천 사이트 편집 모달을 닫는다.
* @returns {void}
*/
const closeRecommendedEdit = () => {
recommendedDiscardModalOpen.value = false
recommendedDeleteModalOpen.value = false
recommendedEditId.value = ''
recommendedEditDraft.value = null
}
/**
* 저장하지 않은 변경이 있으면 확인 모달을 연다.
* @returns {void}
*/
const requestCloseRecommendedEdit = () => {
if (isRecommendedEditDirty.value) {
recommendedDiscardModalOpen.value = true
return
}
if (recommendedCreatingId.value && recommendedCreatingId.value === recommendedEditId.value) {
removeItemCascade(recommendedCreatingId.value)
recommendedCreatingId.value = ''
}
closeRecommendedEdit()
}
/**
* 미저장 변경을 버리고 추천 사이트 편집 모달을 닫는다.
* @returns {void}
*/
const confirmDiscardRecommendedEdit = () => {
if (recommendedCreatingId.value && recommendedCreatingId.value === recommendedEditId.value) {
removeItemCascade(recommendedCreatingId.value)
recommendedCreatingId.value = ''
}
closeRecommendedEdit()
}
/**
* 추천 사이트 삭제 대상 제목
* @returns {string} 제목
*/
const recommendedDeleteTargetLabel = computed(() => {
const draftLabel = String(recommendedEditDraft.value?.label || '').trim()
if (draftLabel) {
return draftLabel
}
const itemLabel = String(recommendedEditItem.value?.label || '').trim()
return itemLabel || '제목 없음'
})
/**
* 추천 사이트 편집 모달 내용을 목록에 반영한다.
* @returns {void}
*/
const applyRecommendedEdit = () => {
const draft = recommendedEditDraft.value
const itemId = recommendedEditId.value
if (!draft || !itemId) {
return
}
const index = items.value.findIndex((row) => String(row.id) === String(itemId))
if (index < 0) {
closeRecommendedEdit()
return
}
items.value[index] = {
...items.value[index],
label: String(draft.label || '').trim(),
url: getRecommendedDraftUrl(draft),
descriptionText: String(draft.descriptionText || '').trim(),
thumbnailUrl: String(draft.thumbnailUrl || '').trim()
}
if (recommendedCreatingId.value === itemId) {
recommendedCreatingId.value = ''
}
closeRecommendedEdit()
}
/**
* 편집 모달에서 파비콘을 갱신한다.
* @returns {Promise<void>}
*/
const refreshRecommendedEditFavicon = async () => {
const item = recommendedEditItem.value
if (!item) {
return
}
await refreshRecommendedFavicon(item)
}
/**
* 추천 사이트 파비콘 캐시 갱신 시각 문구를 반환한다.
* @param {Object} item - 추천 사이트 항목
* @returns {string} 갱신 시각 문구
*/
const getRecommendedFaviconCachedLabel = (item) => {
const raw = String(item?.faviconCachedAt || '').trim()
if (!raw) {
return '캐시 없음'
}
const date = new Date(raw)
if (Number.isNaN(date.getTime())) {
return '캐시 없음'
}
return `캐시 ${date.toLocaleString('ko-KR')}`
}
/**
* 추천 사이트 파비콘을 수동 갱신한다.
* @param {Object} item - 추천 사이트 항목
* @returns {Promise<void>}
*/
const refreshRecommendedFavicon = async (item) => {
const itemId = String(item?.id || '').trim()
if (!itemId || faviconRefreshingIds.value.has(itemId)) {
return
}
if (isNavigationDirty.value) {
showToast('error', '파비콘을 갱신하려면 먼저 메뉴를 저장하세요.')
return
}
faviconRefreshingIds.value = new Set([...faviconRefreshingIds.value, itemId])
clearToast()
try {
const result = await $fetch(`/admin/api/navigation/${itemId}/favicon-refresh`, {
method: 'POST'
})
const index = items.value.findIndex((row) => String(row.id) === itemId)
if (index >= 0) {
items.value[index] = {
...items.value[index],
cachedFaviconUrl: result.cachedFaviconUrl || '',
faviconCachedAt: result.faviconCachedAt || ''
}
}
showToast('success', '파비콘을 갱신했습니다.')
} catch (error) {
const msg = error?.data?.message || error?.message || '파비콘을 갱신하지 못했습니다.'
showToast('error', msg)
} finally {
const next = new Set(faviconRefreshingIds.value)
next.delete(itemId)
faviconRefreshingIds.value = next
}
}
/**
* 하위 항목 id를 모두 모은 뒤 삭제한다.
* @param {string} rootId - 루트 id
* @returns {void}
*/
const removeItemCascade = (rootId) => {
const toRemove = new Set([String(rootId)])
let growing = true
while (growing) {
growing = false
for (const it of items.value) {
const pid = it.parentId ? String(it.parentId) : ''
if (pid && toRemove.has(pid) && !toRemove.has(String(it.id))) {
toRemove.add(String(it.id))
growing = true
}
}
}
items.value = items.value.filter((i) => !toRemove.has(String(i.id)))
}
/**
* 메뉴 항목 삭제 확인 문구를 반환한다.
* @param {Object} item - 메뉴 항목
* @returns {string} 확인 문구
*/
const getNavItemDeleteConfirmMessage = (item) => {
const label = String(item?.label || '').trim() || '제목 없음'
if (item?.location === 'recommended') {
return `"${label}" 추천 사이트를 삭제할까요?`
}
if (item?.location === 'footer') {
return `"${label}" 하단 메뉴를 삭제할까요?`
}
const hasChildren = items.value.some(
(row) => row.location === 'primary' && String(row.parentId || '') === String(item?.id || '')
)
if (hasChildren) {
return `"${label}" 메뉴 항목과 하위 메뉴를 모두 삭제할까요?`
}
return `"${label}" 메뉴 항목을 삭제할까요?`
}
/**
* 메뉴 항목을 삭제한다(하위 포함).
* @param {string} itemId - 항목 id
* @returns {boolean} 삭제 수행 여부
*/
const removeNavItem = (itemId) => {
closeMenu()
const item = getNavItem(itemId)
if (!item) {
return false
}
if (!confirm(getNavItemDeleteConfirmMessage(item))) {
return false
}
removeItemCascade(itemId)
return true
}
/**
* 추천 사이트 편집 모달에서 항목 삭제 확인 모달을 연다.
* @returns {void}
*/
const removeRecommendedFromModal = () => {
if (!recommendedEditId.value) {
return
}
recommendedDeleteModalOpen.value = true
}
/**
* 추천 사이트 편집 모달에서 항목 삭제를 확정한다.
* @returns {void}
*/
const confirmDeleteRecommendedFromModal = () => {
if (!recommendedEditId.value) {
return
}
removeItemCascade(recommendedEditId.value)
if (recommendedCreatingId.value === recommendedEditId.value) {
recommendedCreatingId.value = ''
}
closeRecommendedEdit()
}
/**
* nodeId가 ancestorId의 하위(직·간접)인지
* @param {string} nodeId - 후손 후보
* @param {string} ancestorId - 조상 id
* @returns {boolean}
*/
const isUnderTree = (nodeId, ancestorId) => {
let cur = getNavItem(nodeId)
while (cur) {
const p = cur.parentId ? String(cur.parentId) : ''
if (!p) {
return false
}
if (p === String(ancestorId)) {
return true
}
cur = getNavItem(p)
}
return false
}
/**
* 동일 부모(primary) 형제들의 sortOrder를 10 간격으로 다시 부여한다.
* @param {string|null|undefined} parentId - 부모 id, 루트면 null
* @returns {void}
*/
const normalizeOrdersForParent = (parentId) => {
const pid = parentId == null || parentId === '' ? null : String(parentId)
const sibs = items.value
.filter((i) => {
if (i.location !== 'primary') {
return false
}
const p = i.parentId ? String(i.parentId) : ''
if (pid === null) {
return !p
}
return p === pid
})
.sort((a, b) => (a.sortOrder || 0) - (b.sortOrder || 0))
sibs.forEach((row, idx) => {
row.sortOrder = (idx + 1) * 10
})
}
/**
* 상단 메뉴 한 항목을 드롭 구역에 맞춰 이동·부모 변경한다.
* @param {string} sourceId - 드래그한 항목 id
* @param {string} targetId - 드롭 대상 행 id
* @param {'before'|'into'|'after'} mode - 드롭 구역
* @returns {boolean} 성공 여부
*/
const movePrimaryItem = (sourceId, targetId, mode) => {
const src = getNavItem(sourceId)
const tgt = getNavItem(targetId)
if (!src || !tgt || src.location !== 'primary' || tgt.location !== 'primary') {
return false
}
if (String(sourceId) === String(targetId)) {
return false
}
if (mode === 'into') {
if (tgt.parentId != null && String(tgt.parentId).trim() !== '') {
showToast('error', '상단 메뉴는 하위 한 단계까지만 가능합니다. 루트 항목 가운데에만 하위로 넣을 수 있습니다.')
return false
}
const srcHasPrimaryChildren = items.value.some(
(i) => i.location === 'primary' && String(i.parentId || '') === String(sourceId)
)
if (srcHasPrimaryChildren) {
showToast('error', '이미 하위 메뉴가 있는 항목은 다른 항목의 하위로 넣을 수 없습니다.')
return false
}
if (isUnderTree(targetId, sourceId)) {
showToast('error', '자신의 하위 메뉴 안으로는 옮길 수 없습니다.')
return false
}
const oldPid = src.parentId ? String(src.parentId) : null
const tgtId = String(tgt.id)
src.parentId = tgtId
const children = items.value.filter((i) => i.location === 'primary' && String(i.parentId || '') === tgtId)
const maxOrder = Math.max(0, ...children.map((c) => c.sortOrder || 0))
src.sortOrder = maxOrder + 10
normalizeOrdersForParent(tgtId)
if (String(oldPid || '') !== tgtId) {
normalizeOrdersForParent(oldPid)
}
return true
}
if (isUnderTree(targetId, sourceId)) {
showToast('error', '하위 메뉴와 같은 줄에 끼울 수 없습니다.')
return false
}
if (String(tgt.parentId || '') === String(sourceId)) {
showToast('error', '자신의 바로 아래 항목 앞·뒤로는 옮길 수 없습니다.')
return false
}
const newParentId = tgt.parentId ?? null
const newParentStr = newParentId ? String(newParentId) : ''
if (newParentStr && isUnderTree(newParentStr, sourceId)) {
showToast('error', '이동할 수 없는 위치입니다.')
return false
}
const oldPid = src.parentId ? String(src.parentId) : null
src.parentId = tgt.parentId ?? null
const pKey = newParentStr
const without = items.value
.filter((i) => {
if (i.location !== 'primary') {
return false
}
const ip = i.parentId ? String(i.parentId) : ''
if (!pKey) {
return !ip
}
return ip === pKey
})
.filter((i) => String(i.id) !== String(sourceId))
.sort((a, b) => (a.sortOrder || 0) - (b.sortOrder || 0))
const ti = without.findIndex((i) => String(i.id) === String(targetId))
if (ti < 0) {
return false
}
const insertIdx = mode === 'before' ? ti : ti + 1
const reordered = [...without.slice(0, insertIdx), src, ...without.slice(insertIdx)]
reordered.forEach((row, idx) => {
row.sortOrder = (idx + 1) * 10
})
normalizeOrdersForParent(oldPid)
if (String(oldPid || '') !== String(newParentStr || '')) {
normalizeOrdersForParent(newParentStr || null)
}
return true
}
/**
* 입력·버튼 위에서는 행 드래그를 시작하지 않는다.
* @param {DragEvent} event - 이벤트
* @returns {boolean}
*/
const shouldBlockNavRowDrag = (event) => {
const el = event.target
if (!el || typeof el.closest !== 'function') {
return false
}
return Boolean(el.closest('input, button, textarea, select, a'))
}
/**
* 하단 메뉴 순서 변경
* @param {string} sourceId - 이동할 id
* @param {string} targetId - 기준 id
* @returns {void}
*/
const reorderFooter = (sourceId, targetId) => {
const list = footerItemsSorted.value.map((i) => i)
const ids = list.map((s) => String(s.id))
const si = ids.indexOf(String(sourceId))
const ti = ids.indexOf(String(targetId))
if (si < 0 || ti < 0 || si === ti) {
return
}
const [mv] = list.splice(si, 1)
list.splice(ti, 0, mv)
list.forEach((row, idx) => {
row.sortOrder = (idx + 1) * 10
})
}
/**
* 추천 사이트 순서 변경
* @param {string} sourceId - 이동할 id
* @param {string} targetId - 기준 id
* @param {'before'|'after'} mode - 드롭 구역
* @returns {void}
*/
const moveRecommendedItem = (sourceId, targetId, mode) => {
const list = recommendedItemsSorted.value.map((i) => i)
const si = list.findIndex((i) => String(i.id) === String(sourceId))
const ti = list.findIndex((i) => String(i.id) === String(targetId))
if (si < 0 || ti < 0 || String(sourceId) === String(targetId)) {
return
}
const without = list.filter((i) => String(i.id) !== String(sourceId))
const newTi = without.findIndex((i) => String(i.id) === String(targetId))
if (newTi < 0) {
return
}
const insertIdx = mode === 'before' ? newTi : newTi + 1
const [mv] = list.filter((i) => String(i.id) === String(sourceId))
without.splice(insertIdx, 0, mv)
without.forEach((row, idx) => {
row.sortOrder = (idx + 1) * 10
})
}
/**
* 상단 행 드래그 시작
* @param {DragEvent} event - 이벤트
* @param {string} itemId - 항목 id
* @returns {void}
*/
const onPrimaryDragStart = (event, itemId) => {
if (shouldBlockNavRowDrag(event)) {
event.preventDefault()
return
}
if (!event.dataTransfer) {
return
}
navDraggingId.value = itemId
event.dataTransfer.effectAllowed = 'move'
}
/**
* 상단 행 드래그 오버(행 높이 기준 위·중·아래 구역)
* @param {DragEvent} event - 이벤트
* @param {string} itemId - 행 id
* @returns {void}
*/
const onPrimaryDragOverRow = (event, itemId) => {
event.preventDefault()
if (!navDraggingId.value) {
return
}
const tr = event.currentTarget
if (!(tr instanceof HTMLElement)) {
return
}
const rect = tr.getBoundingClientRect()
const y = event.clientY - rect.top
const ratio = rect.height > 0 ? y / rect.height : 0.5
let zone = 'into'
if (ratio < 0.33) {
zone = 'before'
} else if (ratio > 0.66) {
zone = 'after'
}
const overItem = getNavItem(itemId)
if (zone === 'into' && overItem?.parentId != null && String(overItem.parentId).trim() !== '') {
zone = ratio < 0.5 ? 'before' : 'after'
}
navDragOverId.value = itemId
navDragOverZone.value = zone
}
/**
* 상단 행 드롭
* @param {DragEvent} event - 이벤트
* @param {string} targetId - 대상 행 id
* @returns {void}
*/
const onPrimaryDropRow = (event, targetId) => {
event.preventDefault()
if (!navDraggingId.value) {
return
}
const mode = /** @type {'before'|'into'|'after'} */ (navDragOverZone.value || 'into')
movePrimaryItem(navDraggingId.value, targetId, mode)
onPrimaryDragEnd()
}
/**
* 상단 트리 드래그 종료
* @returns {void}
*/
const onPrimaryDragEnd = () => {
navDraggingId.value = ''
navDragOverId.value = ''
navDragOverZone.value = ''
}
/**
* 하단 행 드래그 시작
* @param {DragEvent} event - 이벤트
* @param {string} id - 항목 id
* @returns {void}
*/
const onFooterDragStart = (event, id) => {
if (shouldBlockNavRowDrag(event)) {
event.preventDefault()
return
}
if (!event.dataTransfer) {
return
}
footerDraggingId.value = id
event.dataTransfer.effectAllowed = 'move'
}
/**
* 하단 행 드래그 오버
* @param {DragEvent} event - 이벤트
* @param {string} id - 항목 id
* @returns {void}
*/
const onFooterDragOver = (event, id) => {
event.preventDefault()
footerDragOverId.value = id
}
/**
* 하단 행 드롭
* @param {DragEvent} event - 이벤트
* @param {string} targetId - 대상 id
* @returns {void}
*/
const onFooterDrop = (event, targetId) => {
event.preventDefault()
if (!footerDraggingId.value) {
return
}
reorderFooter(footerDraggingId.value, targetId)
footerDraggingId.value = ''
footerDragOverId.value = ''
}
/**
* 하단 드래그 종료
* @returns {void}
*/
const onFooterDragEnd = () => {
footerDraggingId.value = ''
footerDragOverId.value = ''
}
/**
* 추천 사이트 행 드래그 시작
* @param {DragEvent} event - 이벤트
* @param {string} id - 항목 id
* @returns {void}
*/
const onRecommendedDragStart = (event, id) => {
if (shouldBlockNavRowDrag(event)) {
event.preventDefault()
return
}
if (!event.dataTransfer) {
return
}
recommendedDraggingId.value = id
event.dataTransfer.effectAllowed = 'move'
}
/**
* 추천 사이트 행 드래그 오버(카드 높이 기준 위·아래 구역)
* @param {DragEvent} event - 이벤트
* @param {string} id - 항목 id
* @returns {void}
*/
const onRecommendedDragOver = (event, id) => {
event.preventDefault()
if (!recommendedDraggingId.value) {
return
}
const row = event.currentTarget
if (!(row instanceof HTMLElement)) {
return
}
const rect = row.getBoundingClientRect()
const y = event.clientY - rect.top
const ratio = rect.height > 0 ? y / rect.height : 0.5
recommendedDragOverId.value = id
recommendedDragOverZone.value = ratio < 0.5 ? 'before' : 'after'
}
/**
* 추천 사이트 행 드롭
* @param {DragEvent} event - 이벤트
* @param {string} targetId - 대상 id
* @returns {void}
*/
const onRecommendedDrop = (event, targetId) => {
event.preventDefault()
if (!recommendedDraggingId.value) {
return
}
const mode = /** @type {'before'|'after'} */ (recommendedDragOverZone.value || 'before')
moveRecommendedItem(recommendedDraggingId.value, targetId, mode)
onRecommendedDragEnd()
}
/**
* 추천 사이트 드래그 종료
* @returns {void}
*/
const onRecommendedDragEnd = () => {
recommendedDraggingId.value = ''
recommendedDragOverId.value = ''
recommendedDragOverZone.value = ''
}
/**
* 추천 사이트 드래그 중 삽입 위치 안내
* @param {string} rowId - 항목 id
* @returns {string}
*/
const recommendedDragZoneCaption = (rowId) => {
if (!recommendedDraggingId.value || recommendedDragOverId.value !== rowId || !recommendedDragOverZone.value) {
return ''
}
return recommendedDragOverZone.value === 'before' ? '앞에 삽입' : '뒤에 삽입'
}
/**
* 추천 사이트 행 하이라이트
* @param {string} id - 항목 id
* @returns {string}
*/
const recommendedRowClass = (id) => {
const parts = ['relative', 'cursor-move']
if (recommendedDragOverId.value === id) {
if (recommendedDragOverZone.value === 'before') {
parts.push('border-t-[3px] border-blue-600 bg-blue-50/40')
} else if (recommendedDragOverZone.value === 'after') {
parts.push('border-b-[3px] border-blue-600 bg-blue-50/40')
}
}
if (recommendedDraggingId.value === id) {
parts.push('opacity-50')
}
return parts.join(' ')
}
/**
* 하단 행 하이라이트(태그 관리와 동일)
* @param {string} id - 항목 id
* @returns {string}
*/
const footerRowClass = (id) => {
const parts = []
if (footerDragOverId.value === id) {
parts.push('bg-[#f9f9f7]')
}
if (footerDraggingId.value === id) {
parts.push('opacity-50')
}
return parts.join(' ')
}
/**
* 드래그 중 대상 행에 표시할 드롭 구역 안내(형제 끼움 vs 하위 편입)
* @param {string} rowId - 행 항목 id
* @returns {string}
*/
const primaryDragZoneCaption = (rowId) => {
if (!navDraggingId.value || navDragOverId.value !== rowId || !navDragOverZone.value) {
return ''
}
const z = navDragOverZone.value
if (z === 'before') {
return '앞에 끼움'
}
if (z === 'after') {
return '뒤에 끼움'
}
return '하위로 넣기'
}
/**
* 상단 테이블 행 드래그·드롭 하이라이트(형제 구역=파랑 끝선, 하위=앰버 링)
* @param {string} id - 항목 id
* @returns {string}
*/
const primaryRowClass = (id) => {
const parts = ['cursor-move', 'relative']
if (navDragOverId.value === id) {
if (navDragOverZone.value === 'before') {
parts.push('border-t-[3px] border-blue-600 bg-blue-50/40')
} else if (navDragOverZone.value === 'after') {
parts.push('border-b-[3px] border-blue-600 bg-blue-50/40')
} else if (navDragOverZone.value === 'into') {
parts.push('bg-amber-50/50 ring-2 ring-inset ring-amber-500')
}
}
if (navDraggingId.value === id) {
parts.push('opacity-50')
}
return parts.join(' ')
}
/**
* 네비게이션 항목 추가 모달을 연다.
* @param {'primary'|'footer'} type - 추가 위치
* @returns {void}
*/
const openNavCreateModal = (type) => {
navCreateModalType.value = type
navCreateDraft.label = ''
navCreateDraft.urlPrefix = ''
navCreateDraft.urlBody = ''
}
/**
* 네비게이션 항목 추가 모달을 닫는다.
* @returns {void}
*/
const closeNavCreateModal = () => {
navCreateModalType.value = ''
navCreateDraft.label = ''
navCreateDraft.urlPrefix = ''
navCreateDraft.urlBody = ''
}
/**
* 네비게이션 항목 추가 모달 제목을 반환한다.
* @returns {string} 모달 제목
*/
const navCreateModalTitle = computed(() => {
if (navCreateModalType.value === 'footer') {
return '하단 메뉴 추가'
}
return '메인 메뉴 추가'
})
/**
* 네비게이션 추가 draft의 URL을 반환한다.
* @returns {string} URL
*/
const getNavCreateDraftUrl = () => normalizeNavigationUrl(composeNavigationUrl(navCreateDraft.urlPrefix, navCreateDraft.urlBody))
/**
* 상단 루트 항목을 추가한다.
* @param {{ label: string, url: string }} input - 추가 입력값
* @returns {void}
*/
const addPrimaryRoot = (input) => {
const roots = items.value.filter((i) => i.location === 'primary' && !i.parentId)
const maxOrder = Math.max(0, ...roots.map((r) => r.sortOrder || 0))
items.value.push({
id: crypto.randomUUID(),
label: input.label,
url: input.url,
descriptionText: '',
thumbnailUrl: '',
location: 'primary',
parentId: null,
sortOrder: maxOrder + 10,
isVisible: true,
isFolder: false
})
}
/**
* 하단 항목 추가
* @param {{ label: string, url: string }} input - 추가 입력값
* @returns {void}
*/
const addFooterItem = (input) => {
const list = items.value.filter((i) => i.location === 'footer' && !i.parentId)
const maxOrder = Math.max(0, ...list.map((r) => r.sortOrder || 0))
items.value.push({
id: crypto.randomUUID(),
label: input.label,
url: input.url,
descriptionText: '',
thumbnailUrl: '',
location: 'footer',
parentId: null,
sortOrder: maxOrder + 10,
isVisible: true,
isFolder: false
})
}
/**
* 네비게이션 항목 추가 모달을 적용한다.
* @returns {void}
*/
const applyNavCreate = () => {
const label = String(navCreateDraft.label || '').trim()
const url = getNavCreateDraftUrl()
if (!label || !url) {
return
}
if (navCreateModalType.value === 'footer') {
addFooterItem({ label, url })
} else {
addPrimaryRoot({ label, url })
}
closeNavCreateModal()
}
/**
* 추천 사이트 항목 추가
* @returns {void}
*/
const addRecommendedItem = () => {
const list = items.value.filter((i) => i.location === 'recommended' && !i.parentId)
const maxOrder = Math.max(0, ...list.map((r) => r.sortOrder || 0))
items.value.push({
id: crypto.randomUUID(),
label: '',
url: '',
descriptionText: '',
thumbnailUrl: '',
cachedFaviconUrl: '',
faviconCachedAt: '',
location: 'recommended',
parentId: null,
sortOrder: maxOrder + 10,
isVisible: true,
isFolder: false
})
const created = items.value[items.value.length - 1]
recommendedCreatingId.value = String(created.id)
openRecommendedEdit(created)
}
/**
* 네비게이션 항목 목록 저장
* @returns {Promise<void>} 저장 결과
*/
const saveNavigation = async () => {
if (saving.value || !isNavigationDirty.value) {
return
}
saving.value = true
clearToast()
showToast('info', '네비게이션을 저장하는 중입니다.')
try {
const savedItems = await $fetch('/admin/api/navigation', {
method: 'PUT',
body: {
items: items.value.map((item) => ({
id: item.id,
label: item.label,
url: normalizeNavigationUrl(String(item.url || '').trim()),
descriptionText: item.descriptionText || '',
thumbnailUrl: item.thumbnailUrl || '',
location: item.location,
sortOrder: Number(item.sortOrder || 0),
isVisible: true,
parentId: item.parentId ?? null,
isFolder: Boolean(item.isFolder)
}))
}
})
items.value = savedItems.map((item) => ({
...item,
descriptionText: item.descriptionText || '',
thumbnailUrl: item.thumbnailUrl || '',
cachedFaviconUrl: item.cachedFaviconUrl || '',
faviconCachedAt: item.faviconCachedAt || '',
parentId: item.parentId ?? null,
isFolder: Boolean(item.isFolder),
isVisible: true
}))
navigationBaseline.value = serializeNavigationItems(items.value)
showToast('success', '네비게이션이 저장되었습니다.')
} catch (error) {
const msg = error?.data?.message || error?.message || '네비게이션을 저장하지 못했습니다.'
showToast('error', msg)
} finally {
saving.value = false
}
}
</script>
<template>
<section class="admin-navigation bg-paper p-6">
<div class="admin-navigation__header mb-6 flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div>
<p class="admin-navigation__eyebrow text-xs font-semibold uppercase text-muted">
Navigation
</p>
<h1 class="admin-navigation__title mt-2 text-3xl font-semibold">
메뉴 관리
</h1>
<p class="admin-navigation__lead mt-2 max-w-xl text-sm text-muted">
메인 네비게이션은 행을 드래그해 순서·깊이를 정합니다. 페이지 섹션은 홈 Pages 진열장 구획과 섹션 내부 페이지 순서를 관리합니다. 하단·추천 사이트 탭은 평면 목록만 드래그로 순서 변경합니다.
</p>
</div>
</div>
<div class="admin-navigation__tabs mb-6 flex gap-2 border-b border-line">
<button
class="admin-navigation__tab -mb-px border-b-2 px-4 py-2 text-sm font-semibold transition-colors"
:class="activeTab === 'primary' ? 'border-ink text-ink' : 'border-transparent text-muted hover:text-ink'"
type="button"
@click="activeTab = 'primary'"
>
메인 네비게이션
</button>
<button
class="admin-navigation__tab -mb-px border-b-2 px-4 py-2 text-sm font-semibold transition-colors"
:class="activeTab === 'pageSections' ? 'border-ink text-ink' : 'border-transparent text-muted hover:text-ink'"
type="button"
@click="activeTab = 'pageSections'"
>
페이지 섹션
</button>
<button
class="admin-navigation__tab -mb-px border-b-2 px-4 py-2 text-sm font-semibold transition-colors"
:class="activeTab === 'footer' ? 'border-ink text-ink' : 'border-transparent text-muted hover:text-ink'"
type="button"
@click="activeTab = 'footer'"
>
하단 네비게이션
</button>
<button
class="admin-navigation__tab -mb-px border-b-2 px-4 py-2 text-sm font-semibold transition-colors"
:class="activeTab === 'recommended' ? 'border-ink text-ink' : 'border-transparent text-muted hover:text-ink'"
type="button"
@click="activeTab = 'recommended'"
>
추천 사이트
</button>
</div>
<div v-show="activeTab === 'primary'" class="admin-navigation__panel-primary space-y-4">
<div class="flex flex-wrap items-center justify-between gap-2">
<button
class="admin-navigation__add rounded border border-line bg-white px-4 py-2 text-sm font-semibold"
type="button"
@click="openNavCreateModal('primary')"
>
메인 메뉴 추가
</button>
<button
class="admin-navigation__submit rounded bg-[#15171a] px-4 py-2 text-sm font-semibold text-white disabled:opacity-50"
type="button"
:disabled="saving || !isNavigationDirty"
@click="saveNavigation"
>
{{ saving ? '저장 중' : '네비게이션 저장' }}
</button>
</div>
<div v-if="primaryTree.length === 0" class="admin-navigation__empty rounded border border-dashed border-line bg-white p-8 text-center text-sm text-muted">
상단 메뉴가 없습니다. 위 버튼으로 항목을 추가하세요.
</div>
<div v-else class="admin-navigation__primary-table overflow-x-auto rounded border border-line bg-white">
<table class="admin-navigation__primary-table-inner w-full border-collapse text-left text-sm">
<thead class="admin-navigation__primary-head bg-[#f5f5f2] text-xs uppercase text-muted">
<tr>
<th class="admin-navigation__primary-cell px-3 py-3">
개요
</th>
<th class="admin-navigation__primary-cell px-4 py-3">
라벨
</th>
<th class="admin-navigation__primary-cell px-4 py-3">
URL
</th>
<th class="admin-navigation__primary-cell admin-navigation__cell-actions w-12 px-2 py-3 text-right">
<span class="sr-only">관리</span>
</th>
</tr>
</thead>
<tbody class="admin-navigation__primary-body divide-y divide-line bg-white">
<tr
v-for="(row, index) in primaryRows"
:key="row.item.id"
class="admin-navigation__primary-row"
:class="primaryRowClass(row.item.id)"
draggable="true"
@dragstart="onPrimaryDragStart($event, row.item.id)"
@dragover="onPrimaryDragOverRow($event, row.item.id)"
@drop="onPrimaryDropRow($event, row.item.id)"
@dragend="onPrimaryDragEnd"
>
<td
class="admin-navigation__primary-cell w-[5.75rem] min-w-[5.75rem] px-3 py-3 align-middle"
>
<div class="admin-navigation__primary-outline flex flex-col items-end gap-0.5">
<span class="tabular-nums text-sm font-medium text-ink">
{{ primaryOutlineLabels[index] }}
</span>
<span
v-if="primaryDragZoneCaption(row.item.id)"
class="admin-navigation__primary-drop-hint max-w-[5.5rem] text-right text-[10px] font-semibold leading-tight"
:class="navDragOverZone === 'into' ? 'text-amber-800' : 'text-blue-700'"
>
{{ primaryDragZoneCaption(row.item.id) }}
</span>
</div>
</td>
<td
class="admin-navigation__primary-cell px-4 py-3 align-middle"
:style="{ paddingLeft: `${16 + row.depth * 28}px` }"
>
<input
v-model="row.item.label"
class="admin-navigation__primary-label w-full min-w-[8rem] rounded border border-line px-3 py-2 text-sm outline-none focus:border-[#8e9cac]"
type="text"
placeholder="라벨"
required
>
</td>
<td class="admin-navigation__primary-cell px-4 py-3 align-middle">
<input
v-model="row.item.url"
class="admin-navigation__primary-url w-full min-w-[10rem] rounded border border-line px-3 py-2 font-mono text-sm outline-none focus:border-[#8e9cac]"
type="text"
placeholder="URL (# 또는 /경로)"
required
>
</td>
<td class="admin-navigation__primary-cell admin-navigation__cell-actions relative w-12 px-2 py-3 text-right align-middle">
<AdminRowMoreMenu
v-model:open-menu-id="openMenuId"
:item-id="`nav-${row.item.id}`"
menu-label="메뉴 항목 메뉴"
>
<button
class="admin-row-more-menu__item admin-row-more-menu__item--danger"
type="button"
role="menuitem"
@click="removeNavItem(row.item.id)"
>
메뉴 항목 삭제
</button>
</AdminRowMoreMenu>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div v-show="activeTab === 'pageSections'" class="admin-navigation__panel-page-sections space-y-4">
<div class="flex flex-wrap items-center justify-between gap-2">
<button
class="admin-navigation__add rounded border border-line bg-white px-4 py-2 text-sm font-semibold"
type="button"
@click="openPageSectionCreateModal"
>
페이지 섹션 추가
</button>
<button
class="admin-navigation__page-section-save-all rounded bg-[#15171a] px-4 py-2 text-sm font-semibold text-white disabled:opacity-50"
type="button"
:disabled="savingPageSections || !isPageSectionDirty"
@click="savePageSections"
>
{{ savingPageSections ? '저장 중' : '페이지 섹션 저장' }}
</button>
</div>
<p v-if="pageSectionMessage" class="admin-navigation__page-section-message rounded border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
{{ pageSectionMessage }}
</p>
<div v-if="pageSections.length === 0" class="admin-navigation__empty rounded border border-dashed border-line bg-white p-8 text-center text-sm text-muted">
페이지 섹션이 없습니다.
</div>
<div v-else class="admin-navigation__page-section-table overflow-x-auto rounded border border-line">
<table class="admin-navigation__page-section-table-inner w-full border-collapse text-left text-sm">
<thead class="admin-navigation__page-section-head bg-[#f5f5f2] text-xs uppercase text-muted">
<tr>
<th class="admin-navigation__page-section-cell px-4 py-3">
#
</th>
<th class="admin-navigation__page-section-cell px-4 py-3">
섹션명
</th>
<th class="admin-navigation__page-section-cell px-4 py-3">
페이지
</th>
<th class="admin-navigation__page-section-cell px-4 py-3">
관리
</th>
</tr>
</thead>
<tbody class="admin-navigation__page-section-body divide-y divide-line bg-white">
<tr
v-for="(section, index) in pageSections"
:key="section.id"
class="admin-navigation__page-section-row"
:class="pageSectionRowClass(section.id)"
draggable="true"
@dragstart="onPageSectionDragStart($event, section.id)"
@dragover="onPageSectionDragOver($event, section.id)"
@drop="onPageSectionDrop($event, section.id)"
@dragend="onPageSectionDragEnd"
>
<td class="admin-navigation__page-section-cell px-4 py-4 text-muted">
{{ index + 1 }}
</td>
<td class="admin-navigation__page-section-cell px-4 py-4">
<input
v-model="section.name"
class="admin-navigation__page-section-name w-full min-w-[10rem] rounded border border-line px-3 py-2 text-sm outline-none focus:border-[#8e9cac]"
type="text"
maxlength="80"
>
</td>
<td class="admin-navigation__page-section-cell px-4 py-4 text-muted">
{{ getPageSectionCountLabel(section) }}
</td>
<td class="admin-navigation__page-section-cell px-4 py-4">
<div class="flex flex-wrap justify-end gap-2">
<button
class="admin-navigation__page-section-pages rounded border border-line px-3 py-2 text-xs font-semibold text-[#394047] transition-colors hover:bg-paper disabled:opacity-45"
type="button"
:disabled="Number(section.pageCount || 0) === 0"
@click="openPageOrderModal(section)"
>
페이지 순서
</button>
<button
class="admin-navigation__page-section-delete rounded border border-line px-3 py-2 text-xs font-semibold text-muted transition-colors hover:bg-paper hover:text-red-600 disabled:opacity-35"
type="button"
:disabled="deletingPageSectionId === section.id || Number(section.pageCount || 0) > 0"
@click="deletePageSection(section)"
>
삭제
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div v-show="activeTab === 'footer'" class="admin-navigation__panel-footer space-y-4">
<div class="flex flex-wrap items-center justify-between gap-2">
<button
class="admin-navigation__add rounded border border-line bg-white px-4 py-2 text-sm font-semibold"
type="button"
@click="openNavCreateModal('footer')"
>
하단 메뉴 추가
</button>
<button
class="admin-navigation__submit rounded bg-[#15171a] px-4 py-2 text-sm font-semibold text-white disabled:opacity-50"
type="button"
:disabled="saving || !isNavigationDirty"
@click="saveNavigation"
>
{{ saving ? '저장 중' : '네비게이션 저장' }}
</button>
</div>
<div v-if="footerItemsSorted.length === 0" class="admin-navigation__empty rounded border border-dashed border-line bg-white p-8 text-center text-sm text-muted">
하단 메뉴가 없습니다.
</div>
<div v-else class="admin-navigation__footer-table overflow-x-auto rounded border border-line">
<table class="admin-navigation__footer-table-inner w-full border-collapse text-left text-sm">
<thead class="admin-navigation__footer-head bg-[#f5f5f2] text-xs uppercase text-muted">
<tr>
<th class="admin-navigation__footer-cell px-4 py-3">
#
</th>
<th class="admin-navigation__footer-cell px-4 py-3">
라벨
</th>
<th class="admin-navigation__footer-cell px-4 py-3">
URL
</th>
<th class="admin-navigation__footer-cell admin-navigation__cell-actions w-12 px-2 py-3 text-right">
<span class="sr-only">관리</span>
</th>
</tr>
</thead>
<tbody class="admin-navigation__footer-body divide-y divide-line bg-white">
<tr
v-for="(item, index) in footerItemsSorted"
:key="item.id"
class="admin-navigation__footer-row cursor-move"
:class="footerRowClass(item.id)"
draggable="true"
@dragstart="onFooterDragStart($event, item.id)"
@dragover="onFooterDragOver($event, item.id)"
@drop="onFooterDrop($event, item.id)"
@dragend="onFooterDragEnd"
>
<td class="admin-navigation__footer-cell px-4 py-4 text-muted">
{{ index + 1 }}
</td>
<td class="admin-navigation__footer-cell px-4 py-4">
<input
v-model="item.label"
class="w-full min-w-[8rem] rounded border border-line px-3 py-2 text-sm outline-none focus:border-[#8e9cac]"
type="text"
placeholder="라벨"
required
>
</td>
<td class="admin-navigation__footer-cell px-4 py-4">
<input
v-model="item.url"
class="w-full min-w-[10rem] rounded border border-line px-3 py-2 font-mono text-sm outline-none focus:border-[#8e9cac]"
type="text"
placeholder="URL"
required
>
</td>
<td class="admin-navigation__footer-cell admin-navigation__cell-actions relative w-12 px-2 py-4 text-right">
<AdminRowMoreMenu
v-model:open-menu-id="openMenuId"
:item-id="`nav-${item.id}`"
menu-label="메뉴 항목 메뉴"
>
<button
class="admin-row-more-menu__item admin-row-more-menu__item--danger"
type="button"
role="menuitem"
@click="removeNavItem(item.id)"
>
메뉴 항목 삭제
</button>
</AdminRowMoreMenu>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div v-show="activeTab === 'recommended'" class="admin-navigation__panel-recommended space-y-4">
<p class="admin-navigation__recommended-note max-w-3xl text-sm text-muted">
공개 Recommended와 같은 카드 목록으로 미리보기합니다. 카드를 드래그해 순서를 바꾸고, 편집 버튼으로 모달에서 전체 값을 수정할 수 있습니다. 파비콘 캐시는 매월 1일 03:00에 자동 갱신되며, 편집 모달에서 수동 갱신할 수 있습니다.
</p>
<div class="flex flex-wrap items-center justify-between gap-2">
<button
class="admin-navigation__add rounded border border-line bg-white px-4 py-2 text-sm font-semibold"
type="button"
@click="addRecommendedItem"
>
추천 사이트 추가
</button>
<button
class="admin-navigation__submit rounded bg-[#15171a] px-4 py-2 text-sm font-semibold text-white disabled:opacity-50"
type="button"
:disabled="saving || !isNavigationDirty"
@click="saveNavigation"
>
{{ saving ? '저장 중' : '네비게이션 저장' }}
</button>
</div>
<div v-if="recommendedItemsSorted.length === 0" class="admin-navigation__empty rounded border border-dashed border-line bg-white p-8 text-center text-sm text-muted">
추천 사이트가 없습니다.
</div>
<ul v-else class="admin-navigation__recommended-list m-0 w-full list-none p-0">
<li
v-for="item in recommendedItemsSorted"
:key="item.id"
class="admin-navigation__recommended-card mb-2 flex items-center gap-2.5 rounded-[10px] border border-line bg-white px-3 py-2.5 transition-colors last:mb-0"
:class="recommendedRowClass(item.id)"
draggable="true"
@dragstart="onRecommendedDragStart($event, item.id)"
@dragover="onRecommendedDragOver($event, item.id)"
@drop="onRecommendedDrop($event, item.id)"
@dragend="onRecommendedDragEnd"
>
<span
v-if="recommendedDragZoneCaption(item.id)"
class="admin-navigation__recommended-drop-hint pointer-events-none absolute left-3 top-1 z-10 text-[10px] font-semibold text-blue-700"
>
{{ recommendedDragZoneCaption(item.id) }}
</span>
<span class="admin-navigation__recommended-icon grid h-8 w-8 shrink-0 place-items-center overflow-hidden rounded-lg border border-line bg-[#f5f5f2] text-xs font-bold text-ink">
<img
v-if="getRecommendedIconPreviewUrl(item)"
class="h-full w-full object-cover"
:src="getRecommendedIconPreviewUrl(item)"
width="32"
height="32"
:alt="item.label || '추천 사이트 아이콘'"
loading="lazy"
referrerpolicy="no-referrer"
>
<span v-else class="px-1 text-center leading-none">{{ (item.label || '?').slice(0, 1) }}</span>
</span>
<span class="admin-navigation__recommended-text min-w-0 shrink-0 max-w-[min(100%,20rem)]">
<span class="block truncate text-sm font-semibold text-ink">{{ item.label || '제목 없음' }}</span>
<span
class="mt-0.5 block truncate text-[11px] text-muted"
:class="item.descriptionText ? '' : 'font-mono'"
>
{{ getRecommendedCardSubtitle(item) }}
</span>
</span>
<span class="admin-navigation__recommended-spacer min-w-4 flex-1" aria-hidden="true" />
<span class="admin-navigation__recommended-cache shrink-0 whitespace-nowrap text-xs text-muted">
{{ getRecommendedFaviconCachedAtLabel(item) }}
</span>
<button
class="admin-navigation__recommended-edit shrink-0 rounded border border-line bg-white px-3 py-1.5 text-xs font-semibold text-ink transition-colors hover:bg-[#f5f5f2]"
type="button"
@click="openRecommendedEdit(item)"
>
편집
</button>
</li>
</ul>
</div>
<div
v-if="navCreateModalType"
class="admin-navigation__create-modal fixed inset-0 z-[60] grid place-items-center bg-black/40 px-5 py-8"
role="dialog"
aria-modal="true"
aria-labelledby="admin-navigation-create-title"
@click.self="closeNavCreateModal"
>
<section class="admin-navigation__create-modal-panel max-h-[90vh] w-full max-w-lg overflow-y-auto rounded border border-line bg-white p-5 shadow-xl">
<div class="admin-navigation__create-modal-head">
<h3 id="admin-navigation-create-title" class="text-lg font-semibold text-ink">
{{ navCreateModalTitle }}
</h3>
<p class="mt-1 text-xs text-muted">
제목과 URL을 입력한 뒤 추가하면 목록에 반영됩니다. 최종 반영은 네비게이션 저장으로 완료합니다.
</p>
</div>
<div class="admin-navigation__create-modal-fields mt-5 grid gap-4">
<label class="grid gap-1.5 text-sm">
<span class="text-xs font-semibold text-muted">라벨</span>
<input
v-model="navCreateDraft.label"
class="rounded border border-line px-3 py-2 text-sm outline-none focus:border-[#8e9cac]"
type="text"
placeholder="메뉴 이름"
required
>
</label>
<label class="grid gap-1.5 text-sm">
<span class="text-xs font-semibold text-muted">URL</span>
<div class="admin-navigation__create-url flex flex-col gap-2 sm:flex-row">
<span class="admin-navigation__create-url-prefix-wrap relative shrink-0">
<select
v-model="navCreateDraft.urlPrefix"
class="admin-navigation__create-url-prefix h-[38px] appearance-none rounded border border-line bg-white py-2 pl-3 pr-10 text-sm outline-none focus:border-[#8e9cac]"
>
<option
v-for="option in navigationUrlPrefixOptions"
:key="option.label"
:value="option.value"
>
{{ option.label }}
</option>
</select>
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-chevron-down pointer-events-none absolute right-3 top-1/2 size-5 -translate-y-1/2 text-[#15171a]" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<polyline points="6 9 12 15 18 9" />
</svg>
</span>
<input
v-model="navCreateDraft.urlBody"
class="admin-navigation__create-url-body min-w-0 flex-1 rounded border border-line px-3 py-2 font-mono text-sm outline-none focus:border-[#8e9cac]"
type="text"
:placeholder="getNavigationUrlBodyPlaceholder(navCreateDraft.urlPrefix)"
required
@keydown.enter.prevent="applyNavCreate"
>
</div>
<span v-if="getNavCreateDraftUrl()" class="break-all pl-2 text-[11px] font-mono text-muted">
{{ getNavCreateDraftUrl() }}
</span>
</label>
</div>
<div class="admin-navigation__create-modal-actions mt-6 flex justify-end gap-2">
<button class="rounded border border-line px-3 py-2 text-xs font-semibold text-ink" type="button" @click="closeNavCreateModal">
취소
</button>
<button
class="rounded bg-[#15171a] px-3 py-2 text-xs font-semibold text-white disabled:opacity-50"
type="button"
:disabled="!navCreateDraft.label.trim() || !getNavCreateDraftUrl()"
@click="applyNavCreate"
>
추가
</button>
</div>
</section>
</div>
<div
v-if="pageSectionCreateModalOpen"
class="admin-navigation__page-section-create-modal fixed inset-0 z-[60] grid place-items-center bg-black/40 px-5 py-8"
role="dialog"
aria-modal="true"
aria-labelledby="admin-navigation-page-section-create-title"
@click.self="closePageSectionCreateModal"
>
<section class="admin-navigation__page-section-create-modal-panel max-h-[90vh] w-full max-w-lg overflow-y-auto rounded border border-line bg-white p-5 shadow-xl">
<div class="admin-navigation__page-section-create-modal-head">
<h3 id="admin-navigation-page-section-create-title" class="text-lg font-semibold text-ink">
페이지 섹션 추가
</h3>
<p class="mt-1 text-xs text-muted">
홈 Pages 진열장에 사용할 섹션 이름을 입력합니다.
</p>
</div>
<label class="mt-5 grid gap-1.5 text-sm">
<span class="text-xs font-semibold text-muted">섹션명</span>
<input
v-model="pageSectionCreateName"
class="rounded border border-line px-3 py-2 text-sm outline-none focus:border-[#8e9cac]"
type="text"
maxlength="80"
placeholder="예: 게임"
required
@keydown.enter.prevent="addPageSection"
>
</label>
<div class="admin-navigation__page-section-create-modal-actions mt-6 flex justify-end gap-2">
<button class="rounded border border-line px-3 py-2 text-xs font-semibold text-ink" type="button" @click="closePageSectionCreateModal">
취소
</button>
<button
class="rounded bg-[#15171a] px-3 py-2 text-xs font-semibold text-white disabled:opacity-50"
type="button"
:disabled="savingPageSectionId === 'new' || !pageSectionCreateName.trim()"
@click="addPageSection"
>
{{ savingPageSectionId === 'new' ? '추가 중' : '추가' }}
</button>
</div>
</section>
</div>
<AdminConfirmModal
:open="recommendedDiscardModalOpen"
title="변경 내용 버리기"
confirm-label="닫기"
cancel-label="계속 편집"
confirm-tone="danger"
@cancel="recommendedDiscardModalOpen = false"
@confirm="confirmDiscardRecommendedEdit"
>
<p>저장하지 않은 변경 내용이 있습니다. 모달을 닫으면 변경 사항이 사라집니다.</p>
</AdminConfirmModal>
<AdminConfirmModal
:open="recommendedDeleteModalOpen"
title="추천 사이트 삭제"
confirm-label="삭제"
confirm-tone="danger"
@cancel="recommendedDeleteModalOpen = false"
@confirm="confirmDeleteRecommendedFromModal"
>
<p>
<strong class="text-[#15171a]">{{ recommendedDeleteTargetLabel }}</strong> 추천 사이트를 삭제할까요?
</p>
</AdminConfirmModal>
<div
v-if="pageOrderModalSection"
class="admin-navigation__page-order-modal fixed inset-0 z-[60] grid place-items-center bg-black/40 px-5 py-8"
role="dialog"
aria-modal="true"
aria-labelledby="admin-navigation-page-order-title"
@click.self="closePageOrderModal"
>
<section class="admin-navigation__page-order-modal-panel max-h-[90vh] w-full max-w-4xl overflow-y-auto rounded border border-line bg-white p-5 shadow-xl">
<div class="admin-navigation__page-order-modal-head flex items-start justify-between gap-4">
<div>
<h3 id="admin-navigation-page-order-title" class="text-lg font-semibold text-ink">
페이지 순서 변경
</h3>
<p class="mt-1 text-xs text-muted">
{{ pageOrderModalSection.name }} 섹션의 홈 표시 순서를 조정합니다.
</p>
</div>
<button class="grid size-8 place-items-center rounded border border-line text-muted transition-colors hover:bg-[#f5f5f2] hover:text-ink" type="button" aria-label="닫기" @click="closePageOrderModal">
×
</button>
</div>
<div v-if="pageOrderDraftPages.length === 0" class="admin-navigation__page-order-empty mt-5 rounded border border-dashed border-line p-6 text-center text-sm text-muted">
이 섹션에 표시 중인 페이지가 없습니다.
</div>
<div v-else class="admin-navigation__page-order-list mt-5 flex gap-3 overflow-x-auto rounded border border-line bg-[#f9f9f7] p-3">
<article
v-for="(page, index) in pageOrderDraftPages"
:key="page.id"
class="admin-navigation__page-order-card group relative flex w-[150px] shrink-0 flex-col rounded-[8px] border border-line bg-white p-2 shadow-sm transition-colors hover:border-[#b9c0c7]"
:class="pageOrderRowClass(page.id)"
draggable="true"
@dragstart="onPageOrderDragStart($event, page.id)"
@dragover="onPageOrderDragOver($event, page.id)"
@drop="onPageOrderDrop($event, page.id)"
@dragend="onPageOrderDragEnd"
>
<span class="admin-navigation__page-order-index absolute left-3 top-3 z-10 rounded-full bg-white/90 px-2 py-0.5 text-[11px] font-semibold tabular-nums text-ink shadow-sm">
{{ index + 1 }}
</span>
<span
v-if="page.isFeatured"
class="admin-navigation__page-order-featured absolute right-3 top-3 z-10 rounded-full bg-[#15171a] px-2 py-0.5 text-[10px] font-semibold text-white shadow-sm"
>
추천
</span>
<figure class="admin-navigation__page-order-cover aspect-[3/4] overflow-hidden rounded-[6px] border border-line bg-[#f5f5f2]">
<img
v-if="getPageOrderCardImage(page)"
class="h-full w-full object-cover"
:src="getPageOrderCardImage(page)"
:alt="page.listingTitle || page.title || '페이지 썸네일'"
loading="lazy"
>
<div v-else class="flex h-full w-full items-center justify-center p-3 text-center text-xs font-semibold leading-tight text-muted">
{{ page.listingTitle || page.title || '제목 없음' }}
</div>
</figure>
<h4 class="admin-navigation__page-order-title mt-2 line-clamp-2 min-h-[2rem] text-xs font-semibold leading-tight text-ink">
{{ page.listingTitle || page.title || '제목 없음' }}
</h4>
<p class="admin-navigation__page-order-slug mt-1 truncate font-mono text-[10px] text-muted">
/pages/{{ page.slug }}
</p>
<button
class="admin-navigation__page-order-remove mt-2 rounded border border-line px-2 py-1.5 text-[11px] font-semibold text-muted transition-colors hover:border-red-200 hover:bg-red-50 hover:text-red-700"
type="button"
@click.stop="removePageFromOrderSection(page)"
>
섹션에서 제거
</button>
</article>
</div>
<p v-if="pageOrderRemovedCount > 0" class="admin-navigation__page-order-remove-note mt-3 rounded border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-800">
{{ pageOrderRemovedCount }}개 페이지는 저장하면 이 섹션에서 제거됩니다.
</p>
<div class="admin-navigation__page-order-actions mt-6 flex justify-end gap-2">
<button class="rounded border border-line px-3 py-2 text-xs font-semibold text-ink" type="button" @click="closePageOrderModal">
취소
</button>
<button
class="rounded bg-[#15171a] px-3 py-2 text-xs font-semibold text-white disabled:opacity-50"
type="button"
:disabled="savingPageOrder || (pageOrderDraftPages.length === 0 && pageOrderRemovedCount === 0)"
@click="savePageOrder"
>
{{ savingPageOrder ? '저장 중' : '저장' }}
</button>
</div>
</section>
</div>
<div
v-if="recommendedEditId && recommendedEditDraft"
class="admin-navigation__recommended-modal fixed inset-0 z-[60] grid place-items-center bg-black/40 px-5 py-8"
role="dialog"
aria-modal="true"
aria-labelledby="admin-navigation-recommended-edit-title"
@click.self="requestCloseRecommendedEdit"
>
<section class="admin-navigation__recommended-modal-panel max-h-[90vh] w-full max-w-lg overflow-y-auto rounded border border-line bg-white p-5 shadow-xl">
<div class="admin-navigation__recommended-modal-head">
<h3 id="admin-navigation-recommended-edit-title" class="text-lg font-semibold text-ink">
추천 사이트 편집
</h3>
<p class="mt-1 text-xs text-muted">
공개 Recommended 카드와 동일한 정보를 편집합니다.
</p>
</div>
<div class="admin-navigation__recommended-modal-preview mt-5 flex items-center gap-3 rounded border border-line bg-[#f9f9f7] p-3">
<span class="grid h-10 w-10 shrink-0 place-items-center overflow-hidden rounded-lg border border-line bg-white text-sm font-bold text-ink">
<img
v-if="recommendedEditPreviewItem && getRecommendedIconPreviewUrl(recommendedEditPreviewItem)"
class="h-full w-full object-cover"
:src="getRecommendedIconPreviewUrl(recommendedEditPreviewItem)"
width="40"
height="40"
alt=""
loading="lazy"
referrerpolicy="no-referrer"
>
<span v-else class="px-1 text-center leading-none">{{ (recommendedEditDraft.label || '?').slice(0, 1) }}</span>
</span>
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-semibold text-ink">
{{ recommendedEditDraft.label || '제목 없음' }}
</p>
<p class="mt-0.5 truncate text-xs text-muted">
{{ recommendedEditPreviewItem ? getRecommendedPreviewSubtitle(recommendedEditPreviewItem) : '' }}
</p>
<p class="mt-1 text-[11px] text-muted">
{{ recommendedEditItem ? getRecommendedFaviconCachedLabel(recommendedEditItem) : '캐시 없음' }}
</p>
</div>
</div>
<div class="admin-navigation__recommended-modal-fields mt-5 grid gap-4">
<label class="grid gap-1.5 text-sm">
<span class="text-xs font-semibold text-muted">제목</span>
<input
v-model="recommendedEditDraft.label"
class="rounded border border-line px-3 py-2 text-sm outline-none focus:border-[#8e9cac]"
type="text"
placeholder="사이트 이름"
required
>
</label>
<label class="grid gap-1.5 text-sm">
<span class="text-xs font-semibold text-muted">URL</span>
<div class="admin-navigation__recommended-url flex flex-col gap-2 sm:flex-row">
<span class="admin-navigation__recommended-url-prefix-wrap relative shrink-0">
<select
v-model="recommendedEditDraft.urlPrefix"
class="admin-navigation__recommended-url-prefix h-[38px] appearance-none rounded border border-line bg-white py-2 pl-3 pr-10 text-sm outline-none focus:border-[#8e9cac]"
>
<option
v-for="option in navigationUrlPrefixOptions"
:key="option.label"
:value="option.value"
>
{{ option.label }}
</option>
</select>
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-chevron-down pointer-events-none absolute right-3 top-1/2 size-5 -translate-y-1/2 text-[#15171a]" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<polyline points="6 9 12 15 18 9" />
</svg>
</span>
<input
v-model="recommendedEditDraft.urlBody"
class="admin-navigation__recommended-url-body min-w-0 flex-1 rounded border border-line px-3 py-2 font-mono text-sm outline-none focus:border-[#8e9cac]"
type="text"
:placeholder="getNavigationUrlBodyPlaceholder(recommendedEditDraft.urlPrefix)"
required
>
</div>
<span
v-if="getRecommendedDraftUrl(recommendedEditDraft)"
class="break-all pl-2 text-[11px] font-mono text-muted"
>
{{ getRecommendedDraftUrl(recommendedEditDraft) }}
</span>
</label>
<label class="grid gap-1.5 text-sm">
<span class="text-xs font-semibold text-muted">대체 텍스트</span>
<input
v-model="recommendedEditDraft.descriptionText"
class="rounded border border-line px-3 py-2 text-sm outline-none focus:border-[#8e9cac]"
type="text"
placeholder="URL 대신 표시할 문구"
>
</label>
<label class="grid gap-1.5 text-sm">
<span class="text-xs font-semibold text-muted">썸네일 URL</span>
<textarea
v-model="recommendedEditDraft.thumbnailUrl"
class="min-h-24 resize-y rounded border border-line px-3 py-2 font-mono text-sm outline-none focus:border-[#8e9cac]"
placeholder="/uploads/…"
/>
<span class="text-[11px] pl-2 text-muted">비워 두면 서버 캐시 파비콘을 사용합니다.</span>
</label>
</div>
<div class="admin-navigation__recommended-modal-tools mt-4 flex flex-wrap items-center gap-2">
<button
class="rounded border border-line bg-white px-3 py-2 text-xs font-semibold text-ink transition-colors hover:bg-[#f5f5f2] disabled:cursor-not-allowed disabled:opacity-50"
type="button"
:disabled="!recommendedEditItem || faviconRefreshingIds.has(String(recommendedEditItem.id))"
@click="refreshRecommendedEditFavicon"
>
{{ recommendedEditItem && faviconRefreshingIds.has(String(recommendedEditItem.id)) ? '파비콘 갱신 중…' : '파비콘 새로고침' }}
</button>
</div>
<div class="admin-navigation__recommended-modal-actions mt-6 flex flex-wrap items-center justify-between gap-2">
<button
class="rounded border border-red-200 px-3 py-2 text-xs font-semibold text-red-700"
type="button"
@click="removeRecommendedFromModal"
>
삭제
</button>
<div class="flex flex-wrap gap-2">
<button
class="rounded border border-line px-3 py-2 text-xs font-semibold text-ink"
type="button"
@click="requestCloseRecommendedEdit"
>
취소
</button>
<button
class="rounded bg-[#15171a] px-3 py-2 text-xs font-semibold text-white disabled:opacity-50"
type="button"
:disabled="!recommendedEditDraft.label.trim() || !getRecommendedDraftUrl(recommendedEditDraft)"
@click="applyRecommendedEdit"
>
적용
</button>
</div>
</div>
</section>
</div>
<div
v-if="toast"
class="admin-navigation__toast fixed right-5 top-5 z-[100] max-w-sm rounded border px-4 py-3 text-sm font-semibold shadow-lg"
:class="{
'border-green-200 bg-green-50 text-green-800': toast.type === 'success',
'border-red-200 bg-red-50 text-red-800': toast.type === 'error',
'border-line bg-white text-ink': toast.type === 'info'
}"
role="status"
>
{{ toast.message }}
</div>
</section>
</template>