Files
sori.studio/components/admin/AdminMemberForm.vue

346 lines
16 KiB
Vue

<script setup>
const props = defineProps({
member: {
type: Object,
default: () => null
},
mode: {
type: String,
default: 'edit'
}
})
const emit = defineEmits(['saved'])
const isNewMember = computed(() => props.mode === 'new')
const saveMessage = ref('')
const saveError = ref('')
const isSaving = ref(false)
const savedMemberSnapshot = ref('')
const form = reactive({
username: '',
email: '',
avatarUrl: '',
labelsText: '',
note: ''
})
/**
* 회원 폼 값을 현재 회원 정보로 동기화한다.
* @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 {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">
<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]" type="button" aria-label="멤버 작업">
<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">
<img
v-if="form.avatarUrl"
class="admin-member-form__avatar h-20 w-20 rounded-full object-cover"
:src="form.avatarUrl"
:alt="pageTitle"
>
<span v-else class="admin-member-form__avatar flex h-20 w-20 items-center justify-center rounded-full bg-[#15171a] text-2xl font-semibold text-white">
{{ memberInitial }}
</span>
<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]">썸네일 URL</span>
<input v-model="form.avatarUrl" 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="500" placeholder="/uploads/members/avatars/...">
</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>
<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>
</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"
/>
</section>
</template>