멤버 상세 수정 모드 정리 v1.5.11
This commit is contained in:
@@ -12,6 +12,8 @@ const props = defineProps({
|
||||
|
||||
const emit = defineEmits(['saved', 'deleted'])
|
||||
|
||||
const { toast, showToast } = useAdminToast()
|
||||
|
||||
const { data: adminSession } = await useFetch('/admin/api/auth/me', {
|
||||
default: () => ({
|
||||
userId: '',
|
||||
@@ -20,18 +22,16 @@ const { data: adminSession } = await useFetch('/admin/api/auth/me', {
|
||||
})
|
||||
|
||||
const isNewMember = computed(() => props.mode === 'new')
|
||||
const saveMessage = ref('')
|
||||
const saveError = ref('')
|
||||
const isSaving = ref(false)
|
||||
const savedMemberSnapshot = ref('')
|
||||
const avatarInputRef = ref(null)
|
||||
const isUploadingAvatar = ref(false)
|
||||
const isEditingMember = ref(props.mode === 'new')
|
||||
const actionMenuOpen = ref(false)
|
||||
const passwordModalOpen = ref(false)
|
||||
const deleteModalOpen = ref(false)
|
||||
const isUpdatingPassword = ref(false)
|
||||
const isDeletingMember = ref(false)
|
||||
const actionMessage = ref('')
|
||||
const actionError = ref('')
|
||||
|
||||
const roleOptions = [
|
||||
@@ -93,15 +93,16 @@ const normalizedLabels = computed(() => [...new Set(
|
||||
.filter(Boolean)
|
||||
)])
|
||||
|
||||
const isFormEditable = computed(() => isNewMember.value || 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 shouldRenderRoleAsText = computed(() => isNewMember.value || !isCurrentAdminPrivileged.value)
|
||||
const shouldRenderRoleAsText = computed(() => !isFormEditable.value || isNewMember.value || !isCurrentAdminPrivileged.value)
|
||||
const canEditRoleSelect = computed(() => {
|
||||
if (shouldRenderRoleAsText.value || isSaving.value) {
|
||||
if (!isFormEditable.value || shouldRenderRoleAsText.value || isSaving.value) {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -127,6 +128,10 @@ const availableRoleOptions = computed(() => {
|
||||
return roleOptions
|
||||
})
|
||||
const roleHelpText = computed(() => {
|
||||
if (!isFormEditable.value) {
|
||||
return '수정하기를 누르면 변경 가능한 항목을 편집할 수 있습니다.'
|
||||
}
|
||||
|
||||
if (shouldRenderRoleAsText.value) {
|
||||
return '멤버와 VIP는 관리자 권한이 없어 등급을 변경할 수 없습니다.'
|
||||
}
|
||||
@@ -229,18 +234,32 @@ const getMemberPayload = () => ({
|
||||
})
|
||||
|
||||
const hasUnsavedMemberChanges = computed(() => serializeMemberPayload() !== savedMemberSnapshot.value)
|
||||
const shouldBlockUnsavedMemberChanges = computed(() => isFormEditable.value && hasUnsavedMemberChanges.value)
|
||||
const canSubmitMemberForm = computed(() => isFormEditable.value && hasUnsavedMemberChanges.value && !isSaving.value)
|
||||
|
||||
const {
|
||||
isUnsavedModalOpen,
|
||||
stayOnUnsavedPage,
|
||||
leaveUnsavedPage
|
||||
} = useAdminUnsavedChangesGuard(hasUnsavedMemberChanges)
|
||||
} = useAdminUnsavedChangesGuard(shouldBlockUnsavedMemberChanges)
|
||||
|
||||
/**
|
||||
* 회원 상세를 수정 모드로 전환한다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const enterEditMode = () => {
|
||||
isEditingMember.value = true
|
||||
}
|
||||
|
||||
/**
|
||||
* 썸네일 파일 선택창을 연다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const openAvatarFilePicker = () => {
|
||||
if (!isFormEditable.value) {
|
||||
return
|
||||
}
|
||||
|
||||
avatarInputRef.value?.click()
|
||||
}
|
||||
|
||||
@@ -257,9 +276,11 @@ const uploadAvatar = async (event) => {
|
||||
return
|
||||
}
|
||||
|
||||
if (!isFormEditable.value) {
|
||||
return
|
||||
}
|
||||
|
||||
isUploadingAvatar.value = true
|
||||
saveError.value = ''
|
||||
saveMessage.value = ''
|
||||
|
||||
try {
|
||||
const formData = new FormData()
|
||||
@@ -278,10 +299,10 @@ const uploadAvatar = async (event) => {
|
||||
if (!isNewMember.value) {
|
||||
emit('saved', result)
|
||||
savedMemberSnapshot.value = serializeMemberPayload()
|
||||
saveMessage.value = '썸네일이 변경되었습니다.'
|
||||
showToast('success', '썸네일이 변경되었습니다.')
|
||||
}
|
||||
} catch (error) {
|
||||
saveError.value = error?.data?.message || '썸네일 업로드에 실패했습니다.'
|
||||
showToast('error', error?.data?.message || '썸네일 업로드에 실패했습니다.')
|
||||
} finally {
|
||||
isUploadingAvatar.value = false
|
||||
if (target) {
|
||||
@@ -295,6 +316,10 @@ const uploadAvatar = async (event) => {
|
||||
* @returns {void}
|
||||
*/
|
||||
const removeAvatar = () => {
|
||||
if (!isFormEditable.value) {
|
||||
return
|
||||
}
|
||||
|
||||
form.avatarUrl = ''
|
||||
}
|
||||
|
||||
@@ -321,7 +346,6 @@ const closeActionMenu = () => {
|
||||
const openPasswordModal = () => {
|
||||
passwordForm.password = ''
|
||||
passwordForm.passwordConfirm = ''
|
||||
actionMessage.value = ''
|
||||
actionError.value = ''
|
||||
passwordModalOpen.value = true
|
||||
closeActionMenu()
|
||||
@@ -333,7 +357,6 @@ const openPasswordModal = () => {
|
||||
*/
|
||||
const openDeleteModal = () => {
|
||||
deleteForm.confirmText = ''
|
||||
actionMessage.value = ''
|
||||
actionError.value = ''
|
||||
deleteModalOpen.value = true
|
||||
closeActionMenu()
|
||||
@@ -368,7 +391,6 @@ const closeDeleteModal = () => {
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const updateMemberPassword = async () => {
|
||||
actionMessage.value = ''
|
||||
actionError.value = ''
|
||||
|
||||
if (!passwordForm.password || passwordForm.password.length < 8 || passwordForm.password.length > 32) {
|
||||
@@ -392,9 +414,11 @@ const updateMemberPassword = async () => {
|
||||
passwordModalOpen.value = false
|
||||
passwordForm.password = ''
|
||||
passwordForm.passwordConfirm = ''
|
||||
actionMessage.value = '비밀번호가 변경되었습니다.'
|
||||
showToast('success', '비밀번호가 변경되었습니다.')
|
||||
} catch (error) {
|
||||
actionError.value = error?.data?.message || '비밀번호 변경에 실패했습니다.'
|
||||
const message = error?.data?.message || '비밀번호 변경에 실패했습니다.'
|
||||
actionError.value = message
|
||||
showToast('error', message)
|
||||
} finally {
|
||||
isUpdatingPassword.value = false
|
||||
}
|
||||
@@ -405,7 +429,6 @@ const updateMemberPassword = async () => {
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const deleteMember = async () => {
|
||||
actionMessage.value = ''
|
||||
actionError.value = ''
|
||||
|
||||
if (deleteForm.confirmText !== form.email) {
|
||||
@@ -420,7 +443,9 @@ const deleteMember = async () => {
|
||||
})
|
||||
emit('deleted')
|
||||
} catch (error) {
|
||||
actionError.value = error?.data?.message || '회원 삭제에 실패했습니다.'
|
||||
const message = error?.data?.message || '회원 삭제에 실패했습니다.'
|
||||
actionError.value = message
|
||||
showToast('error', message)
|
||||
} finally {
|
||||
isDeletingMember.value = false
|
||||
}
|
||||
@@ -435,8 +460,15 @@ const saveMember = async () => {
|
||||
return
|
||||
}
|
||||
|
||||
saveMessage.value = ''
|
||||
saveError.value = ''
|
||||
if (!isFormEditable.value) {
|
||||
enterEditMode()
|
||||
return
|
||||
}
|
||||
|
||||
if (!hasUnsavedMemberChanges.value) {
|
||||
return
|
||||
}
|
||||
|
||||
isSaving.value = true
|
||||
|
||||
try {
|
||||
@@ -462,9 +494,10 @@ const saveMember = async () => {
|
||||
|
||||
savedMemberSnapshot.value = serializeMemberPayload()
|
||||
emit('saved', saved)
|
||||
saveMessage.value = '저장되었습니다.'
|
||||
isEditingMember.value = isNewMember.value
|
||||
showToast('success', '저장되었습니다.')
|
||||
} catch (error) {
|
||||
saveError.value = error?.data?.message || '저장에 실패했습니다.'
|
||||
showToast('error', error?.data?.message || '저장에 실패했습니다.')
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
@@ -516,8 +549,13 @@ watch(() => props.member, () => {
|
||||
<circle cx="12" cy="12.001" r="4.5" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
|
||||
</svg>
|
||||
</button>
|
||||
<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:opacity-50" type="button" :disabled="isSaving" @click="saveMember">
|
||||
{{ isSaving ? '저장 중' : '저장' }}
|
||||
<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"
|
||||
@click="saveMember"
|
||||
>
|
||||
{{ !isFormEditable ? '수정하기' : isSaving ? '저장 중' : '저장' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -531,6 +569,8 @@ watch(() => props.member, () => {
|
||||
class="admin-member-form__avatar-button relative h-20 w-20 overflow-hidden rounded-full bg-[#15171a] text-white"
|
||||
type="button"
|
||||
:aria-label="form.avatarUrl ? '썸네일 변경' : '썸네일 등록'"
|
||||
:disabled="!isFormEditable"
|
||||
:class="{ 'cursor-default': !isFormEditable }"
|
||||
@click="openAvatarFilePicker"
|
||||
>
|
||||
<img
|
||||
@@ -543,11 +583,11 @@ watch(() => props.member, () => {
|
||||
{{ memberInitial }}
|
||||
</span>
|
||||
<span class="admin-member-form__avatar-caption absolute inset-x-0 bottom-0 flex min-h-8 items-end justify-center bg-gradient-to-t from-black/80 via-black/35 to-transparent px-2 pb-2 text-center text-[11px] font-semibold leading-tight text-white opacity-0 transition-opacity group-hover:opacity-100 group-focus-within:opacity-100">
|
||||
{{ isUploadingAvatar ? '업로드 중' : form.avatarUrl ? '썸네일 변경' : '썸네일 등록' }}
|
||||
{{ !isFormEditable ? '현재 썸네일' : isUploadingAvatar ? '업로드 중' : form.avatarUrl ? '썸네일 변경' : '썸네일 등록' }}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="form.avatarUrl"
|
||||
v-if="form.avatarUrl && isFormEditable"
|
||||
class="admin-member-form__avatar-remove absolute right-0 top-0 grid size-6 -translate-y-1 translate-x-1 place-items-center rounded-full bg-black/85 text-white opacity-0 shadow-sm transition hover:bg-[#d21a26] group-hover:opacity-100 group-focus-within:opacity-100"
|
||||
type="button"
|
||||
aria-label="썸네일 제거"
|
||||
@@ -562,7 +602,6 @@ watch(() => props.member, () => {
|
||||
<div class="min-w-0">
|
||||
<h2 class="truncate text-lg font-semibold text-[#15171a]">{{ pageTitle }}</h2>
|
||||
<p class="mt-1 truncate text-sm text-[#657080]">{{ form.email || '이메일 없음' }}</p>
|
||||
<p class="mt-1 text-xs font-semibold uppercase tracking-[0.04em] text-[#9aa4b2]">{{ currentRoleLabel }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -606,11 +645,17 @@ watch(() => props.member, () => {
|
||||
<div class="grid gap-5 md:grid-cols-2">
|
||||
<label class="admin-member-form__field block">
|
||||
<span class="admin-member-form__label mb-2 block text-sm font-semibold text-[#15171a]">이름</span>
|
||||
<input v-model="form.username" class="admin-member-form__input h-12 w-full rounded-md border border-transparent bg-[#eef1f4] px-4 text-sm text-[#15171a] outline-none focus:border-[#c5ccd5] focus:bg-white focus:ring-2 focus:ring-[#dce2e8]" type="text" maxlength="60" required>
|
||||
<span v-if="!isFormEditable" class="admin-member-form__readonly flex min-h-12 items-center text-sm text-[#15171a]">
|
||||
{{ form.username || '-' }}
|
||||
</span>
|
||||
<input v-else v-model="form.username" class="admin-member-form__input h-12 w-full rounded-md border border-[#d7dce0] bg-white px-4 text-sm text-[#15171a] outline-none focus:border-[#8e9cac] focus:ring-2 focus:ring-[#dce2e8]" type="text" maxlength="60" required>
|
||||
</label>
|
||||
<label class="admin-member-form__field block">
|
||||
<span class="admin-member-form__label mb-2 block text-sm font-semibold text-[#15171a]">이메일</span>
|
||||
<input v-model="form.email" class="admin-member-form__input h-12 w-full rounded-md border border-transparent bg-[#eef1f4] px-4 text-sm text-[#15171a] outline-none focus:border-[#c5ccd5] focus:bg-white focus:ring-2 focus:ring-[#dce2e8]" type="email" maxlength="254" required>
|
||||
<span v-if="!isFormEditable" class="admin-member-form__readonly flex min-h-12 items-center text-sm text-[#15171a]">
|
||||
{{ form.email || '-' }}
|
||||
</span>
|
||||
<input v-else v-model="form.email" class="admin-member-form__input h-12 w-full rounded-md border border-[#d7dce0] bg-white px-4 text-sm text-[#15171a] outline-none focus:border-[#8e9cac] focus:ring-2 focus:ring-[#dce2e8]" type="email" maxlength="254" required>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@@ -618,14 +663,15 @@ watch(() => props.member, () => {
|
||||
<span class="admin-member-form__label mb-2 block text-sm font-semibold text-[#15171a]">멤버 등급</span>
|
||||
<span
|
||||
v-if="shouldRenderRoleAsText"
|
||||
class="admin-member-form__role-text flex h-12 w-full items-center rounded-md bg-[#eef1f4] px-4 text-sm font-semibold text-[#15171a]"
|
||||
class="admin-member-form__role-text flex min-h-12 w-full items-center text-sm font-semibold text-[#15171a]"
|
||||
:class="{ 'rounded-md border border-[#d7dce0] bg-white px-4': isFormEditable }"
|
||||
>
|
||||
{{ currentRoleLabel }}
|
||||
</span>
|
||||
<span v-else class="admin-member-form__select-wrap relative block">
|
||||
<select
|
||||
v-model="form.roleCode"
|
||||
class="admin-member-form__select h-12 w-full appearance-none rounded-md border border-transparent bg-[#eef1f4] px-4 pr-10 text-sm text-[#15171a] outline-none focus:border-[#c5ccd5] focus:bg-white focus:ring-2 focus:ring-[#dce2e8] disabled:opacity-60"
|
||||
class="admin-member-form__select h-12 w-full appearance-none rounded-md border border-[#d7dce0] bg-white px-4 pr-10 text-sm text-[#15171a] outline-none focus:border-[#8e9cac] focus:ring-2 focus:ring-[#dce2e8] disabled:opacity-60"
|
||||
:disabled="!canEditRoleSelect"
|
||||
>
|
||||
<option v-for="option in availableRoleOptions" :key="option.value" :value="option.value">
|
||||
@@ -643,23 +689,24 @@ watch(() => props.member, () => {
|
||||
|
||||
<label class="admin-member-form__field mt-5 block">
|
||||
<span class="admin-member-form__label mb-2 block text-sm font-semibold text-[#15171a]">레이블</span>
|
||||
<input v-model="form.labelsText" class="admin-member-form__input h-12 w-full rounded-md border border-transparent bg-[#eef1f4] px-4 text-sm text-[#15171a] outline-none focus:border-[#c5ccd5] focus:bg-white focus:ring-2 focus:ring-[#dce2e8]" type="text" placeholder="쉼표로 구분">
|
||||
<span v-if="!isFormEditable" class="admin-member-form__readonly flex min-h-12 items-center text-sm text-[#15171a]">
|
||||
{{ form.labelsText || '-' }}
|
||||
</span>
|
||||
<input v-else v-model="form.labelsText" class="admin-member-form__input h-12 w-full rounded-md border border-[#d7dce0] bg-white px-4 text-sm text-[#15171a] outline-none focus:border-[#8e9cac] focus:ring-2 focus:ring-[#dce2e8]" type="text" placeholder="쉼표로 구분">
|
||||
</label>
|
||||
|
||||
<label class="admin-member-form__field mt-5 block">
|
||||
<span class="admin-member-form__label mb-2 block text-sm font-semibold text-[#15171a]">
|
||||
노트 <span class="font-normal text-[#657080]">(멤버에게 보이지 않음)</span>
|
||||
</span>
|
||||
<textarea v-model="form.note" class="admin-member-form__textarea min-h-32 w-full resize-y rounded-md border border-transparent bg-[#eef1f4] px-4 py-3 text-sm text-[#15171a] outline-none focus:border-[#c5ccd5] focus:bg-white focus:ring-2 focus:ring-[#dce2e8]" maxlength="500" />
|
||||
<p v-if="!isFormEditable" class="admin-member-form__readonly min-h-24 whitespace-pre-wrap text-sm leading-6 text-[#15171a]">
|
||||
{{ form.note || '-' }}
|
||||
</p>
|
||||
<textarea v-else v-model="form.note" class="admin-member-form__textarea min-h-32 w-full resize-y rounded-md border border-[#d7dce0] bg-white px-4 py-3 text-sm text-[#15171a] outline-none focus:border-[#8e9cac] focus:ring-2 focus:ring-[#dce2e8]" maxlength="500" />
|
||||
<span class="admin-member-form__count mt-2 block text-sm text-[#8a95a5]">
|
||||
최대 500자. 현재 {{ noteLength }}자
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<p v-if="saveMessage" class="admin-member-form__message mt-5 rounded-md border border-green-200 bg-green-50 px-4 py-3 text-sm text-green-700">{{ saveMessage }}</p>
|
||||
<p v-if="saveError" class="admin-member-form__error mt-5 rounded-md border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{{ saveError }}</p>
|
||||
<p v-if="actionMessage" class="admin-member-form__message mt-5 rounded-md border border-green-200 bg-green-50 px-4 py-3 text-sm text-green-700">{{ actionMessage }}</p>
|
||||
<p v-if="actionError && !passwordModalOpen && !deleteModalOpen" class="admin-member-form__error mt-5 rounded-md border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{{ actionError }}</p>
|
||||
</form>
|
||||
|
||||
<section v-if="!isNewMember" class="admin-member-form__activity">
|
||||
@@ -696,6 +743,21 @@ watch(() => props.member, () => {
|
||||
@leave="leaveUnsavedPage"
|
||||
/>
|
||||
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="toast"
|
||||
class="admin-member-form__toast fixed right-5 top-5 z-[100] max-w-[min(24rem,calc(100vw-2.5rem))] rounded border px-4 py-3 text-sm font-semibold shadow-lg"
|
||||
:class="{
|
||||
'border-green-200 bg-green-50 text-green-800': toast.type === 'success',
|
||||
'border-red-200 bg-red-50 text-red-800': toast.type === 'error',
|
||||
'border-[#e3e6e8] bg-white text-[#15171a]': toast.type === 'info'
|
||||
}"
|
||||
role="status"
|
||||
>
|
||||
{{ toast.message }}
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<Teleport to="body">
|
||||
<div v-if="passwordModalOpen" 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)]">
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
# 업데이트 요약
|
||||
|
||||
## v1.5.11
|
||||
|
||||
- 멤버 상세 화면을 보기 모드와 수정 모드로 분리했다.
|
||||
- 멤버 상세 저장 버튼은 변경 사항이 있을 때만 활성화되도록 정리했다.
|
||||
- 멤버 상세 저장 결과를 우측 상단 토스트로 통일했다.
|
||||
- 멤버 목록 검색창을 글 목록 검색창과 같은 스타일로 맞췄다.
|
||||
|
||||
## v1.5.10
|
||||
|
||||
- 멤버 상세에서 변경할 수 없는 등급 셀렉트를 화면에서도 잠그도록 정리했다.
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# 의사결정 이력
|
||||
|
||||
## 2026-05-27 v1.5.11 — 멤버 상세 보기/수정 모드 분리
|
||||
|
||||
멤버 상세 화면은 운영자가 상태를 확인하는 시간이 더 많고, 권한·이메일·관리자 노트 같은 값은 실수로 바뀌면 영향이 크다. 따라서 기존 회원 상세는 먼저 읽기 전용 상태만 보여주고, 명시적으로 `수정하기`를 누른 뒤에만 편집 컨트롤을 노출한다. 저장 버튼도 실제 변경이 생긴 뒤에만 활성화해 “저장할 내용이 있는지”를 버튼 상태로 알 수 있게 했고, 저장 결과는 다른 관리자 화면과 같은 우측 상단 토스트로 통일한다.
|
||||
|
||||
## 2026-05-27 v1.5.10 — 권한 UI와 글 목록 스캔성 보정
|
||||
|
||||
권한 변경은 서버에서 거부되더라도 사용자가 바꿀 수 있는 것처럼 보이면 운영 실수와 혼란이 생긴다. 따라서 소유자 본인 강등, 관리자의 소유자·관리자 조작처럼 서버 규칙상 막히는 상황은 멤버 상세 화면에서도 셀렉트를 비활성화하고, 등급 변경 권한 자체가 없는 세션은 일반 텍스트로만 보여 준다. 글 목록은 필터만으로는 특정 글을 빠르게 찾기 어려우므로 검색을 추가하고, 대표 이미지는 제목 옆 작은 썸네일로만 보여 목록 높이를 키우지 않으면서 이미지 존재 여부를 알 수 있게 했다. 페이지 HTML 문서는 VS Code식 `!`+Tab 습관을 살려 빈 본문에서 기본 골격을 빠르게 채우도록 했다.
|
||||
|
||||
@@ -85,7 +85,7 @@
|
||||
| components/admin/AdminEditorBlockPanel.vue | 게시물 설정 사이드바 오버레이 블록 설정(이미지·갤러리·임베드), 갤러리 선택 이미지 강조 |
|
||||
| components/admin/AdminBlockEditor.vue | 관리자 글 블록형 에디터, 이미지/갤러리/콜아웃/토글/임베드 블록, 콜아웃 Emoji on/off·이모지 프리셋·배경 프리셋 선택(우측 고정 설정 패널), 갤러리 복수 미디어 선택·이미지 수별 열 배치·삽입 위치 표시 드래그 순서 변경, 한글 조합 입력 처리, Shift+Enter 줄바꿈, 코드 블록 단축 변환, AFFiNE 참고 세로 막대형 블록 핸들 선택/삭제/드래그 이동과 삽입선 표시, 하단 빈 입력 블록 유지, 본문 placeholder 표시 |
|
||||
| components/admin/AdminTagForm.vue | 관리자 태그 생성/수정 폼(이름/슬러그/설명/색상만 편집) |
|
||||
| components/admin/AdminMemberForm.vue | 관리자 멤버 추가/수정 폼(Ghost형 3분할, 요약 1fr·입력 2fr, 원형 썸네일 hover 등록·변경·삭제, 기본 정보·레이블·관리자 노트·활동 요약, 설정 메뉴의 비밀번호 변경·멤버 삭제 모달, 미저장 변경사항 이탈 확인) |
|
||||
| components/admin/AdminMemberForm.vue | 관리자 멤버 추가/수정 폼(Ghost형 3분할, 기존 회원 보기/수정 모드 분리, 요약 1fr·입력 2fr, 원형 썸네일 hover 등록·변경·삭제, 기본 정보·레이블·관리자 노트·활동 요약, 설정 메뉴의 비밀번호 변경·멤버 삭제 모달, 저장 토스트, 미저장 변경사항 이탈 확인) |
|
||||
| components/admin/AdminUnsavedChangesModal.vue | 관리자 편집 화면 공통 미저장 변경사항 이탈 확인 모달(상단 40px 위치) |
|
||||
|
||||
## 관리자 컴포저블
|
||||
@@ -141,9 +141,9 @@
|
||||
| pages/admin/tags/[id].vue | 태그 수정 |
|
||||
| pages/admin/settings/index.vue | 사이트 설정 Ghost형 전체 화면, **POST 설정**(`showPostUpdatedAt` 토글·읽기 모드 비활성 토글), 블로그 제목·설명·기타(로고·URL·저작권), **메인 화면**(라이트·다크 커버 이미지·오버레이 텍스트), **어나운스 바**(사용 토글·맞춤 설정·배경색), **스팸 필터**(가입 금지 닉네임), 타임존·Import/Export 플레이스홀더 |
|
||||
| lib/signup-blocked-usernames.js | 가입 금지 닉네임 정리·매칭·안내 문구 |
|
||||
| pages/admin/members/index.vue | 관리자 멤버 목록(Ghost형 테이블, 검색, 조건 필터, 멤버 추가 버튼, 닉네임+이메일, 등급+비활성 상태, 가입일+최근 활동, IP, 댓글 수) |
|
||||
| pages/admin/members/index.vue | 관리자 멤버 목록(Ghost형 테이블, 글 목록과 같은 테두리형 검색, 조건 필터, 멤버 추가 버튼, 닉네임+이메일, 등급+비활성 상태, 가입일+최근 활동, IP, 댓글 수) |
|
||||
| pages/admin/members/new.vue | 관리자 멤버 추가(썸네일 URL, 이름, 이메일, 레이블, 관리자 노트) |
|
||||
| pages/admin/members/[id].vue | 관리자 멤버 상세/수정(회원 요약, 저장 버튼 기반 멤버 등급 변경, 권한 변경 불가 상황 셀렉트 잠금/텍스트 표시, 가입 정보, 활동 요약, 기본 정보 저장, 삭제 후 목록 이동) |
|
||||
| pages/admin/members/[id].vue | 관리자 멤버 상세/수정(회원 요약, 읽기 전용 진입 후 수정하기 전환, 변경 시 저장 버튼 활성, 저장 버튼 기반 멤버 등급 변경, 권한 변경 불가 상황 셀렉트 잠금/텍스트 표시, 가입 정보, 활동 요약, 기본 정보 저장, 삭제 후 목록 이동) |
|
||||
|
||||
## 공개 페이지
|
||||
|
||||
|
||||
@@ -692,9 +692,9 @@ components/content/
|
||||
- 관리자 설정 화면은 사이트 자체 설정만 관리한다. 관리자 프로필과 비밀번호는 멤버 편집 화면의 계정 작업에서 처리한다.
|
||||
- 관리자 사이드바 하단 사용자 메뉴의 `내 프로필`은 `/admin/members/:id` 멤버 편집 화면으로 이동한다.
|
||||
- 관리자 멤버 목록은 Ghost형 테이블 기준으로 닉네임 아래 이메일, 가입일 아래 최근 활동을 함께 표시한다. 상태 열은 멤버 등급을 먼저 표시하고, 비활성 회원만 작은 보조 상태로 표시한다.
|
||||
- 관리자 멤버 목록은 멤버 검색, 멤버 추가 버튼, 조건 필터를 제공한다. 필터는 이름·이메일·레이블 텍스트 조건, 활성/비활성 상태 조건, 최근 접속·가입일 날짜 조건을 지원하며 여러 조건은 AND로 적용한다.
|
||||
- 관리자 멤버 목록은 글 목록과 같은 테두리형 검색 입력, 멤버 추가 버튼, 조건 필터를 제공한다. 필터는 이름·이메일·레이블 텍스트 조건, 활성/비활성 상태 조건, 최근 접속·가입일 날짜 조건을 지원하며 여러 조건은 AND로 적용한다.
|
||||
- 관리자 멤버 목록은 뉴스레터 지표 대신 댓글 작성 개수를 표시한다.
|
||||
- 관리자 멤버 상세/추가 화면은 Ghost형 편집 화면을 기준으로 썸네일, 이름, 이메일, 레이블, 관리자 노트를 편집한다. 본문은 3분할 그리드로 요약 영역 1fr, 입력 영역 2fr 비율을 사용한다. 썸네일은 URL 입력 대신 요약 영역의 원형 썸네일 hover 액션으로 등록·변경·제거한다. 기존 회원 상세는 멤버 등급 선택, 가입 정보와 활동 요약을 표시하고, 신규 회원 화면은 활동 영역을 표시하지 않는다. 멤버 등급은 셀렉트 변경 즉시 저장하지 않고 저장 버튼을 눌렀을 때 기본 정보와 함께 반영한다. 소유자 본인이나 관리자가 다른 관리자·소유자를 보는 등 서버에서 변경 불가한 경우 등급 셀렉트는 비활성화하고, 등급 변경 권한이 없는 세션에서는 등급을 일반 텍스트로 표시한다. 기존 회원 상세의 설정 메뉴는 관리자 직접 비밀번호 변경(`PUT /admin/api/members/:id/password`)과 멤버 삭제(`DELETE /admin/api/members/:id`)를 제공한다.
|
||||
- 관리자 멤버 상세/추가 화면은 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명의 소유자가 항상 남아야 한다.
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
# 업데이트 이력
|
||||
|
||||
## v1.5.11
|
||||
|
||||
- 관리자 멤버 상세: 기존 회원 진입 시 읽기 전용 보기 모드로 표시하고 상단 버튼을 `수정하기`로 변경.
|
||||
- 관리자 멤버 상세: 수정 모드에서만 입력 필드·셀렉트를 편집 컨트롤로 표시하고, 변경 사항이 있을 때만 저장 버튼을 활성화하도록 수정.
|
||||
- 관리자 멤버 상세: 저장·업로드·비밀번호 변경 오류/성공 피드백을 우측 상단 토스트로 통일.
|
||||
- 관리자 멤버 목록: 검색창 스타일을 글 목록 검색창과 같은 테두리형 입력으로 통일.
|
||||
|
||||
## v1.5.10
|
||||
|
||||
- 관리자 멤버 상세: 소유자 본인, 관리자끼리 등 변경 불가한 등급 셀렉트는 UI에서 비활성화하도록 수정.
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "sori.studio",
|
||||
"version": "1.5.10",
|
||||
"version": "1.5.11",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "sori.studio",
|
||||
"version": "1.5.10",
|
||||
"version": "1.5.11",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "sori.studio",
|
||||
"version": "1.5.10",
|
||||
"version": "1.5.11",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"imports": {
|
||||
|
||||
@@ -376,16 +376,17 @@ const formatRelativeTime = (value) => {
|
||||
</h1>
|
||||
</div>
|
||||
<div class="admin-members__actions flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<label class="admin-members__search relative block w-full sm:w-[290px]">
|
||||
<label class="admin-members__search relative block w-full sm:w-52">
|
||||
<span class="sr-only">멤버 검색</span>
|
||||
<svg class="admin-members__search-icon pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[#9aa4b2]" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M23.245 23.996a.743.743 0 01-.53-.22L16.2 17.26a9.824 9.824 0 01-2.553 1.579 9.766 9.766 0 01-7.51.069 9.745 9.745 0 01-5.359-5.262c-1.025-2.412-1.05-5.08-.069-7.51S3.558 1.802 5.97.777a9.744 9.744 0 017.51-.069 9.745 9.745 0 015.359 5.262 9.748 9.748 0 01.069 7.51 9.807 9.807 0 01-1.649 2.718l6.517 6.518a.75.75 0 01-.531 1.28zM9.807 1.49a8.259 8.259 0 00-3.25.667 8.26 8.26 0 00-4.458 4.54 8.26 8.26 0 00.058 6.362 8.26 8.26 0 004.54 4.458 8.259 8.259 0 006.362-.059 8.285 8.285 0 002.594-1.736.365.365 0 01.077-.076 8.245 8.245 0 001.786-2.728 8.255 8.255 0 00-.059-6.362 8.257 8.257 0 00-4.54-4.458 8.28 8.28 0 00-3.11-.608z" fill="currentColor" />
|
||||
<svg class="admin-members__search-icon pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-[#8e9cac]" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="m21 21-4.34-4.34" />
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
</svg>
|
||||
<input
|
||||
v-model="memberSearchQuery"
|
||||
class="admin-members__search-input h-11 w-full rounded-md border border-transparent bg-[#eef1f4] pl-10 pr-3 text-sm text-[#15171a] outline-none transition focus:border-[#c5ccd5] focus:bg-white focus:ring-2 focus:ring-[#dce2e8]"
|
||||
class="admin-members__search-input h-10 w-full rounded border border-line bg-white pl-9 pr-3 text-sm text-[#394047] outline-none transition-colors placeholder:text-[#8e9cac] hover:border-[#c8ced3] focus:border-[#8e9cac]"
|
||||
type="search"
|
||||
placeholder="멤버 검색..."
|
||||
placeholder="멤버 검색"
|
||||
aria-label="멤버 검색"
|
||||
>
|
||||
</label>
|
||||
|
||||
Reference in New Issue
Block a user