v1.7.1: 멤버 권한·추천 사이트·네비게이션 UI 개선
관리자는 VIP·멤버만 수정 가능하도록 서버·UI 권한을 정리하고 소유자 권한 이전 API와 확인 모달을 추가했다. 추천 사이트 파비콘 캐시·URL 접두사·카드 편집 UI를 보강하고 확인 얼럿을 AdminConfirmModal로 통일했다. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
99
components/admin/AdminConfirmModal.vue
Normal file
99
components/admin/AdminConfirmModal.vue
Normal file
@@ -0,0 +1,99 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
open: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
confirmLabel: {
|
||||
type: String,
|
||||
default: '확인'
|
||||
},
|
||||
cancelLabel: {
|
||||
type: String,
|
||||
default: '취소'
|
||||
},
|
||||
confirmTone: {
|
||||
type: String,
|
||||
default: 'primary'
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
loadingLabel: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
})
|
||||
|
||||
defineEmits(['confirm', 'cancel'])
|
||||
|
||||
/**
|
||||
* 확인 버튼 스타일 클래스를 반환한다.
|
||||
* @param {string} tone - 버튼 톤
|
||||
* @returns {string} 클래스 문자열
|
||||
*/
|
||||
const getConfirmButtonClass = (tone) => {
|
||||
if (tone === 'danger') {
|
||||
return 'bg-[#d21a26] text-white hover:bg-[#b81620]'
|
||||
}
|
||||
return 'bg-[#15171a] text-white hover:bg-[#2b2f35]'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="open"
|
||||
class="admin-confirm-modal fixed inset-0 z-[100] flex items-start justify-center bg-black/35 px-4 pt-10"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
:aria-labelledby="'admin-confirm-modal-title'"
|
||||
>
|
||||
<section class="admin-confirm-modal__panel w-full max-w-[520px] rounded-xl bg-white text-[#15171a] shadow-[0_22px_70px_rgba(15,23,42,0.25)]">
|
||||
<header class="admin-confirm-modal__header flex items-center justify-between border-b border-line px-6 py-5">
|
||||
<h2 id="admin-confirm-modal-title" class="admin-confirm-modal__title text-xl font-semibold">
|
||||
{{ title }}
|
||||
</h2>
|
||||
<button
|
||||
class="admin-confirm-modal__close grid h-8 w-8 place-items-center rounded-md text-[#657080] hover:bg-[#f3f5f7]"
|
||||
type="button"
|
||||
aria-label="닫기"
|
||||
:disabled="loading"
|
||||
@click="$emit('cancel')"
|
||||
>
|
||||
<svg class="h-4 w-4" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M12.707 12L23.854.854a.5.5 0 00-.707-.707L12 11.293.854.146a.5.5 0 00-.707.707L11.293 12 .146 23.146a.5.5 0 00.708.708L12 12.707l11.146 11.146a.5.5 0 10.708-.706L12.707 12z" fill="currentColor" />
|
||||
</svg>
|
||||
</button>
|
||||
</header>
|
||||
<div class="admin-confirm-modal__body grid gap-4 px-6 py-5 text-sm leading-6 text-[#657080]">
|
||||
<slot />
|
||||
</div>
|
||||
<footer class="admin-confirm-modal__footer flex justify-end gap-2 border-t border-line px-6 py-4">
|
||||
<button
|
||||
class="admin-confirm-modal__cancel h-10 rounded-md border border-line bg-white px-4 text-sm font-semibold text-[#3f4650]"
|
||||
type="button"
|
||||
:disabled="loading"
|
||||
@click="$emit('cancel')"
|
||||
>
|
||||
{{ cancelLabel }}
|
||||
</button>
|
||||
<button
|
||||
class="admin-confirm-modal__confirm h-10 rounded-md px-4 text-sm font-semibold transition disabled:opacity-50"
|
||||
:class="getConfirmButtonClass(confirmTone)"
|
||||
type="button"
|
||||
:disabled="loading"
|
||||
@click="$emit('confirm')"
|
||||
>
|
||||
{{ loading && loadingLabel ? loadingLabel : confirmLabel }}
|
||||
</button>
|
||||
</footer>
|
||||
</section>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
@@ -59,6 +59,10 @@ const deleteForm = reactive({
|
||||
confirmText: ''
|
||||
})
|
||||
|
||||
/** 소유자 권한 이전 확인 모달 */
|
||||
const ownershipTransferModalOpen = ref(false)
|
||||
const isTransferringOwnership = ref(false)
|
||||
|
||||
/**
|
||||
* 회원 폼 값을 현재 회원 정보로 동기화한다.
|
||||
* @returns {void}
|
||||
@@ -93,16 +97,51 @@ const normalizedLabels = computed(() => [...new Set(
|
||||
.filter(Boolean)
|
||||
)])
|
||||
|
||||
const isFormEditable = computed(() => isNewMember.value || isEditingMember.value)
|
||||
const isFormEditable = computed(() => {
|
||||
if (isNewMember.value) {
|
||||
return true
|
||||
}
|
||||
if (!canEditMemberProfile.value) {
|
||||
return false
|
||||
}
|
||||
return isEditingMember.value
|
||||
})
|
||||
const currentRoleLabel = computed(() => roleOptions.find((option) => option.value === form.roleCode)?.label || '멤버')
|
||||
const currentAdminRoleCode = computed(() => adminSession.value?.roleCode || '')
|
||||
const isCurrentAdminPrivileged = computed(() => ['owner', 'admin'].includes(currentAdminRoleCode.value))
|
||||
const isEditingSelf = computed(() => Boolean(props.member?.id && adminSession.value?.userId)
|
||||
&& String(props.member.id) === String(adminSession.value.userId))
|
||||
const isTargetPrivilegedRole = computed(() => ['owner', 'admin'].includes(props.member?.roleCode || form.roleCode))
|
||||
/** 저장된 대상 회원 등급(권한 판단용) */
|
||||
const targetSavedRoleCode = computed(() => String(props.member?.roleCode || 'member'))
|
||||
const isTargetPrivilegedRole = computed(() => ['owner', 'admin'].includes(targetSavedRoleCode.value))
|
||||
|
||||
/**
|
||||
* 대상 회원 프로필을 수정할 수 있는지 여부
|
||||
* @returns {boolean} 수정 가능 여부
|
||||
*/
|
||||
const canEditMemberProfile = computed(() => {
|
||||
if (isNewMember.value) {
|
||||
return isCurrentAdminPrivileged.value
|
||||
}
|
||||
if (currentAdminRoleCode.value === 'owner') {
|
||||
return true
|
||||
}
|
||||
if (currentAdminRoleCode.value === 'admin') {
|
||||
return !isEditingSelf.value && ['vip', 'member'].includes(targetSavedRoleCode.value)
|
||||
}
|
||||
return false
|
||||
})
|
||||
|
||||
const canOpenEditMode = computed(() => !isNewMember.value && canEditMemberProfile.value && !isEditingMember.value)
|
||||
const canManageMemberActions = computed(() => canEditMemberProfile.value)
|
||||
const shouldRenderRoleAsText = computed(() => !isFormEditable.value || isNewMember.value || !isCurrentAdminPrivileged.value)
|
||||
const canEditRoleSelect = computed(() => {
|
||||
if (!isFormEditable.value || shouldRenderRoleAsText.value || isSaving.value) {
|
||||
|
||||
/**
|
||||
* 회원 등급 변경 권한 여부(저장 중 상태는 제외)
|
||||
* @returns {boolean} 변경 가능 여부
|
||||
*/
|
||||
const canChangeMemberRole = computed(() => {
|
||||
if (!isFormEditable.value || shouldRenderRoleAsText.value) {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -116,6 +155,8 @@ const canEditRoleSelect = computed(() => {
|
||||
|
||||
return false
|
||||
})
|
||||
|
||||
const canEditRoleSelect = computed(() => canChangeMemberRole.value && !isSaving.value)
|
||||
const availableRoleOptions = computed(() => {
|
||||
if (!canEditRoleSelect.value) {
|
||||
return roleOptions
|
||||
@@ -127,6 +168,18 @@ const availableRoleOptions = computed(() => {
|
||||
|
||||
return roleOptions
|
||||
})
|
||||
|
||||
/**
|
||||
* 저장 시 소유자 권한 이전 확인이 필요한지 여부
|
||||
* @returns {boolean} 확인 필요 여부
|
||||
*/
|
||||
const isOwnershipTransferPending = computed(() => {
|
||||
return !isNewMember.value
|
||||
&& currentAdminRoleCode.value === 'owner'
|
||||
&& !isEditingSelf.value
|
||||
&& form.roleCode === 'owner'
|
||||
&& targetSavedRoleCode.value !== 'owner'
|
||||
})
|
||||
const roleHelpText = computed(() => {
|
||||
if (!isFormEditable.value) {
|
||||
return '수정하기를 누르면 변경 가능한 항목을 편집할 수 있습니다.'
|
||||
@@ -136,18 +189,26 @@ const roleHelpText = computed(() => {
|
||||
return '멤버와 VIP는 관리자 권한이 없어 등급을 변경할 수 없습니다.'
|
||||
}
|
||||
|
||||
if (!canEditMemberProfile.value && currentAdminRoleCode.value === 'admin') {
|
||||
return '관리자는 VIP·멤버 정보만 수정할 수 있습니다.'
|
||||
}
|
||||
|
||||
if (isEditingSelf.value && currentAdminRoleCode.value === 'owner') {
|
||||
return '소유자는 본인 권한을 직접 낮출 수 없습니다.'
|
||||
}
|
||||
|
||||
if (currentAdminRoleCode.value === 'admin' && isTargetPrivilegedRole.value) {
|
||||
return '관리자는 소유자 또는 다른 관리자의 등급을 변경할 수 없습니다.'
|
||||
return '관리자는 소유자·관리자 정보를 수정하거나 등급을 변경할 수 없습니다.'
|
||||
}
|
||||
|
||||
if (currentAdminRoleCode.value === 'admin') {
|
||||
return '관리자는 멤버와 VIP 등급만 변경할 수 있습니다.'
|
||||
}
|
||||
|
||||
if (isOwnershipTransferPending.value) {
|
||||
return '소유자 등급으로 저장하면 권한 이전 확인 모달이 표시됩니다. 승인 시 본인은 관리자로 변경됩니다.'
|
||||
}
|
||||
|
||||
return 'VIP 이상 등급은 멤버십 게시물을 볼 수 있습니다.'
|
||||
})
|
||||
|
||||
@@ -248,6 +309,9 @@ const {
|
||||
* @returns {void}
|
||||
*/
|
||||
const enterEditMode = () => {
|
||||
if (!canEditMemberProfile.value) {
|
||||
return
|
||||
}
|
||||
isEditingMember.value = true
|
||||
}
|
||||
|
||||
@@ -285,21 +349,14 @@ const uploadAvatar = async (event) => {
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
const result = isNewMember.value
|
||||
? await $fetch('/admin/api/member-avatar', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
: await $fetch(`/admin/api/members/${props.member.id}/avatar`, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
const result = await $fetch('/admin/api/member-avatar', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
form.avatarUrl = result.avatarUrl || ''
|
||||
|
||||
if (!isNewMember.value) {
|
||||
emit('saved', result)
|
||||
savedMemberSnapshot.value = serializeMemberPayload()
|
||||
showToast('success', '썸네일이 변경되었습니다.')
|
||||
showToast('info', '썸네일 미리보기가 반영되었습니다. 저장을 눌러 적용하세요.')
|
||||
}
|
||||
} catch (error) {
|
||||
showToast('error', error?.data?.message || '썸네일 업로드에 실패했습니다.')
|
||||
@@ -451,6 +508,77 @@ const deleteMember = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 회원 기본 정보를 저장한다.
|
||||
* @returns {Promise<Object>} 저장된 회원
|
||||
*/
|
||||
const performMemberSave = async () => {
|
||||
const payload = getMemberPayload()
|
||||
const roleChanged = !isNewMember.value && form.roleCode !== props.member?.roleCode
|
||||
|
||||
if (roleChanged && canChangeMemberRole.value && form.roleCode !== 'owner') {
|
||||
await $fetch(`/admin/api/members/${props.member.id}/role`, {
|
||||
method: 'PUT',
|
||||
body: {
|
||||
role: form.roleCode
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const saved = isNewMember.value
|
||||
? await $fetch('/admin/api/members', {
|
||||
method: 'POST',
|
||||
body: payload
|
||||
})
|
||||
: await $fetch(`/admin/api/members/${props.member.id}`, {
|
||||
method: 'PUT',
|
||||
body: payload
|
||||
})
|
||||
|
||||
savedMemberSnapshot.value = serializeMemberPayload()
|
||||
emit('saved', saved)
|
||||
isEditingMember.value = isNewMember.value
|
||||
return saved
|
||||
}
|
||||
|
||||
/**
|
||||
* 소유자 권한 이전 모달을 닫는다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const closeOwnershipTransferModal = () => {
|
||||
if (isTransferringOwnership.value) {
|
||||
return
|
||||
}
|
||||
ownershipTransferModalOpen.value = false
|
||||
}
|
||||
|
||||
/**
|
||||
* 소유자 권한 이전을 확정한다.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const confirmOwnershipTransfer = async () => {
|
||||
if (isTransferringOwnership.value || !props.member?.id) {
|
||||
return
|
||||
}
|
||||
|
||||
isTransferringOwnership.value = true
|
||||
try {
|
||||
if (hasUnsavedMemberChanges.value) {
|
||||
await performMemberSave()
|
||||
}
|
||||
await $fetch(`/admin/api/members/${props.member.id}/ownership-transfer`, {
|
||||
method: 'POST'
|
||||
})
|
||||
ownershipTransferModalOpen.value = false
|
||||
showToast('success', '소유자 권한을 이전했습니다. 본인 계정은 관리자로 변경되었습니다.')
|
||||
await navigateTo('/admin/members')
|
||||
} catch (error) {
|
||||
showToast('error', error?.data?.message || '소유자 권한 이전에 실패했습니다.')
|
||||
} finally {
|
||||
isTransferringOwnership.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 회원 기본 정보를 저장한다.
|
||||
* @returns {Promise<void>}
|
||||
@@ -461,6 +589,9 @@ const saveMember = async () => {
|
||||
}
|
||||
|
||||
if (!isFormEditable.value) {
|
||||
if (!canOpenEditMode.value) {
|
||||
return
|
||||
}
|
||||
enterEditMode()
|
||||
return
|
||||
}
|
||||
@@ -469,32 +600,15 @@ const saveMember = async () => {
|
||||
return
|
||||
}
|
||||
|
||||
if (isOwnershipTransferPending.value) {
|
||||
ownershipTransferModalOpen.value = true
|
||||
return
|
||||
}
|
||||
|
||||
isSaving.value = true
|
||||
|
||||
try {
|
||||
const payload = getMemberPayload()
|
||||
if (!isNewMember.value && canEditRoleSelect.value && form.roleCode !== props.member?.roleCode) {
|
||||
await $fetch(`/admin/api/members/${props.member.id}/role`, {
|
||||
method: 'PUT',
|
||||
body: {
|
||||
role: form.roleCode
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const saved = isNewMember.value
|
||||
? await $fetch('/admin/api/members', {
|
||||
method: 'POST',
|
||||
body: payload
|
||||
})
|
||||
: await $fetch(`/admin/api/members/${props.member.id}`, {
|
||||
method: 'PUT',
|
||||
body: payload
|
||||
})
|
||||
|
||||
savedMemberSnapshot.value = serializeMemberPayload()
|
||||
emit('saved', saved)
|
||||
isEditingMember.value = isNewMember.value
|
||||
await performMemberSave()
|
||||
showToast('success', '저장되었습니다.')
|
||||
} catch (error) {
|
||||
showToast('error', error?.data?.message || '저장에 실패했습니다.')
|
||||
@@ -527,7 +641,7 @@ watch(() => props.member, () => {
|
||||
</h1>
|
||||
</div>
|
||||
<div class="admin-member-form__actions flex items-center gap-3">
|
||||
<div v-if="!isNewMember" class="admin-member-form__action-menu relative">
|
||||
<div v-if="!isNewMember && canManageMemberActions" class="admin-member-form__action-menu relative">
|
||||
<button class="admin-member-form__action-icon flex h-11 w-11 items-center justify-center rounded-md border border-line bg-white text-[#3f4650] transition hover:border-[#c5ccd5] hover:bg-[#f4f6f8]" type="button" aria-label="멤버 작업" :aria-expanded="actionMenuOpen" @click="toggleActionMenu">
|
||||
<svg class="h-5 w-5" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M10.546 2.438a1.957 1.957 0 002.908 0L14.4 1.4a1.959 1.959 0 013.41 1.413l-.071 1.4a1.958 1.958 0 002.051 2.054l1.4-.071a1.959 1.959 0 011.41 3.41l-1.042.94a1.96 1.96 0 000 2.909l1.042.94a1.959 1.959 0 01-1.413 3.41l-1.4-.071a1.958 1.958 0 00-2.056 2.056l.071 1.4A1.959 1.959 0 0114.4 22.6l-.941-1.041a1.959 1.959 0 00-2.908 0L9.606 22.6A1.959 1.959 0 016.2 21.192l.072-1.4a1.958 1.958 0 00-2.056-2.056l-1.4.071A1.958 1.958 0 011.4 14.4l1.041-.94a1.96 1.96 0 000-2.909L1.4 9.606A1.958 1.958 0 012.809 6.2l1.4.071a1.958 1.958 0 002.058-2.06L6.2 2.81A1.959 1.959 0 019.606 1.4z" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
|
||||
@@ -552,13 +666,19 @@ watch(() => props.member, () => {
|
||||
<button
|
||||
class="admin-member-form__save h-11 rounded-md bg-[#15171a] px-5 text-sm font-semibold text-white transition hover:bg-[#2b2f35] disabled:cursor-not-allowed disabled:bg-[#c7cdd4] disabled:text-white"
|
||||
type="button"
|
||||
:disabled="isFormEditable && !canSubmitMemberForm"
|
||||
:disabled="(isFormEditable && !canSubmitMemberForm) || (!isFormEditable && !canOpenEditMode)"
|
||||
@click="saveMember"
|
||||
>
|
||||
{{ !isFormEditable ? '수정하기' : isSaving ? '저장 중' : '저장' }}
|
||||
{{ !isFormEditable ? (canOpenEditMode ? '수정하기' : '수정 불가') : isSaving ? '저장 중' : '저장' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p
|
||||
v-if="!isNewMember && !canEditMemberProfile"
|
||||
class="admin-member-form__readonly-note mt-4 rounded-md border border-line bg-[#f9f9f7] px-4 py-3 text-sm text-[#657080]"
|
||||
>
|
||||
관리자는 VIP·멤버 정보만 수정할 수 있습니다. 소유자·관리자 계정은 조회만 가능합니다.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="admin-member-form__body grid gap-8 py-8 xl:grid-cols-3">
|
||||
@@ -824,5 +944,33 @@ watch(() => props.member, () => {
|
||||
</section>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<Teleport to="body">
|
||||
<div v-if="ownershipTransferModalOpen" class="admin-member-form__modal fixed inset-0 z-50 flex items-start justify-center bg-black/35 px-4 pt-10">
|
||||
<section class="admin-member-form__modal-panel w-full max-w-[520px] rounded-xl bg-white text-[#15171a] shadow-[0_22px_70px_rgba(15,23,42,0.25)]">
|
||||
<header class="flex items-center justify-between border-b border-line px-6 py-5">
|
||||
<h2 class="text-xl font-semibold">소유자 권한 이전</h2>
|
||||
<button class="grid h-8 w-8 place-items-center rounded-md text-[#657080] hover:bg-[#f3f5f7]" type="button" aria-label="닫기" @click="closeOwnershipTransferModal">
|
||||
<svg class="h-4 w-4" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M12.707 12L23.854.854a.5.5 0 00-.707-.707L12 11.293.854.146a.5.5 0 00-.707.707L11.293 12 .146 23.146a.5.5 0 00.708.708L12 12.707l11.146 11.146a.5.5 0 10.708-.706L12.707 12z" fill="currentColor" />
|
||||
</svg>
|
||||
</button>
|
||||
</header>
|
||||
<div class="grid gap-4 px-6 py-5">
|
||||
<p class="text-sm leading-6 text-[#657080]">
|
||||
<strong class="text-[#15171a]">{{ form.username || form.email }}</strong> 님을 새로운 소유자로 지정하시겠습니까?<br> 소유자는 한 명만 설정할 수 있으며, 변경 후 현재 계정은 <strong class="text-[#15171a]">관리자 권한</strong>으로 전환됩니다.
|
||||
</p>
|
||||
</div>
|
||||
<footer class="flex justify-end gap-2 border-t border-line px-6 py-4">
|
||||
<button class="h-10 rounded-md border border-line bg-white px-4 text-sm font-semibold text-[#3f4650]" type="button" @click="closeOwnershipTransferModal">
|
||||
취소
|
||||
</button>
|
||||
<button class="h-10 rounded-md bg-[#15171a] px-4 text-sm font-semibold text-white disabled:opacity-50" type="button" :disabled="isTransferringOwnership" @click="confirmOwnershipTransfer">
|
||||
{{ isTransferringOwnership ? '이전 중' : '권한 이전' }}
|
||||
</button>
|
||||
</footer>
|
||||
</section>
|
||||
</div>
|
||||
</Teleport>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -63,7 +63,13 @@ const recommendedImageStage = ref({})
|
||||
* @param {Object} item - 추천 사이트 항목
|
||||
* @returns {string} 파비콘 URL
|
||||
*/
|
||||
const getRecommendedFaviconUrl = (item) => getExternalFaviconUrl(item?.url, 64)
|
||||
const getRecommendedFaviconUrl = (item) => {
|
||||
const cachedFaviconUrl = String(item?.cachedFaviconUrl || '').trim()
|
||||
if (cachedFaviconUrl) {
|
||||
return cachedFaviconUrl
|
||||
}
|
||||
return getExternalFaviconUrl(item?.url, 64)
|
||||
}
|
||||
|
||||
/**
|
||||
* 추천 사이트 이미지 URL을 반환한다.
|
||||
|
||||
@@ -2,6 +2,12 @@ import { onUnmounted, ref } from 'vue'
|
||||
|
||||
const TOAST_AUTO_HIDE_MS = 4000
|
||||
|
||||
/**
|
||||
* 브라우저 타이머 API를 안전하게 사용한다.
|
||||
* @returns {typeof window | null} window 객체
|
||||
*/
|
||||
const getBrowserWindow = () => (typeof window !== 'undefined' ? window : null)
|
||||
|
||||
/**
|
||||
* 관리자 화면 우측 상단 피드백 토스트. 모달(z-50 등)보다 위에 보이도록 사용처에서 `z-[100]` 클래스를 둔다.
|
||||
* @returns {{ toast: import('vue').Ref<null | { type: string, message: string }>, showToast: (type: string, message: string) => void, clearToast: () => void }}
|
||||
@@ -15,7 +21,10 @@ export const useAdminToast = () => {
|
||||
* @returns {void}
|
||||
*/
|
||||
const clearToast = () => {
|
||||
window.clearTimeout(toastTimer)
|
||||
const browserWindow = getBrowserWindow()
|
||||
if (browserWindow) {
|
||||
browserWindow.clearTimeout(toastTimer)
|
||||
}
|
||||
toastTimer = null
|
||||
toast.value = null
|
||||
}
|
||||
@@ -27,20 +36,28 @@ export const useAdminToast = () => {
|
||||
* @returns {void}
|
||||
*/
|
||||
const showToast = (type, message) => {
|
||||
window.clearTimeout(toastTimer)
|
||||
const browserWindow = getBrowserWindow()
|
||||
if (browserWindow) {
|
||||
browserWindow.clearTimeout(toastTimer)
|
||||
}
|
||||
const text = String(message || '').trim() || '알림'
|
||||
toast.value = {
|
||||
type,
|
||||
message: text
|
||||
}
|
||||
toastTimer = window.setTimeout(() => {
|
||||
toast.value = null
|
||||
toastTimer = null
|
||||
}, TOAST_AUTO_HIDE_MS)
|
||||
if (browserWindow) {
|
||||
toastTimer = browserWindow.setTimeout(() => {
|
||||
toast.value = null
|
||||
toastTimer = null
|
||||
}, TOAST_AUTO_HIDE_MS)
|
||||
}
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
window.clearTimeout(toastTimer)
|
||||
const browserWindow = getBrowserWindow()
|
||||
if (browserWindow) {
|
||||
browserWindow.clearTimeout(toastTimer)
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
ALTER TABLE navigation_items
|
||||
ADD COLUMN IF NOT EXISTS cached_favicon_url TEXT NOT NULL DEFAULT '',
|
||||
ADD COLUMN IF NOT EXISTS favicon_cached_at TIMESTAMPTZ;
|
||||
@@ -1,6 +1,6 @@
|
||||
# 배포 가이드
|
||||
|
||||
> 로컬 기준 v1.6.0에서 `npm run lint`, `npm run build` 검증을 통과했다. NAS 실제 컨테이너 기동과 도메인/프록시 접속 검증은 운영 배포 단계에서 진행한다.
|
||||
> 로컬 기준 v1.6.2에서 `npm run lint` 검증을 통과했다. NAS 실제 컨테이너 기동과 도메인/프록시 접속 검증은 운영 배포 단계에서 진행한다.
|
||||
|
||||
## 빌드 유형
|
||||
|
||||
@@ -16,6 +16,14 @@
|
||||
|
||||
## 로컬 개발
|
||||
|
||||
### v1.6.2 참고
|
||||
|
||||
- DB 마이그레이션 `057_navigation_recommended_favicon_cache.sql` 적용이 필요하다.
|
||||
- 관리자 메뉴 > 추천 사이트 탭에서 적용 아이콘 미리보기 열과 파비콘 새로고침 버튼이 보이는지 확인한다.
|
||||
- 추천 사이트 저장 후 캐시가 없는 외부 URL 항목이 `/uploads/system/recommended-favicon-*.png`로 생성되는지 확인한다.
|
||||
- 공개 홈 Recommended 카드가 서버 캐시 파비콘을 우선 표시하는지 확인한다.
|
||||
- 운영 수동 일괄 갱신: `npm run navigation:refresh-recommended-favicons`
|
||||
|
||||
### v1.6.0 참고
|
||||
|
||||
- 추가 DB 마이그레이션은 없다.
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# 의사결정 이력
|
||||
|
||||
## 2026-06-17 v1.6.2 — 추천 사이트 파비콘은 서버 캐시와 월간 갱신으로 둔다
|
||||
|
||||
방문자마다 Google 파비콘 프록시를 호출하면 외부 요청이 누적되고 캐시 주기도 통제하기 어렵다. 추천 사이트 파비콘은 서버가 Google faviconV2에서 한 번 받아 `/uploads/system/`에 저장하고, 공개 화면은 그 로컬 URL을 우선 사용한다. 자동 갱신은 매월 1일 03:00(KST)에 전체 추천 사이트를 대상으로 하고, 운영자는 관리자 추천 사이트 화면에서 수동 새로고침할 수 있다. 수동 썸네일 URL은 기존처럼 최우선이며, 관리자 화면에는 현재 적용될 아이콘 미리보기 열을 둔다.
|
||||
|
||||
## 2026-06-17 v1.6.1 — 외부 파비콘은 domain 대신 origin 기반 faviconV2를 쓴다
|
||||
|
||||
추천 사이트·북마크 카드의 파비콘은 Google `s2/favicons?domain=`로 호스트만 넘기고 있었는데, `uniare.sori.studio`처럼 서브도메인·신규 사이트에서는 Google이 실제 파비콘 대신 기본 지구본 PNG만 돌려주는 경우가 확인됐다. 같은 서비스의 `faviconV2`에 `https://` origin을 넘기면 실제 아이콘을 받을 수 있으므로 `lib/external-favicon-url.js`를 V2 URL 생성으로 바꿨다. Google CDN 캐시 특성상 파비콘 변경 반영은 여전히 지연될 수 있어, 확실한 표시가 필요하면 관리자 썸네일 URL 등록을 유지한다.
|
||||
|
||||
15
docs/map.md
15
docs/map.md
@@ -27,6 +27,8 @@
|
||||
|------|------|
|
||||
| lib/upload-size-limit.js | 관리자 미디어 업로드 종류별 최대 바이트 판별·용량 문구(서버·클라이언트 공용) |
|
||||
| lib/external-favicon-url.js | 외부 URL origin 기준 Google `gstatic.com/faviconV2` 파비콘 URL 생성(내부 경로는 빈 문자열) |
|
||||
| lib/navigation-url.js | 네비게이션 URL 접두사 분리·합성·정규화(`https://` 자동 보정, `/` 내부 경로) |
|
||||
| lib/recommended-site-icon-url.js | 추천 사이트 아이콘 URL 우선순위(수동 썸네일 > 서버 캐시 > 외부 프록시) |
|
||||
| lib/navigation-editor-tree.js | 관리자 메뉴 UI·서버 `renumberSortOrderByTree`가 쓰는 `buildNavigationEditorTree`, 관리자 상단 네비 평면 표용 `flattenNavigationEditorWrappers` |
|
||||
| lib/markdown-content-normalizer.js | 관리자 Markdown-first 전환 후 레거시 블록 배열·객체 본문 값을 저장용 마크다운 문자열로 변환 |
|
||||
| lib/markdown-block-context.js | 관리자 Markdown textarea·라이브 편집 포커스 위치 기준 이미지·갤러리·HTML 임베드·인용·콜아웃·코드·토글 블록 설정 패널 대상 판별, 코드 fenced 블록 여닫는 펜스 쌍 판별 |
|
||||
@@ -61,6 +63,7 @@
|
||||
| 파일 | 용도 |
|
||||
|------|------|
|
||||
| db/migrations/056_site_settings_post_tag_limit.sql | 사이트 설정의 게시물 태그 최대 개수 컬럼 추가 |
|
||||
| db/migrations/057_navigation_recommended_favicon_cache.sql | 추천 사이트 파비콘 캐시 URL·갱신 시각 컬럼 추가 |
|
||||
|
||||
## 서버 미들웨어
|
||||
|
||||
@@ -88,7 +91,7 @@
|
||||
| components/site/SiteAnnouncementBar.vue | 공개 사이트 상단 어나운스 배너(문구·선택 링크·hex 배경색·텍스트 정렬·닫기) |
|
||||
| components/site/SiteHeader.vue | 모든 공개 페이지 상단, 사이트 이름 텍스트 브랜드, `grid-cols-3`로 검색 패널 중앙 정렬(`md+`), 우측 사용자 아바타 드롭다운(아바타 없거나 비로그인 시 사람 아이콘), `/`·`SiteSearchModal` |
|
||||
| components/site/SiteSearchModal.vue | `Teleport`·전체 화면 딤·Tags/Posts 결과·일치 구간 강조, 열림 시 `html.site-search-open` 스크롤 잠금 |
|
||||
| components/site/LeftSidebar.vue | 왼쪽 사이드바, `lg+`는 `sticky`+고정 높이+내부 무스크롤바 스크롤, `lg` 미만은 고정 슬라이드 패널, 상단 메뉴는 `SidebarPrimaryNavList`+`provide`로 트리·펼침 상태(`sori-primary-nav-expanded`), 태그 Categories 아래 공개 `GET /api/navigation`의 `recommended` 카드 목록(대체 텍스트·썸네일 우선, 외부 URL은 Google 파비콘 fallback), Authors 영역은 비공개, 푸터 `footer` 링크는 `flex-wrap`·테마 버튼 `shrink-0`, 태그 카테고리·테마 점은 `site-sidebar-nav-row` 호버 |
|
||||
| components/site/LeftSidebar.vue | 왼쪽 사이드바, `lg+`는 `sticky`+고정 높이+내부 무스크롤바 스크롤, `lg` 미만은 고정 슬라이드 패널, 상단 메뉴는 `SidebarPrimaryNavList`+`provide`로 트리·펼침 상태(`sori-primary-nav-expanded`), 태그 Categories 아래 공개 `GET /api/navigation`의 `recommended` 카드 목록(수동 썸네일 > 서버 캐시 파비콘 > 외부 프록시 fallback), Authors 영역은 비공개, 푸터 `footer` 링크는 `flex-wrap`·테마 버튼 `shrink-0`, 태그 카테고리·테마 점은 `site-sidebar-nav-row` 호버 |
|
||||
| components/site/SidebarPrimaryNavList.vue | 상단 네비: 부모·리프 동일 `before` 막대/호버 원형, 내부 현재 경로 `--site-accent`, 행 `w-full`+`site-sidebar-nav-row` 호버(`#F7F4EF` 라이트), `inject`·`localStorage` 펼침 |
|
||||
| components/site/RightSidebar.vue | 오른쪽 사이드바, 일반 화면은 설정 기반 사이트 로고 48px 정사각형 고정·SNS Follow(프리셋·사용자 SVG, 16px 아이콘 중앙 정렬)·오른쪽 사이드 광고를 표시, 게시글 상세 데스크톱은 블로그 소개·Follow 대신 최상단 H1~H3 TOC와 TOC 아래 게시물 사이드 광고(모바일 숨김, 스크롤 위치 기반 활성 표시·내부 자동 스크롤, 세로 기준선), About 영역은 비공개, `lg+` 스티키 |
|
||||
| components/site/SiteAdSlot.vue | 사이트 설정 Ads HTML 코드 렌더링, 메인 피드·메인 인피드·오른쪽 사이드·게시물 오른쪽 사이드·게시물 본문 상단·인아티클·하단 광고 슬롯 |
|
||||
@@ -116,6 +119,7 @@
|
||||
| components/admin/AdminTagForm.vue | 관리자 태그 생성/수정 폼(이름/슬러그/설명/색상만 편집, 수정 화면 변경 저장 버튼 변경사항 기준 활성화) |
|
||||
| components/admin/AdminMemberForm.vue | 관리자 멤버 추가/수정 폼(Ghost형 3분할, 기존 회원 보기/수정 모드 분리, 요약 1fr·입력 2fr, 원형 썸네일 hover 등록·변경·삭제, 기본 정보·레이블·관리자 노트·활동 요약, 설정 메뉴의 비밀번호 변경·멤버 삭제 모달, 저장 토스트, 미저장 변경사항 이탈 확인) |
|
||||
| components/admin/AdminUnsavedChangesModal.vue | 관리자 편집 화면 공통 미저장 변경사항 이탈 확인 모달(상단 40px 위치) |
|
||||
| components/admin/AdminConfirmModal.vue | 관리자 공통 확인 모달(제목·본문 슬롯·확인/취소 버튼, primary/danger 톤) |
|
||||
|
||||
## 관리자 컴포저블
|
||||
|
||||
@@ -166,7 +170,7 @@
|
||||
| pages/admin/pages/new.vue | 전체 화면 페이지 작성, HTML 문서 기본 모드 저장, 저장 토스트 |
|
||||
| pages/admin/pages/[id].vue | 전체 화면 페이지 수정, HTML 문서/일반 텍스트 모드 저장, 설정 패널 삭제, 저장/삭제 토스트 |
|
||||
| pages/admin/media/index.vue | 미디어 관리, **미디어 라이브러리/카드 썸네일/프로필 이미지** 탭, 글·멤버 목록과 같은 검색창, 파일 직접 추가, 모든 탭 공통 전체/미사용 배지형 필터, 목록 카드 오른쪽 위 사용 개수 배지, 현재 필터 결과의 삭제 가능 항목 전체 선택·선택 삭제, 이미지·비디오·오디오·파일 종류 필터, 원본 fallback·카드 썸네일 사용 배지(카드 썸네일 탭의 카드 배지는 숨김), 대표 이미지 원본 카드 썸네일 생성·재생성, 비디오 프레임 썸네일, 폴더 트리 more vert(폴더 삭제), 검색·드래그·상세 모달 등 |
|
||||
| pages/admin/navigation/index.vue | 메뉴 관리: 상단/하단/추천 탭, 추천 사이트 대체 텍스트·썸네일 URL, 행 more vert(메뉴 항목 삭제), 드래그 정렬, `useAdminToast` |
|
||||
| pages/admin/navigation/index.vue | 메뉴 관리: 상단/하단/추천 탭, 추천 사이트는 공개와 같은 카드 목록+편집 모달(전체 URL·썸네일·파비콘 갱신), 행 more vert(삭제), 드래그 정렬, `useAdminToast` |
|
||||
| components/admin/AdminRowMoreMenu.vue | 관리자 행 more vert 메뉴(트리거·화면 기준 팝오버) |
|
||||
| composables/useAdminRowMenu.js | 관리자 행 메뉴 열림 상태·바깥 클릭 닫기 |
|
||||
| pages/admin/tags/index.vue | 태그 관리(메인 태그 드래그 정렬 자동 저장·more vert 메뉴, 일반 태그 배지 more vert 메뉴·검색/정렬, 태그 추가 버튼), 액션 피드백 토스트 |
|
||||
@@ -267,13 +271,18 @@
|
||||
| server/routes/admin/api/settings/home-cover.post.js | 관리자 메인 화면 커버 업로드 API(`/uploads/system/home-cover-YYYYMM-*.webp` 생성, `시스템` 미디어 메타 저장, 라이트·다크 슬롯용 URL 반환) |
|
||||
| server/routes/ads.txt.get.js | 사이트 설정 ads.txt 루트 text/plain 응답 |
|
||||
| server/routes/admin/api/navigation.get.js | 관리자 네비게이션 목록 API |
|
||||
| server/routes/admin/api/navigation.put.js | 관리자 네비게이션 일괄 저장 API |
|
||||
| server/routes/admin/api/navigation.put.js | 관리자 네비게이션 일괄 저장 API(추천 사이트 파비콘 캐시 보존, 캐시 없는 추천 사이트 백그라운드 최초 갱신) |
|
||||
| server/routes/admin/api/navigation/[id]/favicon-refresh.post.js | 추천 사이트 파비콘 수동 갱신 API |
|
||||
| server/utils/recommended-site-favicon.js | 추천 사이트 파비콘 fetch·`/uploads/system/recommended-favicon-*.png` 저장·일괄 갱신 |
|
||||
| server/plugins/recommended-favicon-cron.js | 매월 1일 03:00(KST) 추천 사이트 파비콘 자동 갱신 |
|
||||
| scripts/refresh-recommended-favicons.js | 추천 사이트 파비콘 수동 일괄 갱신 CLI(`npm run navigation:refresh-recommended-favicons`) |
|
||||
| server/routes/admin/api/members.get.js | 관리자 멤버 목록 API |
|
||||
| server/routes/admin/api/members.post.js | 관리자 멤버 생성 API |
|
||||
| server/routes/admin/api/members/[id].get.js | 관리자 멤버 상세 API |
|
||||
| server/routes/admin/api/members/[id].put.js | 관리자 멤버 기본 정보 수정 API(회원 전용 썸네일 교체·제거 시 메타 연결 분리) |
|
||||
| server/routes/admin/api/members/[id]/avatar.post.js | 관리자 멤버 썸네일 업로드 및 즉시 반영 API |
|
||||
| server/routes/admin/api/members/[id]/role.put.js | 관리자 멤버 권한 변경 API(`owner`/`admin`/`vip`/`member`, 관리자 상호 조작 차단, 마지막 소유자 보호) |
|
||||
| server/routes/admin/api/members/[id]/ownership-transfer.post.js | 소유자 권한 이전 API(대상→소유자, 요청자→관리자) |
|
||||
| server/utils/content-schema.js | Zod 콘텐츠 스키마 |
|
||||
| server/utils/sample-content.js | 샘플 콘텐츠 저장소 |
|
||||
| server/utils/admin-auth.js | 관리자 세션 쿠키 인증 유틸리티 |
|
||||
|
||||
18
docs/spec.md
18
docs/spec.md
@@ -555,7 +555,7 @@ components/content/
|
||||
> 최종 저장 크기는 `AVATAR_MAX_WIDTH`, `AVATAR_MAX_HEIGHT` 중 작은 값을 사용해 `N x N` 정사각형으로 맞춘다.
|
||||
> `AVATAR_WEBP_QUALITY`는 1~100 범위로 보정하며, 최대 해상도 설정이 최소 해상도보다 작으면 서버에서 최소값 이상으로 자동 보정한다.
|
||||
> 회원 썸네일을 새로 업로드하거나 제거·탈퇴할 때, 이전 썸네일 URL에 대한 `media_metadata` 행은 `removeManagedAvatarAsset`으로 제거하되 **디스크 파일은 삭제하지 않는다.** 관리자 미디어 **썸네일** 탭에서 미사용 파일을 확인·삭제한다.
|
||||
> 관리자 미디어 화면의 **썸네일** 탭에서만 회원 썸네일을 목록·검색하며, `GET /admin/api/media` 응답에 `avatarOwner`(닉네임·이메일·마지막 접속·IP)가 포함될 수 있다.
|
||||
> 관리자 미디어 화면의 **썸네일** 탭에서만 회원 썸네일을 목록·검색하며, `GET /admin/api/media` 응답에 `avatarOwner`(닉네임·이메일·등급·마지막 접속·IP)가 포함될 수 있다. 프로필 이미지 목록 카드 배지는 등급(소유자·관리자·VIP·멤버)으로 표시한다.
|
||||
|
||||
### 업로드 파일 제공
|
||||
|
||||
@@ -595,7 +595,7 @@ components/content/
|
||||
- `POST /admin/api/media-folders` - 미디어 폴더 생성
|
||||
- `DELETE /admin/api/media-folders` - 미디어 폴더 삭제(해당 경로·하위 경로로 분류된 `media_metadata`는 `미분류`로 되돌림)
|
||||
- `POST /admin/api/uploads` - 관리자 게시물·페이지용 이미지 업로드(저장 파일명은 원본명 기반·동일 월 폴더 내 충돌 시 `이름-2` 등 넘버링, `/uploads/posts/YYYY/MM` 저장)
|
||||
- `POST /admin/api/member-avatar` - 관리자 새 회원 생성 전 썸네일 사전 업로드(`/uploads/members/avatars/YYYY/MM`, WebP 변환·중앙 1:1 크롭)
|
||||
- `POST /admin/api/member-avatar` - 관리자 멤버 썸네일 사전 업로드(신규·편집 공통, `/uploads/members/avatars/YYYY/MM`, WebP 변환·중앙 1:1 크롭). 편집 화면에서는 미리보기만 반영하고 **저장**(`PUT /admin/api/members/:id`) 시 회원에 연결한다.
|
||||
- `GET /admin/api/tags` - 태그 목록(옵션: `tagType`, `q`, `limit`)
|
||||
- `POST /admin/api/tags` - 태그 생성
|
||||
- `GET /admin/api/tags/:id` - 태그 상세
|
||||
@@ -609,12 +609,14 @@ components/content/
|
||||
- `GET /api/site-settings` - 공개 사이트 설정 조회(`showPostUpdatedAt`, 라이트·다크 홈 커버·어나운스 바·사이트 코드 필드 포함)
|
||||
- `GET /admin/api/navigation` - 네비게이션 항목 목록
|
||||
- `PUT /admin/api/navigation` - 네비게이션 항목 일괄 저장
|
||||
- `POST /admin/api/navigation/:id/favicon-refresh` - 추천 사이트 파비콘 수동 갱신(`{ cachedFaviconUrl, faviconCachedAt }` 반환)
|
||||
- `GET /admin/api/members` - 회원 목록(권한 코드, 최근 접속, 접속 IP, 댓글 수 포함)
|
||||
- `POST /admin/api/members` - 관리자 회원 생성. 본문: `username`, `email`, 선택 `avatarUrl`, `labels`, `note`. 생성된 회원은 `member` 권한이며 초기 비밀번호는 임의 해시로 저장한다.
|
||||
- `GET /admin/api/members/:id` - 관리자 회원 상세(썸네일, 이름, 이메일, 레이블, 관리자 노트, 활동 요약 포함)
|
||||
- `PUT /admin/api/members/:id` - 관리자 회원 기본 정보 수정. 본문: `username`, `email`, 선택 `avatarUrl`, `labels`, `note`. 이전 값이 회원 전용 썸네일 URL이고 새 값과 달라지면 `media_metadata` 연결을 분리한다.
|
||||
- `POST /admin/api/members/:id/avatar` - 관리자 회원 썸네일 업로드 및 즉시 반영(`/uploads/members/avatars/YYYY/MM`, WebP 변환·중앙 1:1 크롭)
|
||||
- `PUT /admin/api/members/:id/role` - 회원 권한 변경(`owner`/`admin`/`vip`/`member`)
|
||||
- `POST /admin/api/members/:id/avatar` - 관리자 회원 썸네일 업로드 및 즉시 DB 반영(레거시 API; 관리자 UI는 `member-avatar` + `PUT` 흐름 사용)
|
||||
- `PUT /admin/api/members/:id/role` - 회원 권한 변경(`owner`/`admin`/`vip`/`member`). 소유자 권한 이전은 `POST /admin/api/members/:id/ownership-transfer` 사용.
|
||||
- `POST /admin/api/members/:id/ownership-transfer` - 소유자 권한 이전(대상→소유자, 요청자→관리자)
|
||||
|
||||
- 글 발행/초안 전환은 `PUT /admin/api/posts/:id`의 `status`와 `published_at`으로 처리한다. 예약 글은 별도 enum이 아니라 `published`와 미래 시각의 `published_at` 조합이다.
|
||||
- 게시물 상태는 `draft`, `published`, `members`, `private`를 사용한다. `members`는 VIP 이상 등급 회원에게만 공개한다. 로그인은 댓글 작성을 위한 기본 회원 기능이며, 멤버십 글 공개 기준으로 사용하지 않는다. `private`는 관리자 편집 화면에서는 보이지만 공개 사용자 화면에서는 노출하지 않는다.
|
||||
@@ -782,10 +784,10 @@ components/content/
|
||||
- 네비게이션은 `navigation_items` 테이블로 관리한다.
|
||||
- 컬럼: `parent_id`(nullable, self FK, `ON DELETE CASCADE`), `is_folder`(boolean, **자식이 있는 상단 항목이면 저장 시 서버가 true로 설정**), `location`(`primary`|`footer`|`recommended`), `sort_order`, `label`, `url`, `is_visible`(저장 시 항상 true로 정리). `(location, label, url)` 유니크 제약은 제거되었다.
|
||||
- 동일 `location`·`parent_id`·`label`·`url` 조합은 중복 저장하지 않는다. 반복 마이그레이션으로 생긴 중복은 `019_dedupe_navigation_items.sql`이 정리하고, 표현식 유니크 인덱스로 재발을 막는다.
|
||||
- `GET /api/navigation` 응답: `primary`는 **트리**(각 노드에 `children` 배열이 있을 수 있음, 리프는 `children` 없음). 노드 필드: `id`, `label`, `url`, `isFolder`, `isVisible`. 트리 조립 시 **동일 `id`는 한 번만 반영**하고, DB 평면 행에 `parent_id`가 있어 자식으로 붙은 항목은 **루트에 두지 않는다**. **상단은 루트 직속 자식만** 트리에 붙이며(부모의 부모가 있는 행은 자식으로 무시), `footer`·`recommended`는 **평면** 배열(`parent_id` 없음)이다. `recommended`는 `id`, `label`, `url`, `descriptionText`, `thumbnailUrl`, `isVisible`을 내려준다.
|
||||
- `GET /api/navigation` 응답: `primary`는 **트리**(각 노드에 `children` 배열이 있을 수 있음, 리프는 `children` 없음). 노드 필드: `id`, `label`, `url`, `isFolder`, `isVisible`. 트리 조립 시 **동일 `id`는 한 번만 반영**하고, DB 평면 행에 `parent_id`가 있어 자식으로 붙은 항목은 **루트에 두지 않는다**. **상단은 루트 직속 자식만** 트리에 붙이며(부모의 부모가 있는 행은 자식으로 무시), `footer`·`recommended`는 **평면** 배열(`parent_id` 없음)이다. `recommended`는 `id`, `label`, `url`, `descriptionText`, `thumbnailUrl`, `cachedFaviconUrl`, `isVisible`을 내려준다. 관리자 `GET /admin/api/navigation`에는 추가로 `faviconCachedAt`을 포함한다.
|
||||
- `PUT /admin/api/navigation` 요청 본문의 각 항목은 반드시 `id`(UUID)를 포함한다. 저장 시 `sort_order`는 `primary`·`footer`·`recommended` 각각 위치별 트리(또는 평면 루트) DFS 순으로 다시 부여한다. `parent_id`는 `primary`에서만 허용되며 **상단은 한 단계(루트→자식)만** 허용한다. `footer`·`recommended` 항목은 항상 루트다. `is_visible`·`is_folder`는 요청값과 무관하게 서버에서 **항상 표시·자식 유무 기준 폴더**로 덮어쓴다.
|
||||
- URL은 `/`로 시작하는 내부 경로, `http(s)://` 외부 URL, 폴더 전용 자리 표시 `#`를 허용한다.
|
||||
- 관리자 메뉴 화면은 **상단 네비게이션**·**하단 네비게이션**·**추천 사이트** 탭으로 구분한다. 편집 UI는 **태그 관리 메인 태그**와 같은 테이블·`cursor-move` 행 드래그 패턴을 쓰며, 드래그는 입력·버튼 위가 아닌 행 여백·개요 열에서 시작한다. 상단은 `buildNavigationEditorTree` 결과를 `flattenNavigationEditorWrappers`로 한 테이블에 평면 표시하고, **같은 행의 위 1/3·가운데·아래 1/3**에 드롭하면 각각 대상 **앞**(동일 부모)·**하위**(대상의 자식, **루트 행에만**)·**뒤**(동일 부모)로 이동한다. **상단은 루트→자식 한 단계만** 허용하며(하위 행에는 가운데 드롭이 형제 앞·뒤로 대체됨, 이미 하위가 있는 항목은 다른 항목의 하위로 넣을 수 없음), 드래그 중 하이라이트는 **형제 앞·뒤**를 파란 가로 끝선·연한 파란 배경, **하위 편입**을 앰버 링·연한 앰버 배경으로 구분하고, 개요 열에 `앞에 끼움` / `뒤에 끼움` / `하위로 넣기` 짧은 문구를 덧붙인다. 개요 열 표기는 `1`, `2.1`, `2.2`, `3`처럼 깊이별 계층 번호로 보이며, 라벨 열은 깊이만큼 `padding-left`로 들여쓴다. 자기 자신·자기 하위로의 편입은 거부한다. 하단·추천 사이트는 평면 목록만 드래그 정렬한다. 추천 사이트는 공개 화면 **왼쪽 사이드바** Categories 아래 Recommended 영역에 카드로 노출되며, 대체 텍스트가 있으면 URL 대신 대체 텍스트를 표시하고 썸네일 URL이 있으면 Google Favicon 프록시 대신 썸네일을 표시한다. 썸네일이 없고 `https://` URL이면 클라이언트에서 origin을 뽑아 Google Favicon V2(`https://t3.gstatic.com/faviconV2?...`) 이미지로 표시할 수 있다(내부 경로만 있으면 아이콘 생략). 썸네일 로드에 실패하면 파비콘, 파비콘도 실패하면 라벨 첫 글자로 대체한다. 오른쪽 사이드바는 추천 사이트를 렌더하지 않고 광고와 게시물 목차 영역에 집중한다.
|
||||
- URL은 `/`로 시작하는 내부 경로, `http(s)://` 외부 URL, 폴더 전용 자리 표시 `#`를 허용한다. 저장 시 `uniare.sori.studio`처럼 프로토콜 없는 외부 호스트는 `https://`를 자동으로 붙인다. 관리자 추천 사이트 편집 모달은 URL 접두사(`https://`·`http://`·내부 경로) 선택과 본문 입력으로 나눈다.
|
||||
- 관리자 메뉴 화면은 **상단 네비게이션**·**하단 네비게이션**·**추천 사이트** 탭으로 구분한다. 편집 UI는 **태그 관리 메인 태그**와 같은 테이블·`cursor-move` 행 드래그 패턴을 쓰며, 드래그는 입력·버튼 위가 아닌 행 여백·개요 열에서 시작한다. 상단은 `buildNavigationEditorTree` 결과를 `flattenNavigationEditorWrappers`로 한 테이블에 평면 표시하고, **같은 행의 위 1/3·가운데·아래 1/3**에 드롭하면 각각 대상 **앞**(동일 부모)·**하위**(대상의 자식, **루트 행에만**)·**뒤**(동일 부모)로 이동한다. **상단은 루트→자식 한 단계만** 허용하며(하위 행에는 가운데 드롭이 형제 앞·뒤로 대체됨, 이미 하위가 있는 항목은 다른 항목의 하위로 넣을 수 없음), 드래그 중 하이라이트는 **형제 앞·뒤**를 파란 가로 끝선·연한 파란 배경, **하위 편입**을 앰버 링·연한 앰버 배경으로 구분하고, 개요 열에 `앞에 끼움` / `뒤에 끼움` / `하위로 넣기` 짧은 문구를 덧붙인다. 개요 열 표기는 `1`, `2.1`, `2.2`, `3`처럼 깊이별 계층 번호로 보이며, 라벨 열은 깊이만큼 `padding-left`로 들여쓴다. 자기 자신·자기 하위로의 편입은 거부한다. 하단·추천 사이트는 평면 목록만 드래그 정렬한다. **추천 사이트** 탭 목록은 공개 Recommended와 같은 **카드**(아이콘·제목·설명 또는 URL) 형태이며, 캐시 갱신 시각은 카드 오른쪽 끝에 표시한다. 추천 사이트 드래그는 카드 **위·아래 절반**에 드롭하면 각각 대상 **앞·뒤**로 삽입되며, 파란 가로 삽입선·`앞에 삽입` / `뒤에 삽입` 안내를 표시한다. 추천 사이트는 공개 화면 **왼쪽 사이드바** Categories 아래 Recommended 영역에 카드로 노출되며, 대체 텍스트가 있으면 URL 대신 대체 텍스트를 표시한다. 아이콘 우선순위는 **수동 썸네일 URL > 서버 캐시 파비콘(`cached_favicon_url`) > 외부 faviconV2 프록시**이다. 서버는 외부 `https://` URL의 파비콘을 `/uploads/system/recommended-favicon-*.png`로 저장하고 `favicon_cached_at`을 기록한다. 자동 갱신은 **매월 1일 03:00(KST)** 에 전체 추천 사이트를 대상으로 수행하며, **관리자 추천 사이트 탭**은 공개와 같은 **카드 목록**으로 미리보기하고 **편집** 버튼으로 모달에서 제목·URL·대체 텍스트·썸네일 URL 전체를 확인·수정한다. 삭제·취소(배경 클릭 포함)는 브라우저 `confirm`으로 한 번 더 확인하며, 모달에서 **적용** 전 입력 변경이 있으면 닫기 전에 안내한다. 편집 모달에서 `POST /admin/api/navigation/:id/favicon-refresh`로 파비콘을 수동 갱신할 수 있다. 네비게이션 저장 시 캐시 없는 추천 사이트는 백그라운드로 최초 캐시를 시도한다. 운영 수동 일괄 갱신은 `npm run navigation:refresh-recommended-favicons`를 사용한다. 오른쪽 사이드바는 추천 사이트를 렌더하지 않고 광고와 게시물 목차 영역에 집중한다.
|
||||
- `parent_id` / `is_folder` 컬럼이 DB에 없을 때 저장은 실패한다. `npm run db:migrate:dev`로 `017_navigation_hierarchy.sql`을 적용해야 한다(저장 API는 해당 경우 한국어 안내 메시지를 반환할 수 있다).
|
||||
- 공개 왼쪽 사이드바 상단은 `SidebarPrimaryNavList`로 렌더링한다. **하위가 있는 노드**는 한 줄 `button`으로, **행 전체(이름·왼쪽 세로 데코·chevron) 클릭**으로 접기/펼친다. 부모·리프 모두 왼쪽 장식은 **기본 세로 막대(`--site-line`)**, **호버 시에만** 리프와 동일하게 **작은 원형**으로 전환한다. **내부 경로**(`/`로 시작, `//`·`http(s)` 제외)이고 현재 `route.path`와 정규화한 경로가 같으면 장식 색을 **`--site-accent`(브랜드 오렌지)**로 둔다. 외부 URL은 비교하지 않는다. 펼침 상태는 `localStorage` 키 `sori-primary-nav-expanded`에 저장된다. 상단·리프 링크·부모 버튼 행은 **`w-full`**로 `site-sidebar-nav-row` 호버 배경이 가로 전체를 쓴다(라이트 `#F7F4EF`, 다크는 `site-panel-hover`와 동일한 `color-mix` 패턴).
|
||||
- 관리자 메뉴 관리 화면에서 저장 API로 보내는 직렬화 결과가 서버에서 불러온 직후와 동일하면 `메뉴 저장` 버튼이 비활성화된다.
|
||||
@@ -807,7 +809,7 @@ components/content/
|
||||
- 관리자 멤버 상세/추가 화면은 Ghost형 편집 화면을 기준으로 썸네일, 이름, 이메일, 레이블, 관리자 노트를 편집한다. 본문은 3분할 그리드로 요약 영역 1fr, 입력 영역 2fr 비율을 사용한다. 기존 회원 상세는 처음에는 읽기 전용 보기 모드로 현재 값만 표시하고, 상단 `수정하기`를 누르면 입력 필드·셀렉트가 편집 컨트롤로 전환된다. 저장 버튼은 수정 모드에서 기존 값과 달라진 내용이 있을 때만 활성화한다. 썸네일은 URL 입력 대신 요약 영역의 원형 썸네일 hover 액션으로 등록·변경·제거한다. 기존 회원 상세는 멤버 등급 선택, 가입 정보와 활동 요약을 표시하고, 신규 회원 화면은 활동 영역을 표시하지 않는다. 멤버 등급은 셀렉트 변경 즉시 저장하지 않고 저장 버튼을 눌렀을 때 기본 정보와 함께 반영한다. 소유자 본인이나 관리자가 다른 관리자·소유자를 보는 등 서버에서 변경 불가한 경우 등급 셀렉트는 비활성화하고, 등급 변경 권한이 없는 세션에서는 등급을 일반 텍스트로 표시한다. 저장·업로드·비밀번호 변경 피드백은 우측 상단 관리자 토스트로 표시한다. 기존 회원 상세의 설정 메뉴는 관리자 직접 비밀번호 변경(`PUT /admin/api/members/:id/password`)과 멤버 삭제(`DELETE /admin/api/members/:id`)를 제공한다.
|
||||
- 관리자 멤버 추가/수정 화면은 저장되지 않은 변경사항이 있을 때 내부 라우트 이동을 공통 확인 모달로 막는다. 관리자 게시글 작성/수정 화면은 **서버에 이미 즉시 발행 또는 예약으로 저장된 글**에서 미저장 변경이 있을 때만 동일 방식으로 내부 이동을 막고, 초안(서버 기준)은 서버 자동 저장과 라우트 이탈 직전 플러시로 처리하여 해당 경우에는 모달을 쓰지 않는다. 브라우저 새로고침·탭 닫기는 브라우저 기본 `beforeunload` 확인을 사용한다. 게시글 화면에서 이탈을 승인하면 레거시 키 `SORI_ADMIN_POST_AUTOSAVE:*`가 있으면 삭제한다.
|
||||
- `users.member_labels`는 관리자용 문자열 배열이며, 이후 사용자별 칭호/분류 용도로 확장한다. `users.member_note`는 관리자에게만 보이는 500자 이하 메모다.
|
||||
- 관리자 멤버 권한은 `소유자(owner)`, `관리자(admin)`, `VIP(vip)`, `멤버(member)` 단계를 사용한다. VIP는 관리자 권한이 없지만 멤버십 게시물을 볼 수 있는 등급이며, 상세 화면에서 변경한다. 권한 변경은 소유자와 관리자만 가능하다. 관리자는 다른 관리자·소유자의 권한을 변경할 수 없고, 소유자·관리자 등급을 부여할 수 없다. 소유자는 모든 등급을 관리할 수 있으나 본인 권한을 직접 낮출 수 없고, 시스템에는 최소 1명의 소유자가 항상 남아야 한다.
|
||||
- 관리자 멤버 권한은 `소유자(owner)`, `관리자(admin)`, `VIP(vip)`, `멤버(member)` 단계를 사용한다. VIP는 관리자 권한이 없지만 멤버십 게시물을 볼 수 있는 등급이며, 상세 화면에서 변경한다. 권한 변경은 소유자와 관리자만 가능하다. **관리자**는 VIP·멤버의 정보만 수정·삭제·비밀번호 변경할 수 있으며, 소유자·관리자·본인 계정은 조회만 한다. 관리자는 소유자·관리자 등급을 부여할 수 없다. **소유자**는 모든 등급 회원 정보를 수정할 수 있고 본인 정보 수정도 가능하나, 본인 등급은 직접 낮출 수 없다. 소유자는 다른 회원을 소유자로 지정할 수 있으며, 이때 확인 모달 후 `POST /admin/api/members/:id/ownership-transfer`로 권한을 이전하고 기존 소유자는 관리자가 된다. 소유자는 항상 1명만 유지된다.
|
||||
- 관리자 페이지 접근은 `/admin/api/auth/me` 확인 후 허용한다.
|
||||
- 관리자 세션 토큰은 `ADMIN_PASSWORD` 기반 HMAC 서명으로 검증한다.
|
||||
- `/admin/api/auth/login`, `/admin/api/auth/logout`을 제외한 관리자 API 요청은 서버 미들웨어에서 세션 사용자 ID의 현재 DB 권한이 `owner` 또는 `admin`인지 다시 확인한다. 권한 변경이나 계정 삭제 이후 남아 있는 기존 관리자 쿠키는 이 단계에서 차단한다.
|
||||
|
||||
@@ -1,5 +1,58 @@
|
||||
# 업데이트 이력
|
||||
|
||||
## v1.7.1
|
||||
|
||||
- 관리자 네비게이션 추천 사이트 편집: 미저장 닫기·삭제 확인을 `AdminConfirmModal` 공통 모달로 교체.
|
||||
|
||||
## v1.7.0
|
||||
|
||||
- 관리자 멤버 권한: 관리자는 VIP·멤버 정보만 수정·삭제·비밀번호 변경 가능, 소유자·관리자·본인 계정은 조회만.
|
||||
- 소유자 멤버 관리: 다른 회원을 소유자로 지정 시 확인 모달 후 권한 이전, 기존 소유자는 관리자로 자동 변경.
|
||||
|
||||
## v1.6.9
|
||||
|
||||
- 버그 수정: 멤버 편집 저장 시 `isSaving` 때문에 등급 변경 API가 건너뛰어져 관리자로 바꿔도 멤버로 되돌아가던 문제 수정.
|
||||
|
||||
## v1.6.8
|
||||
|
||||
- 버그 수정: 메뉴 관리 페이지에서 네비게이션 API 오류 시 `null.map`으로 SSR이 깨지던 문제 수정.
|
||||
- `useAdminToast`: SSR 환경에서 `window` 접근으로 터지지 않도록 보호.
|
||||
|
||||
## v1.6.7
|
||||
|
||||
- 관리자 멤버 편집: 썸네일 업로드는 미리보기만 반영하고 **저장** 시 `PUT /admin/api/members/:id`로 적용.
|
||||
- 관리자 미디어 프로필 이미지: 연결 회원 배지를 등급(소유자·관리자·VIP·멤버)별로 표시.
|
||||
|
||||
## v1.6.6
|
||||
|
||||
- 관리자 네비게이션: 메뉴·추천 사이트 삭제 시 `confirm` 확인 추가(하위 메뉴 포함 안내).
|
||||
- 관리자 추천 사이트 편집 모달: 저장하지 않은 변경이 있을 때 취소·배경 클릭 시 닫기 확인.
|
||||
|
||||
## v1.6.5
|
||||
|
||||
- 관리자 추천 사이트 목록: 공개 Recommended와 동일한 카드 형태(아이콘·제목·설명/URL), 캐시 갱신 시각은 오른쪽 끝 유지.
|
||||
- 관리자 추천 사이트: 드래그 시 카드 위·아래 구역에 파란 삽입선·「앞에/뒤에 삽입」 안내 표시.
|
||||
- 관리자 추천 사이트 편집: URL 접두사 select에 AdminPageForm과 동일한 chevron SVG 적용.
|
||||
|
||||
## v1.6.4
|
||||
|
||||
- 네비게이션 URL: `uniare.sori.studio`처럼 프로토콜 없이 입력해도 `https://`를 자동 보정, 내부 경로 `/post` 지원 유지.
|
||||
- 관리자 추천 사이트 편집: URL 입력에 `https://`·`http://`·내부 경로 접두사 선택 추가.
|
||||
|
||||
## v1.6.3
|
||||
|
||||
- 관리자 추천 사이트: 테이블 대신 공개 Recommended와 같은 카드 목록·편집 모달 UI로 변경.
|
||||
- 관리자 추천 사이트: 목록은 썸네일·사이트·설명·캐시 갱신 시각·편집 버튼 전체 너비 레이아웃, 모달은 취소만으로 닫기.
|
||||
|
||||
## v1.6.2
|
||||
|
||||
- 추천 사이트 파비콘: 서버 캐시 컬럼(`cached_favicon_url`, `favicon_cached_at`) 추가 및 `/uploads/system/recommended-favicon-*.png` 저장.
|
||||
- 추천 사이트 파비콘: 매월 1일 03:00(KST) 자동 갱신 플러그인·`npm run navigation:refresh-recommended-favicons` 스크립트 추가.
|
||||
- 관리자 추천 사이트: 적용 아이콘 미리보기 열·썸네일 URL 옆 수동 파비콘 새로고침 버튼 추가.
|
||||
- 공개 Recommended: 수동 썸네일 > 서버 캐시 파비콘 > 외부 프록시 순으로 아이콘 표시.
|
||||
- 버그 수정: `favicon-refresh` API import 경로 오류로 Nitro 빌드 실패·개발 서버 무응답되던 문제 수정.
|
||||
- 개발 서버 스크립트: Nitro 빌드 완료 후 링크를 출력하고 Rollup/Nitro ERROR 로그를 터미널에 표시하도록 수정.
|
||||
|
||||
## v1.6.1
|
||||
|
||||
- 외부 파비콘 URL 생성: Google `s2/favicons?domain=` 대신 `faviconV2`+origin URL을 사용해 서브도메인·신규 사이트에서 기본 지구본만 나오던 문제 수정.
|
||||
|
||||
98
lib/navigation-url.js
Normal file
98
lib/navigation-url.js
Normal file
@@ -0,0 +1,98 @@
|
||||
/** @typedef {'https://' | 'http://' | ''} NavigationUrlPrefix */
|
||||
|
||||
/**
|
||||
* 네비게이션 URL 접두사 옵션
|
||||
* @type {Array<{ value: NavigationUrlPrefix, label: string }>}
|
||||
*/
|
||||
export const navigationUrlPrefixOptions = [
|
||||
{ value: 'https://', label: 'https://' },
|
||||
{ value: 'http://', label: 'http://' },
|
||||
{ value: '', label: '내부 경로 (/)' }
|
||||
]
|
||||
|
||||
/**
|
||||
* 저장된 URL을 접두사와 본문으로 분리한다.
|
||||
* @param {string} url - 저장된 URL
|
||||
* @returns {{ prefix: NavigationUrlPrefix, body: string }} 접두사와 본문
|
||||
*/
|
||||
export const splitNavigationUrl = (url) => {
|
||||
const trimmed = String(url || '').trim()
|
||||
if (!trimmed) {
|
||||
return { prefix: 'https://', body: '' }
|
||||
}
|
||||
if (trimmed === '#') {
|
||||
return { prefix: '', body: '#' }
|
||||
}
|
||||
if (trimmed.startsWith('/') && !trimmed.startsWith('//')) {
|
||||
return { prefix: '', body: trimmed }
|
||||
}
|
||||
if (trimmed.startsWith('http://')) {
|
||||
return { prefix: 'http://', body: trimmed.slice('http://'.length) }
|
||||
}
|
||||
if (trimmed.startsWith('https://')) {
|
||||
return { prefix: 'https://', body: trimmed.slice('https://'.length) }
|
||||
}
|
||||
if (trimmed.startsWith('//')) {
|
||||
return { prefix: 'https://', body: trimmed.slice('//'.length) }
|
||||
}
|
||||
return { prefix: 'https://', body: trimmed }
|
||||
}
|
||||
|
||||
/**
|
||||
* 접두사와 본문을 저장용 URL로 합친다.
|
||||
* @param {NavigationUrlPrefix|string} prefix - URL 접두사
|
||||
* @param {string} body - 호스트·경로 또는 내부 경로
|
||||
* @returns {string} 합쳐진 URL
|
||||
*/
|
||||
export const composeNavigationUrl = (prefix, body) => {
|
||||
const input = String(body || '').trim()
|
||||
if (!input) {
|
||||
return ''
|
||||
}
|
||||
if (input === '#') {
|
||||
return '#'
|
||||
}
|
||||
|
||||
const normalizedPrefix = String(prefix || '')
|
||||
if (!normalizedPrefix) {
|
||||
if (input.startsWith('/')) {
|
||||
return input
|
||||
}
|
||||
return `/${input.replace(/^\/+/, '')}`
|
||||
}
|
||||
|
||||
const withoutProto = input.replace(/^https?:\/\//i, '').replace(/^\/+/, '')
|
||||
if (!withoutProto) {
|
||||
return ''
|
||||
}
|
||||
return `${normalizedPrefix}${withoutProto}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 네비게이션 URL을 저장용 형식으로 정규화한다.
|
||||
* @param {string} rawUrl - 입력 URL
|
||||
* @returns {string} 정규화된 URL
|
||||
*/
|
||||
export const normalizeNavigationUrl = (rawUrl) => {
|
||||
const trimmed = String(rawUrl || '').trim()
|
||||
if (!trimmed || trimmed === '#') {
|
||||
return trimmed
|
||||
}
|
||||
if (trimmed.startsWith('/') && !trimmed.startsWith('//')) {
|
||||
return trimmed
|
||||
}
|
||||
if (trimmed.startsWith('//')) {
|
||||
return `https:${trimmed}`
|
||||
}
|
||||
if (/^https?:\/\//i.test(trimmed)) {
|
||||
return trimmed
|
||||
}
|
||||
return `https://${trimmed.replace(/^\/+/, '')}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 저장 가능한 네비게이션 URL인지 확인한다.
|
||||
* @param {string} url - URL
|
||||
* @returns {boolean} 유효 여부
|
||||
*/
|
||||
export const isValidNavigationUrl = (url) => /^(\/|https?:\/\/|#)/.test(String(url || '').trim())
|
||||
32
lib/recommended-site-icon-url.js
Normal file
32
lib/recommended-site-icon-url.js
Normal file
@@ -0,0 +1,32 @@
|
||||
import { getExternalFaviconUrl } from './external-favicon-url.js'
|
||||
|
||||
/**
|
||||
* 추천 사이트 외부 URL인지 확인한다.
|
||||
* @param {string} url - 링크 URL
|
||||
* @returns {boolean} 외부 URL 여부
|
||||
*/
|
||||
export const isExternalRecommendedSiteUrl = (url) => /^https?:\/\//i.test(String(url || '').trim())
|
||||
|
||||
/**
|
||||
* 추천 사이트에 표시할 아이콘 URL을 반환한다.
|
||||
* 수동 썸네일 > 서버 캐시 파비콘 > 외부 파비콘 프록시 순이다.
|
||||
* @param {Object} item - 추천 사이트 항목
|
||||
* @param {string} [item.thumbnailUrl] - 수동 썸네일 URL
|
||||
* @param {string} [item.cachedFaviconUrl] - 서버에 저장된 파비콘 URL
|
||||
* @param {string} [item.url] - 사이트 URL
|
||||
* @param {number} [sizePx] - 외부 프록시 요청 크기(px)
|
||||
* @returns {string} 아이콘 URL
|
||||
*/
|
||||
export const getRecommendedSiteIconUrl = (item, sizePx = 64) => {
|
||||
const thumbnailUrl = String(item?.thumbnailUrl || '').trim()
|
||||
if (thumbnailUrl) {
|
||||
return thumbnailUrl
|
||||
}
|
||||
|
||||
const cachedFaviconUrl = String(item?.cachedFaviconUrl || '').trim()
|
||||
if (cachedFaviconUrl) {
|
||||
return cachedFaviconUrl
|
||||
}
|
||||
|
||||
return getExternalFaviconUrl(item?.url, sizePx)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "sori.studio",
|
||||
"version": "1.6.1",
|
||||
"version": "1.7.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"imports": {
|
||||
@@ -23,6 +23,7 @@
|
||||
"db:migrate:prod:status": "sh scripts/migrate-production-db.sh status",
|
||||
"db:migrate:prod:baseline": "sh scripts/migrate-production-db.sh baseline",
|
||||
"images:backfill-post-thumbnails": "node scripts/backfill-post-thumbnails.js",
|
||||
"navigation:refresh-recommended-favicons": "node scripts/refresh-recommended-favicons.js",
|
||||
"postinstall": "nuxt prepare"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -6,6 +6,14 @@ definePageMeta({
|
||||
/** 서버 `MEDIA_THUMBNAIL_ROOT`와 동일한 썸네일 논리 폴더 라벨 */
|
||||
const MEDIA_THUMBNAIL_ROOT = '썸네일'
|
||||
|
||||
/** 회원 등급 코드 → 미디어 배지 라벨 */
|
||||
const memberRoleBadgeLabels = {
|
||||
owner: '소유자',
|
||||
admin: '관리자',
|
||||
vip: 'VIP',
|
||||
member: '멤버'
|
||||
}
|
||||
|
||||
/**
|
||||
* 논리 폴더 경로가 썸네일 전용 루트인지 확인한다.
|
||||
* @param {string} folder - 폴더 경로
|
||||
@@ -98,7 +106,8 @@ const canEditMediaCategory = (item) => !isSystemManagedMediaItem(item)
|
||||
*/
|
||||
const getMediaBadgeLabel = (item) => {
|
||||
if (item?.avatarOwner) {
|
||||
return '회원'
|
||||
const roleCode = String(item.avatarOwner.roleCode || 'member')
|
||||
return memberRoleBadgeLabels[roleCode] || memberRoleBadgeLabels.member
|
||||
}
|
||||
|
||||
if (activeTab.value === 'postThumbnails' && item?.thumbnailStatus?.role === 'card') {
|
||||
@@ -1447,6 +1456,10 @@ watch(filteredMediaUrls, (urls) => {
|
||||
<dt class="text-[11px] font-semibold uppercase tracking-wide text-muted">이메일</dt>
|
||||
<dd class="mt-0.5 break-all text-ink">{{ selectedMedia.avatarOwner.email }}</dd>
|
||||
</div>
|
||||
<div class="admin-media__avatar-owner-row">
|
||||
<dt class="text-[11px] font-semibold uppercase tracking-wide text-muted">등급</dt>
|
||||
<dd class="mt-0.5 text-ink">{{ getMediaBadgeLabel(selectedMedia) }}</dd>
|
||||
</div>
|
||||
<div class="admin-media__avatar-owner-row">
|
||||
<dt class="text-[11px] font-semibold uppercase tracking-wide text-muted">마지막 접속</dt>
|
||||
<dd class="mt-0.5 text-ink">{{ formatDateTime(selectedMedia.avatarOwner.lastSeenAt) }}</dd>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -3,6 +3,7 @@ import { spawn } from 'node:child_process'
|
||||
const host = '127.0.0.1'
|
||||
const port = '43117'
|
||||
let printedLinks = false
|
||||
let nitroReady = false
|
||||
let collectedLines = []
|
||||
const maxCollectedLines = 400
|
||||
|
||||
@@ -24,6 +25,11 @@ const nuxtProcess = spawn('nuxt', [
|
||||
* @returns {void}
|
||||
*/
|
||||
const printDevLinks = () => {
|
||||
if (printedLinks) {
|
||||
return
|
||||
}
|
||||
|
||||
printedLinks = true
|
||||
const origin = `http://${host}:${port}`
|
||||
console.log('')
|
||||
console.log('sori.studio 개발 서버')
|
||||
@@ -33,6 +39,27 @@ const printDevLinks = () => {
|
||||
console.log('')
|
||||
}
|
||||
|
||||
/**
|
||||
* 로그 줄을 터미널에 출력할지 판별한다.
|
||||
* @param {string} line - 로그 한 줄
|
||||
* @returns {boolean} 출력 여부
|
||||
*/
|
||||
const shouldPrintLine = (line) => {
|
||||
if (/(^|\s)(error:|warn:|warning:|failed|fatal|eaddrinuse|cannot|emfile|epipe|eacces|eperm)\b/i.test(line)) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (/\b(ERROR|RollupError)\b/.test(line)) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (/\bat\s.+\(.+\)/.test(line) || /Object\.\$resolve/.test(line)) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Nuxt 로그 처리
|
||||
* @param {Buffer} chunk - 로그 버퍼
|
||||
@@ -49,15 +76,12 @@ const handleNuxtLog = (chunk) => {
|
||||
}
|
||||
}
|
||||
|
||||
const localMatch = line.match(/Local:\s+(https?:\/\/[^\s]+)/)
|
||||
|
||||
if (localMatch && !printedLinks) {
|
||||
printedLinks = true
|
||||
if (/Nuxt Nitro server built/i.test(line)) {
|
||||
nitroReady = true
|
||||
printDevLinks()
|
||||
continue
|
||||
}
|
||||
|
||||
if (/(^|\s)(error:|warn:|warning:|failed|fatal|eaddrinuse|cannot|emfile|epipe|eacces|eperm)\b/i.test(line) || /\bat\s.+\(.+\)/.test(line) || /Object\.\$resolve/.test(line)) {
|
||||
if (shouldPrintLine(line)) {
|
||||
console.log(line)
|
||||
}
|
||||
}
|
||||
|
||||
7
scripts/refresh-recommended-favicons.js
Normal file
7
scripts/refresh-recommended-favicons.js
Normal file
@@ -0,0 +1,7 @@
|
||||
import { refreshAllRecommendedSiteFavicons } from '../server/utils/recommended-site-favicon.js'
|
||||
|
||||
const result = await refreshAllRecommendedSiteFavicons()
|
||||
|
||||
process.stdout.write(
|
||||
`추천 사이트 파비콘 갱신 완료: 성공 ${result.refreshed}개, 실패 ${result.failed}개\n`
|
||||
)
|
||||
25
server/plugins/recommended-favicon-cron.js
Normal file
25
server/plugins/recommended-favicon-cron.js
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Cron } from 'croner'
|
||||
import { refreshAllRecommendedSiteFavicons } from '../utils/recommended-site-favicon.js'
|
||||
|
||||
/**
|
||||
* 매월 1일 추천 사이트 파비콘을 자동 갱신한다.
|
||||
*/
|
||||
export default defineNitroPlugin(() => {
|
||||
if (process.env.NUXT_RECOMMENDED_FAVICON_CRON === '0') {
|
||||
return
|
||||
}
|
||||
|
||||
new Cron('0 3 1 * *', {
|
||||
timezone: 'Asia/Seoul',
|
||||
protect: true
|
||||
}, async () => {
|
||||
try {
|
||||
const result = await refreshAllRecommendedSiteFavicons()
|
||||
console.info(
|
||||
`[recommended-favicon] monthly refresh complete: refreshed=${result.refreshed}, failed=${result.failed}`
|
||||
)
|
||||
} catch (error) {
|
||||
console.error('[recommended-favicon] monthly refresh failed', error)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -153,6 +153,8 @@ const mapNavigationItemRow = (row) => ({
|
||||
url: row.url,
|
||||
descriptionText: row.description_text || '',
|
||||
thumbnailUrl: row.thumbnail_url || '',
|
||||
cachedFaviconUrl: row.cached_favicon_url || '',
|
||||
faviconCachedAt: row.favicon_cached_at ? row.favicon_cached_at.toISOString() : '',
|
||||
location: row.location,
|
||||
sortOrder: row.sort_order,
|
||||
isVisible: row.is_visible,
|
||||
@@ -1131,6 +1133,7 @@ export const getPublicNavigation = async () => {
|
||||
url: item.url,
|
||||
descriptionText: item.descriptionText,
|
||||
thumbnailUrl: item.thumbnailUrl,
|
||||
cachedFaviconUrl: item.cachedFaviconUrl,
|
||||
isVisible: item.isVisible
|
||||
}))
|
||||
}
|
||||
@@ -1148,6 +1151,20 @@ export const updateNavigationItems = async (items) => {
|
||||
throw new Error('DATABASE_REQUIRED')
|
||||
}
|
||||
|
||||
const existingRows = await sql`
|
||||
SELECT id, cached_favicon_url, favicon_cached_at
|
||||
FROM navigation_items
|
||||
`
|
||||
const faviconCacheById = new Map(
|
||||
existingRows.map((row) => [
|
||||
String(row.id),
|
||||
{
|
||||
cachedFaviconUrl: row.cached_favicon_url || '',
|
||||
faviconCachedAt: row.favicon_cached_at ?? null
|
||||
}
|
||||
])
|
||||
)
|
||||
|
||||
await sql.begin(async (transaction) => {
|
||||
await transaction`
|
||||
DELETE FROM navigation_items
|
||||
@@ -1155,6 +1172,11 @@ export const updateNavigationItems = async (items) => {
|
||||
|
||||
const ordered = orderNavigationItemsForInsert(items)
|
||||
for (const item of ordered) {
|
||||
const cached = faviconCacheById.get(String(item.id)) || {
|
||||
cachedFaviconUrl: '',
|
||||
faviconCachedAt: null
|
||||
}
|
||||
|
||||
await transaction`
|
||||
INSERT INTO navigation_items (
|
||||
id,
|
||||
@@ -1162,6 +1184,8 @@ export const updateNavigationItems = async (items) => {
|
||||
url,
|
||||
description_text,
|
||||
thumbnail_url,
|
||||
cached_favicon_url,
|
||||
favicon_cached_at,
|
||||
location,
|
||||
sort_order,
|
||||
is_visible,
|
||||
@@ -1174,6 +1198,8 @@ export const updateNavigationItems = async (items) => {
|
||||
${item.url},
|
||||
${item.descriptionText || ''},
|
||||
${item.thumbnailUrl || ''},
|
||||
${cached.cachedFaviconUrl},
|
||||
${cached.faviconCachedAt},
|
||||
${item.location},
|
||||
${item.sortOrder},
|
||||
${item.isVisible},
|
||||
@@ -1187,6 +1213,54 @@ export const updateNavigationItems = async (items) => {
|
||||
return listNavigationItems()
|
||||
}
|
||||
|
||||
/**
|
||||
* 네비게이션 항목 단건 조회
|
||||
* @param {string} id - 항목 ID
|
||||
* @returns {Promise<Object | null>} 네비게이션 항목
|
||||
*/
|
||||
export const getNavigationItemById = async (id) => {
|
||||
const sql = getPostgresClient()
|
||||
|
||||
if (!sql) {
|
||||
return null
|
||||
}
|
||||
|
||||
const rows = await sql`
|
||||
SELECT *
|
||||
FROM navigation_items
|
||||
WHERE id = ${id}
|
||||
LIMIT 1
|
||||
`
|
||||
|
||||
return rows[0] ? mapNavigationItemRow(rows[0]) : null
|
||||
}
|
||||
|
||||
/**
|
||||
* 추천 사이트 파비콘 캐시를 갱신한다.
|
||||
* @param {string} id - 항목 ID
|
||||
* @param {string} cachedFaviconUrl - 저장된 파비콘 URL
|
||||
* @returns {Promise<Object | null>} 갱신된 항목
|
||||
*/
|
||||
export const updateNavigationItemFaviconCache = async (id, cachedFaviconUrl) => {
|
||||
const sql = getPostgresClient()
|
||||
|
||||
if (!sql) {
|
||||
throw new Error('DATABASE_REQUIRED')
|
||||
}
|
||||
|
||||
const rows = await sql`
|
||||
UPDATE navigation_items
|
||||
SET
|
||||
cached_favicon_url = ${cachedFaviconUrl},
|
||||
favicon_cached_at = NOW(),
|
||||
updated_at = NOW()
|
||||
WHERE id = ${id}
|
||||
RETURNING *
|
||||
`
|
||||
|
||||
return rows[0] ? mapNavigationItemRow(rows[0]) : null
|
||||
}
|
||||
|
||||
/**
|
||||
* 관리자 태그 상세 조회
|
||||
* @param {string} id - 태그 ID
|
||||
|
||||
@@ -10,6 +10,62 @@ export const MEMBER_ROLE = {
|
||||
|
||||
const PRIVILEGED_ROLES = [MEMBER_ROLE.OWNER, MEMBER_ROLE.ADMIN]
|
||||
const MEMBERSHIP_ACCESS_ROLES = [MEMBER_ROLE.OWNER, MEMBER_ROLE.ADMIN, MEMBER_ROLE.VIP]
|
||||
const BELOW_ADMIN_ROLES = [MEMBER_ROLE.VIP, MEMBER_ROLE.MEMBER]
|
||||
|
||||
/**
|
||||
* 관리자가 대상 회원 프로필을 수정할 수 있는지 확인한다.
|
||||
* @param {{ actorRoleCode: string, targetRoleCode: string, actorUserId: string, targetUserId: string }} input - 검사 정보
|
||||
* @returns {boolean} 수정 가능 여부
|
||||
*/
|
||||
export const canActorEditMemberProfile = (input) => {
|
||||
if (input.actorRoleCode === MEMBER_ROLE.OWNER) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (input.actorRoleCode === MEMBER_ROLE.ADMIN) {
|
||||
return String(input.actorUserId) !== String(input.targetUserId)
|
||||
&& BELOW_ADMIN_ROLES.includes(input.targetRoleCode)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* 관리자가 대상 회원 프로필을 수정할 수 있는지 검증한다.
|
||||
* @param {{ actorUserId: string, targetUserId: string }} input - 검증 정보
|
||||
* @returns {Promise<{ actor: Object, target: Object }>} 검증된 회원 정보
|
||||
*/
|
||||
export const assertActorCanEditMemberProfile = async (input) => {
|
||||
const actor = await getMemberForAdmin(input.actorUserId)
|
||||
if (!actor || !isPrivilegedRole(actor.roleCode)) {
|
||||
throw createError({
|
||||
statusCode: 403,
|
||||
message: '회원 수정 권한이 없습니다.'
|
||||
})
|
||||
}
|
||||
|
||||
const target = await getMemberForAdmin(input.targetUserId)
|
||||
if (!target) {
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
message: '회원을 찾을 수 없습니다.'
|
||||
})
|
||||
}
|
||||
|
||||
if (!canActorEditMemberProfile({
|
||||
actorRoleCode: actor.roleCode,
|
||||
targetRoleCode: target.roleCode,
|
||||
actorUserId: actor.id,
|
||||
targetUserId: target.id
|
||||
})) {
|
||||
throw createError({
|
||||
statusCode: 403,
|
||||
message: '이 회원 정보를 수정할 권한이 없습니다.'
|
||||
})
|
||||
}
|
||||
|
||||
return { actor, target }
|
||||
}
|
||||
|
||||
/**
|
||||
* 회원 권한 표시 문자열을 반환한다.
|
||||
@@ -467,15 +523,12 @@ export const deleteMember = async (userId) => {
|
||||
* @returns {Promise<Object>} 삭제된 회원
|
||||
*/
|
||||
export const deleteMemberByAdmin = async (input) => {
|
||||
const target = await getMemberForAdmin(input.targetUserId)
|
||||
if (!target) {
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
message: '회원을 찾을 수 없습니다.'
|
||||
})
|
||||
}
|
||||
const { target } = await assertActorCanEditMemberProfile({
|
||||
actorUserId: input.actorUserId,
|
||||
targetUserId: input.targetUserId
|
||||
})
|
||||
|
||||
if (target.id === input.actorUserId) {
|
||||
if (String(target.id) === String(input.actorUserId)) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '현재 로그인한 계정은 여기서 삭제할 수 없습니다.'
|
||||
@@ -668,10 +721,15 @@ export const createMemberByAdmin = async (input) => {
|
||||
|
||||
/**
|
||||
* 관리자 화면에서 회원 기본 정보를 수정한다.
|
||||
* @param {{ memberId: string, username: string, email: string, avatarUrl: string, labels: string[], note: string }} input - 수정 값
|
||||
* @param {{ actorUserId: string, memberId: string, username: string, email: string, avatarUrl: string, labels: string[], note: string }} input - 수정 값
|
||||
* @returns {Promise<Object | null>} 수정된 회원
|
||||
*/
|
||||
export const updateMemberByAdmin = async (input) => {
|
||||
await assertActorCanEditMemberProfile({
|
||||
actorUserId: input.actorUserId,
|
||||
targetUserId: input.memberId
|
||||
})
|
||||
|
||||
const sql = requireSql()
|
||||
const rows = await sql`
|
||||
UPDATE users
|
||||
@@ -691,10 +749,15 @@ export const updateMemberByAdmin = async (input) => {
|
||||
|
||||
/**
|
||||
* 관리자 화면에서 회원 썸네일만 수정한다.
|
||||
* @param {{ memberId: string, avatarUrl: string }} input - 수정 값
|
||||
* @param {{ actorUserId: string, memberId: string, avatarUrl: string }} input - 수정 값
|
||||
* @returns {Promise<Object | null>} 수정된 회원
|
||||
*/
|
||||
export const updateMemberAvatarByAdmin = async (input) => {
|
||||
await assertActorCanEditMemberProfile({
|
||||
actorUserId: input.actorUserId,
|
||||
targetUserId: input.memberId
|
||||
})
|
||||
|
||||
const sql = requireSql()
|
||||
const rows = await sql`
|
||||
UPDATE users
|
||||
@@ -871,7 +934,7 @@ export const updateMemberRoleByAdmin = async (input) => {
|
||||
if (isPrivilegedRole(target.roleCode)) {
|
||||
throw createError({
|
||||
statusCode: 403,
|
||||
message: '관리자는 다른 소유자 또는 관리자 권한을 변경할 수 없습니다.'
|
||||
message: '관리자는 소유자 또는 관리자 회원의 등급을 변경할 수 없습니다.'
|
||||
})
|
||||
}
|
||||
|
||||
@@ -883,6 +946,17 @@ export const updateMemberRoleByAdmin = async (input) => {
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
normalizedRole === MEMBER_ROLE.OWNER
|
||||
&& actor.roleCode === MEMBER_ROLE.OWNER
|
||||
&& target.id !== actor.id
|
||||
) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '소유자 권한 이전은 전용 확인 절차를 사용하세요.'
|
||||
})
|
||||
}
|
||||
|
||||
if (target.id === input.actorUserId && !isPrivilegedRole(normalizedRole)) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
@@ -934,3 +1008,93 @@ export const updateMemberRoleByAdmin = async (input) => {
|
||||
isAdmin: Boolean(updated.isAdmin)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 소유자 권한을 다른 회원에게 이전하고 기존 소유자를 관리자로 변경한다.
|
||||
* @param {{ actorUserId: string, targetUserId: string }} input - 이전 정보
|
||||
* @returns {Promise<{ target: Object, previousOwner: Object }>} 이전 결과
|
||||
*/
|
||||
export const transferMemberOwnership = async (input) => {
|
||||
const sql = requireSql()
|
||||
|
||||
await sql.begin(async (tx) => {
|
||||
await tx`LOCK TABLE users IN SHARE ROW EXCLUSIVE MODE`
|
||||
|
||||
const actorRows = await tx`
|
||||
SELECT id, user_role AS "roleCode"
|
||||
FROM users
|
||||
WHERE id = ${input.actorUserId}
|
||||
LIMIT 1
|
||||
`
|
||||
const actor = actorRows?.[0]
|
||||
|
||||
if (!actor || actor.roleCode !== MEMBER_ROLE.OWNER) {
|
||||
throw createError({
|
||||
statusCode: 403,
|
||||
message: '소유자만 권한을 이전할 수 있습니다.'
|
||||
})
|
||||
}
|
||||
|
||||
const targetRows = await tx`
|
||||
SELECT id, user_role AS "roleCode"
|
||||
FROM users
|
||||
WHERE id = ${input.targetUserId}
|
||||
LIMIT 1
|
||||
`
|
||||
const target = targetRows?.[0]
|
||||
|
||||
if (!target) {
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
message: '대상 회원을 찾을 수 없습니다.'
|
||||
})
|
||||
}
|
||||
|
||||
if (String(target.id) === String(actor.id)) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '본인에게 소유자 권한을 이전할 수 없습니다.'
|
||||
})
|
||||
}
|
||||
|
||||
if (target.roleCode === MEMBER_ROLE.OWNER) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '이미 소유자입니다.'
|
||||
})
|
||||
}
|
||||
|
||||
await tx`
|
||||
UPDATE users
|
||||
SET
|
||||
user_role = ${MEMBER_ROLE.OWNER},
|
||||
is_admin = true,
|
||||
updated_at = now()
|
||||
WHERE id = ${input.targetUserId}
|
||||
`
|
||||
|
||||
await tx`
|
||||
UPDATE users
|
||||
SET
|
||||
user_role = ${MEMBER_ROLE.ADMIN},
|
||||
is_admin = true,
|
||||
updated_at = now()
|
||||
WHERE id = ${input.actorUserId}
|
||||
`
|
||||
})
|
||||
|
||||
const target = await getMemberForAdmin(input.targetUserId)
|
||||
const previousOwner = await getMemberForAdmin(input.actorUserId)
|
||||
|
||||
if (!target || !previousOwner) {
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
message: '소유자 권한 이전에 실패했습니다.'
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
target,
|
||||
previousOwner
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { uploadMemberAvatarImage } from '../../../utils/member-avatar-upload'
|
||||
import { MEDIA_THUMBNAIL_ROOT, upsertMediaMetadataCategory } from '../../../utils/media-library'
|
||||
|
||||
/**
|
||||
* 관리자 새 회원용 썸네일 사전 업로드 API
|
||||
* 관리자 멤버 썸네일 사전 업로드 API(신규·편집 미리보기)
|
||||
* @param {import('h3').H3Event} event - 요청 이벤트
|
||||
* @returns {Promise<{ avatarUrl: string }>} 업로드 결과
|
||||
*/
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createError, getRouterParam, readBody } from 'h3'
|
||||
import { z } from 'zod'
|
||||
import { requireAdminSession } from '../../../../utils/admin-auth'
|
||||
import { getMemberForAdmin, isEmailTaken, isUsernameTaken, updateMemberByAdmin } from '../../../../repositories/member-repository'
|
||||
import { getMemberForAdmin, isEmailTaken, isUsernameTaken, updateMemberByAdmin, assertActorCanEditMemberProfile } from '../../../../repositories/member-repository'
|
||||
import { removeManagedAvatarAsset } from '../../../../utils/member-avatar'
|
||||
|
||||
const memberInputSchema = z.object({
|
||||
@@ -18,7 +18,7 @@ const memberInputSchema = z.object({
|
||||
* @returns {Promise<Object>} 수정된 회원
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
requireAdminSession(event)
|
||||
const session = requireAdminSession(event)
|
||||
const memberId = String(getRouterParam(event, 'id') || '')
|
||||
const parsedBody = memberInputSchema.safeParse(await readBody(event))
|
||||
|
||||
@@ -62,6 +62,7 @@ export default defineEventHandler(async (event) => {
|
||||
}
|
||||
|
||||
const updated = await updateMemberByAdmin({
|
||||
actorUserId: session.userId,
|
||||
memberId,
|
||||
username: body.username,
|
||||
email,
|
||||
|
||||
@@ -11,7 +11,7 @@ import { MEDIA_THUMBNAIL_ROOT, upsertMediaMetadataCategory } from '../../../../.
|
||||
* @returns {Promise<Object>} 수정된 회원
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
requireAdminSession(event)
|
||||
const session = requireAdminSession(event)
|
||||
const memberId = String(getRouterParam(event, 'id') || '')
|
||||
|
||||
if (!memberId) {
|
||||
@@ -31,6 +31,7 @@ export default defineEventHandler(async (event) => {
|
||||
|
||||
const { avatarUrl } = await uploadMemberAvatarImage(event)
|
||||
const updated = await updateMemberAvatarByAdmin({
|
||||
actorUserId: session.userId,
|
||||
memberId,
|
||||
avatarUrl
|
||||
})
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { createError, getRouterParam } from 'h3'
|
||||
import { requireAdminSession } from '../../../../../utils/admin-auth'
|
||||
import { transferMemberOwnership } from '../../../../../repositories/member-repository'
|
||||
|
||||
/**
|
||||
* 소유자 권한 이전 API
|
||||
* @param {import('h3').H3Event} event - 요청 이벤트
|
||||
* @returns {Promise<{ target: Object, previousOwner: Object }>} 이전 결과
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const session = requireAdminSession(event)
|
||||
const memberId = String(getRouterParam(event, 'id') || '')
|
||||
|
||||
if (!memberId) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '대상 회원 ID가 필요합니다.'
|
||||
})
|
||||
}
|
||||
|
||||
return transferMemberOwnership({
|
||||
actorUserId: session.userId,
|
||||
targetUserId: memberId
|
||||
})
|
||||
})
|
||||
@@ -2,7 +2,7 @@ import bcrypt from 'bcrypt'
|
||||
import { createError, getRouterParam, readBody } from 'h3'
|
||||
import { z } from 'zod'
|
||||
import { requireAdminSession } from '../../../../../utils/admin-auth'
|
||||
import { getMemberForAdmin, updateMemberPassword } from '../../../../../repositories/member-repository'
|
||||
import { getMemberForAdmin, updateMemberPassword, assertActorCanEditMemberProfile } from '../../../../../repositories/member-repository'
|
||||
|
||||
const adminMemberPasswordSchema = z.object({
|
||||
password: z.string().min(8).max(32)
|
||||
@@ -14,7 +14,7 @@ const adminMemberPasswordSchema = z.object({
|
||||
* @returns {Promise<{ ok: true }>} 변경 결과
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
requireAdminSession(event)
|
||||
const session = requireAdminSession(event)
|
||||
const memberId = String(getRouterParam(event, 'id') || '')
|
||||
const parsedBody = adminMemberPasswordSchema.safeParse(await readBody(event))
|
||||
|
||||
@@ -32,6 +32,11 @@ export default defineEventHandler(async (event) => {
|
||||
})
|
||||
}
|
||||
|
||||
await assertActorCanEditMemberProfile({
|
||||
actorUserId: session.userId,
|
||||
targetUserId: memberId
|
||||
})
|
||||
|
||||
const member = await getMemberForAdmin(memberId)
|
||||
if (!member) {
|
||||
throw createError({
|
||||
|
||||
@@ -3,6 +3,7 @@ import { requireAdminSession } from '../../../utils/admin-auth'
|
||||
import { parseAdminNavigationInput } from '../../../utils/admin-navigation-input'
|
||||
import { updateNavigationItems } from '../../../repositories/content-repository'
|
||||
import { renumberSortOrderByTree, validateNavigationItems } from '../../../utils/navigation-tree'
|
||||
import { refreshRecommendedSiteFaviconsWithoutCache } from '../../../utils/recommended-site-favicon'
|
||||
|
||||
/**
|
||||
* 저장 직전에 항목별 표시·폴더 플래그를 정리한다(관리자 UI는 항상 노출, 폴더는 자식 유무로만).
|
||||
@@ -68,7 +69,9 @@ export default defineEventHandler(async (event) => {
|
||||
applyNavigationDerivedFlags(items)
|
||||
|
||||
try {
|
||||
return await updateNavigationItems(items)
|
||||
const saved = await updateNavigationItems(items)
|
||||
void refreshRecommendedSiteFaviconsWithoutCache().catch(() => {})
|
||||
return saved
|
||||
} catch (err) {
|
||||
const msg = err?.message != null ? String(err.message) : String(err)
|
||||
const code = err?.code != null ? String(err.code) : ''
|
||||
@@ -77,6 +80,8 @@ export default defineEventHandler(async (event) => {
|
||||
msg.includes('is_folder') ||
|
||||
msg.includes('description_text') ||
|
||||
msg.includes('thumbnail_url') ||
|
||||
msg.includes('cached_favicon_url') ||
|
||||
msg.includes('favicon_cached_at') ||
|
||||
code === '42703'
|
||||
) {
|
||||
throw createError({
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { createError, getRouterParam } from 'h3'
|
||||
import { requireAdminSession } from '../../../../../utils/admin-auth'
|
||||
import { refreshRecommendedSiteFaviconById } from '../../../../../utils/recommended-site-favicon'
|
||||
|
||||
/**
|
||||
* 추천 사이트 파비콘 수동 갱신 API
|
||||
* @param {import('h3').H3Event} event - 요청 이벤트
|
||||
* @returns {Promise<{ cachedFaviconUrl: string, faviconCachedAt: string }>} 갱신 결과
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
requireAdminSession(event)
|
||||
|
||||
const itemId = String(getRouterParam(event, 'id') || '').trim()
|
||||
if (!itemId) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '네비게이션 항목 ID가 필요합니다.'
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
return await refreshRecommendedSiteFaviconById(itemId)
|
||||
} catch (err) {
|
||||
const msg = err?.message != null ? String(err.message) : '파비콘을 갱신하지 못했습니다.'
|
||||
if (
|
||||
msg.includes('cached_favicon_url')
|
||||
|| msg.includes('favicon_cached_at')
|
||||
|| String(err?.code || '') === '42703'
|
||||
) {
|
||||
throw createError({
|
||||
statusCode: 503,
|
||||
message: 'DB에 추천 사이트 파비콘 캐시 컬럼이 없습니다. npm run db:migrate:dev 로 최신 마이그레이션을 적용한 뒤 다시 시도하세요.'
|
||||
})
|
||||
}
|
||||
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: msg
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -1,9 +1,13 @@
|
||||
import { z } from 'zod'
|
||||
import { isValidNavigationUrl, normalizeNavigationUrl } from '../../lib/navigation-url.js'
|
||||
|
||||
export const adminNavigationItemInputSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
label: z.string().trim().min(1),
|
||||
url: z.string().trim().min(1).regex(/^(\/|https?:\/\/|#)/),
|
||||
url: z.string().trim().min(1).transform(normalizeNavigationUrl).refine(
|
||||
isValidNavigationUrl,
|
||||
{ message: 'URL은 / 로 시작하는 내부 경로, http(s):// 외부 URL, # 중 하나여야 합니다.' }
|
||||
),
|
||||
descriptionText: z.string().trim().max(200).optional().default(''),
|
||||
thumbnailUrl: z.string().trim().max(500).optional().default(''),
|
||||
location: z.enum(['primary', 'footer', 'recommended']),
|
||||
|
||||
@@ -137,7 +137,7 @@ const isAvatarUrlReferencedByProfile = async (url) => {
|
||||
/**
|
||||
* URL 목록에 대해 아바타를 사용 중인 회원 정보를 조회한다.
|
||||
* @param {Array<string>} urls - 미디어 URL 목록
|
||||
* @returns {Promise<Map<string, { id: string, username: string, email: string, lastSeenAt: string | null, lastSeenIp: string }>>} URL별 회원 요약
|
||||
* @returns {Promise<Map<string, { id: string, username: string, email: string, roleCode: string, lastSeenAt: string | null, lastSeenIp: string }>>} URL별 회원 요약
|
||||
*/
|
||||
const getAvatarOwnersByUrls = async (urls) => {
|
||||
const sql = getPostgresClient()
|
||||
@@ -152,6 +152,7 @@ const getAvatarOwnersByUrls = async (urls) => {
|
||||
id,
|
||||
username,
|
||||
email,
|
||||
user_role AS "roleCode",
|
||||
last_seen_at,
|
||||
last_seen_ip,
|
||||
avatar_url
|
||||
@@ -169,6 +170,7 @@ const getAvatarOwnersByUrls = async (urls) => {
|
||||
id: row.id,
|
||||
username: row.username,
|
||||
email: row.email,
|
||||
roleCode: String(row.roleCode || 'member'),
|
||||
lastSeenAt: row.last_seen_at ? row.last_seen_at.toISOString() : null,
|
||||
lastSeenIp: row.last_seen_ip || ''
|
||||
})
|
||||
|
||||
216
server/utils/recommended-site-favicon.js
Normal file
216
server/utils/recommended-site-favicon.js
Normal file
@@ -0,0 +1,216 @@
|
||||
import { mkdir, writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import sharp from 'sharp'
|
||||
import { getExternalFaviconUrl } from '../../lib/external-favicon-url.js'
|
||||
import { isExternalRecommendedSiteUrl } from '../../lib/recommended-site-icon-url.js'
|
||||
import {
|
||||
getNavigationItemById,
|
||||
listNavigationItems,
|
||||
updateNavigationItemFaviconCache
|
||||
} from '../repositories/content-repository.js'
|
||||
import { upsertMediaMetadataCategory } from './media-library.js'
|
||||
|
||||
const FETCH_TIMEOUT_MS = 15000
|
||||
const FAVICON_OUTPUT_SIZE = 64
|
||||
|
||||
/**
|
||||
* 추천 사이트 파비콘 파일명 접미사를 만든다.
|
||||
* @returns {string} 파일명 접미사
|
||||
*/
|
||||
const createFaviconAssetSuffix = () => {
|
||||
const now = new Date()
|
||||
const year = now.getFullYear()
|
||||
const month = String(now.getMonth() + 1).padStart(2, '0')
|
||||
|
||||
return `${year}${month}-${Math.random().toString(36).slice(2, 8)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 업로드 기본 경로를 반환한다.
|
||||
* @returns {string} `/uploads` 형태 경로
|
||||
*/
|
||||
const getUploadBaseUrl = () => {
|
||||
try {
|
||||
const config = useRuntimeConfig()
|
||||
const fromConfig = String(config.uploadDir || '').trim()
|
||||
if (fromConfig) {
|
||||
return fromConfig.replace(/\/+$/g, '') || '/uploads'
|
||||
}
|
||||
} catch {
|
||||
// Nuxt 컨텍스트 밖(스크립트 실행)에서는 환경 변수를 사용한다.
|
||||
}
|
||||
|
||||
return String(process.env.UPLOAD_DIR || '/uploads').replace(/\/+$/g, '') || '/uploads'
|
||||
}
|
||||
|
||||
/**
|
||||
* 외부 파비콘 이미지 바이트를 가져온다.
|
||||
* @param {string} sourceUrl - 파비콘 소스 URL
|
||||
* @returns {Promise<Buffer>} 이미지 바이트
|
||||
*/
|
||||
const fetchFaviconBytes = async (sourceUrl) => {
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS)
|
||||
|
||||
try {
|
||||
const response = await fetch(sourceUrl, {
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
'User-Agent': 'sori.studio recommended-favicon-cache'
|
||||
}
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`파비콘을 가져오지 못했습니다. (${response.status})`)
|
||||
}
|
||||
|
||||
const contentType = String(response.headers.get('content-type') || '').toLowerCase()
|
||||
if (contentType && !contentType.startsWith('image/')) {
|
||||
throw new Error('파비콘 응답이 이미지가 아닙니다.')
|
||||
}
|
||||
|
||||
const bytes = Buffer.from(await response.arrayBuffer())
|
||||
if (!bytes.length) {
|
||||
throw new Error('파비콘 응답이 비어 있습니다.')
|
||||
}
|
||||
|
||||
return bytes
|
||||
} finally {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 추천 사이트 파비콘을 디스크에 저장한다.
|
||||
* @param {string} itemId - 네비게이션 항목 ID
|
||||
* @param {Buffer} bytes - 원본 이미지 바이트
|
||||
* @returns {Promise<string>} 저장된 공개 URL
|
||||
*/
|
||||
const writeRecommendedFaviconFile = async (itemId, bytes) => {
|
||||
const uploadBaseUrl = getUploadBaseUrl()
|
||||
const publicBasePath = uploadBaseUrl.replace(/^\/+/, '')
|
||||
const directoryPath = join(process.cwd(), 'public', publicBasePath, 'system')
|
||||
const shortId = String(itemId).replace(/-/g, '').slice(0, 8)
|
||||
const fileName = `recommended-favicon-${shortId}-${createFaviconAssetSuffix()}.png`
|
||||
const filePath = join(directoryPath, fileName)
|
||||
const publicUrl = `${uploadBaseUrl}/system/${fileName}`
|
||||
|
||||
const output = await sharp(bytes)
|
||||
.rotate()
|
||||
.resize({
|
||||
width: FAVICON_OUTPUT_SIZE,
|
||||
height: FAVICON_OUTPUT_SIZE,
|
||||
fit: 'cover',
|
||||
position: 'centre'
|
||||
})
|
||||
.png()
|
||||
.toBuffer()
|
||||
|
||||
await mkdir(directoryPath, { recursive: true })
|
||||
await writeFile(filePath, output)
|
||||
await upsertMediaMetadataCategory(publicUrl, '시스템')
|
||||
|
||||
return publicUrl
|
||||
}
|
||||
|
||||
/**
|
||||
* 추천 사이트 항목의 파비콘을 서버에 갱신한다.
|
||||
* @param {Object} item - 네비게이션 항목
|
||||
* @returns {Promise<{ cachedFaviconUrl: string, faviconCachedAt: string }>} 갱신 결과
|
||||
*/
|
||||
export const refreshRecommendedSiteFavicon = async (item) => {
|
||||
if (!item || item.location !== 'recommended') {
|
||||
throw new Error('추천 사이트 항목만 파비콘을 갱신할 수 있습니다.')
|
||||
}
|
||||
|
||||
if (!isExternalRecommendedSiteUrl(item.url)) {
|
||||
throw new Error('외부 URL이 있는 추천 사이트만 파비콘을 갱신할 수 있습니다.')
|
||||
}
|
||||
|
||||
const sourceUrl = getExternalFaviconUrl(item.url, 128)
|
||||
if (!sourceUrl) {
|
||||
throw new Error('파비콘 소스 URL을 만들 수 없습니다.')
|
||||
}
|
||||
|
||||
const bytes = await fetchFaviconBytes(sourceUrl)
|
||||
const cachedFaviconUrl = await writeRecommendedFaviconFile(item.id, bytes)
|
||||
const updated = await updateNavigationItemFaviconCache(item.id, cachedFaviconUrl)
|
||||
|
||||
if (!updated) {
|
||||
throw new Error('파비콘 캐시를 저장하지 못했습니다.')
|
||||
}
|
||||
|
||||
return {
|
||||
cachedFaviconUrl: updated.cachedFaviconUrl,
|
||||
faviconCachedAt: updated.faviconCachedAt
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ID로 추천 사이트 파비콘을 갱신한다.
|
||||
* @param {string} itemId - 네비게이션 항목 ID
|
||||
* @returns {Promise<{ cachedFaviconUrl: string, faviconCachedAt: string }>} 갱신 결과
|
||||
*/
|
||||
export const refreshRecommendedSiteFaviconById = async (itemId) => {
|
||||
const item = await getNavigationItemById(itemId)
|
||||
if (!item) {
|
||||
throw new Error('네비게이션 항목을 찾을 수 없습니다.')
|
||||
}
|
||||
|
||||
return refreshRecommendedSiteFavicon(item)
|
||||
}
|
||||
|
||||
/**
|
||||
* 캐시가 없는 추천 사이트 파비콘을 갱신한다.
|
||||
* @returns {Promise<{ refreshed: number, failed: number }>} 처리 결과
|
||||
*/
|
||||
export const refreshRecommendedSiteFaviconsWithoutCache = async () => {
|
||||
const items = await listNavigationItems()
|
||||
const targets = items.filter((item) =>
|
||||
item.location === 'recommended'
|
||||
&& !item.parentId
|
||||
&& isExternalRecommendedSiteUrl(item.url)
|
||||
&& !String(item.cachedFaviconUrl || '').trim()
|
||||
)
|
||||
|
||||
let refreshed = 0
|
||||
let failed = 0
|
||||
|
||||
for (const item of targets) {
|
||||
try {
|
||||
await refreshRecommendedSiteFavicon(item)
|
||||
refreshed += 1
|
||||
} catch {
|
||||
failed += 1
|
||||
}
|
||||
}
|
||||
|
||||
return { refreshed, failed }
|
||||
}
|
||||
|
||||
/**
|
||||
* 모든 추천 사이트 파비콘을 갱신한다.
|
||||
* @returns {Promise<{ refreshed: number, failed: number }>} 처리 결과
|
||||
*/
|
||||
export const refreshAllRecommendedSiteFavicons = async () => {
|
||||
const items = await listNavigationItems()
|
||||
const targets = items.filter((item) =>
|
||||
item.location === 'recommended'
|
||||
&& !item.parentId
|
||||
&& isExternalRecommendedSiteUrl(item.url)
|
||||
)
|
||||
|
||||
let refreshed = 0
|
||||
let failed = 0
|
||||
|
||||
for (const item of targets) {
|
||||
try {
|
||||
await refreshRecommendedSiteFavicon(item)
|
||||
refreshed += 1
|
||||
} catch {
|
||||
failed += 1
|
||||
}
|
||||
}
|
||||
|
||||
return { refreshed, failed }
|
||||
}
|
||||
Reference in New Issue
Block a user