658 lines
29 KiB
Vue
658 lines
29 KiB
Vue
<script setup>
|
|
const props = defineProps({
|
|
member: {
|
|
type: Object,
|
|
default: () => null
|
|
},
|
|
mode: {
|
|
type: String,
|
|
default: 'edit'
|
|
}
|
|
})
|
|
|
|
const emit = defineEmits(['saved', 'deleted'])
|
|
|
|
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 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 form = reactive({
|
|
username: '',
|
|
email: '',
|
|
avatarUrl: '',
|
|
labelsText: '',
|
|
note: ''
|
|
})
|
|
|
|
const passwordForm = reactive({
|
|
password: '',
|
|
passwordConfirm: ''
|
|
})
|
|
|
|
const deleteForm = reactive({
|
|
confirmText: ''
|
|
})
|
|
|
|
/**
|
|
* 회원 폼 값을 현재 회원 정보로 동기화한다.
|
|
* @returns {void}
|
|
*/
|
|
const syncMemberForm = () => {
|
|
const member = props.member || {}
|
|
form.username = member.username || ''
|
|
form.email = member.email || ''
|
|
form.avatarUrl = member.avatarUrl || ''
|
|
form.labelsText = Array.isArray(member.labels) ? member.labels.join(', ') : ''
|
|
form.note = member.note || ''
|
|
}
|
|
|
|
watch(() => props.member, syncMemberForm, { immediate: true })
|
|
|
|
const pageTitle = computed(() => {
|
|
if (isNewMember.value) {
|
|
return '새 멤버'
|
|
}
|
|
|
|
return form.username || props.member?.email || '멤버'
|
|
})
|
|
|
|
const memberInitial = computed(() => String(form.username || form.email || '?').slice(0, 1).toUpperCase())
|
|
const noteLength = computed(() => form.note.length)
|
|
|
|
const normalizedLabels = computed(() => [...new Set(
|
|
form.labelsText
|
|
.split(',')
|
|
.map((label) => label.trim())
|
|
.filter(Boolean)
|
|
)])
|
|
|
|
/**
|
|
* 회원 저장 요청 본문을 문자열로 직렬화한다.
|
|
* @returns {string} 직렬화된 회원 입력값
|
|
*/
|
|
const serializeMemberPayload = () => JSON.stringify(getMemberPayload())
|
|
|
|
/**
|
|
* 날짜 표시 형식 변환
|
|
* @param {string | null} value - ISO 날짜 문자열
|
|
* @returns {string} 화면 표시 날짜
|
|
*/
|
|
const formatDate = (value) => {
|
|
if (!value) {
|
|
return '-'
|
|
}
|
|
|
|
const date = new Date(value)
|
|
if (Number.isNaN(date.getTime())) {
|
|
return '-'
|
|
}
|
|
|
|
const year = date.getFullYear()
|
|
const month = String(date.getMonth() + 1).padStart(2, '0')
|
|
const day = String(date.getDate()).padStart(2, '0')
|
|
|
|
return `${year}.${month}.${day}`
|
|
}
|
|
|
|
/**
|
|
* 최근 활동 시각을 상대 시간으로 표시한다.
|
|
* @param {string | null} value - ISO 시각
|
|
* @returns {string} 상대 시간
|
|
*/
|
|
const formatRelativeTime = (value) => {
|
|
if (!value) {
|
|
return '최근 활동 없음'
|
|
}
|
|
|
|
const date = new Date(value)
|
|
if (Number.isNaN(date.getTime())) {
|
|
return '최근 활동 없음'
|
|
}
|
|
|
|
const diffMs = Date.now() - date.getTime()
|
|
const minute = 1000 * 60
|
|
const hour = minute * 60
|
|
const day = hour * 24
|
|
|
|
if (diffMs < minute) {
|
|
return '방금 전'
|
|
}
|
|
|
|
if (diffMs < hour) {
|
|
return `${Math.floor(diffMs / minute)}분 전`
|
|
}
|
|
|
|
if (diffMs < day) {
|
|
return `${Math.floor(diffMs / hour)}시간 전`
|
|
}
|
|
|
|
if (diffMs < day * 30) {
|
|
return `${Math.floor(diffMs / day)}일 전`
|
|
}
|
|
|
|
return formatDate(value)
|
|
}
|
|
|
|
/**
|
|
* 회원 저장 요청 본문을 만든다.
|
|
* @returns {{ username: string, email: string, avatarUrl: string, labels: string[], note: string }} 저장 본문
|
|
*/
|
|
const getMemberPayload = () => ({
|
|
username: form.username.trim(),
|
|
email: form.email.trim(),
|
|
avatarUrl: form.avatarUrl.trim(),
|
|
labels: normalizedLabels.value,
|
|
note: form.note
|
|
})
|
|
|
|
const hasUnsavedMemberChanges = computed(() => serializeMemberPayload() !== savedMemberSnapshot.value)
|
|
|
|
const {
|
|
isUnsavedModalOpen,
|
|
stayOnUnsavedPage,
|
|
leaveUnsavedPage
|
|
} = useAdminUnsavedChangesGuard(hasUnsavedMemberChanges)
|
|
|
|
/**
|
|
* 썸네일 파일 선택창을 연다.
|
|
* @returns {void}
|
|
*/
|
|
const openAvatarFilePicker = () => {
|
|
avatarInputRef.value?.click()
|
|
}
|
|
|
|
/**
|
|
* 회원 썸네일 파일을 업로드하고 폼에 반영한다.
|
|
* @param {Event} event - 파일 선택 이벤트
|
|
* @returns {Promise<void>}
|
|
*/
|
|
const uploadAvatar = async (event) => {
|
|
const target = event.target instanceof HTMLInputElement ? event.target : null
|
|
const file = target?.files?.[0]
|
|
|
|
if (!file || isUploadingAvatar.value) {
|
|
return
|
|
}
|
|
|
|
isUploadingAvatar.value = true
|
|
saveError.value = ''
|
|
saveMessage.value = ''
|
|
|
|
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
|
|
})
|
|
form.avatarUrl = result.avatarUrl || ''
|
|
|
|
if (!isNewMember.value) {
|
|
emit('saved', result)
|
|
savedMemberSnapshot.value = serializeMemberPayload()
|
|
saveMessage.value = '썸네일이 변경되었습니다.'
|
|
}
|
|
} catch (error) {
|
|
saveError.value = error?.data?.message || '썸네일 업로드에 실패했습니다.'
|
|
} finally {
|
|
isUploadingAvatar.value = false
|
|
if (target) {
|
|
target.value = ''
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 회원 썸네일 연결을 제거한다.
|
|
* @returns {void}
|
|
*/
|
|
const removeAvatar = () => {
|
|
form.avatarUrl = ''
|
|
}
|
|
|
|
/**
|
|
* 회원 작업 메뉴를 토글한다.
|
|
* @returns {void}
|
|
*/
|
|
const toggleActionMenu = () => {
|
|
actionMenuOpen.value = !actionMenuOpen.value
|
|
}
|
|
|
|
/**
|
|
* 회원 작업 메뉴를 닫는다.
|
|
* @returns {void}
|
|
*/
|
|
const closeActionMenu = () => {
|
|
actionMenuOpen.value = false
|
|
}
|
|
|
|
/**
|
|
* 비밀번호 변경 모달을 연다.
|
|
* @returns {void}
|
|
*/
|
|
const openPasswordModal = () => {
|
|
passwordForm.password = ''
|
|
passwordForm.passwordConfirm = ''
|
|
actionMessage.value = ''
|
|
actionError.value = ''
|
|
passwordModalOpen.value = true
|
|
closeActionMenu()
|
|
}
|
|
|
|
/**
|
|
* 회원 삭제 모달을 연다.
|
|
* @returns {void}
|
|
*/
|
|
const openDeleteModal = () => {
|
|
deleteForm.confirmText = ''
|
|
actionMessage.value = ''
|
|
actionError.value = ''
|
|
deleteModalOpen.value = true
|
|
closeActionMenu()
|
|
}
|
|
|
|
/**
|
|
* 비밀번호 변경 모달을 닫는다.
|
|
* @returns {void}
|
|
*/
|
|
const closePasswordModal = () => {
|
|
if (isUpdatingPassword.value) {
|
|
return
|
|
}
|
|
|
|
passwordModalOpen.value = false
|
|
}
|
|
|
|
/**
|
|
* 회원 삭제 모달을 닫는다.
|
|
* @returns {void}
|
|
*/
|
|
const closeDeleteModal = () => {
|
|
if (isDeletingMember.value) {
|
|
return
|
|
}
|
|
|
|
deleteModalOpen.value = false
|
|
}
|
|
|
|
/**
|
|
* 관리자 권한으로 회원 비밀번호를 변경한다.
|
|
* @returns {Promise<void>}
|
|
*/
|
|
const updateMemberPassword = async () => {
|
|
actionMessage.value = ''
|
|
actionError.value = ''
|
|
|
|
if (!passwordForm.password || passwordForm.password.length < 8 || passwordForm.password.length > 32) {
|
|
actionError.value = '새 비밀번호는 8~32자로 입력해 주세요.'
|
|
return
|
|
}
|
|
|
|
if (passwordForm.password !== passwordForm.passwordConfirm) {
|
|
actionError.value = '새 비밀번호와 확인 값이 일치하지 않습니다.'
|
|
return
|
|
}
|
|
|
|
isUpdatingPassword.value = true
|
|
try {
|
|
await $fetch(`/admin/api/members/${props.member.id}/password`, {
|
|
method: 'PUT',
|
|
body: {
|
|
password: passwordForm.password
|
|
}
|
|
})
|
|
passwordModalOpen.value = false
|
|
passwordForm.password = ''
|
|
passwordForm.passwordConfirm = ''
|
|
actionMessage.value = '비밀번호가 변경되었습니다.'
|
|
} catch (error) {
|
|
actionError.value = error?.data?.message || '비밀번호 변경에 실패했습니다.'
|
|
} finally {
|
|
isUpdatingPassword.value = false
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 관리자 권한으로 회원을 삭제한다.
|
|
* @returns {Promise<void>}
|
|
*/
|
|
const deleteMember = async () => {
|
|
actionMessage.value = ''
|
|
actionError.value = ''
|
|
|
|
if (deleteForm.confirmText !== form.email) {
|
|
actionError.value = '삭제하려면 회원 이메일을 정확히 입력해 주세요.'
|
|
return
|
|
}
|
|
|
|
isDeletingMember.value = true
|
|
try {
|
|
await $fetch(`/admin/api/members/${props.member.id}`, {
|
|
method: 'DELETE'
|
|
})
|
|
emit('deleted')
|
|
} catch (error) {
|
|
actionError.value = error?.data?.message || '회원 삭제에 실패했습니다.'
|
|
} finally {
|
|
isDeletingMember.value = false
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 회원 기본 정보를 저장한다.
|
|
* @returns {Promise<void>}
|
|
*/
|
|
const saveMember = async () => {
|
|
if (isSaving.value) {
|
|
return
|
|
}
|
|
|
|
saveMessage.value = ''
|
|
saveError.value = ''
|
|
isSaving.value = true
|
|
|
|
try {
|
|
const payload = getMemberPayload()
|
|
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)
|
|
saveMessage.value = '저장되었습니다.'
|
|
} catch (error) {
|
|
saveError.value = error?.data?.message || '저장에 실패했습니다.'
|
|
} finally {
|
|
isSaving.value = false
|
|
}
|
|
}
|
|
|
|
watch(() => props.member, () => {
|
|
savedMemberSnapshot.value = serializeMemberPayload()
|
|
}, { immediate: true, flush: 'post' })
|
|
</script>
|
|
|
|
<template>
|
|
<section class="admin-member-form bg-paper p-6">
|
|
<div class="admin-member-form__header sticky top-0 z-10 -mx-6 -mt-6 border-b border-line bg-paper/95 px-6 py-5 backdrop-blur">
|
|
<div class="admin-member-form__header-inner flex flex-col gap-4 xl:flex-row xl:items-center xl:justify-between">
|
|
<div class="admin-member-form__title-block">
|
|
<div class="admin-member-form__breadcrumb flex items-center gap-2 text-sm text-[#8a95a5]">
|
|
<NuxtLink class="admin-member-form__breadcrumb-link text-[#3f4650] hover:text-[#15171a]" to="/admin/members">
|
|
멤버
|
|
</NuxtLink>
|
|
<svg class="h-3 w-3" viewBox="0 0 18 27" aria-hidden="true">
|
|
<path d="M2.397 25.426l13.143-11.5-13.143-11.5" stroke-width="3" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round" />
|
|
</svg>
|
|
<span>{{ isNewMember ? '새 멤버' : '멤버 편집' }}</span>
|
|
</div>
|
|
<h1 class="admin-member-form__title mt-4 text-3xl font-semibold tracking-[-0.01em] text-[#15171a]">
|
|
{{ pageTitle }}
|
|
</h1>
|
|
</div>
|
|
<div class="admin-member-form__actions flex items-center gap-3">
|
|
<div v-if="!isNewMember" 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" />
|
|
<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>
|
|
<div v-if="actionMenuOpen" class="admin-member-form__action-popover absolute right-0 top-12 z-20 grid w-52 overflow-hidden rounded-xl border border-line bg-white py-2 text-sm text-[#3f4650] shadow-[0_16px_44px_rgba(15,23,42,0.16)]">
|
|
<button class="admin-member-form__action-item px-4 py-2.5 text-left hover:bg-[#f3f5f7]" type="button" @click="openPasswordModal">
|
|
비밀번호 변경
|
|
</button>
|
|
<button class="admin-member-form__action-item px-4 py-2.5 text-left text-[#d21a26] hover:bg-[#fff1f2]" type="button" @click="openDeleteModal">
|
|
멤버 삭제
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<button v-else class="admin-member-form__action-icon flex h-11 w-11 items-center justify-center rounded-md border border-line bg-white text-[#3f4650]" type="button" aria-label="멤버 작업" disabled>
|
|
<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" />
|
|
<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>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="admin-member-form__body grid gap-8 py-8 xl:grid-cols-3">
|
|
<aside class="admin-member-form__summary">
|
|
<div class="admin-member-form__identity flex items-center gap-4">
|
|
<div class="admin-member-form__avatar-control group relative h-20 w-20 shrink-0">
|
|
<button
|
|
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 ? '썸네일 변경' : '썸네일 등록'"
|
|
@click="openAvatarFilePicker"
|
|
>
|
|
<img
|
|
v-if="form.avatarUrl"
|
|
class="admin-member-form__avatar h-full w-full object-cover"
|
|
:src="form.avatarUrl"
|
|
:alt="pageTitle"
|
|
>
|
|
<span v-else class="admin-member-form__avatar flex h-full w-full items-center justify-center text-2xl font-semibold">
|
|
{{ 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 ? '썸네일 변경' : '썸네일 등록' }}
|
|
</span>
|
|
</button>
|
|
<button
|
|
v-if="form.avatarUrl"
|
|
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="썸네일 제거"
|
|
@click.stop="removeAvatar"
|
|
>
|
|
<svg class="h-3 w-3" 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>
|
|
<input ref="avatarInputRef" class="sr-only" type="file" accept="image/*" @change="uploadAvatar">
|
|
</div>
|
|
<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>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-if="!isNewMember" class="admin-member-form__meta mt-10 space-y-3 text-sm text-[#4d5663]">
|
|
<p class="flex items-center gap-2">
|
|
<svg class="h-4 w-4 text-[#8a95a5]" fill="none" viewBox="0 0 24 26" aria-hidden="true">
|
|
<path d="M12 14.75a4 4 0 100-8 4 4 0 000 8z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
|
<path d="M21 10.75c0 7.9-6.932 12.331-8.629 13.3a.751.751 0 01-.743 0C9.931 23.08 3 18.648 3 10.75a9 9 0 1118 0z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
|
</svg>
|
|
{{ member?.lastSeenIp || '접속 IP 없음' }}
|
|
</p>
|
|
<p class="flex items-center gap-2">
|
|
<svg class="h-4 w-4 text-[#8a95a5]" fill="none" viewBox="0 0 26 24" aria-hidden="true">
|
|
<path d="M13 5.001c-4.03-.078-8.2 3.157-10.82 6.47-.276.35-.428.805-.428 1.277 0 .472.152.928.427 1.278C4.743 17.27 8.9 20.578 13 20.5c4.1.079 8.258-3.23 10.824-6.473.275-.35.428-.806.428-1.278s-.153-.927-.428-1.278C21.2 8.158 17.031 4.923 13 5.001z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
|
<path d="M16.75 12.751a3.75 3.75 0 11-7.5-.002 3.75 3.75 0 017.5.002z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
|
</svg>
|
|
{{ formatRelativeTime(member?.lastSeenAt) }}
|
|
</p>
|
|
</div>
|
|
|
|
<div v-if="!isNewMember" class="admin-member-form__side-section mt-12 border-t border-line pt-6">
|
|
<h3 class="text-xs font-semibold uppercase tracking-[0.04em] text-[#15171a]">가입 정보</h3>
|
|
<p class="mt-5 flex items-center gap-2 text-sm text-[#4d5663]">
|
|
<svg class="h-4 w-4 text-[#8a95a5]" fill="none" viewBox="0 0 24 24" aria-hidden="true">
|
|
<path d="M11.5 12c-2.824 0-2.83.024-4.5.53-3.5 1.058-5 3.176-5 6.386V21h10m7-5v6m-3-3h6m-10.5-7a5.5 5.5 0 100-11 5.5 5.5 0 000 11z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
|
</svg>
|
|
생성됨 — <strong>{{ formatDate(member?.createdAt) }}</strong>
|
|
</p>
|
|
</div>
|
|
|
|
<div v-if="!isNewMember" class="admin-member-form__side-section mt-12 border-t border-line pt-6">
|
|
<h3 class="text-xs font-semibold uppercase tracking-[0.04em] text-[#15171a]">참여도</h3>
|
|
<p class="mt-5 text-sm leading-6 text-[#8a95a5]">
|
|
댓글 작성 {{ member?.commentCount || 0 }}개
|
|
</p>
|
|
</div>
|
|
</aside>
|
|
|
|
<div class="admin-member-form__content space-y-8 xl:col-span-2">
|
|
<form class="admin-member-form__card rounded-xl border border-line bg-white p-5 md:p-6" @submit.prevent="saveMember">
|
|
<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>
|
|
</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>
|
|
</label>
|
|
</div>
|
|
|
|
<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="쉼표로 구분">
|
|
</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" />
|
|
<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">
|
|
<h2 class="admin-member-form__section-title mb-4 text-xs font-semibold uppercase tracking-[0.04em] text-[#15171a]">활동</h2>
|
|
<div class="admin-member-form__activity-card rounded-xl border border-line bg-white px-5 md:px-6">
|
|
<div class="admin-member-form__activity-row flex items-center justify-between gap-4 border-b border-line py-5 text-sm last:border-b-0">
|
|
<span class="flex items-center gap-3 text-[#3f4650]">
|
|
<svg class="h-5 w-5 text-[#6c747d]" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
|
<path d="M4 12h10.31m-3.076-3.076L14.31 12l-3.076 3.077" stroke="currentColor" stroke-width="1.714" stroke-linecap="round" stroke-linejoin="round" />
|
|
<path d="M4.998 16.308a7.69 7.69 0 003.733 3.182 7.238 7.238 0 004.8.189 7.608 7.608 0 003.949-2.88A8.283 8.283 0 0018.998 12c0-1.73-.533-3.414-1.518-4.798a7.607 7.607 0 00-3.949-2.88 7.237 7.237 0 00-4.8.188 7.69 7.69 0 00-3.733 3.182" stroke="currentColor" stroke-width="1.714" stroke-linecap="round" stroke-linejoin="round" />
|
|
</svg>
|
|
로그인
|
|
</span>
|
|
<span class="text-[#9aa4b2]">{{ formatRelativeTime(member?.lastSeenAt) }}</span>
|
|
</div>
|
|
<div class="admin-member-form__activity-row flex items-center justify-between gap-4 py-5 text-sm">
|
|
<span class="flex items-center gap-3 text-[#3f4650]">
|
|
<svg class="h-5 w-5 text-[#6c747d]" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
|
<path d="M11.246 12.144a4.242 4.242 0 100-8.484 4.242 4.242 0 000 8.484zM4 18.761a8.484 8.484 0 0110.5-3.42" stroke="currentColor" stroke-width="1.714" stroke-linecap="round" stroke-linejoin="round" />
|
|
<path d="M17.54 16.077V23m-3.463-3.46H21" stroke="#30CF43" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
|
|
</svg>
|
|
가입
|
|
</span>
|
|
<span class="text-[#9aa4b2]">{{ formatRelativeTime(member?.createdAt) }}</span>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
</div>
|
|
|
|
<AdminUnsavedChangesModal
|
|
:open="isUnsavedModalOpen"
|
|
@stay="stayOnUnsavedPage"
|
|
@leave="leaveUnsavedPage"
|
|
/>
|
|
|
|
<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)]">
|
|
<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="closePasswordModal">
|
|
<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]">
|
|
이메일 전송이 불가능한 상황을 대비해 관리자가 직접 새 비밀번호를 설정합니다.
|
|
</p>
|
|
<label class="grid gap-2 text-sm font-semibold">
|
|
새 비밀번호
|
|
<input v-model="passwordForm.password" class="h-11 rounded-md border border-transparent bg-[#eef1f4] px-4 text-sm outline-none focus:border-[#c5ccd5] focus:bg-white focus:ring-2 focus:ring-[#dce2e8]" type="password" autocomplete="new-password">
|
|
</label>
|
|
<label class="grid gap-2 text-sm font-semibold">
|
|
새 비밀번호 확인
|
|
<input v-model="passwordForm.passwordConfirm" class="h-11 rounded-md border border-transparent bg-[#eef1f4] px-4 text-sm outline-none focus:border-[#c5ccd5] focus:bg-white focus:ring-2 focus:ring-[#dce2e8]" type="password" autocomplete="new-password">
|
|
</label>
|
|
<p v-if="actionError" class="rounded-md border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{{ actionError }}</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="closePasswordModal">
|
|
취소
|
|
</button>
|
|
<button class="h-10 rounded-md bg-[#15171a] px-4 text-sm font-semibold text-white disabled:opacity-50" type="button" :disabled="isUpdatingPassword" @click="updateMemberPassword">
|
|
{{ isUpdatingPassword ? '변경 중' : '변경' }}
|
|
</button>
|
|
</footer>
|
|
</section>
|
|
</div>
|
|
</Teleport>
|
|
|
|
<Teleport to="body">
|
|
<div v-if="deleteModalOpen" 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="closeDeleteModal">
|
|
<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.email }}</strong> 을 입력해 주세요.
|
|
</p>
|
|
<input v-model="deleteForm.confirmText" class="h-11 rounded-md border border-transparent bg-[#eef1f4] px-4 text-sm outline-none focus:border-[#c5ccd5] focus:bg-white focus:ring-2 focus:ring-[#dce2e8]" type="text" autocomplete="off">
|
|
<p v-if="actionError" class="rounded-md border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{{ actionError }}</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="closeDeleteModal">
|
|
취소
|
|
</button>
|
|
<button class="h-10 rounded-md bg-[#d21a26] px-4 text-sm font-semibold text-white disabled:opacity-50" type="button" :disabled="isDeletingMember" @click="deleteMember">
|
|
{{ isDeletingMember ? '삭제 중' : '삭제' }}
|
|
</button>
|
|
</footer>
|
|
</section>
|
|
</div>
|
|
</Teleport>
|
|
</section>
|
|
</template>
|