v1.7.1: 멤버 권한·추천 사이트·네비게이션 UI 개선
관리자는 VIP·멤버만 수정 가능하도록 서버·UI 권한을 정리하고 소유자 권한 이전 API와 확인 모달을 추가했다. 추천 사이트 파비콘 캐시·URL 접두사·카드 편집 UI를 보강하고 확인 얼럿을 AdminConfirmModal로 통일했다. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user