페이지 섹션 관리와 홈 Pages 진열장 연동, 공개 목록 표시개수 설정 관련 누적 로컬 변경을 기능 단위로 정리해 반영한다. 문서 이력과 패키지 버전을 v1.9.23으로 맞춰 코드와 문서 상태를 동기화한다. Co-authored-by: Cursor <cursoragent@cursor.com>
2519 lines
86 KiB
Vue
2519 lines
86 KiB
Vue
<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>
|