v1.7.1: 멤버 권한·추천 사이트·네비게이션 UI 개선

관리자는 VIP·멤버만 수정 가능하도록 서버·UI 권한을 정리하고 소유자 권한 이전 API와 확인 모달을 추가했다. 추천 사이트 파비콘 캐시·URL 접두사·카드 편집 UI를 보강하고 확인 얼럿을 AdminConfirmModal로 통일했다.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-17 14:32:11 +09:00
parent f206499f99
commit 5b2ed97748
30 changed files with 1837 additions and 211 deletions

View File

@@ -1,5 +1,12 @@
<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'
@@ -15,15 +22,39 @@ const { data: navigationItems } = await useFetch('/admin/api/navigation', {
default: () => []
})
const items = ref(navigationItems.value.map((item) => ({
/**
* 항목 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' — 행 위·중·아래 드롭 구역 */
@@ -34,6 +65,8 @@ const footerDragOverId = ref('')
const recommendedDraggingId = ref('')
const recommendedDragOverId = ref('')
/** 'before' | 'after' — 카드 위·아래 드롭 구역 */
const recommendedDragOverZone = ref('')
/**
* 네비게이션 항목을 저장 API와 동일한 형태로 직렬화한다.
@@ -103,6 +136,289 @@ const recommendedItemsSorted = computed(() =>
.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
@@ -125,21 +441,67 @@ const removeItemCascade = (rootId) => {
}
/**
* 메뉴 항목을 삭제한다(하위 포함).
* @param {string} itemId - 항목 id
* @returns {void}
* 메뉴 항목 삭제 확인 문구를 반환한다.
* @param {Object} item - 메뉴 항목
* @returns {string} 확인 문구
*/
const removeNavItem = (itemId) => {
closeMenu()
removeItemCascade(itemId)
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}" 메뉴 항목을 삭제할까요?`
}
/**
* 항목 id로 레코드를 찾는다.
* @param {string} id - 항목 id
* @returns {Object|undefined}
* 메뉴 항목을 삭제한다(하위 포함).
* @param {string} itemId - 항목 id
* @returns {boolean} 삭제 수행 여부
*/
const getNavItem = (id) => items.value.find((i) => String(i.id) === String(id))
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의 하위(직·간접)인지
@@ -320,19 +682,25 @@ const reorderFooter = (sourceId, targetId) => {
* 추천 사이트 순서 변경
* @param {string} sourceId - 이동할 id
* @param {string} targetId - 기준 id
* @param {'before'|'after'} mode - 드롭 구역
* @returns {void}
*/
const reorderRecommended = (sourceId, targetId) => {
const moveRecommendedItem = (sourceId, targetId, mode) => {
const list = recommendedItemsSorted.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) {
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 [mv] = list.splice(si, 1)
list.splice(ti, 0, mv)
list.forEach((row, idx) => {
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
})
}
@@ -486,14 +854,25 @@ const onRecommendedDragStart = (event, id) => {
}
/**
* 추천 사이트 행 드래그 오버
* 추천 사이트 행 드래그 오버(카드 높이 기준 위·아래 구역)
* @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'
}
/**
@@ -507,9 +886,9 @@ const onRecommendedDrop = (event, targetId) => {
if (!recommendedDraggingId.value) {
return
}
reorderRecommended(recommendedDraggingId.value, targetId)
recommendedDraggingId.value = ''
recommendedDragOverId.value = ''
const mode = /** @type {'before'|'after'} */ (recommendedDragOverZone.value || 'before')
moveRecommendedItem(recommendedDraggingId.value, targetId, mode)
onRecommendedDragEnd()
}
/**
@@ -519,6 +898,19 @@ const onRecommendedDrop = (event, targetId) => {
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' ? '앞에 삽입' : '뒤에 삽입'
}
/**
@@ -527,9 +919,13 @@ const onRecommendedDragEnd = () => {
* @returns {string}
*/
const recommendedRowClass = (id) => {
const parts = []
const parts = ['relative', 'cursor-move']
if (recommendedDragOverId.value === id) {
parts.push('bg-[#f9f9f7]')
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')
@@ -646,15 +1042,19 @@ const addRecommendedItem = () => {
items.value.push({
id: crypto.randomUUID(),
label: '',
url: 'https://',
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)
}
/**
@@ -677,7 +1077,7 @@ const saveNavigation = async () => {
items: items.value.map((item) => ({
id: item.id,
label: item.label,
url: item.url,
url: normalizeNavigationUrl(String(item.url || '').trim()),
descriptionText: item.descriptionText || '',
thumbnailUrl: item.thumbnailUrl || '',
location: item.location,
@@ -693,6 +1093,8 @@ const saveNavigation = async () => {
...item,
descriptionText: item.descriptionText || '',
thumbnailUrl: item.thumbnailUrl || '',
cachedFaviconUrl: item.cachedFaviconUrl || '',
faviconCachedAt: item.faviconCachedAt || '',
parentId: item.parentId ?? null,
isFolder: Boolean(item.isFolder),
isVisible: true
@@ -719,7 +1121,7 @@ const saveNavigation = async () => {
메뉴 관리
</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에 카드 형태로 노출되며, <span class="font-semibold text-ink">https://</span> 주소는 저장 후 브라우저에서 Google 파비콘 프록시 URL로 아이콘을 불러옵니다.
상단은 <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">
@@ -954,8 +1356,8 @@ const saveNavigation = async () => {
</div>
<div v-show="activeTab === 'recommended'" class="admin-navigation__panel-recommended space-y-4">
<p class="admin-navigation__recommended-note max-w-xl text-sm text-muted">
공개 홈 왼쪽 사이드바 Recommended 영역에 표시됩니다. 대체 텍스트가 있으면 카드 하단에 URL 대신 표시하고, 썸네일 URL이 있으면 자동 파비콘 대신 썸네일을 사용합니다.
<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
@@ -971,99 +1373,236 @@ const saveNavigation = async () => {
추천 사이트가 없습니다.
</div>
<div v-else class="admin-navigation__recommended-table overflow-x-auto rounded border border-line">
<table class="admin-navigation__recommended-table-inner w-full border-collapse text-left text-sm">
<thead class="admin-navigation__recommended-head bg-[#f5f5f2] text-xs uppercase text-muted">
<tr>
<th class="admin-navigation__recommended-cell px-4 py-3">
#
</th>
<th class="admin-navigation__recommended-cell px-4 py-3">
제목
</th>
<th class="admin-navigation__recommended-cell px-4 py-3">
URL
</th>
<th class="admin-navigation__recommended-cell px-4 py-3">
대체 텍스트
</th>
<th class="admin-navigation__recommended-cell px-4 py-3">
썸네일 URL
</th>
<th class="admin-navigation__recommended-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__recommended-body divide-y divide-line bg-white">
<tr
v-for="(item, index) in recommendedItemsSorted"
:key="item.id"
class="admin-navigation__recommended-row cursor-move"
:class="recommendedRowClass(item.id)"
draggable="true"
@dragstart="onRecommendedDragStart($event, item.id)"
@dragover="onRecommendedDragOver($event, item.id)"
@drop="onRecommendedDrop($event, item.id)"
@dragend="onRecommendedDragEnd"
<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"
>
<td class="admin-navigation__recommended-cell px-4 py-4 text-muted">
{{ index + 1 }}
</td>
<td class="admin-navigation__recommended-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
<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]"
>
</td>
<td class="admin-navigation__recommended-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="https://…"
required
>
</td>
<td class="admin-navigation__recommended-cell px-4 py-4">
<input
v-model="item.descriptionText"
class="w-full min-w-[10rem] rounded border border-line px-3 py-2 text-sm outline-none focus:border-[#8e9cac]"
type="text"
placeholder="URL 대신 표시할 문구"
>
</td>
<td class="admin-navigation__recommended-cell px-4 py-4">
<input
v-model="item.thumbnailUrl"
class="w-full min-w-[12rem] rounded border border-line px-3 py-2 font-mono text-sm outline-none focus:border-[#8e9cac]"
type="text"
placeholder="/uploads/…"
>
</td>
<td class="admin-navigation__recommended-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)"
<option
v-for="option in navigationUrlPrefixOptions"
:key="option.label"
:value="option.value"
>
메뉴 항목 삭제
</button>
</AdminRowMoreMenu>
</td>
</tr>
</tbody>
</table>
</div>
{{ 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