Files
sori.studio/pages/admin/navigation/index.vue
zenn 5b2ed97748 v1.7.1: 멤버 권한·추천 사이트·네비게이션 UI 개선
관리자는 VIP·멤버만 수정 가능하도록 서버·UI 권한을 정리하고 소유자 권한 이전 API와 확인 모달을 추가했다. 추천 사이트 파비콘 캐시·URL 접두사·카드 편집 UI를 보강하고 확인 얼럿을 AdminConfirmModal로 통일했다.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-17 14:32:11 +09:00

1622 lines
55 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: () => []
})
/**
* 항목 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('')
/** 편집 모달 입력값 */
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('')
/**
* 네비게이션 항목을 저장 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)
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))
)
/**
* 추천 사이트에 현재 적용될 아이콘 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
}
closeRecommendedEdit()
}
/**
* 미저장 변경을 버리고 추천 사이트 편집 모달을 닫는다.
* @returns {void}
*/
const confirmDiscardRecommendedEdit = () => {
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()
}
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)
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(' ')
}
/**
* 상단 루트 항목 추가
* @returns {void}
*/
const addPrimaryRoot = () => {
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: '새 메뉴',
url: '/',
descriptionText: '',
thumbnailUrl: '',
location: 'primary',
parentId: null,
sortOrder: maxOrder + 10,
isVisible: true,
isFolder: false
})
}
/**
* 하단 항목 추가
* @returns {void}
*/
const addFooterItem = () => {
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: '',
url: '/',
descriptionText: '',
thumbnailUrl: '',
location: 'footer',
parentId: null,
sortOrder: maxOrder + 10,
isVisible: true,
isFolder: false
})
}
/**
* 추천 사이트 항목 추가
* @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]
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">
상단은 <span class="font-semibold text-ink">상단 메뉴 추가</span>로만 항목을 만들고, 행을 드래그해 순서·깊이를 정합니다. 상단 메뉴는 <span class="font-semibold text-ink">루트 아래 한 단계</span>만 허용됩니다(하위 항목 행에는 가운데 드롭이 적용되지 않으며, 이미 하위가 있는 항목은 다른 항목의 하위로 넣을 수 없습니다). 같은 행의 <span class="font-semibold text-ink">위쪽 1/3</span>에 놓으면 앞에 끼우고, <span class="font-semibold text-ink">아래쪽 1/3</span>이면 뒤에 끼우며, <span class="font-semibold text-ink">가운데</span>에 놓으면 <span class="font-semibold text-ink">루트 항목</span>의 하위로만 들어갑니다. 드래그 중에는 행 끝의 <span class="font-semibold text-blue-700">파란 선</span>이 형제 앞·뒤 끼움, <span class="font-semibold text-amber-800">앰버 테두리</span>가 하위 편입을 뜻하며, 개요 열에 짧은 안내가 함께 뜹니다. 왼쪽 개요 번호는 <span class="font-semibold text-ink">1 · 2.1 · 2.2</span>처럼 깊이별로 이어 붙입니다. 입력란 위에서는 드래그가 시작되지 않습니다. 하단·추천 사이트 탭은 평면 목록만 드래그로 순서 변경합니다. 추천 사이트는 공개 홈 왼쪽 사이드 Recommended에 카드 형태로 노출되며, 관리자 화면에서도 동일한 카드 미리보기와 편집 모달로 설정합니다.
</p>
</div>
<div class="admin-navigation__header-actions flex flex-wrap gap-2">
<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>
<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 === '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 gap-2">
<button
class="admin-navigation__add rounded border border-line bg-white px-4 py-2 text-sm font-semibold"
type="button"
@click="addPrimaryRoot"
>
상단 메뉴 추가
</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 === 'footer'" class="admin-navigation__panel-footer space-y-4">
<div class="flex flex-wrap gap-2">
<button
class="admin-navigation__add rounded border border-line bg-white px-4 py-2 text-sm font-semibold"
type="button"
@click="addFooterItem"
>
하단 메뉴 추가
</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 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>
</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>
<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="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>