Compare commits
18 Commits
4e1056311d
...
v1.0.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 3b331b8fe6 | |||
| 069d1bfbd4 | |||
| 965a8fd1f6 | |||
| 020471a1b8 | |||
| 52f22b4ff1 | |||
| bebf7ee1c9 | |||
| 6481f958f5 | |||
| 79d0a30475 | |||
| fb0dadb7b9 | |||
| b4f3fdb77d | |||
| 6cb6268b43 | |||
| b490d5b90f | |||
| ec9f9ea57f | |||
| 6e25cdfd60 | |||
| 5031b9de22 | |||
| 003fb86fad | |||
| 6a059a9a59 | |||
| 996965740f |
@@ -24,5 +24,10 @@ AVATAR_WEBP_QUALITY=82
|
||||
NUXT_PUBLIC_SITE_URL=https://sori.studio
|
||||
NUXT_PUBLIC_SITE_TITLE=sori.studio
|
||||
|
||||
# Transactional email (Resend, optional — 회원가입 OTP·비밀번호 찾기)
|
||||
# RESEND_API_KEY=
|
||||
# RESEND_FROM_EMAIL=noreply@yourdomain.com
|
||||
# EMAIL_OTP_PEPPER= ← 선택. OTP를 DB에 해시해 저장할 때 섞는 서버 전용 비밀(긴 난문자열 권장, 예: openssl rand -hex 32). 비우면 MEMBER_SESSION_SECRET을 대신 사용.
|
||||
|
||||
# Server
|
||||
APP_PORT=43118
|
||||
|
||||
24
app.vue
24
app.vue
@@ -1,3 +1,27 @@
|
||||
<script setup>
|
||||
const { data: appSiteSettings } = await useFetch('/api/site-settings', {
|
||||
default: () => ({
|
||||
title: 'sori.studio',
|
||||
faviconUrl: ''
|
||||
})
|
||||
})
|
||||
|
||||
useHead(() => ({
|
||||
titleTemplate: (titleChunk) => titleChunk
|
||||
? `${titleChunk} · ${appSiteSettings.value.title}`
|
||||
: appSiteSettings.value.title,
|
||||
link: appSiteSettings.value.faviconUrl
|
||||
? [
|
||||
{
|
||||
rel: 'icon',
|
||||
type: 'image/png',
|
||||
href: appSiteSettings.value.faviconUrl
|
||||
}
|
||||
]
|
||||
: []
|
||||
}))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NuxtLayout>
|
||||
<NuxtPage />
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
646
components/admin/AdminMemberForm.vue
Normal file
646
components/admin/AdminMemberForm.vue
Normal file
@@ -0,0 +1,646 @@
|
||||
<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('files', file)
|
||||
const result = await $fetch('/admin/api/uploads', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
form.avatarUrl = result.files?.[0]?.url || ''
|
||||
} 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>
|
||||
@@ -47,8 +47,15 @@ const isRestoringAutosave = ref(false)
|
||||
const isSettingsOpen = ref(true)
|
||||
const tagInput = ref('')
|
||||
const isTagInputComposing = ref(false)
|
||||
const isTitleInputComposing = ref(false)
|
||||
const activeMediaPickerTab = ref('upload')
|
||||
const selectedMediaPickerUrl = ref('')
|
||||
const savedPostSnapshot = ref('')
|
||||
const isPublishModalOpen = ref(false)
|
||||
const publishStatus = ref('draft')
|
||||
const publishTiming = ref('now')
|
||||
const scheduledPublishAt = ref('')
|
||||
const publishModalExpandedSection = ref(null)
|
||||
|
||||
/**
|
||||
* ISO 날짜를 datetime-local 입력값으로 변환
|
||||
@@ -234,6 +241,49 @@ const editorStatusLabel = computed(() => {
|
||||
return '초안'
|
||||
})
|
||||
|
||||
/**
|
||||
* 발행 모달에 표시할 게시 상태 요약 문구
|
||||
* @returns {string} 요약 문구
|
||||
*/
|
||||
const publishStatusSummaryLabel = computed(() => {
|
||||
if (publishStatus.value === 'published') {
|
||||
return '발행'
|
||||
}
|
||||
|
||||
if (publishStatus.value === 'private') {
|
||||
return '비공개'
|
||||
}
|
||||
|
||||
return '초안'
|
||||
})
|
||||
|
||||
/**
|
||||
* 발행 모달에 표시할 발행 시점 요약 문구
|
||||
* @returns {string} 요약 문구
|
||||
*/
|
||||
const publishTimingSummaryLabel = computed(() => {
|
||||
if (publishTiming.value === 'now') {
|
||||
return '지금 바로'
|
||||
}
|
||||
|
||||
const raw = scheduledPublishAt.value
|
||||
|
||||
if (!raw) {
|
||||
return '예약'
|
||||
}
|
||||
|
||||
const date = new Date(raw)
|
||||
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return '예약'
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat('ko-KR', {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short'
|
||||
}).format(date)
|
||||
})
|
||||
|
||||
/**
|
||||
* 게시물 입력값 생성
|
||||
* @returns {Object} 게시물 입력값
|
||||
@@ -260,6 +310,14 @@ const createPostPayload = () => {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 게시물 입력값을 문자열로 직렬화한다.
|
||||
* @returns {string} 직렬화된 게시물 입력값
|
||||
*/
|
||||
const serializePostPayload = () => JSON.stringify(createPostPayload())
|
||||
|
||||
const hasUnsavedPostChanges = computed(() => serializePostPayload() !== savedPostSnapshot.value)
|
||||
|
||||
/**
|
||||
* 자동 저장 데이터 생성
|
||||
* @returns {Object} 자동 저장 데이터
|
||||
@@ -374,6 +432,15 @@ const discardAutosave = () => {
|
||||
autosaveStatus.value = ''
|
||||
}
|
||||
|
||||
const {
|
||||
isUnsavedModalOpen,
|
||||
stayOnUnsavedPage,
|
||||
leaveUnsavedPage,
|
||||
allowNextRouteLeave
|
||||
} = useAdminUnsavedChangesGuard(hasUnsavedPostChanges, {
|
||||
onLeaveConfirmed: discardAutosave
|
||||
})
|
||||
|
||||
/**
|
||||
* 미디어 라이브러리 목록 조회
|
||||
* @returns {Promise<void>}
|
||||
@@ -544,9 +611,15 @@ const dropFeaturedImage = async (event) => {
|
||||
|
||||
/**
|
||||
* 제목 입력 후 본문 에디터로 이동
|
||||
* @param {KeyboardEvent} event - 키보드 이벤트
|
||||
* @returns {void}
|
||||
*/
|
||||
const focusContentEditor = () => {
|
||||
const focusContentEditor = (event) => {
|
||||
if (event?.isComposing || isTitleInputComposing.value || event?.keyCode === 229) {
|
||||
return
|
||||
}
|
||||
|
||||
event?.preventDefault()
|
||||
blockEditor.value?.focusFirstBlock()
|
||||
}
|
||||
|
||||
@@ -555,6 +628,7 @@ const focusContentEditor = () => {
|
||||
* @returns {void}
|
||||
*/
|
||||
const submitPost = () => {
|
||||
isPublishModalOpen.value = false
|
||||
emit('submit', createPostPayload())
|
||||
}
|
||||
|
||||
@@ -582,9 +656,123 @@ const toggleSettingsPanel = () => {
|
||||
isSettingsOpen.value = !isSettingsOpen.value
|
||||
}
|
||||
|
||||
/**
|
||||
* 발행 모달을 현재 폼 상태로 초기화한다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const syncPublishModalStateFromForm = () => {
|
||||
publishStatus.value = form.status || 'draft'
|
||||
scheduledPublishAt.value = form.publishedAt || toDateTimeLocalValue(new Date(Date.now() + 3600000).toISOString())
|
||||
publishTiming.value = isScheduledPost() ? 'schedule' : 'now'
|
||||
}
|
||||
|
||||
/**
|
||||
* 발행 모달 열기
|
||||
* @returns {void}
|
||||
*/
|
||||
const openPublishModal = () => {
|
||||
syncPublishModalStateFromForm()
|
||||
publishModalExpandedSection.value = null
|
||||
isPublishModalOpen.value = true
|
||||
}
|
||||
|
||||
/**
|
||||
* 발행 모달 닫기
|
||||
* @returns {void}
|
||||
*/
|
||||
const closePublishModal = () => {
|
||||
isPublishModalOpen.value = false
|
||||
}
|
||||
|
||||
/**
|
||||
* 발행 모달에서 선택한 값을 폼에 반영
|
||||
* @returns {void}
|
||||
*/
|
||||
const applyPublishSelectionToForm = () => {
|
||||
form.status = publishStatus.value
|
||||
|
||||
if (publishStatus.value !== 'published') {
|
||||
form.publishedAt = ''
|
||||
return
|
||||
}
|
||||
|
||||
if (publishTiming.value === 'schedule') {
|
||||
form.publishedAt = scheduledPublishAt.value || toDateTimeLocalValue(new Date(Date.now() + 3600000).toISOString())
|
||||
return
|
||||
}
|
||||
|
||||
form.publishedAt = toDateTimeLocalValue(new Date().toISOString())
|
||||
}
|
||||
|
||||
/**
|
||||
* 발행 모달에서 최종 저장/발행 확정
|
||||
* @returns {void}
|
||||
*/
|
||||
const submitFromPublishModal = () => {
|
||||
applyPublishSelectionToForm()
|
||||
submitPost()
|
||||
}
|
||||
|
||||
/**
|
||||
* 발행 모달에서 설정 행 펼침을 토글한다.
|
||||
* @param {'status' | 'timing'} section - 펼칠 행
|
||||
* @returns {void}
|
||||
*/
|
||||
const togglePublishModalSection = (section) => {
|
||||
publishModalExpandedSection.value =
|
||||
publishModalExpandedSection.value === section ? null : section
|
||||
}
|
||||
|
||||
/**
|
||||
* 발행 모달에서 게시 상태를 선택한다.
|
||||
* @param {'published' | 'draft' | 'private'} status - 선택 상태
|
||||
* @returns {void}
|
||||
*/
|
||||
const selectPublishStatus = (status) => {
|
||||
publishStatus.value = status
|
||||
publishModalExpandedSection.value = null
|
||||
|
||||
if (status !== 'published') {
|
||||
publishTiming.value = 'now'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 발행 모달에서 발행 시점을 선택한다.
|
||||
* @param {'now' | 'schedule'} timing - 즉시 또는 예약
|
||||
* @returns {void}
|
||||
*/
|
||||
const selectPublishTiming = (timing) => {
|
||||
publishTiming.value = timing
|
||||
|
||||
if (timing === 'now') {
|
||||
publishModalExpandedSection.value = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 입력값을 저장 완료 기준점으로 표시한다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const markSaved = () => {
|
||||
savedPostSnapshot.value = serializePostPayload()
|
||||
}
|
||||
|
||||
watch(publishStatus, (next) => {
|
||||
if (next !== 'published') {
|
||||
publishTiming.value = 'now'
|
||||
|
||||
if (publishModalExpandedSection.value === 'timing') {
|
||||
publishModalExpandedSection.value = null
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
watch(form, scheduleAutosave, { deep: true })
|
||||
|
||||
onMounted(() => {
|
||||
markSaved()
|
||||
|
||||
const savedRaw = localStorage.getItem(autosaveKey.value)
|
||||
|
||||
if (!savedRaw) {
|
||||
@@ -610,23 +798,47 @@ onBeforeUnmount(() => {
|
||||
})
|
||||
|
||||
defineExpose({
|
||||
clearAutosave: discardAutosave
|
||||
clearAutosave: discardAutosave,
|
||||
markSaved,
|
||||
allowNextRouteLeave
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form class="admin-post-form flex h-screen min-h-screen overflow-hidden bg-white" @submit.prevent="submitPost">
|
||||
<form class="admin-post-form flex h-screen min-h-screen overflow-hidden bg-white" @submit.prevent="openPublishModal">
|
||||
<div class="admin-post-form__workspace flex min-w-0 flex-1 flex-col bg-white">
|
||||
<header class="admin-post-form__toolbar flex h-[56px] shrink-0 items-center bg-white px-8">
|
||||
<div class="admin-post-form__toolbar-inner flex h-[34px] min-w-0 flex-1 items-center justify-between">
|
||||
<div class="admin-post-form__toolbar-left flex h-full min-w-0 items-center gap-3">
|
||||
<NuxtLink class="admin-post-form__toolbar-link inline-flex items-center gap-2 rounded px-2 py-1.5 text-sm font-medium text-[#394047] transition-colors hover:bg-[#eff1f2] hover:text-black" to="/admin/posts">
|
||||
<div class="admin-post-form__toolbar-left flex h-full min-w-0 flex-1 items-center gap-3">
|
||||
<NuxtLink class="admin-post-form__toolbar-link inline-flex shrink-0 items-center gap-2 rounded px-2 py-1.5 text-sm font-medium text-[#394047] transition-colors hover:bg-[#eff1f2] hover:text-black" to="/admin/posts">
|
||||
<span class="admin-post-form__toolbar-back text-lg leading-none" aria-hidden="true"><</span>
|
||||
<span>Posts</span>
|
||||
</NuxtLink>
|
||||
<span class="admin-post-form__toolbar-status truncate rounded px-2 py-1.5 text-sm text-[#8e9cac]">
|
||||
{{ editorStatusLabel }}
|
||||
</span>
|
||||
<div class="admin-post-form__toolbar-status-row flex min-w-0 flex-1 items-center gap-2">
|
||||
<span class="admin-post-form__toolbar-status truncate rounded px-2 py-1.5 text-sm text-[#8e9cac]">
|
||||
{{ editorStatusLabel }}
|
||||
</span>
|
||||
<div
|
||||
v-if="autosaveNotice"
|
||||
class="admin-post-form__toolbar-autosave-actions flex shrink-0 items-center gap-1.5"
|
||||
>
|
||||
<button
|
||||
class="admin-post-form__toolbar-autosave-restore rounded px-2 py-1 text-xs font-semibold text-[#15171a] ring-1 ring-inset ring-[#d7dde2] transition-colors hover:bg-[#eff1f2]"
|
||||
type="button"
|
||||
@click="restoreAutosave"
|
||||
>
|
||||
복원
|
||||
</button>
|
||||
<button
|
||||
class="admin-post-form__toolbar-autosave-discard rounded px-2 py-1 text-xs font-semibold text-[#8e9cac] transition-colors hover:bg-[#eff1f2] hover:text-[#394047]"
|
||||
type="button"
|
||||
title="이 기기에만 있는 자동 저장 초안을 삭제합니다"
|
||||
@click="discardAutosave"
|
||||
>
|
||||
무시
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="admin-post-form__toolbar-actions flex h-full shrink-0 items-center gap-2">
|
||||
<button
|
||||
@@ -637,9 +849,10 @@ defineExpose({
|
||||
미리보기
|
||||
</button>
|
||||
<button
|
||||
class="admin-post-form__toolbar-submit rounded px-3 py-1.5 text-sm font-bold text-[#2bba3c] transition-colors hover:bg-[#eaf8ec] hover:text-[#159624] disabled:opacity-50"
|
||||
type="submit"
|
||||
:disabled="saving"
|
||||
class="admin-post-form__toolbar-submit rounded px-3 py-1.5 text-sm font-bold text-[#2bba3c] transition-colors hover:bg-[#eaf8ec] hover:text-[#159624] disabled:pointer-events-none disabled:text-[#8e9cac] disabled:opacity-60"
|
||||
type="button"
|
||||
:disabled="saving || !hasUnsavedPostChanges"
|
||||
@click="openPublishModal"
|
||||
>
|
||||
{{ saving ? '저장 중' : submitLabel }}
|
||||
</button>
|
||||
@@ -686,26 +899,11 @@ defineExpose({
|
||||
type="text"
|
||||
placeholder="제목"
|
||||
required
|
||||
@keydown.enter.prevent="focusContentEditor"
|
||||
@keydown.enter="focusContentEditor"
|
||||
@compositionstart="isTitleInputComposing = true"
|
||||
@compositionend="isTitleInputComposing = false"
|
||||
>
|
||||
|
||||
<div
|
||||
v-if="autosaveNotice"
|
||||
class="admin-post-form__autosave-notice flex flex-wrap items-center justify-between gap-3 rounded border border-line bg-surface px-4 py-3 text-sm"
|
||||
>
|
||||
<p class="admin-post-form__autosave-message text-muted">
|
||||
{{ formatAutosaveTime(autosaveNotice.savedAt) }}에 저장된 작성 중 내용이 있습니다.
|
||||
</p>
|
||||
<div class="admin-post-form__autosave-actions flex gap-2">
|
||||
<button class="admin-post-form__autosave-restore rounded bg-[#15171a] px-3 py-1.5 text-xs font-semibold text-white transition-colors hover:bg-black" type="button" @click="restoreAutosave">
|
||||
복원
|
||||
</button>
|
||||
<button class="admin-post-form__autosave-discard rounded border border-line bg-white px-3 py-1.5 text-xs font-semibold transition-colors hover:bg-[#eff1f2]" type="button" @click="discardAutosave">
|
||||
삭제
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="admin-post-form__field admin-post-form__content-editor text-sm">
|
||||
<AdminBlockEditor ref="blockEditor" v-model="form.content" />
|
||||
</div>
|
||||
@@ -799,12 +997,14 @@ defineExpose({
|
||||
>
|
||||
<span>{{ tag }}</span>
|
||||
<button
|
||||
class="admin-post-form__tag-remove grid size-3 place-items-center rounded text-[#e04e87] transition-colors hover:bg-[#e7c3d2]"
|
||||
class="admin-post-form__tag-remove inline-flex size-4 shrink-0 items-center justify-center rounded text-[#e04e87] transition-colors hover:bg-[#e7c3d2]"
|
||||
type="button"
|
||||
:aria-label="`${tag} 태그 삭제`"
|
||||
@click="removeTag(tag)"
|
||||
>
|
||||
<span aria-hidden="true">x</span>
|
||||
<svg class="size-2.5" version="1" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path fill="currentColor" 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" />
|
||||
</svg>
|
||||
</button>
|
||||
</span>
|
||||
<input
|
||||
@@ -846,7 +1046,7 @@ defineExpose({
|
||||
</div>
|
||||
<div v-if="showDelete" class="admin-post-form__settings-bottom shrink-0 border-t border-[#e3e6e8] px-8 py-6">
|
||||
<button
|
||||
class="admin-post-form__delete-post flex h-10 w-full items-center justify-center gap-2 rounded border border-[#d21a26] text-sm font-bold text-[#d21a26] transition-colors hover:bg-red-50 disabled:opacity-50"
|
||||
class="admin-post-form__delete-post flex h-10 w-full items-center justify-center gap-2 rounded border border-[#d7dde2] bg-white text-sm font-bold text-[#394047] transition-colors hover:border-[#d21a26] hover:bg-red-50 hover:text-[#d21a26] disabled:opacity-50"
|
||||
type="button"
|
||||
:disabled="deleting"
|
||||
@click="deletePost"
|
||||
@@ -952,5 +1152,182 @@ defineExpose({
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<AdminUnsavedChangesModal
|
||||
:open="isUnsavedModalOpen"
|
||||
@stay="stayOnUnsavedPage"
|
||||
@leave="leaveUnsavedPage"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-if="isPublishModalOpen"
|
||||
class="admin-post-form__publish-modal fixed inset-0 z-[70] flex flex-col bg-white"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="admin-post-form-publish-modal-title"
|
||||
>
|
||||
<div class="flex min-h-0 flex-1 flex-col overflow-y-auto">
|
||||
<header class="admin-post-form__publish-modal-header flex h-14 shrink-0 items-center justify-between px-6">
|
||||
<h2 id="admin-post-form-publish-modal-title" class="text-[15px] font-semibold text-[#15171a]">
|
||||
발행
|
||||
</h2>
|
||||
<div class="flex items-center gap-2">
|
||||
<button class="rounded px-3 py-1.5 text-[13px] font-semibold text-[#394047] hover:bg-[#eff1f2]" type="button" @click="closePublishModal">
|
||||
닫기
|
||||
</button>
|
||||
<button class="rounded border border-[#d7dde2] px-3 py-1.5 text-[13px] font-semibold text-[#394047] hover:bg-[#eff1f2]" type="button" @click="previewPost">
|
||||
미리보기
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="admin-post-form__publish-modal-body flex flex-1 flex-col items-center px-6 pb-16 pt-10 sm:pt-14">
|
||||
<div class="w-full max-w-[640px]">
|
||||
<div class="admin-post-form__publish-modal-hero mb-10 sm:mb-12">
|
||||
<p class="text-[clamp(28px,7vw,46px)] font-black text-[#2bba3c]">
|
||||
준비됐어요, 발행하세요.
|
||||
</p>
|
||||
<p class="text-[clamp(28px,7vw,46px)] font-black leading-[0.95] text-[#15171a]">
|
||||
세상과 공유해 보세요.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="admin-post-form__publish-settings w-full">
|
||||
<div class="admin-post-form__publish-setting">
|
||||
<button
|
||||
class="admin-post-form__publish-setting-title flex w-full items-center gap-3 py-4 text-left text-[15px] font-semibold text-[#15171a] transition-colors"
|
||||
type="button"
|
||||
:aria-expanded="publishModalExpandedSection === 'status'"
|
||||
aria-controls="admin-post-form-publish-status-panel"
|
||||
data-test-setting="publish-type"
|
||||
@click="togglePublishModalSection('status')"
|
||||
>
|
||||
<span class="flex size-6 shrink-0 items-center justify-center text-[#959eab]" aria-hidden="true">
|
||||
<svg class="size-4" fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M23 1L6.21 13.013v9.408L12 17.355" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
|
||||
<path d="M1 9.105L23 1l-3.474 22L1 9.105z" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
|
||||
</svg>
|
||||
</span>
|
||||
<span class="min-w-0 flex-1">{{ publishStatusSummaryLabel }}</span>
|
||||
<span class="flex size-2.5 shrink-0 items-center justify-center text-[#959eab]" aria-hidden="true">
|
||||
<svg
|
||||
class="size-[22px] transition-transform duration-200 ease-out"
|
||||
:class="{ 'rotate-180': publishModalExpandedSection === 'status' }"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 26 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path clip-rule="evenodd" d="M1.043 6.604a1 1 0 011.414 0L13 17.146 23.543 6.604a1 1 0 011.414 1.414l-10.72 10.719a1.75 1.75 0 01-2.474 0L1.042 8.018a1 1 0 010-1.414zm11.78 10.72v-.001zm.355 0v-.001z" fill-rule="evenodd" />
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
<div
|
||||
v-show="publishModalExpandedSection === 'status'"
|
||||
id="admin-post-form-publish-status-panel"
|
||||
class="admin-post-form__publish-setting-panel px-4 pb-4"
|
||||
>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
class="rounded-full border px-3 py-1.5 text-[13px] font-semibold"
|
||||
:class="publishStatus === 'published' ? 'border-[#15171a] bg-[#15171a] text-white' : 'border-[#d7dde2] bg-white text-[#394047]'"
|
||||
type="button"
|
||||
@click="selectPublishStatus('published')"
|
||||
>
|
||||
발행
|
||||
</button>
|
||||
<button
|
||||
class="rounded-full border px-3 py-1.5 text-[13px] font-semibold"
|
||||
:class="publishStatus === 'draft' ? 'border-[#15171a] bg-[#15171a] text-white' : 'border-[#d7dde2] bg-white text-[#394047]'"
|
||||
type="button"
|
||||
@click="selectPublishStatus('draft')"
|
||||
>
|
||||
초안
|
||||
</button>
|
||||
<button
|
||||
class="rounded-full border px-3 py-1.5 text-[13px] font-semibold"
|
||||
:class="publishStatus === 'private' ? 'border-[#15171a] bg-[#15171a] text-white' : 'border-[#d7dde2] bg-white text-[#394047]'"
|
||||
type="button"
|
||||
@click="selectPublishStatus('private')"
|
||||
>
|
||||
비공개
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="publishStatus === 'published'" class="admin-post-form__publish-setting admin-post-form__publish-setting--timing border-t border-[#e3e6e8]">
|
||||
<button
|
||||
class="admin-post-form__publish-setting-title flex w-full items-center gap-3 py-4 text-left text-[15px] font-semibold text-[#15171a] transition-colors"
|
||||
type="button"
|
||||
:aria-expanded="publishModalExpandedSection === 'timing'"
|
||||
aria-controls="admin-post-form-publish-timing-panel"
|
||||
data-test-setting="publish-at"
|
||||
@click="togglePublishModalSection('timing')"
|
||||
>
|
||||
<span class="flex size-6 shrink-0 items-center justify-center text-[#959eab]" aria-hidden="true">
|
||||
<svg class="size-4" fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12 23c6.075 0 11-4.925 11-11S18.075 1 12 1 1 5.925 1 12s4.925 11 11 11z" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
|
||||
<path d="M12 6v6h6" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
|
||||
</svg>
|
||||
</span>
|
||||
<span class="min-w-0 flex-1">{{ publishTimingSummaryLabel }}</span>
|
||||
<span class="flex size-2.5 shrink-0 items-center justify-center text-[#959eab]" aria-hidden="true">
|
||||
<svg
|
||||
class="size-[22px] transition-transform duration-200 ease-out"
|
||||
:class="{ 'rotate-180': publishModalExpandedSection === 'timing' }"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 26 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path clip-rule="evenodd" d="M1.043 6.604a1 1 0 011.414 0L13 17.146 23.543 6.604a1 1 0 011.414 1.414l-10.72 10.719a1.75 1.75 0 01-2.474 0L1.042 8.018a1 1 0 010-1.414zm11.78 10.72v-.001zm.355 0v-.001z" fill-rule="evenodd" />
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
<div
|
||||
v-show="publishModalExpandedSection === 'timing'"
|
||||
id="admin-post-form-publish-timing-panel"
|
||||
class="admin-post-form__publish-setting-panel px-4 pb-4"
|
||||
>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
class="rounded-full border px-3 py-1.5 text-[13px] font-semibold"
|
||||
:class="publishTiming === 'now' ? 'border-[#15171a] bg-[#15171a] text-white' : 'border-[#d7dde2] bg-white text-[#394047]'"
|
||||
type="button"
|
||||
@click="selectPublishTiming('now')"
|
||||
>
|
||||
지금 바로
|
||||
</button>
|
||||
<button
|
||||
class="rounded-full border px-3 py-1.5 text-[13px] font-semibold"
|
||||
:class="publishTiming === 'schedule' ? 'border-[#15171a] bg-[#15171a] text-white' : 'border-[#d7dde2] bg-white text-[#394047]'"
|
||||
type="button"
|
||||
@click="selectPublishTiming('schedule')"
|
||||
>
|
||||
예약
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
v-if="publishTiming === 'schedule'"
|
||||
v-model="scheduledPublishAt"
|
||||
class="admin-post-form__publish-schedule-input mt-3 h-[38px] w-full max-w-[320px] rounded border border-[#e3e6e8] bg-white px-3 py-2 text-[13px] text-[#15171a] outline-none focus:border-[#8e9cac]"
|
||||
type="datetime-local"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="admin-post-form__publish-modal-actions mt-10">
|
||||
<button
|
||||
class="rounded bg-[#15171a] px-5 py-2.5 text-[14px] font-semibold text-white transition-colors hover:bg-black disabled:cursor-not-allowed disabled:bg-[#d7dce0] disabled:text-[#8e9cac]"
|
||||
type="button"
|
||||
:disabled="saving"
|
||||
@click="submitFromPublishModal"
|
||||
>
|
||||
{{ saving ? '저장 중…' : '최종 확인하고 저장 →' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
61
components/admin/AdminUnsavedChangesModal.vue
Normal file
61
components/admin/AdminUnsavedChangesModal.vue
Normal file
@@ -0,0 +1,61 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
open: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
defineEmits(['stay', 'leave'])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="open"
|
||||
class="admin-unsaved-modal fixed inset-0 z-[100] flex items-start justify-center bg-black/40 px-5 pb-8 pt-10"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="admin-unsaved-modal-title"
|
||||
>
|
||||
<div class="admin-unsaved-modal__content relative w-full max-w-[520px] rounded-xl bg-white text-[#15171a] shadow-2xl">
|
||||
<header class="admin-unsaved-modal__header border-b border-[#e3e6e8] px-8 py-6">
|
||||
<h1 id="admin-unsaved-modal-title" class="admin-unsaved-modal__title text-xl font-semibold tracking-[-0.01em]">
|
||||
이 페이지를 떠날까요?
|
||||
</h1>
|
||||
</header>
|
||||
<button
|
||||
class="admin-unsaved-modal__close absolute right-5 top-5 grid size-8 place-items-center rounded-md text-[#4d5663] transition hover:bg-[#eff1f2] hover:text-black"
|
||||
type="button"
|
||||
title="닫기"
|
||||
aria-label="닫기"
|
||||
@click="$emit('stay')"
|
||||
>
|
||||
<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>
|
||||
<div class="admin-unsaved-modal__body space-y-3 px-8 py-7 text-sm leading-6 text-[#4d5663]">
|
||||
<p>저장하지 않은 변경사항이 있습니다.</p>
|
||||
<p>떠나기 전에 저장해 주세요.</p>
|
||||
</div>
|
||||
<footer class="admin-unsaved-modal__footer flex justify-end gap-3 border-t border-[#e3e6e8] px-8 py-5">
|
||||
<button
|
||||
class="admin-unsaved-modal__stay h-10 rounded-md border border-[#d7dce0] bg-white px-4 text-sm font-semibold text-[#394047] transition hover:bg-[#eff1f2]"
|
||||
type="button"
|
||||
@click="$emit('stay')"
|
||||
>
|
||||
머무르기
|
||||
</button>
|
||||
<button
|
||||
class="admin-unsaved-modal__leave h-10 rounded-md bg-[#e5484d] px-4 text-sm font-semibold text-white transition hover:bg-[#d21a26]"
|
||||
type="button"
|
||||
@click="$emit('leave')"
|
||||
>
|
||||
나가기
|
||||
</button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
@@ -6,6 +6,8 @@ const props = defineProps({
|
||||
}
|
||||
})
|
||||
|
||||
const BLANK_PARAGRAPH_MARKER = '<!--sori:blank-paragraph-->'
|
||||
|
||||
const activeLightboxImages = ref([])
|
||||
const activeLightboxIndex = ref(0)
|
||||
|
||||
@@ -30,9 +32,54 @@ const createBlock = (type = 'paragraph', text = '', level = null, id = '', optio
|
||||
ordered: options.ordered || false,
|
||||
width: options.width || 'regular',
|
||||
images: options.images || [],
|
||||
meta: options.meta && typeof options.meta === 'object' ? { ...options.meta } : {}
|
||||
meta: options.meta && typeof options.meta === 'object' ? { ...options.meta } : {},
|
||||
calloutEmojiEnabled: options.calloutEmojiEnabled ?? true,
|
||||
calloutEmoji: options.calloutEmoji || '💡',
|
||||
calloutBackground: options.calloutBackground || 'blue'
|
||||
})
|
||||
|
||||
const calloutBackgroundOptions = ['gray', 'blue', 'green', 'yellow', 'red', 'purple', 'pink']
|
||||
|
||||
/**
|
||||
* 콜아웃 선언부 옵션을 파싱
|
||||
* @param {string} line - 콜아웃 선언 라인
|
||||
* @returns {{calloutEmojiEnabled: boolean, calloutEmoji: string, calloutBackground: string}} 콜아웃 옵션
|
||||
*/
|
||||
const parseCalloutOptions = (line) => {
|
||||
const options = {
|
||||
calloutEmojiEnabled: true,
|
||||
calloutEmoji: '💡',
|
||||
calloutBackground: 'blue'
|
||||
}
|
||||
const tokens = line.trim().split(/\s+/).slice(1)
|
||||
|
||||
tokens.forEach((token) => {
|
||||
const [rawKey, ...rawValueParts] = token.split('=')
|
||||
if (!rawKey || !rawValueParts.length) {
|
||||
return
|
||||
}
|
||||
const key = rawKey.toLowerCase()
|
||||
const value = rawValueParts.join('=').trim()
|
||||
|
||||
if (key === 'emoji') {
|
||||
if (!value || value === 'none') {
|
||||
options.calloutEmojiEnabled = false
|
||||
options.calloutEmoji = '💡'
|
||||
} else {
|
||||
options.calloutEmojiEnabled = true
|
||||
options.calloutEmoji = value
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (key === 'bg' && calloutBackgroundOptions.includes(value)) {
|
||||
options.calloutBackground = value
|
||||
}
|
||||
})
|
||||
|
||||
return options
|
||||
}
|
||||
|
||||
/**
|
||||
* 이미지 마크다운 행을 이미지 데이터로 변환
|
||||
* @param {string} line - 마크다운 행
|
||||
@@ -177,6 +224,12 @@ const parseMarkdownBlocks = (markdown) => {
|
||||
const line = lines[index]
|
||||
const trimmedLine = line.trim()
|
||||
|
||||
if (trimmedLine === BLANK_PARAGRAPH_MARKER) {
|
||||
blocks.push(createBlock('paragraph', '', null, `block-${blocks.length}`))
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
|
||||
if (!trimmedLine) {
|
||||
index += 1
|
||||
continue
|
||||
@@ -232,9 +285,9 @@ const parseMarkdownBlocks = (markdown) => {
|
||||
continue
|
||||
}
|
||||
|
||||
if (trimmedLine === ':::callout') {
|
||||
if (trimmedLine.startsWith(':::callout')) {
|
||||
const { contentLines, nextIndex } = collectFencedLines(lines, index + 1)
|
||||
blocks.push(createBlock('callout', contentLines.join('\n'), null, `block-${blocks.length}`))
|
||||
blocks.push(createBlock('callout', contentLines.join('\n'), null, `block-${blocks.length}`, parseCalloutOptions(trimmedLine)))
|
||||
index = nextIndex
|
||||
continue
|
||||
}
|
||||
@@ -392,7 +445,12 @@ const showNextImage = () => {
|
||||
<ProseImage v-else-if="block.type === 'image'" :src="block.url" :alt="block.alt" :variant="block.width">
|
||||
{{ block.alt }}
|
||||
</ProseImage>
|
||||
<ProseCallout v-else-if="block.type === 'callout'">
|
||||
<ProseCallout
|
||||
v-else-if="block.type === 'callout'"
|
||||
:emoji-enabled="block.calloutEmojiEnabled"
|
||||
:emoji="block.calloutEmoji"
|
||||
:background="block.calloutBackground"
|
||||
>
|
||||
{{ block.text }}
|
||||
</ProseCallout>
|
||||
<ProseToggle v-else-if="block.type === 'toggle'" :title="block.title || '더 보기'">
|
||||
|
||||
@@ -53,10 +53,24 @@ const imageSrc = computed(() => props.thumbnail || faviconUrl.value)
|
||||
* @returns {string}
|
||||
*/
|
||||
const displayTitle = computed(() => props.title || displayHost.value || props.url)
|
||||
|
||||
/**
|
||||
* 외부 링크로 열어도 되는 URL인지 확인한다.
|
||||
* @returns {boolean} 허용 여부
|
||||
*/
|
||||
const isSafeBookmarkUrl = computed(() => {
|
||||
try {
|
||||
const parsedUrl = new URL(props.url)
|
||||
return ['http:', 'https:'].includes(parsedUrl.protocol)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a
|
||||
v-if="isSafeBookmarkUrl"
|
||||
class="prose-bookmark group prose-bookmark-card my-8 flex max-w-full flex-col overflow-hidden rounded-[10px] border border-[var(--site-line)] bg-[var(--site-panel)] no-underline transition-[background-color,box-shadow] hover:bg-[color-mix(in_srgb,var(--site-panel)_86%,var(--site-text)_14%)] sm:flex-row"
|
||||
:href="url"
|
||||
target="_blank"
|
||||
@@ -92,4 +106,7 @@ const displayTitle = computed(() => props.title || displayHost.value || props.ur
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
<p v-else class="prose-bookmark prose-bookmark-invalid my-8 rounded-[10px] border border-[var(--site-line)] bg-[var(--site-panel)] p-5 text-sm font-semibold text-[var(--site-muted)]">
|
||||
지원하지 않는 북마크 URL입니다.
|
||||
</p>
|
||||
</template>
|
||||
|
||||
@@ -1,7 +1,88 @@
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
emojiEnabled: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
emoji: {
|
||||
type: String,
|
||||
default: '💡'
|
||||
},
|
||||
background: {
|
||||
type: String,
|
||||
default: 'blue'
|
||||
}
|
||||
})
|
||||
|
||||
const backgroundClass = computed(() => {
|
||||
if (props.background === 'gray') {
|
||||
return 'prose-callout--gray'
|
||||
}
|
||||
|
||||
if (props.background === 'green') {
|
||||
return 'prose-callout--green'
|
||||
}
|
||||
|
||||
if (props.background === 'yellow') {
|
||||
return 'prose-callout--yellow'
|
||||
}
|
||||
|
||||
if (props.background === 'red') {
|
||||
return 'prose-callout--red'
|
||||
}
|
||||
|
||||
if (props.background === 'purple') {
|
||||
return 'prose-callout--purple'
|
||||
}
|
||||
|
||||
if (props.background === 'pink') {
|
||||
return 'prose-callout--pink'
|
||||
}
|
||||
|
||||
return 'prose-callout--blue'
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside class="prose-callout prose-callout-card my-8 rounded-[10px] border border-[var(--site-line)] border-l-[3px] border-l-[var(--site-accent)] bg-[var(--site-panel)] p-5 pl-4 text-[15px] leading-8 text-[var(--site-text)]">
|
||||
<div class="whitespace-pre-line">
|
||||
<slot />
|
||||
<aside
|
||||
class="prose-callout prose-callout-card mt-8 rounded-[10px] p-5 text-[15px] leading-8 text-[var(--site-text)]"
|
||||
:class="backgroundClass"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<span v-if="emojiEnabled" class="inline-flex shrink-0 text-[20px] leading-none">{{ emoji || '💡' }}</span>
|
||||
<div class="min-w-0 flex-1 whitespace-pre-line">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.prose-callout--gray {
|
||||
background: rgba(100, 116, 139, 0.12);
|
||||
}
|
||||
|
||||
.prose-callout--blue {
|
||||
background: rgba(59, 130, 246, 0.14);
|
||||
}
|
||||
|
||||
.prose-callout--green {
|
||||
background: rgba(34, 197, 94, 0.14);
|
||||
}
|
||||
|
||||
.prose-callout--yellow {
|
||||
background: rgba(245, 158, 11, 0.16);
|
||||
}
|
||||
|
||||
.prose-callout--red {
|
||||
background: rgba(239, 68, 68, 0.14);
|
||||
}
|
||||
|
||||
.prose-callout--purple {
|
||||
background: rgba(168, 85, 247, 0.14);
|
||||
}
|
||||
|
||||
.prose-callout--pink {
|
||||
background: rgba(236, 72, 153, 0.14);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -63,6 +63,22 @@ const youtubeId = computed(() => getYouTubeId(props.url))
|
||||
const youtubeEmbedUrl = computed(() => youtubeId.value ? `https://www.youtube.com/embed/${youtubeId.value}` : '')
|
||||
const tweetId = computed(() => getTweetId(props.url))
|
||||
|
||||
/**
|
||||
* 외부 링크로 열어도 되는 URL인지 확인한다.
|
||||
* @param {string} value - 검사할 URL
|
||||
* @returns {boolean} 허용 여부
|
||||
*/
|
||||
const isSafeExternalUrl = (value) => {
|
||||
try {
|
||||
const parsedUrl = new URL(value)
|
||||
return ['http:', 'https:'].includes(parsedUrl.protocol)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const safeExternalUrl = computed(() => isSafeExternalUrl(props.url) ? props.url : '')
|
||||
|
||||
/**
|
||||
* Twitter 공식 embed iframe 주소
|
||||
* @returns {string}
|
||||
@@ -98,13 +114,16 @@ const tweetEmbedUrl = computed(() => {
|
||||
loading="lazy"
|
||||
/>
|
||||
<a
|
||||
v-else
|
||||
v-else-if="safeExternalUrl"
|
||||
class="prose-embed__link block p-5 text-sm font-semibold text-[var(--site-text)] hover:opacity-70"
|
||||
:href="url"
|
||||
:href="safeExternalUrl"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{{ url }}
|
||||
</a>
|
||||
<p v-else class="prose-embed__invalid p-5 text-sm font-semibold text-[var(--site-muted)]">
|
||||
지원하지 않는 임베드 URL입니다.
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -191,19 +191,22 @@ onMounted(() => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="left-sidebar__footer flex shrink-0 items-center justify-between px-4 py-4 text-xs sm:px-5">
|
||||
<nav class="left-sidebar__footer-nav flex gap-4">
|
||||
<footer class="left-sidebar__footer flex shrink-0 flex-wrap items-center justify-between gap-x-3 gap-y-2 px-4 py-4 text-xs sm:px-5">
|
||||
<nav
|
||||
class="left-sidebar__footer-nav flex min-w-0 flex-1 flex-wrap items-center gap-x-4 gap-y-1"
|
||||
aria-label="하단 링크"
|
||||
>
|
||||
<NuxtLink
|
||||
v-for="item in navigation.footer"
|
||||
:key="item.id"
|
||||
class="site-interactive"
|
||||
class="left-sidebar__footer-link site-interactive shrink-0"
|
||||
:to="item.url"
|
||||
>
|
||||
{{ item.label }}
|
||||
</NuxtLink>
|
||||
</nav>
|
||||
<button
|
||||
class="left-sidebar__theme-dot site-panel-hover site-interactive grid h-7 w-7 place-items-center rounded-full"
|
||||
class="left-sidebar__theme-dot site-panel-hover site-interactive grid h-7 w-7 shrink-0 place-items-center rounded-full"
|
||||
type="button"
|
||||
:aria-label="isDarkMode ? '라이트 모드로 전환' : '다크 모드로 전환'"
|
||||
:title="isDarkMode ? '라이트 모드' : '다크 모드'"
|
||||
|
||||
@@ -13,6 +13,7 @@ const { data: siteSettings } = await useFetch('/api/site-settings', {
|
||||
title: 'sori.studio',
|
||||
description: 'sori.studio 개인 블로그',
|
||||
logoText: '井',
|
||||
logoUrl: '',
|
||||
copyrightText: '©2026 sori.studio'
|
||||
})
|
||||
})
|
||||
@@ -23,8 +24,14 @@ const { data: siteSettings } = await useFetch('/api/site-settings', {
|
||||
<div class="right-sidebar__scroll site-sidebar-scroll min-h-0 flex-1">
|
||||
<div class="right-sidebar__block site-sidebar-section py-5 pl-5 pr-0 max-lg:px-0">
|
||||
<div class="right-sidebar__profile flex items-center gap-3">
|
||||
<div class="right-sidebar__logo grid h-12 w-12 place-items-center rounded-2xl bg-[var(--site-invert)] text-2xl font-bold text-[var(--site-invert-text)]">
|
||||
{{ siteSettings.logoText }}
|
||||
<div class="right-sidebar__logo grid h-12 w-12 place-items-center overflow-hidden rounded-2xl bg-[var(--site-invert)] text-2xl font-bold text-[var(--site-invert-text)]">
|
||||
<img
|
||||
v-if="siteSettings.logoUrl"
|
||||
class="h-full w-full object-cover"
|
||||
:src="siteSettings.logoUrl"
|
||||
:alt="siteSettings.title"
|
||||
>
|
||||
<span v-else>{{ siteSettings.logoText }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="right-sidebar__title font-semibold">
|
||||
|
||||
@@ -7,9 +7,26 @@ defineProps({
|
||||
}
|
||||
})
|
||||
|
||||
const route = useRoute()
|
||||
const expandedSet = inject('sidebarPrimaryNavExpandedSet')
|
||||
const toggleBranch = inject('sidebarPrimaryNavToggle')
|
||||
|
||||
/** 세로바·호버 시 원형으로 바뀌는 공통 before 스타일(리프 링크와 동일) */
|
||||
const navBarBeforeBase =
|
||||
"before:pointer-events-none before:content-[''] before:h-4 before:w-1 before:flex-none before:rounded-sm before:rounded-l-none before:transition-[width,height,border-radius,background-color] before:duration-200 hover:px-3 hover:before:h-2 hover:before:w-2 hover:before:rounded-full"
|
||||
|
||||
/** 비활성 경로: 기본 회색 막대, 호버 시 원형·믹스 색 */
|
||||
const navBarBeforeInactive =
|
||||
`${navBarBeforeBase} before:bg-[var(--site-line)] hover:before:bg-[color:color-mix(in_srgb,var(--site-text)_28%,var(--site-line))]`
|
||||
|
||||
/** 현재 페이지와 일치하는 내부 링크: 브랜드(`--site-accent`) 막대·호버 원형 */
|
||||
const navBarBeforeActive =
|
||||
`${navBarBeforeBase} before:bg-[var(--site-accent)] hover:before:bg-[var(--site-accent)]`
|
||||
|
||||
/** 행 공통: site-panel-hover, flex, 패딩 전환(가로 전체 호버 배경) */
|
||||
const navRowShell =
|
||||
'site-panel-hover flex w-full min-w-0 max-w-full items-center gap-2 rounded-[10px] py-1.5 pr-3 pl-0 leading-tight transition-[padding,background-color] duration-200'
|
||||
|
||||
/**
|
||||
* 노드가 펼쳐져 있는지
|
||||
* @param {string} id - 노드 id
|
||||
@@ -25,6 +42,53 @@ const isExpanded = (id) => expandedSet?.value?.has(String(id)) ?? true
|
||||
const onBranchClick = (id) => {
|
||||
toggleBranch(id)
|
||||
}
|
||||
|
||||
/**
|
||||
* 외부 URL 여부
|
||||
* @param {string} raw - 네비 URL
|
||||
* @returns {boolean}
|
||||
*/
|
||||
const isExternalUrl = (raw) => /^https?:\/\//i.test(String(raw || '').trim())
|
||||
|
||||
/**
|
||||
* 내부 링크이고 현재 경로와 일치하는지(쿼리 무시, 끝 슬래시 정규화)
|
||||
* @param {string} raw - 네비 URL
|
||||
* @returns {boolean}
|
||||
*/
|
||||
const isInternalNavActive = (raw) => {
|
||||
const u = String(raw || '').trim()
|
||||
if (!u || u === '#' || !u.startsWith('/') || u.startsWith('//')) {
|
||||
return false
|
||||
}
|
||||
if (isExternalUrl(u)) {
|
||||
return false
|
||||
}
|
||||
const path = (route.path || '/').split('?')[0] || '/'
|
||||
/**
|
||||
* 경로 정규화
|
||||
* @param {string} s - 경로
|
||||
* @returns {string}
|
||||
*/
|
||||
const norm = (s) => {
|
||||
let x = s || '/'
|
||||
if (x.length > 1 && x.endsWith('/')) {
|
||||
x = x.slice(0, -1)
|
||||
}
|
||||
return x || '/'
|
||||
}
|
||||
return norm(path) === norm(u)
|
||||
}
|
||||
|
||||
/**
|
||||
* 리프 `NuxtLink`용 클래스
|
||||
* @param {string} url - 노드 URL
|
||||
* @returns {string}
|
||||
*/
|
||||
const navLinkClass = (url) => {
|
||||
const active = isInternalNavActive(url)
|
||||
const bar = active ? navBarBeforeActive : navBarBeforeInactive
|
||||
return `sidebar-primary-nav-list__nav-link ${navRowShell} ${bar}`
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -36,12 +100,12 @@ const onBranchClick = (id) => {
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="sidebar-primary-nav-list__branch-toggle site-panel-hover group flex w-full max-w-full items-center gap-2 rounded-[10px] py-1.5 pr-2 pl-0 text-left leading-tight text-[var(--site-text)] transition-[padding,background-color] duration-200 hover:px-3"
|
||||
class="sidebar-primary-nav-list__branch-toggle group flex w-full max-w-full text-left text-[var(--site-text)]"
|
||||
:class="`${navRowShell} ${navBarBeforeInactive}`"
|
||||
:aria-expanded="isExpanded(node.id)"
|
||||
:aria-label="isExpanded(node.id) ? `${node.label} 하위 메뉴 접기` : `${node.label} 하위 메뉴 펼치기`"
|
||||
@click="onBranchClick(node.id)"
|
||||
>
|
||||
<span class="sidebar-primary-nav-list__dot h-1.5 w-1.5 shrink-0 rounded-full bg-[var(--site-muted)]" />
|
||||
<span class="sidebar-primary-nav-list__branch-label min-w-0 flex-1 truncate font-medium transition-transform duration-200 group-hover:translate-x-[3px]">{{ node.label }}</span>
|
||||
<span
|
||||
class="sidebar-primary-nav-list__chevron-wrap grid h-5 w-5 shrink-0 place-items-center text-[var(--site-muted)] transition-transform duration-200 ease-out group-hover:text-[var(--site-text)]"
|
||||
@@ -70,17 +134,18 @@ const onBranchClick = (id) => {
|
||||
>
|
||||
<NuxtLink
|
||||
v-if="node.url && String(node.url).trim() !== '' && String(node.url).trim() !== '#'"
|
||||
class="sidebar-primary-nav-list__nav-link site-panel-hover flex min-w-0 max-w-full flex-1 items-center gap-2 rounded-[10px] py-1.5 pr-3 pl-0 leading-tight transition-[padding,background-color] duration-200 before:h-4 before:w-1 before:flex-none before:rounded-sm before:rounded-l-none before:bg-[var(--site-line)] before:transition-[width,height,border-radius,background-color] before:duration-200 hover:px-3 hover:before:h-2 hover:before:w-2 hover:before:rounded-full hover:before:bg-[color:color-mix(in_srgb,var(--site-text)_28%,var(--site-line))]"
|
||||
:class="navLinkClass(node.url)"
|
||||
:to="node.url"
|
||||
:aria-current="isInternalNavActive(node.url) ? 'page' : undefined"
|
||||
>
|
||||
<span class="sidebar-primary-nav-list__label min-w-0 flex-1 truncate transition-transform duration-200 group-hover:translate-x-[3px]">{{ node.label }}</span>
|
||||
</NuxtLink>
|
||||
<span
|
||||
v-else
|
||||
class="sidebar-primary-nav-list__leaf-static flex min-w-0 max-w-full flex-1 items-center gap-2 rounded-[10px] py-1.5 pr-3 pl-2 text-[var(--site-text)]"
|
||||
class="sidebar-primary-nav-list__leaf-static group text-[var(--site-text)]"
|
||||
:class="`${navRowShell} ${navBarBeforeInactive}`"
|
||||
>
|
||||
<span class="h-1.5 w-1.5 shrink-0 rounded-full bg-[var(--site-muted)]" />
|
||||
<span class="min-w-0 flex-1 truncate font-medium">{{ node.label }}</span>
|
||||
<span class="min-w-0 flex-1 truncate font-medium transition-transform duration-200 group-hover:translate-x-[3px]">{{ node.label }}</span>
|
||||
</span>
|
||||
</li>
|
||||
</template>
|
||||
|
||||
@@ -8,7 +8,8 @@ const member = ref(null)
|
||||
|
||||
const { data: siteSettings } = await useFetch('/api/site-settings', {
|
||||
default: () => ({
|
||||
title: 'sori.studio'
|
||||
title: 'sori.studio',
|
||||
logoUrl: ''
|
||||
})
|
||||
})
|
||||
|
||||
@@ -152,8 +153,9 @@ onBeforeUnmount(() => {
|
||||
|
||||
<template>
|
||||
<header class="site-header sticky top-0 z-20 backdrop-blur">
|
||||
<div class="site-header__inner mx-auto flex h-full max-w-[1294px] items-center justify-between gap-3 px-4 sm:gap-4 lg:gap-5 lg:px-5 xl:gap-6 xl:px-6 2xl:px-0">
|
||||
<NuxtLink class="site-header__brand flex min-w-0 shrink-1 items-center gap-2.5 text-[15px] font-semibold tracking-normal sm:gap-3 sm:text-[18px] lg:max-w-[min(240px,32vw)] xl:max-w-[min(320px,28vw)] 2xl:max-w-none 2xl:flex-1" to="/">
|
||||
<div class="site-header__inner mx-auto grid h-full max-w-[1294px] grid-cols-3 items-center gap-2 px-4 sm:gap-3 lg:gap-4 lg:px-5 xl:gap-5 xl:px-6 2xl:px-0">
|
||||
<div class="site-header__brand-slot flex min-w-0 justify-self-start">
|
||||
<NuxtLink class="site-header__brand flex min-w-0 max-w-full items-center gap-2.5 text-[15px] font-semibold tracking-normal sm:gap-3 sm:text-[18px] lg:max-w-[min(240px,28vw)] xl:max-w-[min(300px,26vw)]" to="/">
|
||||
<button
|
||||
class="site-header__menu-toggle group flex h-7 w-7 items-center justify-center rounded-full transition-transform"
|
||||
type="button"
|
||||
@@ -184,19 +186,28 @@ onBeforeUnmount(() => {
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
<img
|
||||
v-if="siteSettings.logoUrl"
|
||||
class="site-header__brand-logo h-7 w-7 shrink-0 rounded-md object-cover"
|
||||
:src="siteSettings.logoUrl"
|
||||
:alt="siteSettings.title"
|
||||
>
|
||||
<span class="min-w-0 truncate">{{ siteSettings.title }}</span>
|
||||
</NuxtLink>
|
||||
<button
|
||||
</NuxtLink>
|
||||
</div>
|
||||
<div class="site-header__search-slot flex min-w-0 justify-center justify-self-center px-1 sm:px-2">
|
||||
<button
|
||||
type="button"
|
||||
class="site-header__search site-header__search--responsive hidden h-9 min-w-0 flex-1 basis-0 cursor-pointer items-center rounded-lg px-3 text-left text-sm transition-colors hover:opacity-90 md:flex md:max-w-[min(470px,42vw)] lg:max-w-[min(470px,30vw)] xl:max-w-[min(470px,36vw)] 2xl:w-[470px] 2xl:max-w-[470px] 2xl:basis-auto 2xl:flex-none site-input"
|
||||
class="site-header__search site-header__search--responsive hidden h-9 w-full min-w-[470px] max-w-[min(470px,100%)] cursor-pointer items-center rounded-lg px-3 text-left text-sm transition-colors hover:opacity-90 md:flex site-input"
|
||||
aria-label="검색 열기"
|
||||
@click="openSearchModal"
|
||||
>
|
||||
<span class="site-header__search-icon mr-2 text-lg leading-none">⌕</span>
|
||||
<span class="site-header__search-text site-soft">Search</span>
|
||||
<span class="site-header__search-key ml-auto rounded-md px-2 text-xs site-soft site-input">/</span>
|
||||
</button>
|
||||
<nav class="site-header__nav flex shrink-0 items-center gap-3 text-sm sm:gap-3.5">
|
||||
</button>
|
||||
</div>
|
||||
<nav class="site-header__nav site-header__actions flex min-w-0 shrink-0 items-center justify-end justify-self-end gap-3 text-sm sm:gap-3.5">
|
||||
<div class="site-header__user-menu relative">
|
||||
<button
|
||||
ref="userMenuToggleRef"
|
||||
|
||||
100
composables/useAdminUnsavedChangesGuard.js
Normal file
100
composables/useAdminUnsavedChangesGuard.js
Normal file
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* 관리자 편집 화면의 미저장 변경 이탈을 막는다.
|
||||
* @param {import('vue').Ref<boolean> | import('vue').ComputedRef<boolean>} isDirty - 변경 여부
|
||||
* @param {{ onLeaveConfirmed?: () => void | Promise<void> }} options - 이탈 승인 옵션
|
||||
* @returns {{
|
||||
* isUnsavedModalOpen: import('vue').Ref<boolean>,
|
||||
* stayOnUnsavedPage: () => void,
|
||||
* leaveUnsavedPage: () => Promise<void>,
|
||||
* allowNextRouteLeave: () => void
|
||||
* }} 이탈 확인 상태와 동작
|
||||
*/
|
||||
export const useAdminUnsavedChangesGuard = (isDirty, options = {}) => {
|
||||
const isUnsavedModalOpen = ref(false)
|
||||
const pendingRoute = ref(null)
|
||||
const isNextRouteAllowed = ref(false)
|
||||
|
||||
/**
|
||||
* 현재 이탈을 막아야 하는지 확인한다.
|
||||
* @returns {boolean} 이탈 차단 여부
|
||||
*/
|
||||
const shouldBlockLeave = () => Boolean(unref(isDirty)) && !isNextRouteAllowed.value
|
||||
|
||||
/**
|
||||
* 다음 라우트 이동을 한 번 허용한다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const allowNextRouteLeave = () => {
|
||||
isNextRouteAllowed.value = true
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 페이지에 머문다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const stayOnUnsavedPage = () => {
|
||||
pendingRoute.value = null
|
||||
isUnsavedModalOpen.value = false
|
||||
}
|
||||
|
||||
/**
|
||||
* 미저장 변경을 버리고 이동한다.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const leaveUnsavedPage = async () => {
|
||||
const route = pendingRoute.value
|
||||
pendingRoute.value = null
|
||||
isUnsavedModalOpen.value = false
|
||||
isNextRouteAllowed.value = true
|
||||
|
||||
await options.onLeaveConfirmed?.()
|
||||
|
||||
if (route?.fullPath) {
|
||||
await navigateTo(route.fullPath)
|
||||
}
|
||||
}
|
||||
|
||||
onBeforeRouteLeave((to) => {
|
||||
if (isNextRouteAllowed.value) {
|
||||
isNextRouteAllowed.value = false
|
||||
return true
|
||||
}
|
||||
|
||||
if (!shouldBlockLeave()) {
|
||||
return true
|
||||
}
|
||||
|
||||
pendingRoute.value = to
|
||||
isUnsavedModalOpen.value = true
|
||||
return false
|
||||
})
|
||||
|
||||
/**
|
||||
* 브라우저 탭 닫기와 새로고침을 기본 확인창으로 막는다.
|
||||
* @param {BeforeUnloadEvent} event - 브라우저 이탈 이벤트
|
||||
* @returns {void}
|
||||
*/
|
||||
const handleBeforeUnload = (event) => {
|
||||
if (!shouldBlockLeave()) {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
event.returnValue = ''
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('beforeunload', handleBeforeUnload)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('beforeunload', handleBeforeUnload)
|
||||
})
|
||||
|
||||
return {
|
||||
isUnsavedModalOpen,
|
||||
stayOnUnsavedPage,
|
||||
leaveUnsavedPage,
|
||||
allowNextRouteLeave
|
||||
}
|
||||
}
|
||||
@@ -15,15 +15,24 @@ CREATE INDEX IF NOT EXISTS navigation_items_location_sort_order_idx
|
||||
ON navigation_items (location, sort_order ASC, label ASC);
|
||||
|
||||
INSERT INTO navigation_items (label, url, location, sort_order, is_visible)
|
||||
VALUES
|
||||
('Home pages', '/', 'primary', 10, true),
|
||||
('Tags', '/tags', 'primary', 20, true),
|
||||
('Authors', '/pages/about', 'primary', 30, true),
|
||||
('Style', '/post/hello-sori-studio', 'primary', 40, true),
|
||||
('Post types', '/post/custom-writing-tool', 'primary', 50, true),
|
||||
('Members', '/pages/contact', 'primary', 60, true),
|
||||
('Landing pages', '/pages/projects', 'primary', 70, true),
|
||||
('Portal', '/pages/links', 'footer', 10, true),
|
||||
('Docs', '/pages/about', 'footer', 20, true),
|
||||
('Projects', '/pages/projects', 'footer', 30, true)
|
||||
ON CONFLICT DO NOTHING;
|
||||
SELECT seed.label, seed.url, seed.location, seed.sort_order, seed.is_visible
|
||||
FROM (
|
||||
VALUES
|
||||
('Home pages', '/', 'primary', 10, true),
|
||||
('Tags', '/tags', 'primary', 20, true),
|
||||
('Authors', '/pages/about', 'primary', 30, true),
|
||||
('Style', '/post/hello-sori-studio', 'primary', 40, true),
|
||||
('Post types', '/post/custom-writing-tool', 'primary', 50, true),
|
||||
('Members', '/pages/contact', 'primary', 60, true),
|
||||
('Landing pages', '/pages/projects', 'primary', 70, true),
|
||||
('Portal', '/pages/links', 'footer', 10, true),
|
||||
('Docs', '/pages/about', 'footer', 20, true),
|
||||
('Projects', '/pages/projects', 'footer', 30, true)
|
||||
) AS seed(label, url, location, sort_order, is_visible)
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM navigation_items existing
|
||||
WHERE existing.location = seed.location
|
||||
AND existing.label = seed.label
|
||||
AND existing.url = seed.url
|
||||
);
|
||||
|
||||
15
db/migrations/018_email_otp_challenges.sql
Normal file
15
db/migrations/018_email_otp_challenges.sql
Normal file
@@ -0,0 +1,15 @@
|
||||
CREATE TABLE IF NOT EXISTS email_otp_challenges (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
email TEXT NOT NULL,
|
||||
purpose TEXT NOT NULL,
|
||||
code_hash TEXT NOT NULL,
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
consumed_at TIMESTAMPTZ,
|
||||
verify_attempt_count INTEGER NOT NULL DEFAULT 0,
|
||||
created_ip TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
CONSTRAINT email_otp_challenges_purpose_check CHECK (purpose IN ('signup', 'password_reset'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS email_otp_challenges_email_purpose_created_idx
|
||||
ON email_otp_challenges (lower(email), purpose, created_at DESC);
|
||||
23
db/migrations/019_dedupe_navigation_items.sql
Normal file
23
db/migrations/019_dedupe_navigation_items.sql
Normal file
@@ -0,0 +1,23 @@
|
||||
-- 반복 마이그레이션 실행으로 생긴 동일 위치·상위·라벨·URL 메뉴 중복 정리
|
||||
WITH ranked_navigation AS (
|
||||
SELECT
|
||||
id,
|
||||
row_number() OVER (
|
||||
PARTITION BY location, COALESCE(parent_id::text, ''), label, url
|
||||
ORDER BY
|
||||
CASE WHEN is_folder THEN 0 ELSE 1 END,
|
||||
sort_order ASC,
|
||||
created_at ASC,
|
||||
id ASC
|
||||
) AS row_rank
|
||||
FROM navigation_items
|
||||
)
|
||||
DELETE FROM navigation_items
|
||||
WHERE id IN (
|
||||
SELECT id
|
||||
FROM ranked_navigation
|
||||
WHERE row_rank > 1
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS navigation_items_location_parent_label_url_unique_idx
|
||||
ON navigation_items (location, COALESCE(parent_id::text, ''), label, url);
|
||||
5
db/migrations/020_add_member_admin_fields.sql
Normal file
5
db/migrations/020_add_member_admin_fields.sql
Normal file
@@ -0,0 +1,5 @@
|
||||
ALTER TABLE users
|
||||
ADD COLUMN IF NOT EXISTS member_labels TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[];
|
||||
|
||||
ALTER TABLE users
|
||||
ADD COLUMN IF NOT EXISTS member_note TEXT NOT NULL DEFAULT '';
|
||||
12
db/migrations/021_add_member_previous_login.sql
Normal file
12
db/migrations/021_add_member_previous_login.sql
Normal file
@@ -0,0 +1,12 @@
|
||||
ALTER TABLE users
|
||||
ADD COLUMN IF NOT EXISTS previous_last_seen_at TIMESTAMPTZ;
|
||||
|
||||
ALTER TABLE users
|
||||
ADD COLUMN IF NOT EXISTS previous_last_seen_ip TEXT NOT NULL DEFAULT '';
|
||||
|
||||
UPDATE users
|
||||
SET
|
||||
previous_last_seen_at = last_seen_at,
|
||||
previous_last_seen_ip = last_seen_ip
|
||||
WHERE previous_last_seen_at IS NULL
|
||||
AND last_seen_at IS NOT NULL;
|
||||
5
db/migrations/022_add_site_logo_urls.sql
Normal file
5
db/migrations/022_add_site_logo_urls.sql
Normal file
@@ -0,0 +1,5 @@
|
||||
ALTER TABLE site_settings
|
||||
ADD COLUMN IF NOT EXISTS logo_url TEXT NOT NULL DEFAULT '';
|
||||
|
||||
ALTER TABLE site_settings
|
||||
ADD COLUMN IF NOT EXISTS favicon_url TEXT NOT NULL DEFAULT '';
|
||||
@@ -1,5 +1,14 @@
|
||||
# 업데이트 요약
|
||||
|
||||
## v1.0.0
|
||||
|
||||
- 운영 시작 기준 버전.
|
||||
- 운영 환경 DB 설정 누락 시 샘플 콘텐츠 대신 즉시 실패하도록 보강.
|
||||
- 회원 세션 비밀값을 관리자 비밀번호와 분리.
|
||||
- JavaScript 문법 점검과 프로덕션 빌드를 묶은 검증 스크립트 추가.
|
||||
- Nitro 보안 권고 반영 및 취약점 0건 확인.
|
||||
- Docker compose 설정과 앱 이미지 빌드 검증 완료.
|
||||
|
||||
## v0.0.6
|
||||
|
||||
- `.env.example`을 실제 비밀값이 없는 공유 템플릿으로 정리.
|
||||
|
||||
@@ -55,3 +55,10 @@
|
||||
- 하드코딩 금지
|
||||
- 로컬 개발 설정과 NAS 운영 설정은 별도 환경 파일로 분리
|
||||
- 운영 DB 접속 정보는 개발용 `.env`에 기록하지 않음
|
||||
- 운영 환경에서는 `DATABASE_URL`과 `MEMBER_SESSION_SECRET` 누락을 허용하지 않음
|
||||
|
||||
## 검증
|
||||
|
||||
- `npm run lint`: JavaScript 파일 문법 점검
|
||||
- `npm run test`: Nuxt 프로덕션 빌드 기반 회귀 검증
|
||||
- `npm run verify`: 문법 점검과 빌드를 함께 실행
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 배포 가이드
|
||||
|
||||
> 현재 프로젝트는 Nuxt 3 초기 스캐폴딩 상태다. Docker 설정은 파일 기준 초안이 있으며 운영 DB 확정 후 NAS에서 검증한다.
|
||||
> 로컬 기준 `npm run build`, `docker compose --env-file .env.production config --quiet`, `docker compose --env-file .env.production build sori-studio` 검증을 통과했다. NAS 실제 컨테이너 기동과 도메인/프록시 접속 검증은 운영 배포 단계에서 진행한다.
|
||||
|
||||
## 빌드 유형
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
|------|--------|------|
|
||||
| 개발 | `npm run dev` | 로컬 테스트, 개발 서버 |
|
||||
| 프로덕션 | `npm run build` | NAS 배포, 운영 서버 |
|
||||
| 검증 | `npm run verify` | JavaScript 문법 점검 + 프로덕션 빌드 |
|
||||
|
||||
> `npm run dev`는 프로젝트 전용 실행 스크립트를 통해 개발 서버, Admin, Tailwind Viewer 링크만 요약 출력한다.
|
||||
|
||||
@@ -128,6 +129,7 @@ cd sori.studio
|
||||
# .env.production은 Git에 올리지 않는 운영 전용 파일
|
||||
cp .env.example .env.production
|
||||
# .env.production 파일에 운영 DB 연결 정보, 운영 전용 랜덤 비밀번호, APP_PORT=43118 입력
|
||||
# MEMBER_SESSION_SECRET은 ADMIN_PASSWORD와 다른 긴 난수 문자열로 반드시 입력
|
||||
# Docker 내부 앱에서 PostgreSQL에 접근할 때는 sori-studio-db:5432 사용
|
||||
|
||||
# Docker 빌드 및 실행
|
||||
@@ -163,13 +165,30 @@ docker run -d -p 3000:3000 sori.studio:latest
|
||||
- NAS Docker 예시: `postgres://sori_studio:비밀번호@sori-studio-db:5432/sori_studio`
|
||||
- `.env.example`에는 실제 비밀번호나 개인 이메일을 기록하지 않음
|
||||
- 개발/운영 DB 비밀번호와 관리자 비밀번호는 서로 다른 랜덤 값을 사용
|
||||
- 회원 세션 서명용 `MEMBER_SESSION_SECRET`은 관리자 비밀번호와 분리된 긴 난수 문자열을 사용
|
||||
- 개발 DB와 운영 DB는 반드시 별도 인스턴스 또는 별도 데이터베이스로 분리
|
||||
- 운영 DB는 로컬 개발 서버에서 직접 연결하지 않음
|
||||
- 운영 환경에서 `DATABASE_URL`이 없으면 샘플 콘텐츠로 대체하지 않고 서버 오류로 실패
|
||||
|
||||
### 이메일 인증(Resend, 선택)
|
||||
|
||||
회원가입(일반)·비밀번호 찾기에 이메일 OTP를 쓰려면 `npm run db:migrate:dev`로 `018_email_otp_challenges.sql`을 적용하고, `.env`에 다음을 설정한다.
|
||||
|
||||
| 변수 | 설명 |
|
||||
|------|------|
|
||||
| `RESEND_API_KEY` | [Resend](https://resend.com) API 키 |
|
||||
| `RESEND_FROM_EMAIL` | 발신 주소(Resend에서 허용된 도메인 또는 테스트 발신자) |
|
||||
| `MEMBER_SESSION_SECRET` | 회원 세션 쿠키 서명용 비밀값. 운영에서는 필수이며 `ADMIN_PASSWORD`와 분리된 긴 난수 문자열을 사용한다. **OTP 해시에 쓰는 pepper로도 사용**되므로, `EMAIL_OTP_PEPPER`를 비워 두면 이 값이 OTP용 비밀 재료가 된다. |
|
||||
| `EMAIL_OTP_PEPPER` | **선택.** 이메일로 받은 6자리 숫자를 DB에 넣기 전 SHA256 해시할 때 섞는 **서버 전용 비밀 문자열**이다. DB가 유출돼도 pepper를 모르면 인증번호 역산·무차별 대입이 어렵다. **짧은 숫자 한두 개가 아니라**, `openssl rand -hex 32`처럼 **긴 난수 문자열(32바이트 이상 권장)**을 쓰는 것이 안전하다. 비우면 `MEMBER_SESSION_SECRET`을 pepper로 쓴다. |
|
||||
|
||||
`RESEND_API_KEY`와 `RESEND_FROM_EMAIL`이 비어 있으면 기존처럼 이메일 OTP 없이 최초 관리자 가입·일반 가입(OTP 생략)이 동작한다.
|
||||
- 관리 도구: CloudBeaver 등으로 추후 연결 가능하게 설계
|
||||
- NAS Docker 배포 시 PostgreSQL 초기 스키마는 `db/migrations/`의 SQL로 생성
|
||||
- 로컬 개발 Docker Compose 실행 시 `ENV_FILE=.env.development`와 `--env-file .env.development`를 함께 사용
|
||||
- 로컬 개발 DB 마이그레이션은 `npm run db:migrate:dev`로 실행
|
||||
- 네비게이션 계층(`parent_id`, `is_folder`)은 `017_navigation_hierarchy.sql` 적용 후 저장 API가 정상 동작한다(미적용 시 `INSERT` 컬럼 불일치).
|
||||
- 회원 마지막 로그인 표시(`previous_last_seen_at`, `previous_last_seen_ip`)는 `021_add_member_previous_login.sql` 적용 후 정상 동작한다.
|
||||
- 사이트 로고와 파비콘 저장(`logo_url`, `favicon_url`)은 `022_add_site_logo_urls.sql` 적용 후 정상 동작한다.
|
||||
|
||||
### 개발/운영 DB 분리 검증 절차
|
||||
|
||||
@@ -203,6 +222,7 @@ test -f .env.production && rg -n "^(DATABASE_URL|POSTGRES_DB|POSTGRES_USER|APP_P
|
||||
- NAS Docker 내부 실행 기준이면 `DATABASE_URL` 호스트가 `sori-studio-db`
|
||||
- NAS 외부 DB를 별도 인스턴스로 쓰는 경우에도 로컬 개발 DB(`127.0.0.1:43119`)를 가리키지 않음
|
||||
- `APP_PORT=43118`
|
||||
- `MEMBER_SESSION_SECRET`이 비어 있지 않고 `ADMIN_PASSWORD`와 다름
|
||||
- `.env.development`와 DB 비밀번호, 관리자 비밀번호가 서로 다름
|
||||
|
||||
3. 로컬 개발 DB 연결 확인.
|
||||
|
||||
170
docs/history.md
170
docs/history.md
@@ -1,5 +1,175 @@
|
||||
# 의사결정 이력
|
||||
|
||||
## 2026-05-14 v1.0.0
|
||||
|
||||
### 운영 환경의 샘플 콘텐츠 fallback 차단
|
||||
|
||||
개발 단계에서는 DB 없이도 화면 구조를 확인할 수 있도록 샘플 게시물 fallback이 유용했지만, 운영 환경에서 `DATABASE_URL` 누락을 샘플 콘텐츠로 숨기면 잘못된 배포를 정상 서비스처럼 보이게 만든다. 따라서 `NODE_ENV=production`에서는 DB URL이 없으면 즉시 실패하도록 바꾸고, 샘플 콘텐츠 fallback은 개발 환경의 보조 장치로만 남긴다.
|
||||
|
||||
### 회원 세션 비밀값 분리
|
||||
|
||||
회원 세션 서명값이 `ADMIN_PASSWORD`로 fallback되면 관리자 로그인 비밀번호와 회원 쿠키 서명 책임이 섞인다. 운영에서 키 회전과 사고 대응을 분리할 수 있도록 `MEMBER_SESSION_SECRET`을 필수값으로 두고, 누락 시 명확한 서버 오류를 반환한다.
|
||||
|
||||
### 최소 회귀 검증 스크립트 추가
|
||||
|
||||
현재 프로젝트에는 전용 테스트 프레임워크가 없으므로 먼저 적용 가능한 최소 자동 검증으로 JavaScript 문법 점검과 Nuxt 프로덕션 빌드를 묶었다. `npm run verify`는 이후 단위 테스트나 E2E 테스트가 추가될 때 같은 진입점으로 확장한다.
|
||||
|
||||
## 2026-05-13 v0.0.121
|
||||
|
||||
### 자동 저장 안내를 툴바로 이동
|
||||
|
||||
자동 저장본이 있을 때 본문 상단에 배너를 띄우면 제목·본문 입력 흐름을 가리고 시각적으로도 무겁다. 이미 툴바 상태 영역에 자동 저장 시각 안내 문자열을 표시하고 있으므로, 같은 줄에 복원과 로컬 초안 삭제(무시)만 작은 버튼으로 붙이면 기능은 유지하면서 편집 영역 침범을 없앨 수 있다.
|
||||
|
||||
## 2026-05-13 v0.0.120
|
||||
|
||||
### 발행 모달을 Ghost 설정 행 패턴에 맞춤
|
||||
|
||||
첫 구현은 설정 제목과 버튼이 항상 펼쳐져 있어 고스트의 `gh-publish-setting`처럼 “현재 값만 보이다가 클릭 시 옵션 노출” 흐름과 달랐다. 사용자가 제공한 마크업에 맞춰 종이비행기·시계·펼침 화살표 SVG를 그대로 쓰고, 행 단위 접기/펼침으로 요약 표시를 맞췄다. 설정 블록 외곽의 상하 보더는 제거하고 행 사이 구분선만 두어 시각적 잡음을 줄였다.
|
||||
|
||||
## 2026-05-13 v0.0.119
|
||||
|
||||
### 게시물 저장 전 최종 발행 모달 도입
|
||||
|
||||
우측 설정 패널의 상태 셀렉트만으로 발행/초안/비공개를 반복 전환하는 흐름은 저장 전 최종 상태를 한눈에 확인하기 어렵고 조작도 번거롭다. 저장 버튼을 눌렀을 때 고스트 스타일의 전체 화면 발행 모달을 열고, 상태(발행/초안/비공개)와 발행 시점(즉시/예약)을 버튼식으로 빠르게 선택한 뒤 최종 확정하도록 정리했다. 뉴스레터 관련 섹션은 현재 기능 범위에 없으므로 제외했다.
|
||||
|
||||
## 2026-05-13 v0.0.119
|
||||
|
||||
### 콜아웃을 옵션 메타 기반으로 확장
|
||||
|
||||
콜아웃은 단순 본문만 저장하면 디자인 옵션(이모지 노출 여부, 이모지 종류, 배경 톤)을 유지할 수 없어, 작성 화면과 공개 화면의 결과를 일치시키기 어렵다. 기존 fenced 문법을 유지하면서 선언부 메타(`emoji`, `bg`)를 추가해 저장 포맷 변경 범위를 최소화했다. 이 방식은 기존 `:::callout` 콘텐츠와 호환되며, 이후 색상/아이콘 프리셋이 늘어나도 본문 포맷을 다시 바꾸지 않고 확장할 수 있다.
|
||||
편집 UI는 카드 내부에 옵션 컨트롤을 넣으면 실제 공개 결과와 작성 화면이 달라 보이므로, 고스트처럼 콜아웃 카드 자체는 결과 형태를 유지하고 설정은 별도 패널로 분리했다.
|
||||
콜아웃 카드 자체는 테두리 장식이 과하면 본문 흐름에서 떠 보이므로 보더를 제거하고 배경 톤 중심으로 정리했다. 이모지 선택은 정해진 목록만 강제하지 않고 별도 팝업 입력을 함께 제공해 시스템 이모지 입력 흐름을 수용한다.
|
||||
|
||||
## 2026-05-13 v0.0.118
|
||||
|
||||
### 게시글 저장·삭제 액션 강조도 조정
|
||||
|
||||
게시글 편집 화면의 저장 버튼은 변경사항이 없을 때도 활성화되어 있어 실제 저장 필요 상태를 구분하기 어렵다. 이미 미저장 변경사항 감지 기준을 가지고 있으므로 같은 기준으로 저장 버튼 활성 상태를 제어한다. 삭제 버튼은 파괴적 액션이지만 항상 빨간색이면 편집 흐름에서 과하게 눈에 띄므로 기본 상태는 중립 톤으로 두고 hover 시에만 위험 색상으로 전환한다. 태그 삭제는 텍스트 `x` 대신 SVG 닫기 아이콘을 사용해 배지 안 정렬을 안정화한다.
|
||||
|
||||
## 2026-05-13 v0.0.117
|
||||
|
||||
### 갤러리 선택과 순서 편집 흐름 정리
|
||||
|
||||
갤러리는 단일 이미지 블록과 달리 여러 이미지를 한 번에 구성하는 블록이므로, 미디어 클릭 즉시 적용하면 선택을 이어갈 수 없고 실수 수정도 번거롭다. 갤러리 미디어 선택은 모달 안에서 복수 선택 상태를 유지한 뒤 확인 시점에 블록에 반영한다. 이미지 개수별 열 수를 1·2·3열로 제한해 빈 칸을 줄이고, 작성자가 시각 흐름을 직접 정할 수 있도록 갤러리 내부 이미지는 드래그로 재정렬한다. 드래그 중에는 이미지 사이 삽입 위치를 선으로 표시해 어느 위치에 들어갈지 명확히 보여준다.
|
||||
|
||||
## 2026-05-13 v0.0.116
|
||||
|
||||
### 게시글 제목 IME 입력과 목록 태그 표시 보정
|
||||
|
||||
게시글 제목 입력에서 한글 조합 중 Enter를 본문 포커스 이동으로 함께 처리하면 마지막 조합 글자가 본문 에디터에 들어갈 수 있다. Enter가 IME 조합 확정인지 일반 이동 명령인지 구분해 조합 중에는 본문 이동을 막는다. 게시글 목록 태그는 편집 가능한 입력과 달리 삭제 액션이 필요 없으므로, 태그 관리와 같은 배지 인식성만 유지한 읽기 전용 형태로 표시한다.
|
||||
|
||||
## 2026-05-13 v0.0.115
|
||||
|
||||
### 사이트 설정과 계정 설정 책임 분리
|
||||
|
||||
관리자 사이트 설정에 관리자 프로필과 비밀번호 변경을 함께 두면 사이트 메타데이터와 개인 계정 관리가 섞인다. 계정 정보는 멤버 편집 화면에서 처리하고, 사이트 설정은 이름·설명·URL·로고·저작권처럼 공개 사이트 자체에 영향을 주는 값만 남긴다. 공개 사용자 설정은 중앙 컬럼 폭이 좁아 3분할 구조가 답답하므로 요약을 상단에 둔 세로형 흐름으로 바꾸고, 로고는 텍스트 대신 1:1 이미지를 저장해 공개 로고와 파비콘에 함께 사용한다.
|
||||
|
||||
## 2026-05-13 v0.0.114
|
||||
|
||||
### 멤버 계정 작업과 사용자 설정 화면 정리
|
||||
|
||||
관리자 하단의 `내 프로필`은 공개 사용자 설정으로 이동하면 관리자 컨텍스트가 끊기므로, 같은 계정이라도 관리자 멤버 편집 화면으로 보내 계정 관리 흐름을 유지한다. 비밀번호 직접 변경은 이메일 전송 장애 같은 비상 상황을 위한 관리자 전용 보조 수단으로 두고, 일반 사용자 설정에서는 비밀번호 변경과 회원 탈퇴를 상시 노출하지 않고 설정 메뉴의 모달 액션으로 낮췄다. 마지막 로그인은 현재 세션 조회 때마다 갱신하면 의미가 흐려지므로, 로그인 성공 시 기존 `last_seen_*` 값을 `previous_last_seen_*`로 옮긴 뒤 현재 로그인만 갱신한다.
|
||||
|
||||
## 2026-05-13 v0.0.113
|
||||
|
||||
### 멤버 필터와 썸네일 편집 방식 정리
|
||||
|
||||
멤버 목록은 검색만으로는 “Gmail 사용자 제외”, “비활성 사용자”, “특정 날짜 이후 접속 없음”처럼 운영자가 자주 쓰는 조건을 표현하기 어렵다. 서버 API를 먼저 확장하지 않고 현재 화면의 회원 목록 데이터를 기준으로 클라이언트 조건 필터를 제공해 UI 흐름을 빠르게 검증한다. 멤버 상세의 썸네일은 URL 문자열보다 이미지 자체를 클릭해 등록·변경·제거하는 방식이 더 자연스러우므로, URL 입력 필드는 숨기고 요약 영역의 원형 썸네일 액션으로 책임을 옮긴다.
|
||||
|
||||
## 2026-05-13 v0.0.112
|
||||
|
||||
### 관리자 편집 화면 이탈 확인 공통화
|
||||
|
||||
게시글 작성 화면은 로컬 자동 저장이 있지만 서버 저장 전 변경사항을 사용자가 의식하지 못한 채 목록으로 이동할 수 있었다. 멤버 편집 화면도 같은 편집 맥락을 가지므로, 라우트 이탈은 Ghost형 공통 모달로 한 번 확인하고 브라우저 새로고침·탭 닫기는 브라우저 기본 확인에 맡긴다. 게시글에서 이탈을 승인한 경우에는 임시 자동 저장본도 함께 버려 사용자가 명시적으로 떠난 내용을 다음 진입 때 다시 제안하지 않도록 한다.
|
||||
|
||||
## 2026-05-13 v0.0.111
|
||||
|
||||
### 관리자 멤버 상세와 추가 화면 분리
|
||||
|
||||
멤버 목록에서 모든 편집 기능을 처리하면 목록의 스캔성이 떨어지고 권한 변경 같은 민감 액션도 너무 쉽게 노출된다. 목록은 관측과 진입에 집중하고, 개별 회원의 이름·이메일·레이블·관리자 노트는 별도 상세 화면에서 저장한다. 레이블은 아직 공개 기능에 쓰지 않지만 이후 사용자별 칭호나 분류로 확장할 수 있도록 배열 컬럼으로 두고, 신규 회원은 활동 이력이 없으므로 활동 섹션을 렌더링하지 않는다.
|
||||
|
||||
## 2026-05-13 v0.0.110
|
||||
|
||||
### 관리자 멤버 목록 정보 밀도 정리
|
||||
|
||||
멤버 목록에서 이름, 이메일, 접속일, 권한 변경 컨트롤을 모두 별도 컬럼으로 두면 한 사람의 정보가 가로로 흩어지고 목록이 지나치게 넓어진다. Ghost 관리자처럼 닉네임 아래 이메일, 가입일 아래 최근 활동을 함께 묶어 읽는 구조로 바꾸고, 권한 변경은 사용자를 선택한 뒤 처리하는 후속 화면의 책임으로 분리한다. 뉴스레터 지표는 이 프로젝트에 없으므로 같은 위치에는 댓글 작성 개수를 표시한다.
|
||||
|
||||
## 2026-05-13 v0.0.109
|
||||
|
||||
### 관리자 사이드바 하단 사용자 영역 정리
|
||||
|
||||
상단 메뉴 아래에 로그아웃이 바로 붙어 있으면 Ghost형 관리자 내비게이션의 정보 구조와 달라지고, 주요 메뉴와 세션 액션이 같은 레벨로 보인다. 로그아웃은 하단 사용자 썸네일 드롭다운으로 옮기고, 설정은 하단 아이콘으로 배치해 상단 메뉴는 콘텐츠 관리 항목 중심으로 유지한다. 멤버 항목에는 총 멤버 수를 함께 보여 관리자가 현재 규모를 즉시 확인할 수 있게 한다.
|
||||
|
||||
## 2026-05-13 v0.0.108
|
||||
|
||||
### 관리자 캔버스 높이와 사이드바 폭 정리
|
||||
|
||||
관리자 개별 페이지가 각자 `section` 배경과 여백을 책임하면 콘텐츠가 짧은 화면에서 우측 배경이 끊겨 보인다. Ghost 관리자처럼 레이아웃의 우측 캔버스가 기본 화면 높이와 배경을 먼저 책임지게 하고, 사이드바는 320px 고정 폭으로 맞춰 목록·설정 화면의 기준 여백을 더 여유롭게 잡는다.
|
||||
|
||||
## 2026-05-13 v0.0.107
|
||||
|
||||
### 관리자 사이드바 Ghost형 톤 전환
|
||||
|
||||
기능 구현이 어느 정도 갖춰진 뒤에도 관리자 첫 화면의 어두운 사이드바는 오래된 CMS 느낌을 강하게 만들었다. Ghost 관리자처럼 밝은 바탕, 낮은 대비의 활성 행, 아이콘+라벨 내비게이션으로 바꾸고, 게시글 행 우측에 새 글 작성 `+` 버튼을 두어 목록을 거치지 않고 바로 작성으로 들어가게 했다.
|
||||
|
||||
## 2026-05-12 v0.0.105
|
||||
|
||||
### 네비게이션 기본 시드 중복 방지
|
||||
|
||||
`017_navigation_hierarchy.sql`에서 메뉴 계층 지원을 위해 `(location,label,url)` 유니크 제약을 제거한 뒤, 기존 `005_add_navigation_items.sql`의 `ON CONFLICT DO NOTHING`은 더 이상 동일 라벨·URL 기본 메뉴 중복을 막지 못했다. 개발 DB 마이그레이션은 전체 SQL 파일을 반복 실행하므로 기본 메뉴가 새 UUID로 누적될 수 있어, 시드 삽입을 `NOT EXISTS` 조건으로 바꾸고 `019_dedupe_navigation_items.sql`에서 기존 중복을 정리한 뒤 표현식 유니크 인덱스로 재발을 막는다.
|
||||
|
||||
## 2026-05-12 v0.0.104
|
||||
|
||||
### 관리자 권한 재검증과 마지막 소유자 보호
|
||||
|
||||
관리자 세션 쿠키는 서명과 만료만으로는 권한 변경·회원 탈퇴 이후 상태를 반영하지 못한다. `/admin/api/*` 요청마다 DB의 현재 `owner`/`admin` 권한을 다시 확인하는 서버 미들웨어를 추가해, 권한이 내려가거나 계정이 삭제된 세션은 즉시 차단한다. 회원 탈퇴는 마지막 `owner`를 없애지 못하도록 막고, 탈퇴 시 관리자 쿠키도 함께 정리한다.
|
||||
|
||||
### OTP 발송 실패와 초기 owner 판정 안정화
|
||||
|
||||
OTP는 메일 발송에 실패했는데 DB 챌린지만 남으면 사용자가 코드를 받지 못한 채 쿨다운에 걸릴 수 있다. 새 챌린지는 먼저 만들되 발송 실패 시 즉시 삭제하고, 발송 성공 후 이전 pending 챌린지를 정리한다. 첫 회원 생성은 동시에 들어온 요청이 모두 owner로 판정되지 않도록 `users` 테이블 잠금 안에서 처리한다.
|
||||
|
||||
## 2026-05-12 v0.0.103
|
||||
|
||||
### map.md와 관리자 메뉴 화면 동기화
|
||||
|
||||
본문에서 마이그레이션 안내 블록을 제거했으므로 `docs/map.md` 설명에서도 해당 문구를 뺐다.
|
||||
|
||||
## 2026-05-12 v0.0.102
|
||||
|
||||
### 관리자 블록 에디터 빈 단락·키보드·슬래시 메뉴
|
||||
|
||||
Markdown 직렬화에서 연속 빈 줄은 사라져 중간 빈 단락을 유지하기 어렵다. 편집용으로만 `<!--sori:blank-paragraph-->` 한 줄을 쓰고 공개 렌더러에서 동일하게 빈 단락으로 복원한다. 슬래시 팔레트는 필터 변경 시 하이라이트를 초기화하고, 긴 목록은 스크롤·`scrollIntoView`로 따라가며, `/`가 아닐 때는 위아래 키로 블록 간 커서를 옮긴다.
|
||||
|
||||
## 2026-05-12 v0.0.101
|
||||
|
||||
### 공개 primary 네비 트리 중복 방지
|
||||
|
||||
`parent_id`가 있는 행과 루트로 들어온 동일 id가 공존하거나, 평면 목록에 id가 중복되면 한 루프로는 같은 노드가 `roots`와 부모 `children`에 동시에 들어갈 수 있다. 서버에서 id 단위로 한 번만 쓰고, 자식으로 연결된 id는 루트 후보에서 빼서 UI 중복을 제거한다.
|
||||
|
||||
## 2026-05-12 v0.0.100
|
||||
|
||||
### EMAIL_OTP_PEPPER 문서화
|
||||
|
||||
운영자가 짧은 숫자만 넣는 오해를 줄이기 위해, pepper는 세션 비밀과 별도로 OTP 해시에만 쓰이는 **긴 난수 문자열**임을 배포 문서·예시 env에 명시했다.
|
||||
|
||||
## 2026-05-12 v0.0.99
|
||||
|
||||
### 헤더 검색 중앙·Resend 이메일 OTP
|
||||
|
||||
헤더는 좌측 브랜드·우측 계정 사이에서 검색을 시각적 중심에 두는 편이 Ghost/Thred류 기대와 맞다. 트랜잭션 메일은 외부 SMTP 대신 Resend 단일 API로 운영 부담을 줄이고, 키가 없을 때는 기존 가입 흐름을 유지한다.
|
||||
|
||||
## 2026-05-12 v0.0.98
|
||||
|
||||
### 사이드바 푸터 링크 줄바꿈·상단 네비 호버 너비
|
||||
|
||||
`footer` 항목이 한 줄 `flex`로만 두면 좁은 사이드바에서 가로로 삐져나간다. `nav`에 `flex-wrap`과 `min-w-0 flex-1`을 주고 링크는 줄 단위로 감싸지게 했다. 상단 네비 행은 링크가 콘텐츠 너비만 차지하면 `site-panel-hover` 배경이 짧게 보이므로 `w-full`로 행 전체와 맞췄다.
|
||||
|
||||
## 2026-05-12 v0.0.97
|
||||
|
||||
### 상단 네비 장식·현재 페이지 표시
|
||||
|
||||
부모 행만 점으로 두면 리프와 시각 언어가 갈린다. `before` 세로바를 통일하고, 내부 활성 경로는 브랜드 악센트로 구분했다.
|
||||
|
||||
## 2026-05-12 v0.0.96
|
||||
|
||||
### 사이드바 상단 네비 폴더 토글 UX
|
||||
|
||||
75
docs/map.md
75
docs/map.md
@@ -8,8 +8,9 @@
|
||||
|------|------|
|
||||
| layouts/default.vue | 메인·목록·태그 — 3열 `gap`+중앙 `1fr`, `site-main` `max-w-[720px]`, 모바일 슬라이드 메뉴 |
|
||||
| layouts/post.vue | 개별 게시물 — `default`와 동일 |
|
||||
| layouts/admin.vue | 관리자 전체, 글 작성/수정 화면의 전체 화면 편집 모드와 문서 스크롤 잠금 |
|
||||
| layouts/admin.vue | 관리자 전체, 320px 밝은 Ghost형 사이드바, 우측 전체 높이 캔버스, 멤버 수 표시, 하단 사용자 드롭다운·설정, `내 프로필` 멤버 편집 이동, 게시글 `+` 새 글 진입, 글 작성/수정 화면의 전체 화면 편집 모드와 문서 스크롤 잠금 |
|
||||
| layouts/page.vue | 고정 페이지 전체 화면 |
|
||||
| app.vue | 공개 사이트 설정 기반 파비콘 head 링크와 기본 title template |
|
||||
|
||||
## Composables
|
||||
|
||||
@@ -30,16 +31,28 @@
|
||||
|------|------|
|
||||
| modules/nuxt-ssr-paths-write.mjs | `paths.mjs`를 `.nuxt`에 기록해 Node가 `#internal/nuxt/paths`를 해석할 수 있게 함 |
|
||||
|
||||
## Scripts
|
||||
|
||||
| 파일 | 용도 |
|
||||
|------|------|
|
||||
| scripts/check-js-syntax.js | `npm run lint`에서 JS/MJS/CJS 파일을 `node --check`로 문법 점검 |
|
||||
|
||||
## 서버 미들웨어
|
||||
|
||||
| 파일 | 용도 |
|
||||
|------|------|
|
||||
| server/middleware/admin-api-session.js | `/admin/api/*` 요청마다 관리자 세션과 현재 DB 권한(`owner`/`admin`) 재확인 |
|
||||
|
||||
## 사이트 컴포넌트
|
||||
|
||||
| 파일 | 화면 위치 |
|
||||
|------|-----------|
|
||||
| components/auth/AuthPasswordVisibilityToggle.vue | 로그인·회원가입 비밀번호 표시/숨김 토글(SVG, scoped 스타일·`field-name`으로 접근성 레이블 구분) |
|
||||
| components/site/SiteHeader.vue | 모든 공개 페이지 상단, 우측 사용자 아바타 드롭다운(로그인: 설정/로그아웃, 비로그인: Sign up/Sign in), `lg`~`xl` 헤더 여백·반응형 검색창 폭, `/`·검색 영역으로 `SiteSearchModal` 연동 |
|
||||
| components/site/SiteHeader.vue | 모든 공개 페이지 상단, 이미지 로고 fallback, `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`) |
|
||||
| components/site/SidebarPrimaryNavList.vue | 공개 상단 네비 트리: 하위 있으면 행 전체 `button` 토글·SVG chevron 회전·`grid` 높이 애니메이션, `inject`로 펼침·`localStorage` `sori-primary-nav-expanded` |
|
||||
| components/site/RightSidebar.vue | 오른쪽 사이드바, `lg+`는 고정 열 높이·스티키, 모바일은 본문 아래 전체 너비, 하단 푸터 `pr-3` |
|
||||
| components/site/LeftSidebar.vue | 왼쪽 사이드바, `lg+`는 `sticky`+고정 높이+내부 무스크롤바 스크롤, `lg` 미만은 고정 슬라이드 패널, 상단 메뉴는 `SidebarPrimaryNavList`+`provide`로 트리·펼침 상태(`sori-primary-nav-expanded`), 푸터 `footer` 링크는 `flex-wrap`·테마 버튼 `shrink-0` |
|
||||
| components/site/SidebarPrimaryNavList.vue | 상단 네비: 부모·리프 동일 `before` 막대/호버 원형, 내부 현재 경로 `--site-accent`, 행 `w-full`로 패널 호버 배경 폭, `inject`·`localStorage` 펼침 |
|
||||
| components/site/RightSidebar.vue | 오른쪽 사이드바, 공개 사이트 이미지 로고 fallback, `lg+`는 고정 열 높이·스티키, 모바일은 본문 아래 전체 너비, 하단 푸터 `pr-3` |
|
||||
| components/site/MainColumn.vue | 메인 화면 중앙, `lg:max-w-[720px]`로 본문 상한 |
|
||||
| components/site/PostCard.vue | 목록의 게시물 카드, 대표 이미지 썸네일, 카드 hover 인터랙션 |
|
||||
| components/site/TagHeader.vue | 태그 페이지 헤더 |
|
||||
@@ -49,11 +62,19 @@
|
||||
|
||||
| 파일 | 화면 위치 |
|
||||
|------|-----------|
|
||||
| components/admin/AdminPostForm.vue | 관리자 글 작성/수정 폼, Ghost 스타일 전체 화면 에디터, 좌우 분할 설정 패널, 설정 패널 전환 애니메이션, Post URL 보기 액션, 하단 삭제 액션, 대표 이미지 hover 액션과 업로드/미디어 선택 확정, 배지형 태그 입력(한글 유지), 로컬 자동 저장, 예약 발행 시각 입력, 검색 노출 제외(noindex), 저장 시 제목·요약을 SEO 메타로 반영, 미리보기 요청 |
|
||||
| components/admin/AdminPostForm.vue | 관리자 글 작성/수정 폼, Ghost 스타일 전체 화면 에디터, 변경사항 기반 저장 버튼 활성화, 저장 클릭 시 전체 화면 발행 모달(행 접기/펼침, Ghost 동일 SVG 아이콘, 상태 요약 후 발행/초안/비공개 선택, 발행 시에만 시점 행·즉시/예약·datetime-local), 좌우 분할 설정 패널, 설정 패널 전환 애니메이션, Post URL 보기 액션, 중립 톤 삭제 액션, 대표 이미지 hover 액션과 업로드/미디어 선택 확정, 제목 IME Enter 가드, SVG 닫기 아이콘 배지형 태그 입력(한글 유지), 로컬 자동 저장(툴바 상태 옆 복원·무시), 미저장 변경사항 이탈 확인, 검색 노출 제외(noindex), 저장 시 제목·요약을 SEO 메타로 반영, 미리보기 요청 |
|
||||
| components/admin/AdminPageForm.vue | 관리자 페이지 작성/수정 폼, 대표 이미지 선택 |
|
||||
| components/admin/AdminBlockEditor.vue | 관리자 글 블록형 에디터, 이미지/갤러리/콜아웃/토글/임베드 블록, 한글 조합 입력 처리, Shift+Enter 줄바꿈, 코드 블록 단축 변환, AFFiNE 참고 세로 막대형 블록 핸들 선택/삭제/드래그 이동과 삽입선 표시, 하단 빈 입력 블록 유지, 본문 placeholder 표시 |
|
||||
| components/admin/AdminBlockEditor.vue | 관리자 글 블록형 에디터, 이미지/갤러리/콜아웃/토글/임베드 블록, 콜아웃 Emoji on/off·이모지 프리셋·배경 프리셋 선택(우측 고정 설정 패널), 갤러리 복수 미디어 선택·이미지 수별 열 배치·삽입 위치 표시 드래그 순서 변경, 한글 조합 입력 처리, Shift+Enter 줄바꿈, 코드 블록 단축 변환, AFFiNE 참고 세로 막대형 블록 핸들 선택/삭제/드래그 이동과 삽입선 표시, 하단 빈 입력 블록 유지, 본문 placeholder 표시 |
|
||||
| components/admin/AdminNavPrimaryBranch.vue | 관리자 상단 네비 트리(테이블·태그와 동일한 행 드래그 하이라이트, 하위·삭제) |
|
||||
| components/admin/AdminTagForm.vue | 관리자 태그 생성/수정 폼(이름/슬러그/설명/색상만 편집) |
|
||||
| components/admin/AdminMemberForm.vue | 관리자 멤버 추가/수정 폼(Ghost형 3분할, 요약 1fr·입력 2fr, 원형 썸네일 hover 등록·변경·삭제, 기본 정보·레이블·관리자 노트·활동 요약, 설정 메뉴의 비밀번호 변경·멤버 삭제 모달, 미저장 변경사항 이탈 확인) |
|
||||
| components/admin/AdminUnsavedChangesModal.vue | 관리자 편집 화면 공통 미저장 변경사항 이탈 확인 모달(상단 40px 위치) |
|
||||
|
||||
## 관리자 컴포저블
|
||||
|
||||
| 파일 | 화면 위치 |
|
||||
|------|-----------|
|
||||
| composables/useAdminUnsavedChangesGuard.js | 관리자 게시글/멤버 편집 화면 라우트 이탈 확인과 브라우저 `beforeunload` 연결 |
|
||||
|
||||
## 콘텐츠 컴포넌트
|
||||
|
||||
@@ -66,16 +87,16 @@
|
||||
| components/content/ProseList.vue | 목록 |
|
||||
| components/content/ProseBlockquote.vue | 인용구 |
|
||||
| components/content/ProseButton.vue | 버튼 |
|
||||
| components/content/ProseCallout.vue | Callout 카드 |
|
||||
| components/content/ProseCallout.vue | Callout 카드(Emoji 표시/숨김, 배경 프리셋, 상단 여백 중심) |
|
||||
| components/content/ProseToggle.vue | Toggle 카드 |
|
||||
| components/content/ProseVideo.vue | 비디오 |
|
||||
| components/content/ProseAudio.vue | 오디오 |
|
||||
| components/content/ProseFile.vue | 파일 |
|
||||
| components/content/ProseProduct.vue | 상품 카드 |
|
||||
| components/content/ProseBookmark.vue | 본문 북마크 카드(썸네일·도메인·외부 링크) |
|
||||
| components/content/ProseBookmark.vue | 본문 북마크 카드(썸네일·도메인·`http(s)` 외부 링크) |
|
||||
| components/content/ProseSignup.vue | 본문 회원가입·뉴스레터 CTA 카드 |
|
||||
| components/content/ProseHeaderCard.vue | 헤더 카드 |
|
||||
| components/content/ProseEmbed.vue | YouTube·Twitter/X iframe 임베드, 기타 외부 링크 |
|
||||
| components/content/ProseEmbed.vue | YouTube·Twitter/X iframe 임베드, 기타 `http(s)` 외부 링크 |
|
||||
|
||||
## 관리자 페이지
|
||||
|
||||
@@ -83,7 +104,7 @@
|
||||
|------|------|
|
||||
| pages/admin/index.vue | 대시보드 |
|
||||
| pages/admin/login.vue | 관리자 로그인(이메일·비밀번호 모두 입력 시에만 제출 버튼 활성) |
|
||||
| pages/admin/posts/index.vue | 글 목록, 예약 발행 상태 표시, 삭제는 휴지통 아이콘·기본 낮은 강조 |
|
||||
| pages/admin/posts/index.vue | 글 목록, 예약 발행 상태 표시, 읽기 전용 태그 배지, 삭제는 휴지통 아이콘·기본 낮은 강조 |
|
||||
| pages/admin/posts/new.vue | 글 작성, Ghost 스타일 작성 폼, 저장 토스트 |
|
||||
| pages/admin/posts/[id].vue | 글 수정, Ghost 스타일 작성 폼, 저장/삭제 토스트 |
|
||||
| pages/admin/posts/preview.vue | 글 미리보기(공개 상세와 동일한 중앙 컬럼·수평 패딩) |
|
||||
@@ -91,12 +112,14 @@
|
||||
| pages/admin/pages/new.vue | 페이지 작성, 저장 토스트 |
|
||||
| pages/admin/pages/[id].vue | 페이지 수정, 저장/삭제 토스트 |
|
||||
| pages/admin/media/index.vue | 미디어 관리, **미디어 라이브러리/썸네일** 탭, 검색은 파일명·게시물 사용처 제목만, 라이브러리: 폴더 트리·드래그 이동 등, 썸네일: 미참조 파일 삭제·이름 변경 가능, 상세 모달(연결 회원·폴더 편집·**다운로드**) |
|
||||
| pages/admin/navigation/index.vue | 메뉴 관리: 상단/하단 탭, 테이블+행 드래그(태그 메인과 동일 톤), `useAdminToast`, 마이그레이션 안내 |
|
||||
| pages/admin/navigation/index.vue | 메뉴 관리: 상단/하단 탭, 테이블+행 드래그(태그 메인과 동일 톤), `useAdminToast` |
|
||||
| pages/admin/tags/index.vue | 태그 관리(메인 태그 드래그 정렬·일반 강등, 일반 태그 검색/메인 전환/삭제), 액션 피드백 토스트, 순서 변경 시에만 정렬 저장 활성 |
|
||||
| pages/admin/tags/new.vue | 태그 생성 |
|
||||
| pages/admin/tags/[id].vue | 태그 수정 |
|
||||
| pages/admin/settings/index.vue | 사이트 설정 + 관리자 프로필(썸네일/이름) + 관리자 비밀번호 변경 |
|
||||
| pages/admin/members/index.vue | 관리자 멤버 목록(닉네임, 이메일, 최근 접속, IP, 댓글 수, 활동 상태) |
|
||||
| pages/admin/settings/index.vue | 사이트 설정(이름, 설명, URL, 1:1 로고 업로드·파비콘 생성, 저작권 문구) |
|
||||
| pages/admin/members/index.vue | 관리자 멤버 목록(Ghost형 테이블, 검색, 조건 필터, 멤버 추가 버튼, 닉네임+이메일, 가입일+최근 활동, IP, 댓글 수, 활동 상태 텍스트) |
|
||||
| pages/admin/members/new.vue | 관리자 멤버 추가(썸네일 URL, 이름, 이메일, 레이블, 관리자 노트) |
|
||||
| pages/admin/members/[id].vue | 관리자 멤버 상세/수정(회원 요약, 가입 정보, 활동 요약, 기본 정보 저장, 삭제 후 목록 이동) |
|
||||
|
||||
## 공개 페이지
|
||||
|
||||
@@ -108,11 +131,12 @@
|
||||
| pages/post/[slug].vue | 블로그 글 상세, 게시물 SEO/OG 메타 출력, 공유 모달(X/Bluesky/Facebook/LinkedIn/Email/링크복사), 회원 댓글 섹션 |
|
||||
| pages/tags/index.vue | 태그 전체 목록, 중앙 히어로와 3열 태그 카드, 좌측 컬러 보더/hover 오버레이 |
|
||||
| pages/tags/[slug].vue | `/tag/:slug` 리다이렉트 |
|
||||
| pages/tag/[slug].vue | 태그별 글 목록, 상단 태그 헤더 + 리스트형 게시물 카드 |
|
||||
| pages/tag/[slug].vue | 태그별 글 목록, 상단 태그 헤더 + 공통 섹션 패딩을 쓰는 리스트형 게시물 카드 |
|
||||
| pages/pages/[slug].vue | 고정 페이지 상세 |
|
||||
| pages/signup.vue | 회원가입 3단계, 2단계 입력에 `auth-form-input`, 패널 `auth-signup__panel`(보더·배경) |
|
||||
| pages/signin.vue | 로그인(다크 폼, `[color-scheme:dark]`, 입력 `auth-form-input`), 비밀번호 SVG 토글, 회원 로그인 API 연동, 이메일·비밀번호 입력 시에만 제출 버튼 활성 |
|
||||
| pages/settings/index.vue | 회원 설정(썸네일 미리보기/이미지 변경·제거, 닉네임 변경/중복확인, 비밀번호 변경, 회원 탈퇴) |
|
||||
| pages/signup.vue | 회원가입 3단계, `emailOtpConfigured`일 때 이메일 OTP·인증번호 받기, `POST /api/auth/email-otp/request` |
|
||||
| pages/signin.vue | 로그인, `/forgot-password` 링크 |
|
||||
| pages/forgot-password.vue | 비밀번호 찾기(Resend 설정 시 OTP 발송·`POST /api/auth/password-reset/confirm`) |
|
||||
| pages/settings/index.vue | 회원 설정(상단 프로필 요약, 가입 정보, 댓글 참여도, 하단 프로필 입력·이전 로그인 활동, 썸네일 변경·제거, 닉네임 저장, 설정 메뉴 모달 비밀번호 변경·회원 탈퇴) |
|
||||
|
||||
## 서버 API
|
||||
|
||||
@@ -126,8 +150,13 @@
|
||||
| server/api/search.get.js | 통합 검색 API(`q` 쿼리) |
|
||||
| server/api/site-settings.get.js | 공개 사이트 설정 API |
|
||||
| server/api/navigation.get.js | 공개 네비게이션 API |
|
||||
| server/api/auth/signup.post.js | 회원 가입 API |
|
||||
| server/api/auth/bootstrap-status.get.js | 최초 관리자 등록 필요 여부 조회 API |
|
||||
| server/api/auth/signup.post.js | 회원 가입 API(선택 `emailOtp`, Resend 설정 시 일반 가입에 필수) |
|
||||
| server/api/auth/bootstrap-status.get.js | 최초 관리자 여부 + `emailOtpConfigured` |
|
||||
| server/api/auth/email-otp/request.post.js | Resend로 OTP 발송(`signup` / `password_reset`), 발송 실패 챌린지 삭제 |
|
||||
| server/api/auth/password-reset/confirm.post.js | OTP 검증 후 비밀번호 재설정 |
|
||||
| server/repositories/email-otp-repository.js | `email_otp_challenges` CRUD·검증 |
|
||||
| server/utils/email-otp.js | OTP 생성·해시 |
|
||||
| server/utils/resend-mail.js | Resend REST 발송 |
|
||||
| server/api/auth/login.post.js | 회원 로그인 API |
|
||||
| server/api/auth/me.get.js | 회원 세션 조회 API |
|
||||
| server/api/auth/logout.post.js | 회원 로그아웃 API |
|
||||
@@ -137,7 +166,7 @@
|
||||
| server/api/auth/avatar.delete.js | 회원 썸네일 삭제 API |
|
||||
| server/api/auth/check-username.get.js | 닉네임 중복 확인 API |
|
||||
| server/api/auth/password.put.js | 회원 비밀번호 변경 API |
|
||||
| server/api/auth/account.delete.js | 회원 탈퇴 API |
|
||||
| server/api/auth/account.delete.js | 회원 탈퇴 API(마지막 `owner` 보호, 관리자 세션 함께 정리) |
|
||||
| server/api/posts/[slug]/comments.get.js | 게시물 댓글 목록 조회 API |
|
||||
| server/api/posts/[slug]/comments.post.js | 게시물 댓글 작성 API |
|
||||
| server/api/posts/[slug]/comments/[commentId]/like.post.js | 댓글 좋아요 토글 API |
|
||||
@@ -171,6 +200,9 @@
|
||||
| server/routes/admin/api/navigation.get.js | 관리자 네비게이션 목록 API |
|
||||
| server/routes/admin/api/navigation.put.js | 관리자 네비게이션 일괄 저장 API |
|
||||
| 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]/role.put.js | 관리자 멤버 권한 변경 API |
|
||||
| server/utils/content-schema.js | Zod 콘텐츠 스키마 |
|
||||
| server/utils/sample-content.js | 샘플 콘텐츠 저장소 |
|
||||
@@ -200,6 +232,7 @@
|
||||
| db/migrations/003_add_tag_display_fields.sql | 태그 표시 순서와 색상 필드 추가 |
|
||||
| db/migrations/004_add_site_settings.sql | 사이트 설정 테이블 추가 |
|
||||
| db/migrations/017_navigation_hierarchy.sql | `navigation_items`에 `parent_id`·`is_folder`, `(location,label,url)` 유니크 제거 |
|
||||
| db/migrations/019_dedupe_navigation_items.sql | 반복 마이그레이션으로 생긴 네비게이션 중복 행 정리 및 중복 방지 인덱스 |
|
||||
| db/migrations/005_add_navigation_items.sql | 네비게이션 항목 테이블 추가 |
|
||||
| db/migrations/006_add_media_metadata.sql | 미디어 메타데이터 테이블 추가 |
|
||||
| db/migrations/007_add_media_folders.sql | 미디어 폴더 테이블 추가 |
|
||||
|
||||
82
docs/spec.md
82
docs/spec.md
@@ -18,11 +18,11 @@
|
||||
|
||||
| 요소 | 크기/속성 |
|
||||
|------|-----------|
|
||||
| Header | 높이 57px, `sticky top-0`, `shrink-0`. `lg`~`xl` 구간은 내부 `px-5`~`px-6`로 좌우 여백을 두고, 검색창은 뷰포트에 맞춰 `max-w`로 단계 축소한다(`2xl`에서 고정 470px). |
|
||||
| Header | 높이 57px, `sticky top-0`, `shrink-0`. 내부는 `grid-cols-3`로 **좌(브랜드·메뉴) / 중앙(검색, `md+`에서만 표시) / 우(사용자 메뉴)** 배치해 검색 패널을 가운데 열에 정렬한다. 검색 버튼은 중앙 열 안에서 `max-w-[min(470px,100%)]`로 폭을 제한한다. |
|
||||
| Shell | `min-height: 100vh`, `flex` 세로 컬럼 |
|
||||
| 그리드(데스크톱 `lg+`) | `items-start`, 3열 그리드(`287px / minmax(0,1fr) / 287px`)를 사용하고 열 간 `column-gap`은 두지 않는다(`gap-x-0`). 경계선은 사이드바 보더로만 구분해 이중 패딩처럼 보이는 여백을 방지한다. 긴 본문은 **문서(`html`/`body`) 스크롤**로 처리한다. |
|
||||
| 그리드(모바일 `lg` 미만) | 단일 세로 흐름: **본문 → 오른쪽 사이드** 순. 왼쪽 사이드는 레이아웃 흐름에서 분리된 고정 슬라이드 패널로 표시 |
|
||||
| Left Aside | 너비 287px, `sticky top-[57px]`, `h-[calc(100vh-57px)]`와 `max-h` 동일(뷰포트 기준 고정 높이), 내부 상단은 `.site-sidebar-scroll`(스크롤바 숨김), 하단 푸터 `shrink-0`·상단 보더로 스크롤 영역과 구분, 푸터 좌우는 `px-4`~`sm:px-5`로 본문 블록과 유사한 여백 |
|
||||
| Left Aside | 너비 287px, `sticky top-[57px]`, `h-[calc(100vh-57px)]`와 `max-h` 동일(뷰포트 기준 고정 높이), 내부 상단은 `.site-sidebar-scroll`(스크롤바 숨김), 하단 푸터 `shrink-0`·상단 보더로 스크롤 영역과 구분, 푸터 좌우는 `px-4`~`sm:px-5`로 본문 블록과 유사한 여백. 푸터의 API `footer` 링크 영역은 `flex-wrap`·`min-w-0 flex-1`로 항목이 많을 때 줄바꿈되어 패널 밖으로 넘치지 않는다 |
|
||||
| Left Aside(모바일) | `fixed` 좌측 패널, 열림 시 `translate-x-0`, 닫힘 시 화면 밖으로 이동. 열린 동안 백드롭과 `html.site-mobile-nav-open`으로 문서 스크롤 잠금 |
|
||||
| Main | 중앙 열 안에서 `max-width: 720px`·`justify-self: start`, 별도 `overflow-y` 없음. 공개 페이지의 가로 패딩은 레이아웃 그리드(`public-layout__grid`)의 `px-*` 한 번만 사용하고, 본문 섹션의 `px-*`는 두지 않는다. |
|
||||
| Right Aside | Left와 동일 패턴(스티키·고정 높이·내부 무스크롤바 스크롤·하단 카피라이트) |
|
||||
@@ -58,6 +58,7 @@
|
||||
|
||||
- 게시물 화면은 레이아웃 그리드의 좌우 패딩을 기본으로 사용하고, 섹션 단위 `px-*`는 내부 래퍼로만 두어 패딩이 2중 적용되지 않게 한다.
|
||||
- 댓글 시작 섹션의 구분선(`border-y`)은 패딩 없이 전체 폭으로 표시하고, 내용만 내부 래퍼에 패딩을 둔다.
|
||||
- 본문 끝과 댓글 섹션 시작 사이 간격은 `48px`(`mt-12`)로 유지한다.
|
||||
- 댓글은 로그인 회원만 작성 가능하며, 대댓글은 1단까지만 허용한다.
|
||||
- 댓글은 작성자 썸네일(없으면 이니셜)과 좋아요 수를 표시한다.
|
||||
- 댓글 시간은 24시간 이내일 때 상대 시간(분/시간 전), 이후에는 날짜로 표시한다.
|
||||
@@ -86,6 +87,7 @@
|
||||
- `/post/:slug` - 개별 게시물 상세
|
||||
- `/tags` - 태그 전체 목록
|
||||
- `/tag/:slug` - 태그별 게시물 목록
|
||||
- `/tag/:slug` 화면의 헤더와 게시물 목록은 공개 목록 공통 섹션 패딩(`site-section-header`, `site-section-body`)을 사용해 메인 컬럼 좌우 여백을 다른 목록 화면과 맞춘다.
|
||||
- `/signup` - 회원가입(3단계: 환영/입력/이메일 확인)
|
||||
- `/signin` - 로그인
|
||||
- `/settings` - 회원 설정(썸네일 미리보기/변경/제거, 닉네임, 비밀번호, 회원 탈퇴)
|
||||
@@ -100,6 +102,7 @@
|
||||
- 로그인·회원가입(2단계) 비밀번호 입력은 `AuthPasswordVisibilityToggle` SVG(눈 열림/가림) 토글로 표시 여부를 바꾼다. 스크린 리더용 `aria-label`은 필드별 `field-name`으로 구분한다. 텍스트 입력은 `.auth-form-input`으로 글자색·캐럿 등을 보정한다.
|
||||
- 인증 화면 상태 메시지는 오류/안내를 분리해 `aria-live`로 노출한다.
|
||||
- 회원가입 1단계의 타이틀/설명은 `GET /api/site-settings`의 `title`, `description` 값을 우선 사용한다.
|
||||
- 회원 세션 쿠키 서명에는 `MEMBER_SESSION_SECRET`만 사용하며, 관리자 비밀번호를 fallback으로 쓰지 않는다.
|
||||
|
||||
### 레이아웃 파일
|
||||
|
||||
@@ -110,6 +113,17 @@ layouts/
|
||||
└── admin.vue # 관리자 화면
|
||||
```
|
||||
|
||||
### 관리자 레이아웃
|
||||
|
||||
- 관리자 사이드바는 밝은 Ghost형 톤을 기준으로 하며, 메뉴 행은 아이콘+라벨 구조를 사용한다.
|
||||
- 관리자 사이드바는 데스크톱에서 320px 너비를 사용한다.
|
||||
- 관리자 우측 캔버스는 기본 `min-h-screen`과 `bg-paper`를 유지해 콘텐츠 높이가 짧아도 배경이 화면 전체를 채운다. 일반 관리자 화면은 레이아웃 레벨에서 넉넉한 외부 여백을 둔다.
|
||||
- 게시글 메뉴 라벨은 `게시글`로 표시하고, 우측 `+` 아이콘은 `/admin/posts/new`로 바로 이동한다.
|
||||
- 메뉴 관리 항목은 `네비게이션`으로 표시한다.
|
||||
- 게시글·페이지·태그·미디어·네비게이션·멤버·설정 메뉴는 전용 SVG 아이콘을 사용한다.
|
||||
- 멤버 메뉴는 우측에 현재 총 멤버 수를 표시한다.
|
||||
- 로그아웃은 사이드바 상단 메뉴가 아니라 하단 사용자 썸네일 드롭다운 안에서 제공한다. 하단에는 사용자 썸네일 트리거와 설정 아이콘을 둔다.
|
||||
|
||||
---
|
||||
|
||||
## 컴포넌트 구조
|
||||
@@ -184,6 +198,7 @@ components/content/
|
||||
- 로컬 개발 환경과 NAS 운영 환경은 서로 다른 DB를 사용
|
||||
- 로컬 개발 서버는 개발 DB만 연결
|
||||
- NAS 배포 환경은 운영 DB만 연결
|
||||
- 운영 환경(`NODE_ENV=production`)에서는 `DATABASE_URL` 누락 시 샘플 콘텐츠로 대체하지 않고 서버 오류로 즉시 실패
|
||||
- 운영 DB 접속 정보는 로컬 기본 `.env`에 기록하지 않음
|
||||
- DB 관리 도구는 CloudBeaver 등을 사용할 수 있도록 접속 정보를 환경별로 분리
|
||||
|
||||
@@ -278,7 +293,9 @@ components/content/
|
||||
| title | String | 사이트 이름 |
|
||||
| description | String | 사이트 설명 |
|
||||
| site_url | String | 사이트 기본 URL |
|
||||
| logo_text | String | 텍스트 로고 |
|
||||
| logo_text | String | 레거시 텍스트 로고 fallback |
|
||||
| logo_url | String | 공개 로고 이미지 URL |
|
||||
| favicon_url | String | 파비콘 이미지 URL |
|
||||
| copyright_text | String | 저작권 문구 |
|
||||
| updated_at | DateTime | 수정일 |
|
||||
|
||||
@@ -349,8 +366,10 @@ components/content/
|
||||
- `GET /api/search?q=` - 통합 검색(태그 `name`·`slug`, 게시물 `title`·`excerpt`·`content` 부분 일치, 각 최대 12건, 발행 게시물만)
|
||||
- `GET /api/site-settings` - 공개 사이트 설정
|
||||
- `GET /api/navigation` - 공개 네비게이션(`primary`는 트리·`footer`는 평면, 상세는 위 메뉴/네비게이션 절)
|
||||
- `POST /api/auth/signup` - 회원 가입
|
||||
- `GET /api/auth/bootstrap-status` - 최초 관리자 등록 필요 여부 조회
|
||||
- `POST /api/auth/signup` - 회원 가입. 본문: `username`, `email`, `password`, 선택 `emailOtp`(6자리 숫자). **`GET /api/auth/bootstrap-status`의 `emailOtpConfigured`가 true이고 `needsAdminSetup`이 false일 때**(서버에 Resend·pepper 설정됨) `emailOtp`가 필수이며, 직전에 `POST /api/auth/email-otp/request`로 같은 이메일·`purpose: "signup"` 발송분과 일치해야 한다. 최초 관리자 부트스트랩(`needsAdminSetup: true`)에서는 `emailOtp` 없이 가입 가능. 이메일은 저장 시 소문자로 정규화한다.
|
||||
- `GET /api/auth/bootstrap-status` - 최초 관리자 등록 필요 여부(`hasUsers`, `needsAdminSetup`) 및 **이메일 OTP 사용 가능 여부** `emailOtpConfigured`(서버 `RESEND_API_KEY`·`RESEND_FROM_EMAIL` 및 pepper: 비어 있지 않은 `EMAIL_OTP_PEPPER` **또는** `MEMBER_SESSION_SECRET`)를 반환한다. pepper는 OTP 6자리를 DB에 해시해 둘 때만 쓰이는 서버 비밀(긴 난문자열 권장)이다.
|
||||
- `POST /api/auth/email-otp/request` - 본문: `email`, `purpose`(`"signup"` | `"password_reset"`). Resend로 6자리 OTP 메일을 보낸다. 미설정 시 503. `signup`은 이미 가입된 이메일이면 409, 최초 관리자 단계에서는 400. `password_reset`은 존재하지 않는 이메일도 동일한 성공 메시지를 반환하며(이메일 미발송), 요청 빈도 제한을 위해 DB에 더미 챌린지를 남길 수 있다. 55초 쿨다운·시간당 5회 한도. 실제 메일 발송이 실패하면 방금 생성한 챌린지는 즉시 삭제한다. 발송 성공 후 같은 이메일·용도의 이전 pending 챌린지는 무효화한다. DB 테이블 `email_otp_challenges`(마이그레이션 `018_email_otp_challenges.sql`) 필요.
|
||||
- `POST /api/auth/password-reset/confirm` - 본문: `email`, `code`(6자리), `newPassword`(8~32자). `password_reset` OTP 검증·소진 후 해당 이메일 회원 비밀번호를 갱신한다.
|
||||
- `POST /api/auth/login` - 회원 로그인
|
||||
- `GET /api/auth/me` - 현재 회원 세션 조회
|
||||
- `POST /api/auth/logout` - 회원 로그아웃
|
||||
@@ -360,7 +379,7 @@ components/content/
|
||||
- `DELETE /api/auth/avatar` - 회원 썸네일 제거
|
||||
- `GET /api/auth/check-username?username=` - 닉네임 중복 확인
|
||||
- `PUT /api/auth/password` - 회원 비밀번호 변경
|
||||
- `DELETE /api/auth/account` - 회원 탈퇴
|
||||
- `DELETE /api/auth/account` - 회원 탈퇴. 마지막 `owner` 계정은 삭제할 수 없으며, 탈퇴 성공 시 회원 세션과 관리자 세션 쿠키를 함께 정리한다.
|
||||
|
||||
> 회원 썸네일 이미지는 `/uploads/members/avatars/YYYY/MM` 경로로 저장하며, 업로드 시 WebP로 변환하고 `AVATAR_MIN_WIDTH`/`AVATAR_MIN_HEIGHT` 최소 해상도 검증 후 중앙 기준 1:1 정사각형으로 크롭한다.
|
||||
> 최종 저장 크기는 `AVATAR_MAX_WIDTH`, `AVATAR_MAX_HEIGHT` 중 작은 값을 사용해 `N x N` 정사각형으로 맞춘다.
|
||||
@@ -400,7 +419,10 @@ components/content/
|
||||
- `PUT /admin/api/settings` - 사이트 설정 수정
|
||||
- `GET /admin/api/navigation` - 네비게이션 항목 목록
|
||||
- `PUT /admin/api/navigation` - 네비게이션 항목 일괄 저장
|
||||
- `GET /admin/api/members` - 회원 목록(권한, 최근 접속, 접속 IP, 댓글 수 포함)
|
||||
- `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`
|
||||
- `PUT /admin/api/members/:id/role` - 회원 권한 변경(`owner`/`admin`/`member`)
|
||||
|
||||
> 글 발행/초안/비공개 전환은 현재 `PUT /admin/api/posts/:id`의 `status` 값으로 처리한다.
|
||||
@@ -449,13 +471,15 @@ components/content/
|
||||
- 에디터 마지막에는 클릭 가능한 빈 문단 블록을 항상 유지하며, 해당 블록이 비어 있으면 저장 콘텐츠에는 포함하지 않는다.
|
||||
- 제목은 별도 라벨 영역이 아니라 에디터 상단의 큰 제목 입력으로 표시한다.
|
||||
- 글 작성/수정 화면은 페이지 제목 헤더를 별도로 두지 않고, 폼 상단의 Ghost 스타일 도구막대에서 목록 이동, 상태, 미리보기, 저장, 설정 패널 토글을 제공한다.
|
||||
- 글 작성/수정 화면의 저장 버튼은 즉시 저장하지 않고, 전체 화면 발행 모달에서 상태(발행/초안/비공개)와 발행 시점(즉시/예약)을 최종 선택한 뒤 확정한다. 모달에서는 행마다 접힌 상태로 현재 선택 요약만 보이고, 행을 눌러 펼친 뒤 버튼으로 값을 고른다. 게시 상태 행에는 Ghost와 같은 종이비행기 아이콘·펼침 화살표 SVG를 쓰고, 발행 시점 행에는 시계 아이콘·동일 화살표를 쓴다. 발행이 아닌 상태에서는 발행 시점 행 자체를 숨긴다.
|
||||
- 글 작성/수정 화면의 저장 버튼은 현재 입력값이 마지막 저장 기준점과 다를 때만 활성화한다.
|
||||
- 글 작성/수정 화면은 관리자 사이드바 네비게이션을 숨기고, 관리자 공통 내부 패딩 없이 전체 화면 편집 모드로 표시한다.
|
||||
- 글 작성/수정 화면은 브라우저 문서가 아니라 에디터 작업 영역 내부에서만 세로 스크롤한다.
|
||||
- 글 작성/수정 폼은 에디터 작업 영역과 우측 설정 패널을 먼저 좌우로 나누고, 에디터 작업 영역 안에 상단 도구막대와 본문 스크롤 영역을 배치한다.
|
||||
- 본문 에디터는 데스크톱에서 좌우 32px 패딩을 포함한 804px 중앙 컬럼으로 배치해 실제 입력 영역 최대 폭을 740px로 유지한다.
|
||||
- 게시물 설정은 데스크톱에서 420px 우측 패널로 표시하며, 도구막대 버튼으로 열고 닫을 수 있고 너비 전환 애니메이션을 적용한다.
|
||||
- 공개 가능한 글의 보기 액션은 게시물 설정 패널의 Post URL 라벨 오른쪽에 표시한다.
|
||||
- 글 삭제 액션은 게시물 설정 패널 하단의 빨간 outline 버튼으로 제공한다.
|
||||
- 글 삭제 액션은 게시물 설정 패널 하단에 기본 중립 톤 버튼으로 제공하고 hover 시 위험 색상으로 강조한다.
|
||||
- 대표 이미지는 본문 제목 위의 에디터 흐름 안에서 추가, 변경, 삭제한다.
|
||||
- 대표 이미지가 설정된 상태의 변경/삭제 액션은 실제 이미지 레이아웃을 바꾸지 않도록 이미지 hover 또는 focus 오버레이로 표시한다.
|
||||
- 대표 이미지 선택 모달은 이미지 업로드 탭과 미디어 라이브러리 탭을 제공한다.
|
||||
@@ -463,7 +487,7 @@ components/content/
|
||||
- 새 글 작성처럼 본문이 비어 있는 경우에도 빈 문단 블록을 먼저 생성한다.
|
||||
- 글 작성/수정 중인 입력값은 브라우저 `localStorage`에 자동 저장한다.
|
||||
- 자동 저장 키는 `SORI_ADMIN_POST_AUTOSAVE:new` 또는 `SORI_ADMIN_POST_AUTOSAVE:{postId}` 형식을 사용한다.
|
||||
- 자동 저장본이 있으면 작성 화면에서 복원 또는 삭제를 선택할 수 있다.
|
||||
- 자동 저장본이 있으면 상단 툴바의 상태 문구 옆에서 복원 또는 무시(로컬 초안 삭제)를 선택할 수 있다.
|
||||
- 글 저장 성공 시 해당 자동 저장본은 삭제한다.
|
||||
- 미리보기 버튼은 현재 작성 폼 값을 `SORI_ADMIN_POST_PREVIEW` 브라우저 저장소에 기록한 뒤 `/admin/posts/preview` 새 탭에서 렌더링한다.
|
||||
- 미리보기 화면은 공개 게시물 상세와 같은 본문 렌더링 컴포넌트를 사용하되, 데이터베이스에는 임시 글을 저장하지 않는다.
|
||||
@@ -474,7 +498,10 @@ components/content/
|
||||
- 관리자 글 에디터의 실제 입력 텍스트 색상은 placeholder보다 진하게 표시한다.
|
||||
- 관리자 작성 화면과 공개 본문은 같은 마크다운 렌더링 기준을 사용한다.
|
||||
- 태그 입력은 배지형 입력으로 제공하며 Enter 또는 쉼표 입력 시 태그를 추가하고, 배지의 x 버튼으로 삭제한다.
|
||||
- 태그 배지 삭제 버튼은 SVG 닫기 아이콘으로 표시한다.
|
||||
- 태그 토큰은 게시물 URL용 `toSlug`(한글 로마자화)와 분리하여 한글을 유지하고, 공백은 하이픈으로만 정리하며 `a-z0-9가-힣` 및 하이픈만 허용한다.
|
||||
- 제목 입력에서 한글 IME 조합 중 Enter는 조합 확정으로만 처리하고 본문 에디터 포커스 이동을 실행하지 않는다.
|
||||
- 관리자 게시글 목록의 태그는 쉼표 구분 문자열이 아니라 읽기 전용 배지 목록으로 표시한다.
|
||||
- 대표 이미지는 URL 직접 입력이 아니라 미디어 선택 또는 이미지 업로드 탭으로 설정한다.
|
||||
- 대표 이미지 선택 모달에서 미디어를 클릭하면 선택 상태만 표시하고, 하단 대표 이미지로 적용 버튼을 눌렀을 때 글 폼에 반영한다.
|
||||
- 대표 이미지가 설정되면 관리자 글 폼에 썸네일과 hover 오버레이 삭제/변경 액션을 표시한다.
|
||||
@@ -489,9 +516,15 @@ components/content/
|
||||
- 이미지 블록 alt 입력은 이미지 hover 또는 focus 상태에서만 표시한다.
|
||||
- 이미지 블록 표시 옵션은 `regular`, `wide`, `full` 값을 사용하며 `regular`는 width 옵션을 생략한다.
|
||||
- 갤러리 블록은 `:::gallery` fenced block 안에 이미지 마크다운 행을 여러 개 저장한다.
|
||||
- 관리자 갤러리 미디어 선택은 여러 이미지를 선택한 뒤 확인 버튼으로 적용한다.
|
||||
- 관리자 갤러리 블록은 이미지 1개일 때 1열, 2개일 때 2열, 3개 이상일 때 3열로 표시한다.
|
||||
- 관리자 갤러리 블록의 이미지 순서는 드래그 앤 드롭으로 변경하며, 드래그 중 삽입 위치를 이미지 사이 라인으로 표시한다.
|
||||
- 공개 갤러리는 한 줄에 2~3개 이미지 그리드로 표시하고 클릭 시 라이트박스로 크게 확인한다.
|
||||
- 이미지와 갤러리 블록은 기존 업로드 미디어 선택 또는 새 이미지 업로드를 제공한다.
|
||||
- 콜아웃 블록은 `:::callout` fenced block 안에 본문을 저장한다.
|
||||
- 콜아웃 블록은 선언부 옵션으로 `emoji`, `bg`를 저장할 수 있다. 예: `:::callout emoji=💡 bg=blue`
|
||||
- `emoji=none`이면 공개 렌더러에서 이모지를 숨긴다.
|
||||
- 콜아웃 배경 프리셋은 `gray`, `blue`, `green`, `yellow`, `red`, `purple`, `pink`를 지원한다.
|
||||
- 토글 블록은 `:::toggle 제목` fenced block 안에 펼침 본문을 저장한다.
|
||||
- 임베드 블록은 `:::embed` fenced block 안에 URL을 저장한다.
|
||||
- YouTube 임베드 URL은 공개 화면에서 iframe으로 렌더링한다.
|
||||
@@ -510,43 +543,56 @@ components/content/
|
||||
### 사이트 설정
|
||||
|
||||
- 사이트 설정은 `site_settings` 테이블의 단일 레코드로 관리한다.
|
||||
- 관리자는 사이트 이름, 설명, 사이트 URL, 텍스트 로고, 저작권 문구를 수정할 수 있다.
|
||||
- 관리자는 사이트 이름, 설명, 사이트 URL, 로고 이미지, 저작권 문구를 수정할 수 있다.
|
||||
- 로고 이미지는 1:1 비율로 저장하며 `/admin/api/settings/logo` 업로드 시 `/uploads/system/logo.webp`와 `/uploads/system/favicon.png`를 함께 생성한다.
|
||||
- 공개 헤더와 오른쪽 사이드바는 공개 사이트 설정 API 값을 사용한다.
|
||||
- 공개 헤더와 오른쪽 사이드바는 `logo_url`이 있으면 이미지 로고를 표시하고, 없으면 `logo_text` fallback을 쓴다. `favicon_url`은 head의 icon 링크로 연결한다.
|
||||
- DB 연결이 없는 환경에서는 환경 변수와 기본값 기반 설정을 사용한다.
|
||||
|
||||
### 메뉴/네비게이션
|
||||
|
||||
- 네비게이션은 `navigation_items` 테이블로 관리한다.
|
||||
- 컬럼: `parent_id`(nullable, self FK, `ON DELETE CASCADE`), `is_folder`(boolean, **자식이 있는 상단 항목이면 저장 시 서버가 true로 설정**), `location`(`primary`|`footer`), `sort_order`, `label`, `url`, `is_visible`(저장 시 항상 true로 정리). `(location, label, url)` 유니크 제약은 제거되었다.
|
||||
- `GET /api/navigation` 응답: `primary`는 **트리**(각 노드에 `children` 배열이 있을 수 있음, 리프는 `children` 없음). 노드 필드: `id`, `label`, `url`, `isFolder`, `isVisible`. `footer`는 **평면** 배열(`parent_id` 없음)이며 `id`, `label`, `url`, `isVisible`만 내려간다.
|
||||
- 동일 `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`는 **평면** 배열(`parent_id` 없음)이며 `id`, `label`, `url`, `isVisible`만 내려간다.
|
||||
- `PUT /admin/api/navigation` 요청 본문의 각 항목은 반드시 `id`(UUID)를 포함한다. 저장 시 `sort_order`는 서버가 위치별 트리 DFS 순으로 다시 부여한다. `parent_id`는 `primary`에서만 허용되며 `footer` 항목은 항상 루트다. `is_visible`·`is_folder`는 요청값과 무관하게 서버에서 **항상 표시·자식 유무 기준 폴더**로 덮어쓴다.
|
||||
- URL은 `/`로 시작하는 내부 경로, `http(s)://` 외부 URL, 폴더 전용 자리 표시 `#`를 허용한다.
|
||||
- 관리자 메뉴 화면은 **상단 네비게이션**·**하단 네비게이션** 탭으로 구분한다. 편집 UI는 **태그 관리 메인 태그**와 같은 테이블·`cursor-move` 행 드래그 패턴을 쓰며, 드래그는 입력·버튼 위가 아닌 행 여백·번호 열에서 시작한다. 상단은 `AdminNavPrimaryBranch`로 트리·동일 부모 내 순서 변경·하위 추가를 제공한다. 하단은 한 단계 목록만 드래그 정렬한다.
|
||||
- `parent_id` / `is_folder` 컬럼이 DB에 없을 때 저장은 실패한다. `npm run db:migrate:dev`로 `017_navigation_hierarchy.sql`을 적용해야 한다(저장 API는 해당 경우 한국어 안내 메시지를 반환할 수 있다).
|
||||
- 공개 왼쪽 사이드바 상단은 `SidebarPrimaryNavList`로 렌더링한다. **하위가 있는 노드**는 한 줄 `button`으로, **행 전체(이름·왼쪽 점·chevron) 클릭**으로 접기/펼친다(별도 chevron 전용 버튼 없음). chevron은 SVG에 `transition-transform`으로 회전하고, 하위 목록은 `grid-template-rows` `0fr`↔`1fr` 전환으로 높이가 자연스럽게 바뀐다. 펼침 행에만 깔리던 배경 하이라이트는 두지 않는다. **하위가 있는 부모**는 토글 전용이므로 공개 화면에서 부모 `url`로 이동하지 않는다(링크는 리프·하위 항목에 둔다). 펼침 상태는 `localStorage` 키 `sori-primary-nav-expanded`에 저장된다.
|
||||
- 공개 왼쪽 사이드바 상단은 `SidebarPrimaryNavList`로 렌더링한다. **하위가 있는 노드**는 한 줄 `button`으로, **행 전체(이름·왼쪽 세로 데코·chevron) 클릭**으로 접기/펼친다. 부모·리프 모두 왼쪽 장식은 **기본 세로 막대(`--site-line`)**, **호버 시에만** 리프와 동일하게 **작은 원형**으로 전환한다. **내부 경로**(`/`로 시작, `//`·`http(s)` 제외)이고 현재 `route.path`와 정규화한 경로가 같으면 장식 색을 **`--site-accent`(브랜드 오렌지)**로 둔다. 외부 URL은 비교하지 않는다. 펼침 상태는 `localStorage` 키 `sori-primary-nav-expanded`에 저장된다. 상단·리프 링크·부모 버튼 행은 **`w-full`**로 `site-panel-hover` 배경이 가로 전체를 쓴다.
|
||||
- 관리자 메뉴 관리 화면에서 저장 API로 보내는 직렬화 결과가 서버에서 불러온 직후와 동일하면 `메뉴 저장` 버튼이 비활성화된다.
|
||||
|
||||
### 관리자 인증
|
||||
|
||||
- 관리자 인증은 `users.is_admin=true` 회원의 이메일/비밀번호(bcrypt 해시) 기반으로 처리한다.
|
||||
- DB에 사용자가 없으면 `/signup`에서 관리자 등록 모드(관리자 이름/이메일/비밀번호/비밀번호 확인)로 첫 계정을 생성한다.
|
||||
- 첫 계정 생성 시 `is_admin=true`, `user_role=owner`를 자동 부여한다.
|
||||
- 첫 계정 생성 시 `is_admin=true`, `user_role=owner`를 자동 부여한다. 최초 가입 판정은 동시 요청에서 중복 owner가 생기지 않도록 `users` 테이블 잠금 안에서 처리한다.
|
||||
- 관리자 로그인 성공 시 httpOnly 세션 쿠키를 `/admin` 경로에 설정한다.
|
||||
- `/admin/login` 로그인 제출 버튼은 이메일·비밀번호가 모두 입력된 경우에만 활성화한다.
|
||||
- 관리자 로그인 성공 시 관리자/회원 세션 쿠키를 함께 설정해 관리자 화면에서도 프로필(썸네일, 이름) 수정 API를 공통으로 사용한다.
|
||||
- 관리자 설정 화면에서 관리자 프로필(이름, 썸네일)과 관리자 비밀번호를 수정할 수 있다.
|
||||
- 관리자 멤버 화면에서 권한은 `소유자(owner)`, `관리자(admin)`, `멤버(member)` 3단계로 표시하고 변경할 수 있다.
|
||||
- 관리자 로그인 성공 시 관리자/회원 세션 쿠키를 함께 설정하고 `users.previous_last_seen_at`/`previous_last_seen_ip`에 직전 로그인 값을 보존한 뒤 `last_seen_at`/`last_seen_ip`를 현재 로그인으로 갱신한다.
|
||||
- 관리자 설정 화면은 사이트 자체 설정만 관리한다. 관리자 프로필과 비밀번호는 멤버 편집 화면의 계정 작업에서 처리한다.
|
||||
- 관리자 사이드바 하단 사용자 메뉴의 `내 프로필`은 `/admin/members/:id` 멤버 편집 화면으로 이동한다.
|
||||
- 관리자 멤버 목록은 Ghost형 테이블 기준으로 닉네임 아래 이메일, 가입일 아래 최근 활동을 함께 표시한다.
|
||||
- 관리자 멤버 목록은 멤버 검색, 멤버 추가 버튼, 조건 필터를 제공한다. 필터는 이름·이메일·레이블 텍스트 조건, 활성/비활성 상태 조건, 최근 접속·가입일 날짜 조건을 지원하며 여러 조건은 AND로 적용한다.
|
||||
- 관리자 멤버 목록은 뉴스레터 지표 대신 댓글 작성 개수를 표시한다.
|
||||
- 관리자 멤버 상세/추가 화면은 Ghost형 편집 화면을 기준으로 썸네일, 이름, 이메일, 레이블, 관리자 노트를 편집한다. 본문은 3분할 그리드로 요약 영역 1fr, 입력 영역 2fr 비율을 사용한다. 썸네일은 URL 입력 대신 요약 영역의 원형 썸네일 hover 액션으로 등록·변경·제거한다. 기존 회원 상세는 가입 정보와 활동 요약을 표시하고, 신규 회원 화면은 활동 영역을 표시하지 않는다. 기존 회원 상세의 설정 메뉴는 관리자 직접 비밀번호 변경(`PUT /admin/api/members/:id/password`)과 멤버 삭제(`DELETE /admin/api/members/:id`)를 제공한다.
|
||||
- 관리자 게시글 작성/수정과 멤버 추가/수정 화면은 저장되지 않은 변경사항이 있을 때 내부 라우트 이동을 공통 확인 모달로 막는다. 브라우저 새로고침·탭 닫기는 브라우저 기본 `beforeunload` 확인을 사용한다. 게시글 화면에서 이탈을 승인하면 해당 로컬 자동 저장본은 삭제한다.
|
||||
- `users.member_labels`는 관리자용 문자열 배열이며, 이후 사용자별 칭호/분류 용도로 확장한다. `users.member_note`는 관리자에게만 보이는 500자 이하 메모다.
|
||||
- 관리자 멤버 권한은 `소유자(owner)`, `관리자(admin)`, `멤버(member)` 3단계를 유지하되, 목록 화면에서는 변경 UI를 노출하지 않고 상세/후속 화면에서 변경한다.
|
||||
- 관리자 페이지 접근은 `/admin/api/auth/me` 확인 후 허용한다.
|
||||
- 관리자 세션 토큰은 `ADMIN_PASSWORD` 기반 HMAC 서명으로 검증한다.
|
||||
- `/admin/api/auth/login`, `/admin/api/auth/logout`을 제외한 관리자 API 요청은 서버 미들웨어에서 세션 사용자 ID의 현재 DB 권한이 `owner` 또는 `admin`인지 다시 확인한다. 권한 변경이나 계정 삭제 이후 남아 있는 기존 관리자 쿠키는 이 단계에서 차단한다.
|
||||
|
||||
### 회원 인증
|
||||
|
||||
- 회원 인증은 `users` 테이블의 이메일/비밀번호(bcrypt 해시) 기반으로 처리한다.
|
||||
- `/signin` 로그인 제출 버튼은 이메일·비밀번호가 모두 입력된 경우에만 활성화한다.
|
||||
- 로그인 성공 시 httpOnly 세션 쿠키(`sori_member_session`)를 `/` 경로에 설정한다.
|
||||
- 회원 API(`POST /api/auth/signup`, `POST /api/auth/login`, `GET /api/auth/me`, `POST /api/auth/logout`)로 세션을 관리한다.
|
||||
- 회원 API(`POST /api/auth/signup`, `POST /api/auth/login`, `GET /api/auth/me`, `GET/PUT /api/auth/profile`, `POST /api/auth/logout`, `GET /api/auth/bootstrap-status`, `POST /api/auth/email-otp/request`, `POST /api/auth/password-reset/confirm`)로 세션·이메일 OTP를 관리한다.
|
||||
- 회원 로그인 성공 시 `previous_last_seen_at`/`previous_last_seen_ip`에 직전 로그인 값을 보존한 뒤 `last_seen_at`/`last_seen_ip`를 현재 로그인으로 갱신한다. `/api/auth/me`는 세션 확인만 수행하고 로그인 이력을 갱신하지 않는다.
|
||||
- 사용자 설정 화면은 공개 본문 폭에 맞춰 프로필 요약을 상단에 두고, 프로필 입력과 활동 정보를 하단에 배치한다. 비밀번호 변경과 회원 탈퇴는 설정 버튼의 모달 액션으로만 노출한다. 활동 정보의 `마지막 로그인`은 현재 로그인 이전에 저장된 `previous_last_seen_at`을 표시한다.
|
||||
- 첫 회원가입 시 관리자 세션도 함께 설정되어 관리자 화면(`/admin`)으로 바로 진입할 수 있다.
|
||||
- 회원 세션 서명은 `MEMBER_SESSION_SECRET`을 우선 사용하고, 값이 없으면 개발 편의를 위해 `ADMIN_PASSWORD`를 fallback으로 사용한다.
|
||||
- 회원 세션 서명은 `MEMBER_SESSION_SECRET`만 사용하며, 값이 없으면 서버 오류로 실패한다.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
## 2차 관리자 개발
|
||||
|
||||
- [ ] 미디어 라이브러리 프로필/사이트 설정 이미지 사용처 추적
|
||||
- [ ] 관리자 멤버 추가 후 초대 메일 또는 비밀번호 설정 안내 플로우 연결
|
||||
|
||||
## 프론트엔드 개발
|
||||
|
||||
@@ -30,6 +31,7 @@
|
||||
- [ ] ProseFile 실제 파일 데이터 연결
|
||||
- [ ] ProseProduct 실제 상품 카드 데이터 연결
|
||||
- [ ] ProseHeaderCard Simple/Wide/Full-width/Split 변형 구현
|
||||
|
||||
## 데이터베이스
|
||||
|
||||
- [ ] NAS 운영 DB 연결 설정 실제 값 작성
|
||||
@@ -37,7 +39,5 @@
|
||||
|
||||
## 배포
|
||||
|
||||
- [ ] UGREEN NAS Docker 배포 가이드 작성
|
||||
- [ ] Docker 빌드 검증
|
||||
- [ ] `.env.production` 작성 후 `docker compose --env-file .env.production config` 검증
|
||||
- [ ] NAS 운영 환경 변수 작성
|
||||
- [ ] NAS 운영 환경 변수 최종 점검
|
||||
- [ ] NAS 실제 컨테이너 기동 및 도메인/프록시 접속 QA
|
||||
|
||||
207
docs/update.md
207
docs/update.md
@@ -1,5 +1,212 @@
|
||||
# 업데이트 이력
|
||||
|
||||
## v1.0.0
|
||||
|
||||
- 운영 환경에서 `DATABASE_URL` 누락 시 샘플 콘텐츠 fallback 대신 즉시 실패하도록 수정.
|
||||
- 회원 세션 서명 비밀값을 `ADMIN_PASSWORD` fallback 없이 `MEMBER_SESSION_SECRET` 필수 사용으로 분리.
|
||||
- JavaScript 문법 점검 스크립트(`scripts/check-js-syntax.js`)와 `lint`·`test`·`verify` npm 스크립트 추가.
|
||||
- `npm audit fix`로 Nitro 취약점 권고를 반영하고 취약점 0건 확인.
|
||||
- Docker compose 설정 검증과 Docker 앱 이미지 빌드 검증 진행.
|
||||
- `.env.production`의 `MEMBER_SESSION_SECRET` 설정 여부 확인 후 배포 todo 정리.
|
||||
- 운영 시작 기준 버전 `1.0.0`으로 갱신.
|
||||
|
||||
## v0.0.121
|
||||
|
||||
- 게시글 작성 본문 위 자동 저장 안내 배너를 제거하고, 툴바 상태 문구 옆에 복원·무시 버튼을 두도록 변경.
|
||||
- 패키지 버전 `0.0.121`로 갱신.
|
||||
|
||||
## v0.0.120
|
||||
|
||||
- 발행 모달 설정을 행 단위 접기/펼침으로 정리하고, 접힌 상태에서는 현재 선택값만 표시하도록 변경.
|
||||
- 발행 모달에 Ghost와 동일한 SVG(게시 형태·펼침 화살표·발행 시점 시계)를 적용하고 본문·헤더·CTA 문구를 한글로 통일.
|
||||
- 발행 설정 영역의 외곽 상하 보더를 제거하고 행 사이 구분선만 유지하도록 조정.
|
||||
- 발행 모달 본문 영역을 가로 중앙 정렬(`max-w` 컬럼)로 맞춤.
|
||||
- 패키지 버전 `0.0.120`으로 갱신.
|
||||
|
||||
## v0.0.119
|
||||
|
||||
- 게시물 저장 버튼 클릭 시 고스트 스타일의 전체 화면 발행 모달을 열도록 변경.
|
||||
- 발행 모달에서 뉴스레터 옵션을 제거하고, 상태(발행/초안/비공개)를 버튼식으로 선택하도록 추가.
|
||||
- 발행 모달에서 발행 시점(즉시/예약) 버튼 선택과 예약 시각 입력을 지원하도록 추가.
|
||||
- 발행 모달의 `Continue, final review →` 확정 시 실제 저장/발행이 수행되도록 연결.
|
||||
- 관리자 블록 에디터 콜아웃에 Emoji ON/OFF, 이모지 프리셋 선택, 배경 프리셋 선택 기능 추가.
|
||||
- 콜아웃 마크다운 저장 형식을 `:::callout emoji=... bg=...` 메타 포함 형태로 확장.
|
||||
- 공개 본문 콜아웃 렌더러에 이모지 표시/숨김과 배경 프리셋 렌더링 연결.
|
||||
- 공개 콜아웃 카드 외부 여백을 상단 중심(`mt-8`)으로 조정.
|
||||
- 관리자 콜아웃 편집 UI를 카드 내부에서 분리해 우측 고정 설정 패널로 이동하고, 편집 카드가 공개 렌더와 동일하게 보이도록 정리.
|
||||
- 콜아웃 카드 보더를 제거해 Ghost 톤으로 정리하고, 콜아웃 설정 패널을 블록 옆 위치로 이동.
|
||||
- 콜아웃 이모지 설정을 고정 프리셋 버튼만 사용하지 않고 입력 팝업(직접 입력/붙여넣기 + 빠른 선택) 방식으로 확장.
|
||||
- 콜아웃 본문 왼쪽 이모지 버튼에서 이모지 입력 팝오버를 직접 여는 흐름으로 정리.
|
||||
- 배경색은 컬러 버튼 클릭 시에만 팔레트 팝오버가 열리도록 단순화하고, 텍스트 색상을 관리자 화면에서 고정 진한 톤으로 보정.
|
||||
- 콜아웃 이모지 입력을 가변 길이 contenteditable에서 단일 이모지 입력 필드로 정리하고, 첫 그래프림만 반영하도록 보정.
|
||||
- 공개 콜아웃의 이모지·텍스트 정렬을 `items-center` 기준으로 조정해 관리자 편집 카드와 높이 체감을 맞춤.
|
||||
- 콜아웃 이모지 입력 필드에 한글 IME 조합 종료 시점 반영을 추가해 `가` 입력 시 자모 분리 대신 완성형 문자로 저장되도록 보정.
|
||||
- 관리자 블록 에디터에서 Enter 등 키보드 입력 직후 hover 강조를 잠시 비활성화하고, 마우스 이동 시 hover가 다시 동작하도록 조정.
|
||||
- 키보드 우선 모드에서 블록 왼쪽 핸들(세로 마커) hover 표시도 함께 비활성화해, 포인터가 다른 문단 위에 있어도 현재 입력 문맥을 유지하도록 보정.
|
||||
- 블록 에디터 슬래시 입력 상태에서 Enter 처리 조건을 보정해, `/` 또는 `//` 입력 상태에서는 엔터가 일반 줄바꿈/다음 문단 생성으로 동작하도록 수정.
|
||||
- 한글 IME 조합 중 `/제목` 입력 뒤 Enter 시 조합 종료 직후 슬래시 명령이 바로 적용되도록 pending 명령 처리 추가.
|
||||
- 빈 문단 삭제 동작 이후 콜아웃이 일반 문단으로 변환되는 경로를 차단하기 위해 Enter의 빈 블록 기본 문단 전환 대상에서 콜아웃을 제외.
|
||||
- 한글 IME 조합 종료 직후 슬래시 메뉴가 늦게 뜨는 문제를 줄이기 위해 조합 종료 동기화를 즉시+지연 2회 수행하도록 보정.
|
||||
- 게시물 상세에서 본문과 댓글 섹션 사이 간격을 Ghost 기준 `48px`(`mt-12`)으로 조정.
|
||||
- 이미지/갤러리 블록을 생성만 하고 사용하지 않은 상태로 다른 블록으로 이동하면 해당 미사용 구조형 블록을 자동 정리하도록 보정.
|
||||
|
||||
## v0.0.118
|
||||
|
||||
- 관리자 게시글 저장 버튼을 변경사항이 있을 때만 활성화하도록 수정.
|
||||
- 관리자 게시글 수정 삭제 버튼을 기본 중립 톤, hover 시 위험 색상 강조로 조정.
|
||||
- 관리자 게시글 태그 배지 삭제 버튼을 SVG 아이콘으로 교체하고 정렬 보정.
|
||||
- 패키지 버전 `0.0.118`로 갱신.
|
||||
|
||||
## v0.0.117
|
||||
|
||||
- 관리자 글쓰기 갤러리 미디어 선택을 복수 선택 후 확인 적용 방식으로 변경.
|
||||
- 관리자 갤러리 블록의 이미지 수에 따라 1개는 전체 너비, 2개는 2열, 3개 이상은 3열로 표시하도록 수정.
|
||||
- 관리자 갤러리 블록 이미지 드래그 순서 변경과 삽입 위치 표시 추가.
|
||||
- 패키지 버전 `0.0.117`로 갱신.
|
||||
|
||||
## v0.0.116
|
||||
|
||||
- 관리자 게시글 제목 입력에서 한글 조합 중 Enter가 본문으로 마지막 글자를 넘기지 않도록 IME 조합 상태 가드 추가.
|
||||
- 관리자 게시글 목록 태그 표시를 쉼표 구분 텍스트에서 읽기 전용 배지 목록으로 변경.
|
||||
- 패키지 버전 `0.0.116`으로 갱신.
|
||||
|
||||
## v0.0.115
|
||||
|
||||
- 사용자 설정 화면을 좁은 공개 본문 폭에 맞춰 요약 영역 상단, 프로필·활동 영역 하단 구조로 재배치.
|
||||
- 사용자 설정 비밀번호 변경·회원 탈퇴 모달 입력 필드 보더가 보이도록 정리.
|
||||
- 관리자 사이트 설정에서 관리자 프로필·관리자 비밀번호 변경 섹션 제거.
|
||||
- 관리자 사이트 설정에 1:1 로고 이미지 업로드와 파비콘 생성 기능 추가.
|
||||
- 사이트 설정 로고 URL·파비콘 URL 저장 컬럼 마이그레이션(`022_add_site_logo_urls.sql`) 추가.
|
||||
- 공개 헤더와 오른쪽 사이드바에 이미지 로고 표시를 연결하고 파비콘 head 링크를 추가.
|
||||
- 패키지 버전 `0.0.115`로 갱신.
|
||||
|
||||
## v0.0.114
|
||||
|
||||
- 관리자 하단 사용자 메뉴의 `내 프로필` 경로를 사용자 설정에서 관리자 멤버 편집 화면으로 변경.
|
||||
- 관리자 멤버 편집 설정 메뉴에 비밀번호 직접 변경과 멤버 삭제 모달 추가.
|
||||
- 사용자 설정 화면을 관리자 멤버 편집과 같은 요약/본문 구조로 재정리.
|
||||
- 사용자 설정의 비밀번호 변경과 회원 탈퇴를 설정 메뉴 모달로 분리.
|
||||
- 로그인 시 이전 로그인 시각/IP를 보존하는 `021_add_member_previous_login.sql` 마이그레이션 추가.
|
||||
- 회원 프로필 API에 가입일, 이전 로그인, 댓글 수 정보를 추가.
|
||||
- 패키지 버전 `0.0.114`로 갱신.
|
||||
|
||||
## v0.0.113
|
||||
|
||||
- 관리자 미저장 변경사항 모달을 화면 상단 40px 여백 위치로 조정.
|
||||
- 관리자 멤버 상세 폼에서 썸네일 URL 입력 필드 제거.
|
||||
- 관리자 멤버 상세 요약 썸네일에 hover 등록·변경·삭제 UI 추가.
|
||||
- 관리자 멤버 목록 상태 표시를 배지에서 일반 텍스트로 변경.
|
||||
- 관리자 멤버 목록에 이름·이메일·레이블·상태·최근 접속·가입일 조건 필터 추가.
|
||||
- 패키지 버전 `0.0.113`으로 갱신.
|
||||
|
||||
## v0.0.112
|
||||
|
||||
- 관리자 멤버 폼 본문을 3분할 그리드로 변경하고 요약 1fr, 입력 영역 2fr 비율로 조정.
|
||||
- 관리자 공통 미저장 변경사항 이탈 확인 모달 추가.
|
||||
- 관리자 게시글 작성/수정 화면에 미저장 변경사항 라우트 이탈 확인과 브라우저 이탈 기본 확인 연결.
|
||||
- 관리자 멤버 추가/수정 화면에 미저장 변경사항 라우트 이탈 확인과 브라우저 이탈 기본 확인 연결.
|
||||
- 이탈 승인 시 게시글 로컬 자동 저장본 삭제 처리 추가.
|
||||
- 패키지 버전 `0.0.112`로 갱신.
|
||||
|
||||
## v0.0.111
|
||||
|
||||
- 관리자 멤버 상세 화면(`/admin/members/:id`) 추가.
|
||||
- 관리자 멤버 추가 화면(`/admin/members/new`) 추가.
|
||||
- 멤버 목록 행 클릭 시 상세 화면으로 이동하도록 수정.
|
||||
- 멤버 기본 정보 저장 API(`GET/PUT /admin/api/members/:id`, `POST /admin/api/members`) 추가.
|
||||
- 회원 레이블·관리자 노트 저장 컬럼 마이그레이션(`020_add_member_admin_fields.sql`) 추가.
|
||||
- 멤버 폼에 썸네일 URL, 이름, 이메일, 레이블, 관리자 노트, 기존 회원 활동 요약 표시.
|
||||
- 패키지 버전 `0.0.111`로 갱신.
|
||||
|
||||
## v0.0.110
|
||||
|
||||
- 관리자 멤버 목록을 Ghost형 테이블 구조로 재정리.
|
||||
- 멤버 이름 아래 이메일, 가입일 아래 최근 활동을 함께 표시하도록 수정.
|
||||
- 멤버 목록에서 권한 변경 선택·저장 UI 제거.
|
||||
- 멤버 검색 입력과 멤버 추가 버튼 추가.
|
||||
- 뉴스레터 Open rate 대체 컬럼으로 댓글 작성 개수 표시 유지.
|
||||
- 패키지 버전 `0.0.110`으로 갱신.
|
||||
|
||||
## v0.0.109
|
||||
|
||||
- 관리자 사이드바 `메뉴` 항목을 `네비게이션`으로 변경하고 전용 아이콘 적용.
|
||||
- 관리자 게시글 아이콘을 Ghost형 편집 아이콘으로 교체.
|
||||
- 관리자 멤버 메뉴 우측에 총 멤버 수 표시 추가.
|
||||
- 관리자 로그아웃을 상단 메뉴에서 제거하고 하단 사용자 드롭다운으로 이동.
|
||||
- 관리자 하단에 사용자 썸네일 트리거와 설정 아이콘 추가.
|
||||
- 패키지 버전 `0.0.109`로 갱신.
|
||||
|
||||
## v0.0.108
|
||||
|
||||
- 관리자 사이드바 너비를 Ghost 기준 320px로 조정.
|
||||
- 관리자 우측 캔버스가 기본 화면 높이와 배경색을 유지하도록 `admin-layout__main` 배경·여백 수정.
|
||||
- 관리자 사이드바 페이지·미디어·설정 아이콘 추가.
|
||||
- 패키지 버전 `0.0.108`로 갱신.
|
||||
|
||||
## v0.0.107
|
||||
|
||||
- 관리자 사이드바를 밝은 Ghost형 톤으로 조정.
|
||||
- 관리자 `글` 메뉴명을 `게시글`로 변경하고 게시글·태그·멤버 메뉴 아이콘 추가.
|
||||
- 게시글 메뉴 우측 `+` 버튼으로 `/admin/posts/new` 바로 진입 추가.
|
||||
- 패키지 버전 `0.0.107`로 갱신.
|
||||
|
||||
## v0.0.106
|
||||
|
||||
- 태그 상세(`/tag/:slug`) 헤더와 게시물 목록을 공통 `site-section-header`·`site-section-body` 패딩 구조로 맞춤.
|
||||
- 태그 상세 게시물 목록의 중복 구분선을 정리.
|
||||
- 패키지 버전 `0.0.106`으로 갱신.
|
||||
|
||||
## v0.0.105
|
||||
|
||||
- `005_add_navigation_items.sql`: `(location,label,url)` 유니크 제약 제거 후에도 기본 메뉴가 재삽입되지 않도록 `NOT EXISTS` 기반 시드로 수정.
|
||||
- `019_dedupe_navigation_items.sql`: 반복 마이그레이션 실행으로 생긴 네비게이션 중복 행 정리 및 중복 방지 인덱스 추가.
|
||||
- 로컬 개발 DB 네비게이션 중복 행 정리.
|
||||
- 패키지 버전 `0.0.105`로 갱신.
|
||||
|
||||
## v0.0.104
|
||||
|
||||
- 관리자 API 요청마다 현재 DB 권한 재확인 미들웨어 추가.
|
||||
- 회원 탈퇴 시 마지막 `owner` 계정 삭제 차단 및 관리자 세션 쿠키 정리.
|
||||
- 최초 회원 생성 시 `users` 테이블 잠금으로 동시 가입 owner 판정 안정화.
|
||||
- 이메일 OTP 발송 실패 시 방금 만든 챌린지 삭제, 발송 성공 후 이전 pending 챌린지 정리.
|
||||
- 본문 북마크·임베드 외부 링크를 `http(s)` URL로 제한.
|
||||
- 패키지 버전 `0.0.104`로 갱신.
|
||||
|
||||
## v0.0.103
|
||||
|
||||
- `docs/map.md`: 관리자 메뉴 관리 행 설명에서 제거된 마이그레이션 안내 문구 반영.
|
||||
|
||||
## v0.0.102
|
||||
|
||||
- `AdminBlockEditor`: 빈 단락은 HTML 주석 마커로 직렬화·복원, 슬래시 메뉴 하이라이트·스크롤·긴 목록 `max-h`·블록 경계에서 위/아래 방향키로 인접 블록 이동.
|
||||
- `ContentMarkdownRenderer`: 동일 마커 줄을 빈 단락으로 표시.
|
||||
- `SiteHeader`: 검색 열기 버튼에 `min-w-[470px]`(md 이상)로 최소 폭 고정.
|
||||
- `pages/admin/navigation`: 상단 마이그레이션 안내 문단 제거.
|
||||
|
||||
## v0.0.101
|
||||
|
||||
- `server/utils/navigation-tree.js` `buildPublicPrimaryTree`: 평면 `primary`에서 **동일 id 중복 제거**(정렬 후 첫 행 유지), **유효 부모 아래에 붙은 항목은 루트에서 제외**해 사이드바에 항목이 두 번 보이던 현상 방지.
|
||||
|
||||
## v0.0.100
|
||||
|
||||
- `.env.example`·`docs/deploy.md`·`docs/spec.md`: **`EMAIL_OTP_PEPPER` 의미**(OTP 해시용 서버 비밀, 긴 난문자열 권장·미설정 시 `MEMBER_SESSION_SECRET` 사용) 문구 보강.
|
||||
|
||||
## v0.0.99
|
||||
|
||||
- `SiteHeader`: 헤더 내부 `grid-cols-3`로 검색 패널 **중앙 열 정렬**(Ghost류 레이아웃).
|
||||
- Resend 기반 **이메일 OTP**: 마이그레이션 `018_email_otp_challenges.sql`, `POST /api/auth/email-otp/request`, `POST /api/auth/password-reset/confirm`, `signup`·`bootstrap-status` 연동.
|
||||
- `pages/forgot-password.vue`, `signin`에서 비밀번호 찾기 링크. `getUserByEmail`·로그인 이메일 **대소문자 무시** 조회.
|
||||
- `nuxt.config` `runtimeConfig`: `resendApiKey`, `resendFromEmail`, `emailOtpPepper`.
|
||||
|
||||
## v0.0.98
|
||||
|
||||
- `SidebarPrimaryNavList`: `navRowShell`에 **`w-full`** 추가해 `site-panel-hover` 배경이 행 가로 전체를 쓰도록 함.
|
||||
- `LeftSidebar`: 푸터 API `footer` 링크 **`flex-wrap`**·`nav` **`min-w-0 flex-1`**, 링크·테마 버튼 **`shrink-0`**로 항목이 많을 때 가로 오버플로 방지.
|
||||
|
||||
## v0.0.97
|
||||
|
||||
- `SidebarPrimaryNavList`: 하위 폴더 부모도 리프와 동일 **세로 막대→호버 원형** `before` 장식. 내부 링크가 현재 경로와 일치하면 장식을 **`--site-accent`**로 표시(`aria-current="page"`).
|
||||
|
||||
## v0.0.96
|
||||
|
||||
- `SidebarPrimaryNavList`: 하위가 있는 부모는 **행 전체 버튼**으로 토글, chevron **회전 애니메이션**, 하위 영역 **높이 전환 애니메이션**, 펼침 전용 배경 제거·너비·간격 리프와 통일.
|
||||
|
||||
@@ -5,6 +5,66 @@ const isPostEditorRoute = computed(() => route.path === '/admin/posts/new'
|
||||
|| (route.path.startsWith('/admin/posts/') && route.path !== '/admin/posts/preview'))
|
||||
|
||||
const editorDocumentClass = 'admin-post-editor-document'
|
||||
const adminUserMenuOpen = ref(false)
|
||||
|
||||
const { data: adminMember } = await useFetch('/api/auth/me', {
|
||||
default: () => ({
|
||||
username: '',
|
||||
email: '',
|
||||
avatarUrl: ''
|
||||
})
|
||||
})
|
||||
|
||||
const { data: adminMembers } = await useFetch('/admin/api/members', {
|
||||
default: () => []
|
||||
})
|
||||
|
||||
const memberCount = computed(() => adminMembers.value.length)
|
||||
const adminDisplayName = computed(() => adminMember.value?.username || adminMember.value?.email || '관리자')
|
||||
const adminDisplayEmail = computed(() => adminMember.value?.email || '')
|
||||
const adminAvatarUrl = computed(() => adminMember.value?.avatarUrl || '')
|
||||
const adminAvatarInitial = computed(() => adminDisplayName.value.slice(0, 1).toUpperCase() || 'A')
|
||||
const adminProfilePath = computed(() => adminMember.value?.id ? `/admin/members/${adminMember.value.id}` : '/admin/members')
|
||||
|
||||
/**
|
||||
* 관리자 내비게이션 활성 경로 확인
|
||||
* @param {string} path - 확인할 경로
|
||||
* @returns {boolean} 활성 여부
|
||||
*/
|
||||
const isAdminNavActive = (path) => route.path === path || route.path.startsWith(`${path}/`)
|
||||
|
||||
/**
|
||||
* 관리자 사용자 메뉴 닫기
|
||||
* @returns {void}
|
||||
*/
|
||||
const closeAdminUserMenu = () => {
|
||||
adminUserMenuOpen.value = false
|
||||
}
|
||||
|
||||
/**
|
||||
* 관리자 사용자 메뉴 토글
|
||||
* @returns {void}
|
||||
*/
|
||||
const toggleAdminUserMenu = () => {
|
||||
adminUserMenuOpen.value = !adminUserMenuOpen.value
|
||||
}
|
||||
|
||||
/**
|
||||
* 외부 클릭 시 관리자 사용자 메뉴 닫기
|
||||
* @param {PointerEvent} event - 포인터 이벤트
|
||||
* @returns {void}
|
||||
*/
|
||||
const onAdminDocumentPointerDown = (event) => {
|
||||
if (!adminUserMenuOpen.value || !(event.target instanceof HTMLElement)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (event.target.closest('[data-admin-user-menu]')) {
|
||||
return
|
||||
}
|
||||
|
||||
closeAdminUserMenu()
|
||||
}
|
||||
|
||||
/**
|
||||
* 글쓰기 전체 화면 문서 스크롤 잠금 적용
|
||||
@@ -21,6 +81,10 @@ const syncPostEditorDocumentClass = () => {
|
||||
|
||||
watchEffect(syncPostEditorDocumentClass)
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('pointerdown', onAdminDocumentPointerDown)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (!import.meta.client) {
|
||||
return
|
||||
@@ -28,6 +92,7 @@ onBeforeUnmount(() => {
|
||||
|
||||
document.documentElement.classList.remove(editorDocumentClass)
|
||||
document.body.classList.remove(editorDocumentClass)
|
||||
document.removeEventListener('pointerdown', onAdminDocumentPointerDown)
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -38,54 +103,167 @@ const logoutAdmin = async () => {
|
||||
await $fetch('/admin/api/auth/logout', {
|
||||
method: 'POST'
|
||||
})
|
||||
closeAdminUserMenu()
|
||||
await navigateTo('/admin/login')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="admin-layout bg-[#f5f5f2] text-ink"
|
||||
class="admin-layout bg-[#f7f8fa] text-ink"
|
||||
:class="isPostEditorRoute ? 'h-screen overflow-hidden bg-white' : 'min-h-screen'"
|
||||
>
|
||||
<aside
|
||||
v-if="!isPostEditorRoute"
|
||||
class="admin-layout__sidebar fixed inset-y-0 left-0 hidden w-64 border-r border-line bg-[#15171a] p-5 text-white lg:block"
|
||||
class="admin-layout__sidebar fixed inset-y-0 left-0 hidden w-80 flex-col border-r border-[#e6e8eb] bg-[#f7f8fa] px-5 py-6 text-[#15171a] lg:flex"
|
||||
>
|
||||
<NuxtLink class="admin-layout__brand block text-lg font-semibold" to="/admin">
|
||||
sori.studio
|
||||
<NuxtLink class="admin-layout__brand flex items-center gap-3 px-2 text-[0.95rem] font-semibold tracking-[-0.01em]" to="/admin">
|
||||
<span class="admin-layout__brand-mark flex h-8 w-8 items-center justify-center rounded-full border border-[#d8dce1] bg-white shadow-[0_1px_2px_rgba(15,23,42,0.05)]">
|
||||
<span class="h-5 w-5 rounded-full border-2 border-[#15171a]" />
|
||||
</span>
|
||||
<span>sori.studio</span>
|
||||
</NuxtLink>
|
||||
<nav class="admin-layout__nav mt-8 grid gap-2 text-sm text-white/75">
|
||||
<NuxtLink class="admin-layout__nav-link rounded px-3 py-2 transition-colors hover:bg-white/10 hover:text-white" to="/admin/posts">
|
||||
글
|
||||
<nav class="admin-layout__nav mt-10 grid gap-1.5 text-sm font-medium text-[#5d6673]">
|
||||
<div
|
||||
class="admin-layout__nav-item group flex items-center rounded-md transition-colors"
|
||||
:class="isAdminNavActive('/admin/posts') ? 'bg-[#e9ecef] text-[#15171a]' : 'hover:bg-[#eceff2] hover:text-[#15171a]'"
|
||||
>
|
||||
<NuxtLink class="admin-layout__nav-link flex min-w-0 flex-1 items-center gap-3 px-3 py-2" to="/admin/posts">
|
||||
<svg class="admin-layout__nav-icon h-4 w-4 shrink-0" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M12.667 14.136l-3.712.531.53-3.713 9.546-9.546a2.25 2.25 0 013.182 3.182z" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
|
||||
<path d="M19.122 14.25v7.5a1.5 1.5 0 01-1.5 1.5h-15a1.5 1.5 0 01-1.5-1.5v-15a1.5 1.5 0 011.5-1.5h7.5" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
|
||||
</svg>
|
||||
<span class="truncate">게시글</span>
|
||||
</NuxtLink>
|
||||
<NuxtLink
|
||||
class="admin-layout__nav-create mr-2 flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-[#5d6673] transition-colors hover:bg-white hover:text-[#15171a] hover:shadow-[0_1px_2px_rgba(15,23,42,0.08)]"
|
||||
to="/admin/posts/new"
|
||||
aria-label="새 게시글 작성"
|
||||
>
|
||||
<svg class="h-3.5 w-3.5" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
||||
<path d="M8 1v14M1 8h14" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
|
||||
</svg>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
<NuxtLink
|
||||
class="admin-layout__nav-link flex items-center gap-3 rounded-md px-3 py-2 transition-colors hover:bg-[#eceff2] hover:text-[#15171a]"
|
||||
:class="isAdminNavActive('/admin/pages') ? 'bg-[#e9ecef] text-[#15171a]' : ''"
|
||||
to="/admin/pages"
|
||||
>
|
||||
<svg class="admin-layout__nav-icon h-4 w-4 shrink-0" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M16.5 21.513a1.5 1.5 0 01-1.9 1.446L4.1 20.042A1.5 1.5 0 013 18.6V2.487a1.5 1.5 0 011.9-1.446l10.5 3.391a1.5 1.5 0 011.1 1.445z" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
|
||||
<path d="M4.5.987h15a1.5 1.5 0 011.5 1.5v15.75a1.5 1.5 0 01-1.5 1.5h-3" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
|
||||
</svg>
|
||||
<span>페이지</span>
|
||||
</NuxtLink>
|
||||
<NuxtLink class="admin-layout__nav-link rounded px-3 py-2 transition-colors hover:bg-white/10 hover:text-white" to="/admin/pages">
|
||||
페이지
|
||||
<NuxtLink
|
||||
class="admin-layout__nav-link flex items-center gap-3 rounded-md px-3 py-2 transition-colors hover:bg-[#eceff2] hover:text-[#15171a]"
|
||||
:class="isAdminNavActive('/admin/tags') ? 'bg-[#e9ecef] text-[#15171a]' : ''"
|
||||
to="/admin/tags"
|
||||
>
|
||||
<svg class="admin-layout__nav-icon h-4 w-4 shrink-0" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M1.061 2.56v6.257a3 3 0 00.878 2.121L13.5 22.5a1.5 1.5 0 002.121 0l6.879-6.88a1.5 1.5 0 000-2.121L10.939 1.938a3 3 0 00-2.121-.878H2.561a1.5 1.5 0 00-1.5 1.5z" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
|
||||
</svg>
|
||||
<span>태그</span>
|
||||
</NuxtLink>
|
||||
<NuxtLink class="admin-layout__nav-link rounded px-3 py-2 transition-colors hover:bg-white/10 hover:text-white" to="/admin/tags">
|
||||
태그
|
||||
<NuxtLink
|
||||
class="admin-layout__nav-link flex items-center gap-3 rounded-md px-3 py-2 transition-colors hover:bg-[#eceff2] hover:text-[#15171a]"
|
||||
:class="isAdminNavActive('/admin/media') ? 'bg-[#e9ecef] text-[#15171a]' : ''"
|
||||
to="/admin/media"
|
||||
>
|
||||
<svg class="admin-layout__nav-icon h-4 w-4 shrink-0" xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960" fill="currentColor" aria-hidden="true">
|
||||
<path d="M305-193.85v-191.54l153.46 95.77L305-193.85Zm215-373.84q-47.44 0-80.64-33.18-33.21-33.18-33.21-80.58t33.21-80.67q33.2-33.26 80.64-33.26h43.85v47.69H520q-27.56 0-46.86 19.32-19.29 19.32-19.29 46.92t19.29 46.83q19.3 19.24 46.86 19.24h43.85v47.69H520Zm116.15 0v-47.69H680q27.56 0 46.86-19.33 19.29-19.32 19.29-46.92t-19.29-46.83q-19.3-19.23-46.86-19.23h-43.85v-47.69H680q47.44 0 80.64 33.17 33.21 33.18 33.21 80.58t-33.21 80.67q-33.2 33.27-80.64 33.27h-43.85Zm-110-90v-47.69h147.7v47.69h-147.7Zm106.7 239.61v-60h194.84q5.39 0 8.85-3.46t3.46-8.85v-335.38q0-5.38-3.46-8.84-3.46-3.47-8.85-3.47H372.31q-5.39 0-8.85 3.47-3.46 3.46-3.46 8.84v336.15h-60v-336.15q0-29.82 21.24-51.07 21.24-21.24 51.07-21.24h455.38q29.83 0 51.07 21.24Q900-855.59 900-825.77v335.38q0 29.83-21.24 51.07-21.24 21.24-51.07 21.24H632.85ZM132.31-61.92q-29.83 0-51.07-21.24Q60-104.41 60-134.23V-445q0-29.83 21.24-51.07 21.24-21.24 51.07-21.24h455.38q29.83 0 51.07 21.24Q660-474.83 660-445v310.77q0 29.82-21.24 51.07-21.24 21.24-51.07 21.24H132.31Zm0-60h455.38q5.39 0 8.85-3.47 3.46-3.46 3.46-8.84V-445q0-5.39-3.46-8.85t-8.85-3.46H132.31q-5.39 0-8.85 3.46T120-445v310.77q0 5.38 3.46 8.84 3.46 3.47 8.85 3.47ZM600-658.08ZM360-289.62Z" />
|
||||
</svg>
|
||||
<span>미디어</span>
|
||||
</NuxtLink>
|
||||
<NuxtLink class="admin-layout__nav-link rounded px-3 py-2 transition-colors hover:bg-white/10 hover:text-white" to="/admin/media">
|
||||
미디어
|
||||
<NuxtLink
|
||||
class="admin-layout__nav-link flex items-center gap-3 rounded-md px-3 py-2 transition-colors hover:bg-[#eceff2] hover:text-[#15171a]"
|
||||
:class="isAdminNavActive('/admin/navigation') ? 'bg-[#e9ecef] text-[#15171a]' : ''"
|
||||
to="/admin/navigation"
|
||||
>
|
||||
<svg class="admin-layout__nav-icon h-4 w-4 shrink-0" xmlns="http://www.w3.org/2000/svg" viewBox="-0.75 -0.75 24 24" aria-hidden="true">
|
||||
<path d="M2.109375 0.7059375h18.28125s1.40625 0 1.40625 1.40625v18.28125s0 1.40625 -1.40625 1.40625H2.109375s-1.40625 0 -1.40625 -1.40625v-18.28125s0 -1.40625 1.40625 -1.40625" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
|
||||
<path d="m6.328125 7.0340625 9.84375 0" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
|
||||
<path d="m6.328125 11.252812500000001 9.84375 0" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
|
||||
<path d="m6.328125 15.471562500000001 9.84375 0" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
|
||||
</svg>
|
||||
<span>네비게이션</span>
|
||||
</NuxtLink>
|
||||
<NuxtLink class="admin-layout__nav-link rounded px-3 py-2 transition-colors hover:bg-white/10 hover:text-white" to="/admin/navigation">
|
||||
메뉴
|
||||
<NuxtLink
|
||||
class="admin-layout__nav-link flex items-center gap-3 rounded-md px-3 py-2 transition-colors hover:bg-[#eceff2] hover:text-[#15171a]"
|
||||
:class="isAdminNavActive('/admin/members') ? 'bg-[#e9ecef] text-[#15171a]' : ''"
|
||||
to="/admin/members"
|
||||
>
|
||||
<svg class="admin-layout__nav-icon h-4 w-4 shrink-0" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<circle cx="7.5" cy="7.875" r="4.125" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
|
||||
<path d="M.75 20.25a6.75 6.75 0 0113.5 0" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
|
||||
<circle cx="17.727" cy="10.125" r="3.375" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
|
||||
<path d="M15.813 15.068a5.526 5.526 0 017.437 5.182" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
|
||||
</svg>
|
||||
<span class="min-w-0 flex-1">멤버</span>
|
||||
<span class="admin-layout__member-count ml-auto rounded-full bg-[#e1e5e9] px-2 py-0.5 text-xs font-semibold text-[#5d6673]">
|
||||
{{ memberCount }}
|
||||
</span>
|
||||
</NuxtLink>
|
||||
<NuxtLink class="admin-layout__nav-link rounded px-3 py-2 transition-colors hover:bg-white/10 hover:text-white" to="/admin/members">
|
||||
멤버
|
||||
</NuxtLink>
|
||||
<NuxtLink class="admin-layout__nav-link rounded px-3 py-2 transition-colors hover:bg-white/10 hover:text-white" to="/admin/settings">
|
||||
설정
|
||||
</NuxtLink>
|
||||
<button class="admin-layout__logout rounded px-3 py-2 text-left transition-colors hover:bg-white/10 hover:text-white" type="button" @click="logoutAdmin">
|
||||
로그아웃
|
||||
</button>
|
||||
</nav>
|
||||
<div class="admin-layout__bottom relative mt-auto pt-6" data-admin-user-menu>
|
||||
<div
|
||||
v-if="adminUserMenuOpen"
|
||||
class="admin-layout__user-popover absolute bottom-14 left-0 right-0 overflow-hidden rounded-xl border border-[#e2e5e9] bg-white text-[#15171a] shadow-[0_18px_50px_rgba(15,23,42,0.16)]"
|
||||
>
|
||||
<div class="admin-layout__user-summary flex items-center gap-3 border-b border-[#eceff2] px-4 py-4">
|
||||
<img
|
||||
v-if="adminAvatarUrl"
|
||||
class="admin-layout__user-avatar h-11 w-11 rounded-full border border-[#e2e5e9] object-cover"
|
||||
:src="adminAvatarUrl"
|
||||
:alt="adminDisplayName"
|
||||
>
|
||||
<span v-else class="admin-layout__user-avatar flex h-11 w-11 items-center justify-center rounded-full border border-[#e2e5e9] bg-[#15171a] text-sm font-semibold text-white">
|
||||
{{ adminAvatarInitial }}
|
||||
</span>
|
||||
<span class="min-w-0">
|
||||
<span class="block truncate text-sm font-semibold">{{ adminDisplayName }}</span>
|
||||
<span class="block truncate text-xs text-[#657080]">{{ adminDisplayEmail }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="admin-layout__user-actions grid py-2 text-sm text-[#3f4650]">
|
||||
<NuxtLink class="admin-layout__user-action px-4 py-2.5 hover:bg-[#f3f5f7]" :to="adminProfilePath" @click="closeAdminUserMenu">
|
||||
내 프로필
|
||||
</NuxtLink>
|
||||
<button class="admin-layout__user-action px-4 py-2.5 text-left hover:bg-[#f3f5f7]" type="button" @click="logoutAdmin">
|
||||
로그아웃
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="admin-layout__bottom-row flex items-center justify-between gap-3 px-2">
|
||||
<button class="admin-layout__user-trigger flex min-w-0 items-center gap-2 rounded-md px-1.5 py-1.5 transition-colors hover:bg-[#eceff2]" type="button" :aria-expanded="adminUserMenuOpen" @click="toggleAdminUserMenu">
|
||||
<img
|
||||
v-if="adminAvatarUrl"
|
||||
class="admin-layout__user-trigger-avatar h-8 w-8 rounded-full border border-[#d8dce1] object-cover"
|
||||
:src="adminAvatarUrl"
|
||||
:alt="adminDisplayName"
|
||||
>
|
||||
<span v-else class="admin-layout__user-trigger-avatar flex h-8 w-8 items-center justify-center rounded-full border border-[#d8dce1] bg-[#15171a] text-xs font-semibold text-white">
|
||||
{{ adminAvatarInitial }}
|
||||
</span>
|
||||
<svg class="h-3 w-3 shrink-0 text-[#5d6673]" fill="none" viewBox="0 0 26 24" aria-hidden="true">
|
||||
<path clip-rule="evenodd" d="M1.043 6.604a1 1 0 011.414 0L13 17.146 23.543 6.604a1 1 0 011.414 1.414l-10.72 10.719a1.75 1.75 0 01-2.474 0L1.042 8.018a1 1 0 010-1.414zm11.78 10.72v-.001zm.355 0v-.001z" fill="currentColor" />
|
||||
</svg>
|
||||
</button>
|
||||
<NuxtLink class="admin-layout__bottom-settings flex h-9 w-9 items-center justify-center rounded-md text-[#3f4650] transition-colors hover:bg-[#eceff2] hover:text-[#15171a]" to="/admin/settings" 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>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
<main
|
||||
class="admin-layout__main"
|
||||
class="admin-layout__main bg-paper"
|
||||
:class="[
|
||||
isPostEditorRoute ? 'h-screen overflow-hidden' : 'min-h-screen',
|
||||
{ 'lg:ml-64': !isPostEditorRoute }
|
||||
isPostEditorRoute ? 'h-screen overflow-hidden' : 'min-h-screen px-8 py-8 xl:px-12 xl:py-10',
|
||||
{ 'lg:ml-80': !isPostEditorRoute }
|
||||
]"
|
||||
>
|
||||
<slot />
|
||||
|
||||
@@ -51,6 +51,9 @@ export default defineNuxtConfig({
|
||||
adminEmail: process.env.ADMIN_EMAIL || '',
|
||||
adminPassword: process.env.ADMIN_PASSWORD || '',
|
||||
memberSessionSecret: process.env.MEMBER_SESSION_SECRET || '',
|
||||
resendApiKey: process.env.RESEND_API_KEY || '',
|
||||
resendFromEmail: process.env.RESEND_FROM_EMAIL || '',
|
||||
emailOtpPepper: process.env.EMAIL_OTP_PEPPER || '',
|
||||
uploadDir: process.env.UPLOAD_DIR || '/uploads',
|
||||
maxFileSize: Number(process.env.MAX_FILE_SIZE || 10485760),
|
||||
avatarMinWidth: Number(process.env.AVATAR_MIN_WIDTH || 96),
|
||||
|
||||
491
package-lock.json
generated
491
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "sori.studio",
|
||||
"version": "0.0.73",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "sori.studio",
|
||||
"version": "0.0.73",
|
||||
"version": "1.0.0",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||
@@ -7993,9 +7993,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/nitropack": {
|
||||
"version": "2.13.3",
|
||||
"resolved": "https://registry.npmjs.org/nitropack/-/nitropack-2.13.3.tgz",
|
||||
"integrity": "sha512-C8vO7RxkU0AQ3HbYUumuG6MVM5JjRaBchke/rYFOp3EvrLtTBHZYhDVGECdpa27vNuOYRzm3GtQMn2YDOjDJLA==",
|
||||
"version": "2.13.4",
|
||||
"resolved": "https://registry.npmjs.org/nitropack/-/nitropack-2.13.4.tgz",
|
||||
"integrity": "sha512-tX7bT6zxNeMwkc6hxHiZeUoTOjVrcjoh1Z3cmxOlodIqjl4HISgqfGOmkWSayky3Nv9Z5+KQH52F8nmXJY5AAA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@cloudflare/kv-asset-handler": "^0.4.2",
|
||||
@@ -8018,18 +8018,18 @@
|
||||
"croner": "^10.0.1",
|
||||
"crossws": "^0.3.5",
|
||||
"db0": "^0.3.4",
|
||||
"defu": "^6.1.6",
|
||||
"defu": "^6.1.7",
|
||||
"destr": "^2.0.5",
|
||||
"dot-prop": "^10.1.0",
|
||||
"esbuild": "^0.27.5",
|
||||
"esbuild": "^0.28.0",
|
||||
"escape-string-regexp": "^5.0.0",
|
||||
"etag": "^1.8.1",
|
||||
"exsolve": "^1.0.8",
|
||||
"globby": "^16.2.0",
|
||||
"gzip-size": "^7.0.0",
|
||||
"h3": "^1.15.10",
|
||||
"h3": "^1.15.11",
|
||||
"hookable": "^5.5.3",
|
||||
"httpxy": "^0.5.0",
|
||||
"httpxy": "^0.5.1",
|
||||
"ioredis": "^5.10.1",
|
||||
"jiti": "^2.6.1",
|
||||
"klona": "^2.0.6",
|
||||
@@ -8045,23 +8045,23 @@
|
||||
"ohash": "^2.0.11",
|
||||
"pathe": "^2.0.3",
|
||||
"perfect-debounce": "^2.1.0",
|
||||
"pkg-types": "^2.3.0",
|
||||
"pkg-types": "^2.3.1",
|
||||
"pretty-bytes": "^7.1.0",
|
||||
"radix3": "^1.1.2",
|
||||
"rollup": "^4.60.1",
|
||||
"rollup": "^4.60.2",
|
||||
"rollup-plugin-visualizer": "^7.0.1",
|
||||
"scule": "^1.3.0",
|
||||
"semver": "^7.7.4",
|
||||
"serve-placeholder": "^2.0.2",
|
||||
"serve-static": "^2.2.1",
|
||||
"source-map": "^0.7.6",
|
||||
"std-env": "^4.0.0",
|
||||
"ufo": "^1.6.3",
|
||||
"std-env": "^4.1.0",
|
||||
"ufo": "^1.6.4",
|
||||
"ultrahtml": "^1.6.0",
|
||||
"uncrypto": "^0.1.3",
|
||||
"unctx": "^2.5.0",
|
||||
"unenv": "2.0.0-rc.24",
|
||||
"unimport": "^6.0.2",
|
||||
"unimport": "^6.2.0",
|
||||
"unplugin-utils": "^0.3.1",
|
||||
"unstorage": "^1.17.5",
|
||||
"untyped": "^2.0.0",
|
||||
@@ -8085,12 +8085,469 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/nitropack/node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz",
|
||||
"integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"aix"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/nitropack/node_modules/@esbuild/android-arm": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz",
|
||||
"integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/nitropack/node_modules/@esbuild/android-arm64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/nitropack/node_modules/@esbuild/android-x64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/nitropack/node_modules/@esbuild/darwin-arm64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/nitropack/node_modules/@esbuild/darwin-x64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/nitropack/node_modules/@esbuild/freebsd-arm64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/nitropack/node_modules/@esbuild/freebsd-x64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/nitropack/node_modules/@esbuild/linux-arm": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz",
|
||||
"integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/nitropack/node_modules/@esbuild/linux-arm64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/nitropack/node_modules/@esbuild/linux-ia32": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz",
|
||||
"integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/nitropack/node_modules/@esbuild/linux-loong64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz",
|
||||
"integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/nitropack/node_modules/@esbuild/linux-mips64el": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz",
|
||||
"integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/nitropack/node_modules/@esbuild/linux-ppc64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz",
|
||||
"integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/nitropack/node_modules/@esbuild/linux-riscv64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz",
|
||||
"integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/nitropack/node_modules/@esbuild/linux-s390x": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz",
|
||||
"integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/nitropack/node_modules/@esbuild/linux-x64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/nitropack/node_modules/@esbuild/netbsd-arm64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/nitropack/node_modules/@esbuild/netbsd-x64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/nitropack/node_modules/@esbuild/openbsd-arm64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/nitropack/node_modules/@esbuild/openbsd-x64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/nitropack/node_modules/@esbuild/openharmony-arm64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openharmony"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/nitropack/node_modules/@esbuild/sunos-x64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"sunos"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/nitropack/node_modules/@esbuild/win32-arm64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/nitropack/node_modules/@esbuild/win32-ia32": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz",
|
||||
"integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/nitropack/node_modules/@esbuild/win32-x64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/nitropack/node_modules/cookie-es": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/cookie-es/-/cookie-es-2.0.1.tgz",
|
||||
"integrity": "sha512-aVf4A4hI2w70LnF7GG+7xDQUkliwiXWXFvTjkip4+b64ygDQ2sJPRSKFDHbxn8o0xu9QzPkMuuiWIXyFSE2slA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nitropack/node_modules/esbuild": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz",
|
||||
"integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"esbuild": "bin/esbuild"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@esbuild/aix-ppc64": "0.28.0",
|
||||
"@esbuild/android-arm": "0.28.0",
|
||||
"@esbuild/android-arm64": "0.28.0",
|
||||
"@esbuild/android-x64": "0.28.0",
|
||||
"@esbuild/darwin-arm64": "0.28.0",
|
||||
"@esbuild/darwin-x64": "0.28.0",
|
||||
"@esbuild/freebsd-arm64": "0.28.0",
|
||||
"@esbuild/freebsd-x64": "0.28.0",
|
||||
"@esbuild/linux-arm": "0.28.0",
|
||||
"@esbuild/linux-arm64": "0.28.0",
|
||||
"@esbuild/linux-ia32": "0.28.0",
|
||||
"@esbuild/linux-loong64": "0.28.0",
|
||||
"@esbuild/linux-mips64el": "0.28.0",
|
||||
"@esbuild/linux-ppc64": "0.28.0",
|
||||
"@esbuild/linux-riscv64": "0.28.0",
|
||||
"@esbuild/linux-s390x": "0.28.0",
|
||||
"@esbuild/linux-x64": "0.28.0",
|
||||
"@esbuild/netbsd-arm64": "0.28.0",
|
||||
"@esbuild/netbsd-x64": "0.28.0",
|
||||
"@esbuild/openbsd-arm64": "0.28.0",
|
||||
"@esbuild/openbsd-x64": "0.28.0",
|
||||
"@esbuild/openharmony-arm64": "0.28.0",
|
||||
"@esbuild/sunos-x64": "0.28.0",
|
||||
"@esbuild/win32-arm64": "0.28.0",
|
||||
"@esbuild/win32-ia32": "0.28.0",
|
||||
"@esbuild/win32-x64": "0.28.0"
|
||||
}
|
||||
},
|
||||
"node_modules/node-addon-api": {
|
||||
"version": "7.1.1",
|
||||
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz",
|
||||
@@ -11041,9 +11498,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ufo": {
|
||||
"version": "1.6.3",
|
||||
"resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.3.tgz",
|
||||
"integrity": "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==",
|
||||
"version": "1.6.4",
|
||||
"resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.4.tgz",
|
||||
"integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ultrahtml": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "sori.studio",
|
||||
"version": "0.0.96",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"imports": {
|
||||
@@ -12,6 +12,9 @@
|
||||
"scripts": {
|
||||
"dev": "node scripts/dev-server.js",
|
||||
"build": "nuxt build",
|
||||
"lint": "node scripts/check-js-syntax.js",
|
||||
"test": "npm run build",
|
||||
"verify": "npm run lint && npm run test",
|
||||
"preview": "nuxt preview --dotenv .env.development --host 127.0.0.1 --port 43117",
|
||||
"db:migrate:dev": "node scripts/migrate-development-db.js",
|
||||
"postinstall": "nuxt prepare"
|
||||
|
||||
44
pages/admin/members/[id].vue
Normal file
44
pages/admin/members/[id].vue
Normal file
@@ -0,0 +1,44 @@
|
||||
<script setup>
|
||||
definePageMeta({
|
||||
layout: 'admin'
|
||||
})
|
||||
|
||||
const route = useRoute()
|
||||
const memberId = computed(() => String(route.params.id || ''))
|
||||
|
||||
const { data: member, error } = await useFetch(() => `/admin/api/members/${memberId.value}`, {
|
||||
default: () => null
|
||||
})
|
||||
|
||||
/**
|
||||
* 저장된 회원 정보로 화면 상태를 갱신한다.
|
||||
* @param {Object} savedMember - 저장된 회원
|
||||
* @returns {void}
|
||||
*/
|
||||
const handleMemberSaved = (savedMember) => {
|
||||
member.value = savedMember
|
||||
}
|
||||
|
||||
/**
|
||||
* 회원 삭제 후 목록 화면으로 이동한다.
|
||||
* @returns {Promise<void>} 이동 처리
|
||||
*/
|
||||
const handleMemberDeleted = async () => {
|
||||
await navigateTo('/admin/members')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AdminMemberForm
|
||||
v-if="member"
|
||||
:member="member"
|
||||
mode="edit"
|
||||
@saved="handleMemberSaved"
|
||||
@deleted="handleMemberDeleted"
|
||||
/>
|
||||
<section v-else class="admin-member-detail bg-paper p-6">
|
||||
<div class="rounded-xl border border-line bg-white px-5 py-8 text-sm text-muted">
|
||||
{{ error?.data?.message || '회원 정보를 불러올 수 없습니다.' }}
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -3,24 +3,311 @@ definePageMeta({
|
||||
layout: 'admin'
|
||||
})
|
||||
|
||||
const memberSearchQuery = ref('')
|
||||
const isFilterOpen = ref(false)
|
||||
const filterDrafts = ref([
|
||||
{
|
||||
id: 1,
|
||||
field: 'email',
|
||||
operator: 'contains',
|
||||
value: ''
|
||||
}
|
||||
])
|
||||
const activeFilters = ref([])
|
||||
let nextFilterId = 2
|
||||
|
||||
const { data: members } = await useFetch('/admin/api/members', {
|
||||
default: () => []
|
||||
})
|
||||
const roleSavingIds = ref([])
|
||||
const roleMessage = ref('')
|
||||
|
||||
const roleOptions = [
|
||||
{ value: 'owner', label: '소유자' },
|
||||
{ value: 'admin', label: '관리자' },
|
||||
{ value: 'member', label: '멤버' }
|
||||
const dateFilterFields = ['lastSeenAt', 'createdAt']
|
||||
|
||||
const filterFieldOptions = [
|
||||
{ value: 'name', label: '이름' },
|
||||
{ value: 'email', label: '이메일' },
|
||||
{ value: 'label', label: '레이블' },
|
||||
{ value: 'status', label: '상태' },
|
||||
{ value: 'lastSeenAt', label: '최근 접속' },
|
||||
{ value: 'createdAt', label: '가입일' }
|
||||
]
|
||||
|
||||
const operatorOptionsByFieldType = {
|
||||
text: [
|
||||
{ value: 'contains', label: '포함' },
|
||||
{ value: 'notContains', label: '포함하지 않음' },
|
||||
{ value: 'is', label: '일치' },
|
||||
{ value: 'startsWith', label: '시작' },
|
||||
{ value: 'endsWith', label: '끝남' }
|
||||
],
|
||||
status: [
|
||||
{ value: 'is', label: '일치' },
|
||||
{ value: 'isNot', label: '일치하지 않음' }
|
||||
],
|
||||
date: [
|
||||
{ value: 'before', label: '이전' },
|
||||
{ value: 'after', label: '이후' },
|
||||
{ value: 'empty', label: '없음' },
|
||||
{ value: 'notEmpty', label: '있음' }
|
||||
]
|
||||
}
|
||||
|
||||
const statusFilterOptions = [
|
||||
{ value: '활성', label: '활성' },
|
||||
{ value: '비활성', label: '비활성' }
|
||||
]
|
||||
|
||||
/**
|
||||
* 최근 접속 시각 표시 문자열을 반환한다.
|
||||
* @param {string | null} value - ISO 시각
|
||||
* @returns {string} 표시 문자열
|
||||
* 필터 필드 타입을 반환한다.
|
||||
* @param {string} field - 필터 필드
|
||||
* @returns {'text'|'status'|'date'} 필드 타입
|
||||
*/
|
||||
const formatLastSeen = (value) => {
|
||||
const getFilterFieldType = (field) => {
|
||||
if (field === 'status') {
|
||||
return 'status'
|
||||
}
|
||||
|
||||
if (dateFilterFields.includes(field)) {
|
||||
return 'date'
|
||||
}
|
||||
|
||||
return 'text'
|
||||
}
|
||||
|
||||
/**
|
||||
* 필터 필드에 맞는 연산자 목록을 반환한다.
|
||||
* @param {string} field - 필터 필드
|
||||
* @returns {Array<{ value: string, label: string }>} 연산자 목록
|
||||
*/
|
||||
const getFilterOperatorOptions = (field) => operatorOptionsByFieldType[getFilterFieldType(field)]
|
||||
|
||||
/**
|
||||
* 필터 대상 회원 값을 반환한다.
|
||||
* @param {Object} member - 회원 정보
|
||||
* @param {string} field - 필터 필드
|
||||
* @returns {string} 필터 대상 값
|
||||
*/
|
||||
const getFilterMemberValue = (member, field) => {
|
||||
if (field === 'name') {
|
||||
return member.username || ''
|
||||
}
|
||||
|
||||
if (field === 'label') {
|
||||
return Array.isArray(member.labels) ? member.labels.join(', ') : ''
|
||||
}
|
||||
|
||||
if (field === 'status') {
|
||||
return member.activityStatus || '비활성'
|
||||
}
|
||||
|
||||
return member[field] || ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 텍스트 필터 조건을 검사한다.
|
||||
* @param {string} source - 원본 값
|
||||
* @param {string} operator - 연산자
|
||||
* @param {string} target - 비교 값
|
||||
* @returns {boolean} 조건 충족 여부
|
||||
*/
|
||||
const matchesTextFilter = (source, operator, target) => {
|
||||
const sourceText = source.toLowerCase()
|
||||
const targetText = target.toLowerCase()
|
||||
|
||||
if (!targetText) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (operator === 'is') {
|
||||
return sourceText === targetText
|
||||
}
|
||||
|
||||
if (operator === 'notContains') {
|
||||
return !sourceText.includes(targetText)
|
||||
}
|
||||
|
||||
if (operator === 'startsWith') {
|
||||
return sourceText.startsWith(targetText)
|
||||
}
|
||||
|
||||
if (operator === 'endsWith') {
|
||||
return sourceText.endsWith(targetText)
|
||||
}
|
||||
|
||||
return sourceText.includes(targetText)
|
||||
}
|
||||
|
||||
/**
|
||||
* 날짜 필터 조건을 검사한다.
|
||||
* @param {string | null} value - ISO 날짜 문자열
|
||||
* @param {string} operator - 연산자
|
||||
* @param {string} target - yyyy-mm-dd 비교 값
|
||||
* @returns {boolean} 조건 충족 여부
|
||||
*/
|
||||
const matchesDateFilter = (value, operator, target) => {
|
||||
if (operator === 'empty') {
|
||||
return !value
|
||||
}
|
||||
|
||||
if (operator === 'notEmpty') {
|
||||
return Boolean(value)
|
||||
}
|
||||
|
||||
if (!value || !target) {
|
||||
return true
|
||||
}
|
||||
|
||||
const sourceTime = new Date(value).getTime()
|
||||
const targetTime = new Date(`${target}T00:00:00`).getTime()
|
||||
|
||||
if (Number.isNaN(sourceTime) || Number.isNaN(targetTime)) {
|
||||
return true
|
||||
}
|
||||
|
||||
return operator === 'before'
|
||||
? sourceTime < targetTime
|
||||
: sourceTime >= targetTime
|
||||
}
|
||||
|
||||
/**
|
||||
* 회원이 단일 필터 조건을 만족하는지 확인한다.
|
||||
* @param {Object} member - 회원 정보
|
||||
* @param {Object} filter - 필터 조건
|
||||
* @returns {boolean} 조건 충족 여부
|
||||
*/
|
||||
const matchesMemberFilter = (member, filter) => {
|
||||
const fieldType = getFilterFieldType(filter.field)
|
||||
const source = String(getFilterMemberValue(member, filter.field) || '')
|
||||
|
||||
if (fieldType === 'date') {
|
||||
return matchesDateFilter(source, filter.operator, filter.value)
|
||||
}
|
||||
|
||||
if (fieldType === 'status') {
|
||||
return filter.operator === 'isNot'
|
||||
? source !== filter.value
|
||||
: source === filter.value
|
||||
}
|
||||
|
||||
return matchesTextFilter(source, filter.operator, filter.value)
|
||||
}
|
||||
|
||||
/**
|
||||
* 필터 조건 필드를 변경한다.
|
||||
* @param {Object} filter - 필터 조건
|
||||
* @returns {void}
|
||||
*/
|
||||
const changeFilterField = (filter) => {
|
||||
const fieldType = getFilterFieldType(filter.field)
|
||||
filter.operator = getFilterOperatorOptions(filter.field)[0].value
|
||||
filter.value = fieldType === 'status' ? '활성' : ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 새 필터 조건을 추가한다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const addFilter = () => {
|
||||
filterDrafts.value.push({
|
||||
id: nextFilterId,
|
||||
field: 'email',
|
||||
operator: 'contains',
|
||||
value: ''
|
||||
})
|
||||
nextFilterId += 1
|
||||
}
|
||||
|
||||
/**
|
||||
* 필터 조건을 삭제한다.
|
||||
* @param {number} id - 필터 ID
|
||||
* @returns {void}
|
||||
*/
|
||||
const removeFilter = (id) => {
|
||||
filterDrafts.value = filterDrafts.value.filter((filter) => filter.id !== id)
|
||||
|
||||
if (!filterDrafts.value.length) {
|
||||
addFilter()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 필터 조건을 적용한다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const applyFilters = () => {
|
||||
activeFilters.value = filterDrafts.value
|
||||
.filter((filter) => ['empty', 'notEmpty'].includes(filter.operator) || String(filter.value || '').trim())
|
||||
.map((filter) => ({ ...filter }))
|
||||
isFilterOpen.value = false
|
||||
}
|
||||
|
||||
/**
|
||||
* 모든 필터 조건을 초기화한다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const resetFilters = () => {
|
||||
filterDrafts.value = [
|
||||
{
|
||||
id: nextFilterId,
|
||||
field: 'email',
|
||||
operator: 'contains',
|
||||
value: ''
|
||||
}
|
||||
]
|
||||
nextFilterId += 1
|
||||
activeFilters.value = []
|
||||
}
|
||||
|
||||
const filteredMembers = computed(() => {
|
||||
const query = memberSearchQuery.value.trim().toLowerCase()
|
||||
const searchedMembers = !query
|
||||
? members.value
|
||||
: members.value.filter((member) => [
|
||||
member.username,
|
||||
member.email,
|
||||
member.lastSeenIp,
|
||||
member.activityStatus
|
||||
].some((value) => String(value || '').toLowerCase().includes(query)))
|
||||
|
||||
if (!activeFilters.value.length) {
|
||||
return searchedMembers
|
||||
}
|
||||
|
||||
return searchedMembers.filter((member) => activeFilters.value.every((filter) => matchesMemberFilter(member, filter)))
|
||||
})
|
||||
|
||||
const memberCountLabel = computed(() => {
|
||||
const count = filteredMembers.value.length
|
||||
return `${count}명`
|
||||
})
|
||||
|
||||
const activeFilterCount = computed(() => activeFilters.value.length)
|
||||
|
||||
/**
|
||||
* 회원 상세 화면으로 이동한다.
|
||||
* @param {Object} member - 회원 정보
|
||||
* @returns {Promise<void>} 이동 처리
|
||||
*/
|
||||
const navigateToMember = async (member) => {
|
||||
if (!member?.id) {
|
||||
return
|
||||
}
|
||||
|
||||
await navigateTo(`/admin/members/${member.id}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 회원 이니셜을 반환한다.
|
||||
* @param {Object} member - 회원 정보
|
||||
* @returns {string} 이니셜
|
||||
*/
|
||||
const getMemberInitial = (member) => String(member?.username || member?.email || '?').slice(0, 1).toUpperCase()
|
||||
|
||||
/**
|
||||
* 날짜 표시 형식 변환
|
||||
* @param {string | null} value - ISO 날짜 문자열
|
||||
* @returns {string} 화면 표시 날짜
|
||||
*/
|
||||
const formatDate = (value) => {
|
||||
if (!value) {
|
||||
return '-'
|
||||
}
|
||||
@@ -30,154 +317,261 @@ const formatLastSeen = (value) => {
|
||||
return '-'
|
||||
}
|
||||
|
||||
return date.toLocaleString('ko-KR', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
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} memberId - 회원 ID
|
||||
* @returns {boolean} 진행 여부
|
||||
* 최근 활동 시각을 상대 시간으로 표시한다.
|
||||
* @param {string | null} value - ISO 시각
|
||||
* @returns {string} 상대 시간
|
||||
*/
|
||||
const isSavingRole = (memberId) => roleSavingIds.value.includes(memberId)
|
||||
|
||||
/**
|
||||
* 회원 권한을 변경한다.
|
||||
* @param {Object} member - 회원 정보
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const updateRole = async (member) => {
|
||||
if (!member?.id || isSavingRole(member.id)) {
|
||||
return
|
||||
const formatRelativeTime = (value) => {
|
||||
if (!value) {
|
||||
return '최근 활동 없음'
|
||||
}
|
||||
|
||||
roleSavingIds.value = [...roleSavingIds.value, member.id]
|
||||
roleMessage.value = ''
|
||||
|
||||
try {
|
||||
const result = await $fetch(`/admin/api/members/${member.id}/role`, {
|
||||
method: 'PUT',
|
||||
body: {
|
||||
role: member.roleCode
|
||||
}
|
||||
})
|
||||
|
||||
members.value = members.value.map((item) => {
|
||||
if (item.id !== member.id) {
|
||||
return item
|
||||
}
|
||||
|
||||
return {
|
||||
...item,
|
||||
role: result.role,
|
||||
roleCode: result.roleCode,
|
||||
isAdmin: Boolean(result.isAdmin)
|
||||
}
|
||||
})
|
||||
|
||||
roleMessage.value = '권한이 변경되었습니다.'
|
||||
} catch (error) {
|
||||
roleMessage.value = error?.data?.message || '권한 변경에 실패했습니다.'
|
||||
} finally {
|
||||
roleSavingIds.value = roleSavingIds.value.filter((id) => id !== member.id)
|
||||
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)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="admin-members min-h-screen bg-paper">
|
||||
<div class="border-b border-line bg-paper px-6 py-5">
|
||||
<p class="text-xs font-semibold uppercase text-muted">Admin</p>
|
||||
<h1 class="mt-2 text-2xl font-semibold text-ink">멤버</h1>
|
||||
<section class="admin-members bg-paper p-6">
|
||||
<div class="admin-members__header flex flex-col gap-5 pb-6 xl:flex-row xl:items-center xl:justify-between">
|
||||
<div>
|
||||
<p class="admin-members__eyebrow text-xs font-semibold uppercase text-muted">
|
||||
Members
|
||||
</p>
|
||||
<h1 class="admin-members__title mt-2 text-3xl font-semibold tracking-[-0.01em] text-[#15171a]">
|
||||
멤버
|
||||
</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]">
|
||||
<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>
|
||||
<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]"
|
||||
type="search"
|
||||
placeholder="멤버 검색..."
|
||||
aria-label="멤버 검색"
|
||||
>
|
||||
</label>
|
||||
<button
|
||||
class="admin-members__filter inline-flex h-11 items-center justify-center gap-2 rounded-md border border-line bg-white px-4 text-sm font-semibold text-[#394047] transition hover:bg-[#eff1f2] hover:text-black"
|
||||
type="button"
|
||||
:aria-expanded="isFilterOpen"
|
||||
@click="isFilterOpen = !isFilterOpen"
|
||||
>
|
||||
<svg class="h-4 w-4" viewBox="0 0 18 18" fill="none" aria-hidden="true">
|
||||
<path d="M6.5 13.502H12M4 9.004L14 9M1.688 4.502h14.624" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
<span>필터</span>
|
||||
<span v-if="activeFilterCount" class="admin-members__filter-count rounded-full bg-[#15171a] px-1.5 py-0.5 text-[11px] leading-none text-white">
|
||||
{{ activeFilterCount }}
|
||||
</span>
|
||||
</button>
|
||||
<NuxtLink
|
||||
class="admin-members__new inline-flex h-11 items-center rounded-md bg-[#15171a] px-4 text-sm font-semibold text-white transition hover:bg-[#2b2f35] focus-visible:outline focus-visible:ring-2 focus-visible:ring-[#8e9cac] focus-visible:ring-offset-2"
|
||||
to="/admin/members/new"
|
||||
>
|
||||
멤버 추가
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="px-6 py-5">
|
||||
<div class="overflow-x-auto rounded-[10px] border border-line bg-white">
|
||||
<table class="min-w-full text-left text-sm">
|
||||
<thead class="bg-[#f7f7f5] text-xs uppercase text-muted">
|
||||
<tr>
|
||||
<th class="px-3 py-2.5">닉네임</th>
|
||||
<th class="px-3 py-2.5">권한</th>
|
||||
<th class="px-3 py-2.5">이메일</th>
|
||||
<th class="px-3 py-2.5">최근 활동</th>
|
||||
<th class="px-3 py-2.5">접속 IP</th>
|
||||
<th class="px-3 py-2.5">활동 현황</th>
|
||||
<th class="px-3 py-2.5 text-right">댓글 개수</th>
|
||||
<th class="px-3 py-2.5 text-right">권한 변경</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="member in members" :key="member.id" class="border-t border-line/70">
|
||||
<td class="px-3 py-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<img
|
||||
v-if="member.avatarUrl"
|
||||
:src="member.avatarUrl"
|
||||
:alt="member.username"
|
||||
class="h-7 w-7 rounded-full object-cover"
|
||||
>
|
||||
<span v-else class="grid h-7 w-7 place-items-center rounded-full bg-[#efefec] text-xs font-semibold text-ink">
|
||||
{{ (member.username || '?').slice(0, 1).toUpperCase() }}
|
||||
</span>
|
||||
<span>{{ member.username }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-3 py-3">
|
||||
<span
|
||||
class="rounded-full border px-2 py-0.5 text-xs"
|
||||
:class="member.roleCode === 'owner'
|
||||
? 'border-[#8a56ff]/35 text-[#8a56ff]'
|
||||
: member.roleCode === 'admin'
|
||||
? 'border-[#ff4f2e]/30 text-[#ff4f2e]'
|
||||
: 'border-line text-muted'"
|
||||
>
|
||||
{{ member.role || '멤버' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-3 py-3 text-muted">{{ member.email }}</td>
|
||||
<td class="px-3 py-3 text-muted">{{ formatLastSeen(member.lastSeenAt) }}</td>
|
||||
<td class="px-3 py-3 text-muted">{{ member.lastSeenIp || '-' }}</td>
|
||||
<td class="px-3 py-3">
|
||||
<span class="rounded-full border border-line px-2 py-0.5 text-xs">{{ member.activityStatus }}</span>
|
||||
</td>
|
||||
<td class="px-3 py-3 text-right font-semibold text-ink">{{ member.commentCount }}</td>
|
||||
<td class="px-3 py-3">
|
||||
<div class="flex justify-end gap-2">
|
||||
<select
|
||||
v-model="member.roleCode"
|
||||
class="rounded border border-line bg-white px-2 py-1 text-xs"
|
||||
:disabled="isSavingRole(member.id)"
|
||||
>
|
||||
<option v-for="option in roleOptions" :key="option.value" :value="option.value">
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded border border-line px-2 py-1 text-xs font-semibold hover:bg-paper disabled:opacity-50"
|
||||
:disabled="isSavingRole(member.id)"
|
||||
@click="updateRole(member)"
|
||||
>
|
||||
{{ isSavingRole(member.id) ? '저장 중' : '저장' }}
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="members.length === 0">
|
||||
<td colspan="8" class="px-3 py-6 text-center text-sm text-muted">등록된 회원이 없습니다.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<section v-if="isFilterOpen" class="admin-members__filter-panel mt-2 rounded-lg border border-line bg-white p-6 shadow-xl">
|
||||
<h2 class="admin-members__filter-title text-2xl font-semibold tracking-[-0.01em] text-[#15171a]">
|
||||
필터 목록
|
||||
</h2>
|
||||
<div class="admin-members__filter-body mt-6 rounded-md bg-[#eef1f4] p-5">
|
||||
<div class="admin-members__filter-rules grid gap-3">
|
||||
<div
|
||||
v-for="filter in filterDrafts"
|
||||
:key="filter.id"
|
||||
class="admin-members__filter-row grid gap-2 lg:grid-cols-[minmax(0,1fr)_220px_minmax(0,1fr)_36px]"
|
||||
>
|
||||
<label class="admin-members__filter-field">
|
||||
<span class="sr-only">필터 항목</span>
|
||||
<select
|
||||
v-model="filter.field"
|
||||
class="admin-members__filter-select h-11 w-full rounded-md border border-[#d7dce0] bg-white px-3 text-sm text-[#15171a] outline-none focus:border-[#8e9cac] focus:ring-2 focus:ring-[#dce2e8]"
|
||||
@change="changeFilterField(filter)"
|
||||
>
|
||||
<option v-for="option in filterFieldOptions" :key="option.value" :value="option.value">
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="admin-members__filter-operator">
|
||||
<span class="sr-only">필터 조건</span>
|
||||
<select
|
||||
v-model="filter.operator"
|
||||
class="admin-members__filter-select h-11 w-full rounded-md border border-[#d7dce0] bg-white px-3 text-sm text-[#15171a] outline-none focus:border-[#8e9cac] focus:ring-2 focus:ring-[#dce2e8]"
|
||||
>
|
||||
<option v-for="option in getFilterOperatorOptions(filter.field)" :key="option.value" :value="option.value">
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="admin-members__filter-value">
|
||||
<span class="sr-only">필터 값</span>
|
||||
<select
|
||||
v-if="getFilterFieldType(filter.field) === 'status'"
|
||||
v-model="filter.value"
|
||||
class="admin-members__filter-select h-11 w-full rounded-md border border-[#d7dce0] bg-white px-3 text-sm text-[#15171a] outline-none focus:border-[#8e9cac] focus:ring-2 focus:ring-[#dce2e8]"
|
||||
>
|
||||
<option v-for="option in statusFilterOptions" :key="option.value" :value="option.value">
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</select>
|
||||
<input
|
||||
v-else-if="getFilterFieldType(filter.field) === 'date' && !['empty', 'notEmpty'].includes(filter.operator)"
|
||||
v-model="filter.value"
|
||||
class="admin-members__filter-input h-11 w-full rounded-md border border-[#d7dce0] bg-white px-3 text-sm text-[#15171a] outline-none focus:border-[#8e9cac] focus:ring-2 focus:ring-[#dce2e8]"
|
||||
type="date"
|
||||
>
|
||||
<input
|
||||
v-else-if="getFilterFieldType(filter.field) !== 'date'"
|
||||
v-model="filter.value"
|
||||
class="admin-members__filter-input h-11 w-full rounded-md border border-[#d7dce0] bg-white px-3 text-sm text-[#15171a] outline-none focus:border-[#8e9cac] focus:ring-2 focus:ring-[#dce2e8]"
|
||||
type="text"
|
||||
placeholder="값 입력"
|
||||
>
|
||||
<span v-else class="admin-members__filter-empty-value flex h-11 items-center rounded-md border border-[#d7dce0] bg-white px-3 text-sm text-[#8a95a5]">
|
||||
값 필요 없음
|
||||
</span>
|
||||
</label>
|
||||
<button
|
||||
class="admin-members__filter-delete grid h-11 w-9 place-items-center rounded-md text-[#657080] transition hover:bg-white hover:text-[#15171a]"
|
||||
type="button"
|
||||
aria-label="필터 삭제"
|
||||
@click="removeFilter(filter.id)"
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
class="admin-members__filter-add mt-5 inline-flex items-center gap-2 text-sm font-semibold text-[#17a62b]"
|
||||
type="button"
|
||||
@click="addFilter"
|
||||
>
|
||||
<svg class="h-3.5 w-3.5" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
||||
<path d="M8 1v14M1 8h14" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
|
||||
</svg>
|
||||
<span>필터 추가</span>
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="roleMessage" class="mt-3 text-xs text-muted">
|
||||
{{ roleMessage }}
|
||||
</p>
|
||||
<div class="admin-members__filter-footer mt-5 flex items-center justify-between">
|
||||
<button class="admin-members__filter-reset h-10 rounded-md border border-line bg-white px-4 text-sm font-semibold text-[#394047] transition hover:bg-[#eff1f2]" type="button" @click="resetFilters">
|
||||
모두 초기화
|
||||
</button>
|
||||
<button class="admin-members__filter-apply h-10 rounded-md bg-[#15171a] px-4 text-sm font-semibold text-white transition hover:bg-black" type="button" @click="applyFilters">
|
||||
필터 적용
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="admin-members__table mt-7 overflow-x-auto">
|
||||
<table class="admin-members__table-inner min-w-[920px] w-full border-collapse text-left text-sm">
|
||||
<thead class="admin-members__table-head border-b border-line text-xs uppercase tracking-[0.02em] text-[#15171a]">
|
||||
<tr>
|
||||
<th class="admin-members__cell w-[32%] py-4 pr-5 font-semibold">{{ memberCountLabel }}</th>
|
||||
<th class="admin-members__cell w-[15%] px-5 py-4 font-semibold">상태</th>
|
||||
<th class="admin-members__cell w-[16%] px-5 py-4 font-semibold">댓글 작성</th>
|
||||
<th class="admin-members__cell w-[17%] px-5 py-4 font-semibold">접속 IP</th>
|
||||
<th class="admin-members__cell w-[20%] py-4 pl-5 font-semibold">가입일</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="admin-members__table-body divide-y divide-line/70 bg-paper">
|
||||
<tr
|
||||
v-for="member in filteredMembers"
|
||||
:key="member.id"
|
||||
class="admin-members__row cursor-pointer align-middle transition-colors hover:bg-[#f7f8fa] focus-within:bg-[#f7f8fa]"
|
||||
tabindex="0"
|
||||
@click="navigateToMember(member)"
|
||||
@keydown.enter.prevent="navigateToMember(member)"
|
||||
>
|
||||
<td class="admin-members__cell py-5 pr-5">
|
||||
<div class="admin-members__profile flex min-w-0 items-center gap-3">
|
||||
<img
|
||||
v-if="member.avatarUrl"
|
||||
:src="member.avatarUrl"
|
||||
:alt="member.username"
|
||||
class="admin-members__avatar h-11 w-11 shrink-0 rounded-full object-cover"
|
||||
>
|
||||
<span v-else class="admin-members__avatar grid h-11 w-11 shrink-0 place-items-center rounded-full bg-[#15171a] text-sm font-semibold text-white">
|
||||
{{ getMemberInitial(member) }}
|
||||
</span>
|
||||
<span class="admin-members__identity min-w-0">
|
||||
<span class="admin-members__name block truncate text-base font-semibold text-[#15171a]">
|
||||
{{ member.username || '이름 없음' }}
|
||||
</span>
|
||||
<span class="admin-members__email mt-0.5 block truncate text-sm text-[#657080]">
|
||||
{{ member.email }}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="admin-members__cell px-5 py-5 text-[#2f343b]">
|
||||
<span class="admin-members__status text-sm text-[#394047]">
|
||||
{{ member.activityStatus }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="admin-members__cell px-5 py-5 text-[#2f343b]">
|
||||
<span class="admin-members__comments font-semibold text-[#15171a]">{{ member.commentCount }}</span>
|
||||
<span class="admin-members__comments-label ml-1 text-xs text-[#8c96a3]">개</span>
|
||||
</td>
|
||||
<td class="admin-members__cell px-5 py-5 text-sm text-[#657080]">
|
||||
{{ member.lastSeenIp || '-' }}
|
||||
</td>
|
||||
<td class="admin-members__cell py-5 pl-5">
|
||||
<span class="admin-members__created block text-sm text-[#15171a]">{{ formatDate(member.createdAt) }}</span>
|
||||
<span class="admin-members__last-seen mt-1 block text-sm text-[#9aa4b2]">{{ formatRelativeTime(member.lastSeenAt) }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="filteredMembers.length === 0">
|
||||
<td colspan="5" class="admin-members__empty px-4 py-12 text-center text-sm text-muted">
|
||||
검색 결과가 없습니다.
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
|
||||
22
pages/admin/members/new.vue
Normal file
22
pages/admin/members/new.vue
Normal file
@@ -0,0 +1,22 @@
|
||||
<script setup>
|
||||
definePageMeta({
|
||||
layout: 'admin'
|
||||
})
|
||||
|
||||
/**
|
||||
* 새 회원 저장 후 상세 화면으로 이동한다.
|
||||
* @param {Object} member - 저장된 회원
|
||||
* @returns {Promise<void>} 이동 처리
|
||||
*/
|
||||
const handleMemberSaved = async (member) => {
|
||||
if (!member?.id) {
|
||||
return
|
||||
}
|
||||
|
||||
await navigateTo(`/admin/members/${member.id}`)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AdminMemberForm mode="new" @saved="handleMemberSaved" />
|
||||
</template>
|
||||
@@ -381,9 +381,6 @@ const saveNavigation = async () => {
|
||||
<p class="admin-navigation__lead mt-2 max-w-xl text-sm text-muted">
|
||||
상단·하단을 탭으로 나눕니다. 순서는 <span class="font-semibold text-ink">태그 관리의 메인 태그</span>와 같이 행을 드래그합니다(번호·여백에서 잡고 이동). 입력란 위에서는 드래그가 시작되지 않습니다. 하위 메뉴가 있으면 공개 사이드바에서 접는 그룹으로 보입니다.
|
||||
</p>
|
||||
<p class="admin-navigation__migrate-hint mt-2 max-w-xl rounded border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-900">
|
||||
저장 시 <code class="rounded bg-white/80 px-1">parent_id</code> 오류가 나면 DB에 마이그레이션이 아직 없는 것입니다. 로컬에서 <code class="rounded bg-white/80 px-1">npm run db:migrate:dev</code>로 <code class="rounded bg-white/80 px-1">017_navigation_hierarchy.sql</code>을 적용하세요.
|
||||
</p>
|
||||
</div>
|
||||
<div class="admin-navigation__header-actions flex flex-wrap gap-2">
|
||||
<button
|
||||
|
||||
@@ -95,6 +95,7 @@ const savePost = async (payload) => {
|
||||
})
|
||||
|
||||
post.value = updatedPost
|
||||
postForm.value?.markSaved()
|
||||
postForm.value?.clearAutosave()
|
||||
showToast('success', '변경 내용이 저장되었습니다.')
|
||||
} catch (error) {
|
||||
@@ -122,6 +123,7 @@ const deletePost = async () => {
|
||||
await $fetch(`/admin/api/posts/${id.value}`, {
|
||||
method: 'DELETE'
|
||||
})
|
||||
postForm.value?.allowNextRouteLeave()
|
||||
await navigateTo('/admin/posts')
|
||||
} catch (error) {
|
||||
errorMessage.value = error?.data?.message || '글을 삭제하지 못했습니다.'
|
||||
|
||||
@@ -141,7 +141,16 @@ const deletePost = async (post) => {
|
||||
</p>
|
||||
</td>
|
||||
<td class="admin-posts__cell px-4 py-4">
|
||||
{{ post.tags.join(', ') || '-' }}
|
||||
<div v-if="post.tags.length" class="admin-posts__tag-list flex flex-wrap gap-1.5">
|
||||
<span
|
||||
v-for="tag in post.tags"
|
||||
:key="tag"
|
||||
class="admin-posts__tag-badge inline-flex h-6 items-center rounded-[3px] bg-[#ecd2de] px-2 text-xs font-semibold text-[#e04e87]"
|
||||
>
|
||||
{{ tag }}
|
||||
</span>
|
||||
</div>
|
||||
<span v-else class="admin-posts__tag-empty text-muted">-</span>
|
||||
</td>
|
||||
<td class="admin-posts__cell px-4 py-4">
|
||||
{{ formatDate(post.updatedAt) }}
|
||||
|
||||
@@ -52,7 +52,9 @@ const savePost = async (payload) => {
|
||||
body: payload
|
||||
})
|
||||
|
||||
postForm.value?.markSaved()
|
||||
postForm.value?.clearAutosave()
|
||||
postForm.value?.allowNextRouteLeave()
|
||||
sessionStorage.setItem('SORI_ADMIN_POST_TOAST', JSON.stringify({
|
||||
type: 'success',
|
||||
message: '글이 저장되었습니다.'
|
||||
|
||||
@@ -4,17 +4,11 @@ definePageMeta({
|
||||
})
|
||||
|
||||
const saving = ref(false)
|
||||
const loadingProfile = ref(true)
|
||||
const savingProfile = ref(false)
|
||||
const savingPassword = ref(false)
|
||||
const uploadingAvatar = ref(false)
|
||||
const removingAvatar = ref(false)
|
||||
const uploadingLogo = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const profileMessage = ref('')
|
||||
const passwordMessage = ref('')
|
||||
const toast = ref(null)
|
||||
const logoInputRef = ref(null)
|
||||
let toastTimer = null
|
||||
const avatarInputRef = ref(null)
|
||||
|
||||
const { data: settings } = await useFetch('/admin/api/settings')
|
||||
|
||||
@@ -23,207 +17,11 @@ const form = reactive({
|
||||
description: settings.value?.description || '',
|
||||
siteUrl: settings.value?.siteUrl || 'https://sori.studio',
|
||||
logoText: settings.value?.logoText || '井',
|
||||
logoUrl: settings.value?.logoUrl || '',
|
||||
faviconUrl: settings.value?.faviconUrl || '',
|
||||
copyrightText: settings.value?.copyrightText || '©2026 sori.studio'
|
||||
})
|
||||
|
||||
const profileForm = reactive({
|
||||
email: '',
|
||||
username: '',
|
||||
avatarUrl: ''
|
||||
})
|
||||
|
||||
const passwordForm = reactive({
|
||||
currentPassword: '',
|
||||
nextPassword: '',
|
||||
nextPasswordConfirm: ''
|
||||
})
|
||||
|
||||
/**
|
||||
* 관리자 프로필을 조회한다.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const loadAdminProfile = async () => {
|
||||
loadingProfile.value = true
|
||||
profileMessage.value = ''
|
||||
|
||||
try {
|
||||
const profile = await $fetch('/api/auth/profile')
|
||||
profileForm.email = profile.email || ''
|
||||
profileForm.username = profile.username || ''
|
||||
profileForm.avatarUrl = profile.avatarUrl || ''
|
||||
} catch {
|
||||
profileMessage.value = '관리자 프로필을 불러오지 못했습니다. 다시 로그인해 주세요.'
|
||||
} finally {
|
||||
loadingProfile.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 닉네임 중복 여부를 확인한다.
|
||||
* @returns {Promise<boolean>} 사용 가능 여부
|
||||
*/
|
||||
const checkUsernameAvailable = async () => {
|
||||
const username = profileForm.username.trim()
|
||||
if (!username) {
|
||||
profileMessage.value = '관리자 이름을 입력해 주세요.'
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await $fetch('/api/auth/check-username', {
|
||||
query: {
|
||||
username
|
||||
}
|
||||
})
|
||||
|
||||
if (!result.available) {
|
||||
profileMessage.value = '이미 사용 중인 이름입니다.'
|
||||
return false
|
||||
}
|
||||
} catch (error) {
|
||||
profileMessage.value = error?.data?.message || '이름 중복 확인에 실패했습니다.'
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* 관리자 프로필을 저장한다.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const saveAdminProfile = async () => {
|
||||
const available = await checkUsernameAvailable()
|
||||
if (!available) {
|
||||
return
|
||||
}
|
||||
|
||||
savingProfile.value = true
|
||||
profileMessage.value = ''
|
||||
|
||||
try {
|
||||
await $fetch('/api/auth/profile', {
|
||||
method: 'PUT',
|
||||
body: {
|
||||
username: profileForm.username.trim(),
|
||||
avatarUrl: profileForm.avatarUrl.trim()
|
||||
}
|
||||
})
|
||||
profileMessage.value = '관리자 프로필이 저장되었습니다.'
|
||||
} catch (error) {
|
||||
profileMessage.value = error?.data?.message || '관리자 프로필 저장에 실패했습니다.'
|
||||
} finally {
|
||||
savingProfile.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 관리자 썸네일 파일 선택창을 연다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const openAvatarFilePicker = () => {
|
||||
avatarInputRef.value?.click()
|
||||
}
|
||||
|
||||
/**
|
||||
* 관리자 썸네일을 업로드한다.
|
||||
* @param {Event} event - 파일 선택 이벤트
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const uploadAvatar = async (event) => {
|
||||
const target = /** @type {HTMLInputElement | null} */ (event.target instanceof HTMLInputElement ? event.target : null)
|
||||
const file = target?.files?.[0]
|
||||
|
||||
if (!file || uploadingAvatar.value) {
|
||||
return
|
||||
}
|
||||
|
||||
uploadingAvatar.value = true
|
||||
profileMessage.value = ''
|
||||
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
const result = await $fetch('/api/auth/avatar', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
profileForm.avatarUrl = result.avatarUrl || ''
|
||||
profileMessage.value = '관리자 썸네일이 업로드되었습니다.'
|
||||
} catch (error) {
|
||||
profileMessage.value = error?.data?.message || '관리자 썸네일 업로드에 실패했습니다.'
|
||||
} finally {
|
||||
uploadingAvatar.value = false
|
||||
if (target) {
|
||||
target.value = ''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 관리자 썸네일을 제거한다.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const removeAvatar = async () => {
|
||||
if (removingAvatar.value) {
|
||||
return
|
||||
}
|
||||
|
||||
removingAvatar.value = true
|
||||
profileMessage.value = ''
|
||||
|
||||
try {
|
||||
await $fetch('/api/auth/avatar', {
|
||||
method: 'DELETE'
|
||||
})
|
||||
profileForm.avatarUrl = ''
|
||||
profileMessage.value = '관리자 썸네일이 제거되었습니다.'
|
||||
} catch (error) {
|
||||
profileMessage.value = error?.data?.message || '관리자 썸네일 제거에 실패했습니다.'
|
||||
} finally {
|
||||
removingAvatar.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 관리자 비밀번호를 변경한다.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const saveAdminPassword = async () => {
|
||||
passwordMessage.value = ''
|
||||
|
||||
if (!passwordForm.currentPassword || !passwordForm.nextPassword || !passwordForm.nextPasswordConfirm) {
|
||||
passwordMessage.value = '모든 비밀번호 입력값을 작성해 주세요.'
|
||||
return
|
||||
}
|
||||
|
||||
if (passwordForm.nextPassword !== passwordForm.nextPasswordConfirm) {
|
||||
passwordMessage.value = '새 비밀번호와 확인 값이 일치하지 않습니다.'
|
||||
return
|
||||
}
|
||||
|
||||
savingPassword.value = true
|
||||
|
||||
try {
|
||||
await $fetch('/api/auth/password', {
|
||||
method: 'PUT',
|
||||
body: {
|
||||
currentPassword: passwordForm.currentPassword,
|
||||
nextPassword: passwordForm.nextPassword
|
||||
}
|
||||
})
|
||||
|
||||
passwordForm.currentPassword = ''
|
||||
passwordForm.nextPassword = ''
|
||||
passwordForm.nextPasswordConfirm = ''
|
||||
passwordMessage.value = '관리자 비밀번호가 변경되었습니다.'
|
||||
} catch (error) {
|
||||
passwordMessage.value = error?.data?.message || '관리자 비밀번호 변경에 실패했습니다.'
|
||||
} finally {
|
||||
savingPassword.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 저장 상태 토스트 표시
|
||||
* @param {'success'|'error'|'info'} type - 토스트 타입
|
||||
@@ -238,6 +36,51 @@ const showToast = (type, message) => {
|
||||
}, 3200)
|
||||
}
|
||||
|
||||
/**
|
||||
* 로고 파일 선택창을 연다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const openLogoFilePicker = () => {
|
||||
logoInputRef.value?.click()
|
||||
}
|
||||
|
||||
/**
|
||||
* 사이트 로고를 업로드한다.
|
||||
* @param {Event} event - 파일 선택 이벤트
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const uploadLogo = async (event) => {
|
||||
const target = /** @type {HTMLInputElement | null} */ (event.target instanceof HTMLInputElement ? event.target : null)
|
||||
const file = target?.files?.[0]
|
||||
|
||||
if (!file || uploadingLogo.value) {
|
||||
return
|
||||
}
|
||||
|
||||
uploadingLogo.value = true
|
||||
errorMessage.value = ''
|
||||
showToast('info', '로고를 업로드하는 중입니다.')
|
||||
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
const updatedSettings = await $fetch('/admin/api/settings/logo', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
Object.assign(form, updatedSettings)
|
||||
showToast('success', '로고가 등록되었습니다.')
|
||||
} catch (error) {
|
||||
errorMessage.value = error?.data?.message || '로고 업로드에 실패했습니다.'
|
||||
showToast('error', errorMessage.value)
|
||||
} finally {
|
||||
uploadingLogo.value = false
|
||||
if (target) {
|
||||
target.value = ''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 사이트 설정 저장
|
||||
* @returns {Promise<void>} 저장 결과
|
||||
@@ -254,7 +97,9 @@ const saveSettings = async () => {
|
||||
title: form.title,
|
||||
description: form.description,
|
||||
siteUrl: form.siteUrl,
|
||||
logoText: form.logoText,
|
||||
logoText: form.logoText || '井',
|
||||
logoUrl: form.logoUrl,
|
||||
faviconUrl: form.faviconUrl,
|
||||
copyrightText: form.copyrightText
|
||||
}
|
||||
})
|
||||
@@ -272,8 +117,6 @@ const saveSettings = async () => {
|
||||
onBeforeUnmount(() => {
|
||||
window.clearTimeout(toastTimer)
|
||||
})
|
||||
|
||||
onMounted(loadAdminProfile)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -291,140 +134,47 @@ onMounted(loadAdminProfile)
|
||||
{{ errorMessage }}
|
||||
</p>
|
||||
|
||||
<section class="admin-settings__profile mb-6 max-w-3xl rounded border border-line bg-white p-5">
|
||||
<h2 class="text-base font-semibold text-ink">관리자 프로필</h2>
|
||||
<p class="mt-1 text-xs text-muted">
|
||||
썸네일과 이름을 수정할 수 있습니다.
|
||||
</p>
|
||||
|
||||
<div v-if="loadingProfile" class="mt-4 text-sm text-muted">
|
||||
관리자 프로필을 불러오는 중입니다.
|
||||
</div>
|
||||
|
||||
<div v-else class="mt-4 flex flex-col gap-4">
|
||||
<div class="flex flex-col gap-3 rounded border border-line bg-paper p-3 md:flex-row md:items-center">
|
||||
<div class="relative w-fit shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
class="group relative h-24 w-24 overflow-hidden rounded-full border border-line bg-white"
|
||||
:disabled="uploadingAvatar || removingAvatar"
|
||||
@click="openAvatarFilePicker"
|
||||
>
|
||||
<form class="admin-settings__form grid max-w-4xl gap-6" @submit.prevent="saveSettings">
|
||||
<section class="admin-settings__logo rounded-xl border border-line bg-white p-5">
|
||||
<div class="flex flex-col gap-5 md:flex-row md:items-center md:justify-between">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="admin-settings__logo-preview grid h-20 w-20 shrink-0 place-items-center overflow-hidden rounded-2xl border border-line bg-paper">
|
||||
<img
|
||||
v-if="profileForm.avatarUrl"
|
||||
:src="profileForm.avatarUrl"
|
||||
alt="관리자 썸네일"
|
||||
v-if="form.logoUrl"
|
||||
class="h-full w-full object-cover"
|
||||
:src="form.logoUrl"
|
||||
alt="사이트 로고"
|
||||
>
|
||||
<span
|
||||
v-else
|
||||
class="grid h-full w-full place-items-center text-2xl font-semibold text-muted"
|
||||
>
|
||||
{{ (profileForm.username || profileForm.email || '@').slice(0, 1).toUpperCase() }}
|
||||
<span v-else class="text-2xl font-semibold text-muted">
|
||||
{{ form.logoText || '井' }}
|
||||
</span>
|
||||
<span class="pointer-events-none absolute inset-0 grid place-items-center bg-black/45 text-[11px] font-medium text-white opacity-0 transition-opacity duration-200 group-hover:opacity-100">
|
||||
{{ profileForm.avatarUrl ? '이미지 변경' : '썸네일 등록' }}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="profileForm.avatarUrl"
|
||||
type="button"
|
||||
class="absolute right-0 top-0 grid h-6 w-6 -translate-y-1/3 translate-x-1/3 place-items-center rounded-full border border-line bg-paper text-xs text-muted transition-opacity hover:opacity-80 disabled:opacity-50"
|
||||
:disabled="removingAvatar"
|
||||
@click.stop="removeAvatar"
|
||||
>
|
||||
{{ removingAvatar ? '...' : 'X' }}
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="text-base font-semibold text-ink">로고</h2>
|
||||
<p class="mt-1 max-w-md text-sm leading-6 text-muted">
|
||||
1:1 비율 이미지로 등록합니다. 같은 이미지가 공개 로고와 파비콘으로 함께 사용됩니다.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid min-w-0 flex-1 gap-3">
|
||||
<label class="grid gap-1 text-sm">
|
||||
<span class="text-xs text-muted">관리자 이름</span>
|
||||
<input
|
||||
v-model="profileForm.username"
|
||||
type="text"
|
||||
class="rounded border border-line bg-white px-3 py-2"
|
||||
>
|
||||
</label>
|
||||
<label class="grid gap-1 text-sm">
|
||||
<span class="text-xs text-muted">관리자 이메일</span>
|
||||
<input
|
||||
:value="profileForm.email"
|
||||
type="text"
|
||||
class="rounded border border-line bg-[#f7f7f5] px-3 py-2 text-muted"
|
||||
readonly
|
||||
>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref="avatarInputRef"
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp,image/gif"
|
||||
class="hidden"
|
||||
:disabled="uploadingAvatar"
|
||||
@change="uploadAvatar"
|
||||
>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
class="rounded bg-[#15171a] px-4 py-2 text-sm font-semibold text-white disabled:opacity-50"
|
||||
class="admin-settings__logo-button h-10 rounded-md border border-line bg-white px-4 text-sm font-semibold text-[#15171a] transition hover:bg-[#f3f5f7] disabled:opacity-50"
|
||||
type="button"
|
||||
:disabled="savingProfile"
|
||||
@click="saveAdminProfile"
|
||||
:disabled="uploadingLogo"
|
||||
@click="openLogoFilePicker"
|
||||
>
|
||||
{{ savingProfile ? '저장 중' : '관리자 프로필 저장' }}
|
||||
{{ uploadingLogo ? '업로드 중' : form.logoUrl ? '로고 변경' : '로고 등록' }}
|
||||
</button>
|
||||
<p v-if="profileMessage" class="text-xs text-muted">
|
||||
{{ profileMessage }}
|
||||
</p>
|
||||
<input
|
||||
ref="logoInputRef"
|
||||
class="hidden"
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp"
|
||||
:disabled="uploadingLogo"
|
||||
@change="uploadLogo"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<section class="admin-settings__password mb-6 max-w-3xl rounded border border-line bg-white p-5">
|
||||
<h2 class="text-base font-semibold text-ink">관리자 비밀번호 변경</h2>
|
||||
<p class="mt-1 text-xs text-muted">
|
||||
현재 비밀번호 확인 후 새 비밀번호로 변경할 수 있습니다.
|
||||
</p>
|
||||
|
||||
<div class="mt-4 grid gap-3">
|
||||
<input
|
||||
v-model="passwordForm.currentPassword"
|
||||
type="password"
|
||||
class="rounded border border-line bg-white px-3 py-2 text-sm"
|
||||
placeholder="현재 비밀번호"
|
||||
>
|
||||
<input
|
||||
v-model="passwordForm.nextPassword"
|
||||
type="password"
|
||||
class="rounded border border-line bg-white px-3 py-2 text-sm"
|
||||
placeholder="새 비밀번호"
|
||||
>
|
||||
<input
|
||||
v-model="passwordForm.nextPasswordConfirm"
|
||||
type="password"
|
||||
class="rounded border border-line bg-white px-3 py-2 text-sm"
|
||||
placeholder="새 비밀번호 확인"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex items-center gap-2">
|
||||
<button
|
||||
class="rounded bg-[#15171a] px-4 py-2 text-sm font-semibold text-white disabled:opacity-50"
|
||||
type="button"
|
||||
:disabled="savingPassword"
|
||||
@click="saveAdminPassword"
|
||||
>
|
||||
{{ savingPassword ? '변경 중' : '관리자 비밀번호 변경' }}
|
||||
</button>
|
||||
<p v-if="passwordMessage" class="text-xs text-muted">
|
||||
{{ passwordMessage }}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<form class="admin-settings__form grid max-w-3xl gap-6" @submit.prevent="saveSettings">
|
||||
<label class="admin-settings__field grid gap-2 text-sm">
|
||||
<span class="admin-settings__label font-medium">사이트 이름</span>
|
||||
<input
|
||||
@@ -440,6 +190,7 @@ onMounted(loadAdminProfile)
|
||||
<textarea
|
||||
v-model="form.description"
|
||||
class="admin-settings__textarea min-h-28 resize-y rounded border border-line bg-white px-3 py-2"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
|
||||
@@ -453,36 +204,29 @@ onMounted(loadAdminProfile)
|
||||
>
|
||||
</label>
|
||||
|
||||
<div class="admin-settings__grid grid gap-4 md:grid-cols-2">
|
||||
<label class="admin-settings__field grid gap-2 text-sm">
|
||||
<span class="admin-settings__label font-medium">로고 텍스트</span>
|
||||
<input
|
||||
v-model="form.logoText"
|
||||
class="admin-settings__input rounded border border-line bg-white px-3 py-2"
|
||||
type="text"
|
||||
maxlength="8"
|
||||
required
|
||||
>
|
||||
</label>
|
||||
<label class="admin-settings__field grid gap-2 text-sm">
|
||||
<span class="admin-settings__label font-medium">저작권 문구</span>
|
||||
<input
|
||||
v-model="form.copyrightText"
|
||||
class="admin-settings__input rounded border border-line bg-white px-3 py-2"
|
||||
type="text"
|
||||
required
|
||||
>
|
||||
</label>
|
||||
|
||||
<label class="admin-settings__field grid gap-2 text-sm">
|
||||
<span class="admin-settings__label font-medium">저작권 문구</span>
|
||||
<input
|
||||
v-model="form.copyrightText"
|
||||
class="admin-settings__input rounded border border-line bg-white px-3 py-2"
|
||||
type="text"
|
||||
required
|
||||
>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="admin-settings__preview rounded border border-line bg-white p-5">
|
||||
<div class="admin-settings__preview rounded-xl border border-line bg-white p-5">
|
||||
<p class="admin-settings__preview-label text-xs font-semibold uppercase text-muted">
|
||||
공개 화면 미리보기
|
||||
</p>
|
||||
<div class="admin-settings__preview-body mt-4 flex items-center gap-3">
|
||||
<div class="admin-settings__preview-logo grid h-12 w-12 place-items-center rounded-2xl bg-[#15171a] text-2xl font-bold text-white">
|
||||
{{ form.logoText || '井' }}
|
||||
<div class="admin-settings__preview-logo grid h-12 w-12 place-items-center overflow-hidden rounded-xl bg-[#15171a] text-2xl font-bold text-white">
|
||||
<img
|
||||
v-if="form.logoUrl"
|
||||
class="h-full w-full object-cover"
|
||||
:src="form.logoUrl"
|
||||
alt=""
|
||||
>
|
||||
<span v-else>{{ form.logoText || '井' }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="admin-settings__preview-title font-semibold">
|
||||
|
||||
233
pages/forgot-password.vue
Normal file
233
pages/forgot-password.vue
Normal file
@@ -0,0 +1,233 @@
|
||||
<script setup>
|
||||
definePageMeta({
|
||||
layout: 'page'
|
||||
})
|
||||
|
||||
const { data: bootstrapStatus } = await useFetch('/api/auth/bootstrap-status', {
|
||||
default: () => ({
|
||||
emailOtpConfigured: false
|
||||
})
|
||||
})
|
||||
|
||||
const step = ref(1)
|
||||
const isSubmitting = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const statusMessage = ref('')
|
||||
const otpRequestLoading = ref(false)
|
||||
const otpCooldownSeconds = ref(0)
|
||||
let otpCooldownTimerId = null
|
||||
|
||||
const form = reactive({
|
||||
email: '',
|
||||
code: '',
|
||||
newPassword: '',
|
||||
newPasswordConfirm: ''
|
||||
})
|
||||
|
||||
const showPassword = ref(false)
|
||||
const showPasswordConfirm = ref(false)
|
||||
|
||||
const emailOtpAvailable = computed(() => Boolean(bootstrapStatus.value?.emailOtpConfigured))
|
||||
|
||||
/**
|
||||
* 비밀번호 재설정용 인증번호를 요청한다.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const requestResetOtp = async () => {
|
||||
errorMessage.value = ''
|
||||
statusMessage.value = ''
|
||||
if (!emailOtpAvailable.value) {
|
||||
errorMessage.value = '비밀번호 재설정 이메일을 사용하려면 서버에 Resend(RESEND_API_KEY, RESEND_FROM_EMAIL)가 설정되어 있어야 합니다.'
|
||||
return
|
||||
}
|
||||
if (!form.email.trim() || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email.trim())) {
|
||||
errorMessage.value = '유효한 이메일을 입력해 주세요.'
|
||||
return
|
||||
}
|
||||
if (otpCooldownSeconds.value > 0 || otpRequestLoading.value) {
|
||||
return
|
||||
}
|
||||
otpRequestLoading.value = true
|
||||
try {
|
||||
const res = await $fetch('/api/auth/email-otp/request', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
email: form.email.trim(),
|
||||
purpose: 'password_reset'
|
||||
}
|
||||
})
|
||||
statusMessage.value = res?.message || '요청을 처리했습니다.'
|
||||
step.value = 2
|
||||
otpCooldownSeconds.value = 60
|
||||
if (otpCooldownTimerId) {
|
||||
clearInterval(otpCooldownTimerId)
|
||||
}
|
||||
otpCooldownTimerId = setInterval(() => {
|
||||
otpCooldownSeconds.value -= 1
|
||||
if (otpCooldownSeconds.value <= 0 && otpCooldownTimerId) {
|
||||
clearInterval(otpCooldownTimerId)
|
||||
otpCooldownTimerId = null
|
||||
}
|
||||
}, 1000)
|
||||
} catch (error) {
|
||||
errorMessage.value = error?.data?.message || '인증번호 요청에 실패했습니다.'
|
||||
} finally {
|
||||
otpRequestLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 인증번호와 새 비밀번호로 재설정을 완료한다.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const submitReset = async () => {
|
||||
errorMessage.value = ''
|
||||
statusMessage.value = ''
|
||||
const digits = String(form.code || '').replace(/\D/g, '')
|
||||
if (digits.length !== 6) {
|
||||
errorMessage.value = '6자리 인증번호를 입력해 주세요.'
|
||||
return
|
||||
}
|
||||
if (!form.newPassword || form.newPassword.length < 8 || form.newPassword.length > 32) {
|
||||
errorMessage.value = '새 비밀번호는 8~32자로 입력해 주세요.'
|
||||
return
|
||||
}
|
||||
if (form.newPassword !== form.newPasswordConfirm) {
|
||||
errorMessage.value = '새 비밀번호와 확인 값이 일치하지 않습니다.'
|
||||
return
|
||||
}
|
||||
isSubmitting.value = true
|
||||
try {
|
||||
await $fetch('/api/auth/password-reset/confirm', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
email: form.email.trim(),
|
||||
code: digits,
|
||||
newPassword: form.newPassword
|
||||
}
|
||||
})
|
||||
statusMessage.value = '비밀번호가 변경되었습니다. 로그인 페이지로 이동합니다.'
|
||||
await navigateTo('/signin')
|
||||
} catch (error) {
|
||||
errorMessage.value = error?.data?.message || '비밀번호 재설정에 실패했습니다.'
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (otpCooldownTimerId) {
|
||||
clearInterval(otpCooldownTimerId)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="auth-forgot-password min-h-screen bg-[#0a0b0d] text-[#f5f7fa] [color-scheme:dark]">
|
||||
<div class="mx-auto flex min-h-screen w-full max-w-[1280px] items-center px-5 py-12 sm:px-10 lg:px-16">
|
||||
<div class="auth-forgot-password__panel w-full max-w-[430px] p-5 sm:p-8">
|
||||
<p class="text-2xl font-semibold leading-tight">
|
||||
비밀번호 찾기
|
||||
</p>
|
||||
<p class="mt-2 text-sm text-[#9ba3af]">
|
||||
가입에 사용한 이메일로 인증번호를 보낸 뒤, 새 비밀번호를 설정합니다.
|
||||
</p>
|
||||
|
||||
<div v-if="!emailOtpAvailable" class="auth-forgot-password__warn mt-6 rounded-[10px] border border-[#3d2a1a] bg-[#1a1410] p-4 text-sm text-[#d8dee6]">
|
||||
서버에 Resend가 설정되지 않았습니다. 운영 환경에서 <span class="font-mono text-xs">RESEND_API_KEY</span>, <span class="font-mono text-xs">RESEND_FROM_EMAIL</span>, <span class="font-mono text-xs">MEMBER_SESSION_SECRET</span>(또는 <span class="font-mono text-xs">EMAIL_OTP_PEPPER</span>)를 설정한 뒤 다시 시도해 주세요.
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div v-if="step === 1" class="mt-8 space-y-5">
|
||||
<div class="space-y-1.5">
|
||||
<label class="text-xs text-[#d8dee6]">이메일</label>
|
||||
<input
|
||||
v-model="form.email"
|
||||
class="auth-form-input h-10 w-full rounded-[8px] border border-[#1a212a] bg-transparent px-3 text-sm outline-none transition-colors focus:border-[#2f6feb]"
|
||||
type="email"
|
||||
autocomplete="email"
|
||||
>
|
||||
</div>
|
||||
<button
|
||||
class="h-9 rounded-[8px] bg-[#2f6feb] px-8 text-xs font-medium text-white transition-opacity hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
type="button"
|
||||
:disabled="otpRequestLoading"
|
||||
@click="requestResetOtp"
|
||||
>
|
||||
{{ otpRequestLoading ? '처리 중…' : '인증번호 받기' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form v-else class="auth-forgot-password__step2 mt-8 space-y-5" @submit.prevent="submitReset">
|
||||
<p class="text-xs text-[#9ba3af]">
|
||||
{{ form.email }} 로 발송된 인증번호를 입력해 주세요.
|
||||
</p>
|
||||
<div class="space-y-1.5">
|
||||
<label class="text-xs text-[#d8dee6]">인증번호</label>
|
||||
<input
|
||||
v-model="form.code"
|
||||
class="auth-form-input h-10 w-full rounded-[8px] border border-[#1a212a] bg-transparent px-3 text-sm tracking-widest outline-none transition-colors focus:border-[#2f6feb]"
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
maxlength="6"
|
||||
autocomplete="one-time-code"
|
||||
>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<label class="text-xs text-[#d8dee6]">새 비밀번호</label>
|
||||
<div class="flex items-center rounded-[8px] border border-[#1a212a] transition-colors focus-within:border-[#2f6feb]">
|
||||
<input
|
||||
v-model="form.newPassword"
|
||||
class="auth-form-input h-10 min-w-0 flex-1 bg-transparent px-3 text-sm outline-none"
|
||||
:type="showPassword ? 'text' : 'password'"
|
||||
autocomplete="new-password"
|
||||
>
|
||||
<AuthPasswordVisibilityToggle v-model="showPassword" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<label class="text-xs text-[#d8dee6]">새 비밀번호 확인</label>
|
||||
<div class="flex items-center rounded-[8px] border border-[#1a212a] transition-colors focus-within:border-[#2f6feb]">
|
||||
<input
|
||||
v-model="form.newPasswordConfirm"
|
||||
class="auth-form-input h-10 min-w-0 flex-1 bg-transparent px-3 text-sm outline-none"
|
||||
:type="showPasswordConfirm ? 'text' : 'password'"
|
||||
autocomplete="new-password"
|
||||
>
|
||||
<AuthPasswordVisibilityToggle v-model="showPasswordConfirm" field-name="새 비밀번호 확인" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
class="h-9 rounded-[8px] border border-[#1a212a] px-4 text-xs text-[#d8dee6] transition-opacity hover:opacity-75"
|
||||
type="button"
|
||||
:disabled="isSubmitting"
|
||||
@click="step = 1"
|
||||
>
|
||||
이메일 다시 입력
|
||||
</button>
|
||||
<button
|
||||
class="h-9 rounded-[8px] bg-[#2f6feb] px-8 text-xs font-medium text-white transition-opacity hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
type="submit"
|
||||
:disabled="isSubmitting"
|
||||
>
|
||||
비밀번호 변경
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<p v-if="errorMessage" class="mt-4 text-xs text-[#e5acb1]" aria-live="polite">
|
||||
{{ errorMessage }}
|
||||
</p>
|
||||
<p v-if="statusMessage" class="mt-4 text-xs text-[#9fc4ff]" aria-live="polite">
|
||||
{{ statusMessage }}
|
||||
</p>
|
||||
|
||||
<p class="mt-8 text-sm text-[#9ba3af]">
|
||||
<NuxtLink class="text-[#7eb8ff] hover:opacity-80" to="/signin">로그인으로 돌아가기</NuxtLink>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -274,7 +274,7 @@ useHead(() => ({
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="comments" class="mb-6 border-y border-[var(--site-line)] bg-[var(--site-panel-strong)] py-5 scroll-mt-14">
|
||||
<section id="comments" class="mt-12 mb-6 border-y border-[var(--site-line)] bg-[var(--site-panel-strong)] py-5 scroll-mt-14">
|
||||
<div class="mx-auto max-w-[720px] px-4 text-sm sm:px-5">
|
||||
<PostComments :slug="post.slug" />
|
||||
</div>
|
||||
|
||||
@@ -8,11 +8,20 @@ const removingAvatar = ref(false)
|
||||
const profileMessage = ref('')
|
||||
const passwordMessage = ref('')
|
||||
const deleteMessage = ref('')
|
||||
const actionMenuOpen = ref(false)
|
||||
const passwordModalOpen = ref(false)
|
||||
const deleteModalOpen = ref(false)
|
||||
|
||||
const profileForm = reactive({
|
||||
email: '',
|
||||
username: '',
|
||||
avatarUrl: ''
|
||||
avatarUrl: '',
|
||||
createdAt: '',
|
||||
updatedAt: '',
|
||||
lastSeenAt: '',
|
||||
previousLastSeenAt: '',
|
||||
previousLastSeenIp: '',
|
||||
commentCount: 0
|
||||
})
|
||||
|
||||
const avatarInputRef = ref(null)
|
||||
@@ -27,6 +36,72 @@ const deleteForm = reactive({
|
||||
password: ''
|
||||
})
|
||||
|
||||
const displayName = computed(() => profileForm.username || profileForm.email || '멤버')
|
||||
const avatarInitial = computed(() => String(displayName.value || '?').slice(0, 1).toUpperCase())
|
||||
const previousLoginText = computed(() => formatRelativeTime(profileForm.previousLastSeenAt, '이전 로그인 기록 없음'))
|
||||
|
||||
/**
|
||||
* 날짜 표시 형식을 변환한다.
|
||||
* @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 날짜 문자열
|
||||
* @param {string} emptyText - 값이 없을 때 문구
|
||||
* @returns {string} 상대 시간
|
||||
*/
|
||||
const formatRelativeTime = (value, emptyText = '기록 없음') => {
|
||||
if (!value) {
|
||||
return emptyText
|
||||
}
|
||||
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return emptyText
|
||||
}
|
||||
|
||||
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 {Promise<void>}
|
||||
@@ -37,6 +112,12 @@ const loadProfile = async () => {
|
||||
profileForm.email = profile.email || ''
|
||||
profileForm.username = profile.username || ''
|
||||
profileForm.avatarUrl = profile.avatarUrl || ''
|
||||
profileForm.createdAt = profile.createdAt || ''
|
||||
profileForm.updatedAt = profile.updatedAt || ''
|
||||
profileForm.lastSeenAt = profile.lastSeenAt || ''
|
||||
profileForm.previousLastSeenAt = profile.previousLastSeenAt || ''
|
||||
profileForm.previousLastSeenIp = profile.previousLastSeenIp || ''
|
||||
profileForm.commentCount = Number(profile.commentCount || 0)
|
||||
} catch {
|
||||
await navigateTo('/signin')
|
||||
} finally {
|
||||
@@ -102,6 +183,14 @@ const saveProfile = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 썸네일 파일 선택창을 연다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const openAvatarFilePicker = () => {
|
||||
avatarInputRef.value?.click()
|
||||
}
|
||||
|
||||
/**
|
||||
* 썸네일을 제거한다.
|
||||
* @returns {Promise<void>}
|
||||
@@ -163,11 +252,67 @@ const uploadAvatar = async (event) => {
|
||||
}
|
||||
|
||||
/**
|
||||
* 썸네일 파일 선택창을 연다.
|
||||
* 작업 메뉴를 토글한다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const openAvatarFilePicker = () => {
|
||||
avatarInputRef.value?.click()
|
||||
const toggleActionMenu = () => {
|
||||
actionMenuOpen.value = !actionMenuOpen.value
|
||||
}
|
||||
|
||||
/**
|
||||
* 작업 메뉴를 닫는다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const closeActionMenu = () => {
|
||||
actionMenuOpen.value = false
|
||||
}
|
||||
|
||||
/**
|
||||
* 비밀번호 변경 모달을 연다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const openPasswordModal = () => {
|
||||
passwordForm.currentPassword = ''
|
||||
passwordForm.nextPassword = ''
|
||||
passwordForm.nextPasswordConfirm = ''
|
||||
passwordMessage.value = ''
|
||||
passwordModalOpen.value = true
|
||||
closeActionMenu()
|
||||
}
|
||||
|
||||
/**
|
||||
* 회원 탈퇴 모달을 연다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const openDeleteModal = () => {
|
||||
deleteForm.password = ''
|
||||
deleteMessage.value = ''
|
||||
deleteModalOpen.value = true
|
||||
closeActionMenu()
|
||||
}
|
||||
|
||||
/**
|
||||
* 비밀번호 변경 모달을 닫는다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const closePasswordModal = () => {
|
||||
if (savingPassword.value) {
|
||||
return
|
||||
}
|
||||
|
||||
passwordModalOpen.value = false
|
||||
}
|
||||
|
||||
/**
|
||||
* 회원 탈퇴 모달을 닫는다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const closeDeleteModal = () => {
|
||||
if (deletingAccount.value) {
|
||||
return
|
||||
}
|
||||
|
||||
deleteModalOpen.value = false
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -197,7 +342,8 @@ const savePassword = async () => {
|
||||
passwordForm.currentPassword = ''
|
||||
passwordForm.nextPassword = ''
|
||||
passwordForm.nextPasswordConfirm = ''
|
||||
passwordMessage.value = '비밀번호가 변경되었습니다.'
|
||||
passwordModalOpen.value = false
|
||||
profileMessage.value = '비밀번호가 변경되었습니다.'
|
||||
} catch (error) {
|
||||
passwordMessage.value = error?.data?.message || '비밀번호 변경에 실패했습니다.'
|
||||
} finally {
|
||||
@@ -236,149 +382,198 @@ onMounted(loadProfile)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="settings-page mx-auto w-full max-w-[720px] px-4 py-8 sm:px-5">
|
||||
<h1 class="text-xl font-semibold">사용자 설정</h1>
|
||||
<section class="settings-page mx-auto w-full max-w-[1180px] px-4 py-8 sm:px-6 lg:px-8">
|
||||
<div class="settings-page__header flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p class="settings-page__breadcrumb text-sm text-[var(--site-muted)]">내 계정</p>
|
||||
<h1 class="settings-page__title mt-3 text-3xl font-semibold tracking-[-0.01em]">
|
||||
{{ displayName }}
|
||||
</h1>
|
||||
</div>
|
||||
<div class="settings-page__actions relative">
|
||||
<button class="settings-page__settings-button grid h-11 w-11 place-items-center rounded-[8px] border border-[var(--site-line)] bg-[var(--site-bg)] transition hover:bg-[var(--site-panel)]" 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="settings-page__action-menu absolute right-0 top-12 z-20 grid w-52 overflow-hidden rounded-[12px] border border-[var(--site-line)] bg-[var(--site-bg)] py-2 text-sm shadow-[0_18px_50px_rgba(15,23,42,0.16)]">
|
||||
<button class="settings-page__action-item px-4 py-2.5 text-left transition hover:bg-[var(--site-panel)]" type="button" @click="openPasswordModal">
|
||||
비밀번호 변경
|
||||
</button>
|
||||
<button class="settings-page__action-item px-4 py-2.5 text-left text-red-500 transition hover:bg-red-500/10" type="button" @click="openDeleteModal">
|
||||
회원 탈퇴
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="mt-4 text-sm site-muted">
|
||||
<div v-if="loading" class="settings-page__loading mt-8 text-sm site-muted">
|
||||
설정 정보를 불러오는 중입니다.
|
||||
</div>
|
||||
|
||||
<div v-else class="mt-5 flex flex-col gap-5">
|
||||
<section class="rounded-[10px] border border-[var(--site-line)] bg-[var(--site-bg)] p-4">
|
||||
<h2 class="text-sm font-semibold">프로필</h2>
|
||||
<div class="mt-3 flex flex-col gap-4">
|
||||
<div class="settings-profile-account flex flex-col gap-3 rounded-[12px] border border-[var(--site-line)] bg-[var(--site-panel)] p-3 md:flex-row md:items-center md:gap-4">
|
||||
<div class="relative w-fit shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
class="group relative h-24 w-24 overflow-hidden rounded-full border border-[var(--site-line)] bg-[var(--site-bg)]"
|
||||
:disabled="uploadingAvatar || removingAvatar"
|
||||
@click="openAvatarFilePicker"
|
||||
>
|
||||
<img
|
||||
v-if="profileForm.avatarUrl"
|
||||
:src="profileForm.avatarUrl"
|
||||
alt="프로필 썸네일"
|
||||
class="h-full w-full object-cover"
|
||||
>
|
||||
<span
|
||||
v-else
|
||||
class="grid h-full w-full place-items-center text-2xl font-semibold site-muted"
|
||||
>
|
||||
{{ (profileForm.username || profileForm.email || '@').slice(0, 1).toUpperCase() }}
|
||||
</span>
|
||||
<span class="pointer-events-none absolute inset-0 grid place-items-center bg-black/45 text-[11px] font-medium text-white opacity-0 transition-opacity duration-200 group-hover:opacity-100">
|
||||
{{ profileForm.avatarUrl ? '이미지 변경' : '썸네일 등록' }}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="profileForm.avatarUrl"
|
||||
type="button"
|
||||
class="absolute right-0 top-0 grid h-6 w-6 -translate-y-1/3 translate-x-1/3 place-items-center rounded-full border border-[var(--site-line)] bg-[var(--site-panel-strong)] text-xs site-muted transition-opacity hover:opacity-80 disabled:opacity-50"
|
||||
:disabled="removingAvatar"
|
||||
@click.stop="removeAvatar"
|
||||
>
|
||||
{{ removingAvatar ? '...' : 'X' }}
|
||||
</button>
|
||||
<div v-else class="settings-page__body mt-10 grid gap-8">
|
||||
<aside class="settings-page__summary rounded-[12px] border border-[var(--site-line)] bg-[var(--site-bg)] p-5 sm:p-6">
|
||||
<div class="settings-page__identity flex items-center gap-4">
|
||||
<div class="settings-page__avatar-control group relative h-24 w-24 shrink-0">
|
||||
<button class="settings-page__avatar-button relative h-24 w-24 overflow-hidden rounded-full border border-[var(--site-line)] bg-[var(--site-panel)]" type="button" :disabled="uploadingAvatar || removingAvatar" :aria-label="profileForm.avatarUrl ? '썸네일 변경' : '썸네일 등록'" @click="openAvatarFilePicker">
|
||||
<img v-if="profileForm.avatarUrl" class="settings-page__avatar h-full w-full object-cover" :src="profileForm.avatarUrl" alt="프로필 썸네일">
|
||||
<span v-else class="settings-page__avatar-initial grid h-full w-full place-items-center text-2xl font-semibold site-muted">
|
||||
{{ avatarInitial }}
|
||||
</span>
|
||||
<span class="settings-page__avatar-caption pointer-events-none absolute inset-x-0 bottom-0 flex min-h-9 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 text-white opacity-0 transition-opacity group-hover:opacity-100">
|
||||
{{ uploadingAvatar ? '업로드 중' : profileForm.avatarUrl ? '썸네일 변경' : '썸네일 등록' }}
|
||||
</span>
|
||||
</button>
|
||||
<button v-if="profileForm.avatarUrl" class="settings-page__avatar-remove absolute right-0 top-0 grid h-7 w-7 -translate-y-1 translate-x-1 place-items-center rounded-full bg-black/85 text-white opacity-0 transition hover:bg-red-500 group-hover:opacity-100" type="button" aria-label="썸네일 제거" :disabled="removingAvatar" @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="hidden" type="file" accept="image/jpeg,image/png,image/webp,image/gif" :disabled="uploadingAvatar" @change="uploadAvatar">
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<h2 class="truncate text-lg font-semibold">{{ displayName }}</h2>
|
||||
<p class="mt-1 truncate text-sm site-muted">{{ profileForm.email }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-page__summary-grid mt-6 grid gap-4 border-t border-[var(--site-line)] pt-5 sm:grid-cols-2">
|
||||
<section class="settings-page__side-section">
|
||||
<h3 class="text-xs font-semibold uppercase tracking-[0.04em]">가입 정보</h3>
|
||||
<p class="mt-3 text-sm site-muted">
|
||||
생성됨 — <strong class="text-[var(--site-text)]">{{ formatDate(profileForm.createdAt) }}</strong>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="settings-page__side-section">
|
||||
<h3 class="text-xs font-semibold uppercase tracking-[0.04em]">참여도</h3>
|
||||
<p class="mt-3 text-sm site-muted">
|
||||
댓글 작성 {{ profileForm.commentCount }}개
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div class="settings-page__content space-y-8">
|
||||
<form class="settings-page__profile rounded-[12px] border border-[var(--site-line)] bg-[var(--site-bg)] p-5 sm:p-6" @submit.prevent="saveProfile">
|
||||
<div class="grid gap-5 md:grid-cols-2">
|
||||
<label class="settings-page__field grid gap-2 text-sm font-semibold">
|
||||
닉네임
|
||||
<input v-model="profileForm.username" class="settings-page__input h-12 rounded-[8px] border border-[var(--site-line)] bg-[var(--site-panel)] px-4 text-sm outline-none focus:bg-[var(--site-bg)]" type="text" maxlength="60">
|
||||
</label>
|
||||
<label class="settings-page__field grid gap-2 text-sm font-semibold">
|
||||
이메일
|
||||
<input class="settings-page__input h-12 rounded-[8px] border border-[var(--site-line)] bg-[var(--site-panel)] px-4 text-sm outline-none" type="email" :value="profileForm.email" readonly>
|
||||
</label>
|
||||
</div>
|
||||
<div class="mt-5 flex items-center justify-between gap-3 border-t border-[var(--site-line)] pt-5">
|
||||
<p v-if="profileMessage" class="settings-page__message text-sm site-muted">{{ profileMessage }}</p>
|
||||
<span v-else />
|
||||
<button class="site-accent-button h-10 rounded-[8px] px-4 text-sm font-semibold disabled:opacity-60" type="submit" :disabled="savingProfile">
|
||||
{{ savingProfile ? '저장 중' : '프로필 저장' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<section class="settings-page__activity">
|
||||
<h2 class="settings-page__section-title mb-4 text-xs font-semibold uppercase tracking-[0.04em]">활동 정보</h2>
|
||||
<div class="settings-page__activity-card rounded-[12px] border border-[var(--site-line)] bg-[var(--site-bg)] px-5 sm:px-6">
|
||||
<div class="settings-page__activity-row flex items-center justify-between gap-4 border-b border-[var(--site-line)] py-5 text-sm">
|
||||
<span class="flex items-center gap-3">
|
||||
<svg class="h-5 w-5 site-muted" 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-right site-muted">
|
||||
{{ previousLoginText }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex min-w-0 flex-1 flex-col gap-2">
|
||||
<div class="rounded-[12px] border border-[var(--site-line)] bg-[var(--site-bg)] px-3 py-2.5">
|
||||
<p class="text-[11px] site-muted">닉네임</p>
|
||||
<input
|
||||
v-model="profileForm.username"
|
||||
type="text"
|
||||
class="mt-1 h-7 w-full border-none bg-transparent p-0 text-sm font-semibold outline-none focus-visible:ring-0"
|
||||
>
|
||||
</div>
|
||||
<div class="rounded-[12px] border border-[var(--site-line)] bg-[var(--site-bg)] px-3 py-2.5">
|
||||
<p class="text-[11px] site-muted">ID (이메일)</p>
|
||||
<p class="mt-1 truncate text-sm font-semibold">
|
||||
{{ profileForm.email }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="settings-page__activity-row flex items-center justify-between gap-4 py-5 text-sm">
|
||||
<span class="flex items-center gap-3">
|
||||
<svg class="h-5 w-5 site-muted" 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-right site-muted">
|
||||
{{ formatRelativeTime(profileForm.createdAt) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
ref="avatarInputRef"
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp,image/gif"
|
||||
class="hidden"
|
||||
:disabled="uploadingAvatar"
|
||||
@change="uploadAvatar"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="site-accent-button w-fit rounded-[10px] px-3 py-1.5 text-xs font-semibold disabled:opacity-60"
|
||||
:disabled="savingProfile"
|
||||
@click="saveProfile"
|
||||
>
|
||||
{{ savingProfile ? '저장 중...' : '프로필 저장' }}
|
||||
</button>
|
||||
<p v-if="profileMessage" class="text-xs site-muted">
|
||||
{{ profileMessage }}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="rounded-[10px] border border-[var(--site-line)] bg-[var(--site-bg)] p-4">
|
||||
<h2 class="text-sm font-semibold">비밀번호 변경</h2>
|
||||
<div class="mt-3 flex flex-col gap-3">
|
||||
<input
|
||||
v-model="passwordForm.currentPassword"
|
||||
type="password"
|
||||
class="h-10 rounded-[8px] border border-[var(--site-line)] bg-transparent px-3 text-sm outline-none focus-visible:border-[var(--site-accent)]"
|
||||
placeholder="현재 비밀번호"
|
||||
>
|
||||
<input
|
||||
v-model="passwordForm.nextPassword"
|
||||
type="password"
|
||||
class="h-10 rounded-[8px] border border-[var(--site-line)] bg-transparent px-3 text-sm outline-none focus-visible:border-[var(--site-accent)]"
|
||||
placeholder="새 비밀번호"
|
||||
>
|
||||
<input
|
||||
v-model="passwordForm.nextPasswordConfirm"
|
||||
type="password"
|
||||
class="h-10 rounded-[8px] border border-[var(--site-line)] bg-transparent px-3 text-sm outline-none focus-visible:border-[var(--site-accent)]"
|
||||
placeholder="새 비밀번호 확인"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="site-accent-button mt-1 w-fit rounded-[10px] px-3 py-1.5 text-xs font-semibold disabled:opacity-60"
|
||||
:disabled="savingPassword"
|
||||
@click="savePassword"
|
||||
>
|
||||
{{ savingPassword ? '변경 중...' : '비밀번호 변경' }}
|
||||
</button>
|
||||
<p v-if="passwordMessage" class="text-xs site-muted">
|
||||
{{ passwordMessage }}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="rounded-[10px] border border-[var(--site-line)] bg-[var(--site-bg)] p-4">
|
||||
<h2 class="text-sm font-semibold text-red-500/70">회원 탈퇴</h2>
|
||||
<p class="mt-2 text-xs site-muted">
|
||||
탈퇴 시 작성한 댓글과 계정 정보가 삭제됩니다.
|
||||
</p>
|
||||
<input
|
||||
v-model="deleteForm.password"
|
||||
type="password"
|
||||
class="mt-3 h-10 w-full rounded-[8px] border border-[var(--site-line)] bg-transparent px-3 text-sm outline-none focus-visible:border-red-400"
|
||||
placeholder="비밀번호 확인"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="mt-3 rounded-[10px] border border-red-400/40 px-3 py-1.5 text-xs text-red-500/70 transition-opacity hover:opacity-80 disabled:opacity-50"
|
||||
:disabled="deletingAccount"
|
||||
@click="removeAccount"
|
||||
>
|
||||
{{ deletingAccount ? '처리 중...' : '회원 탈퇴' }}
|
||||
</button>
|
||||
<p v-if="deleteMessage" class="mt-2 text-xs site-muted">
|
||||
{{ deleteMessage }}
|
||||
</p>
|
||||
</section>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Teleport to="body">
|
||||
<div v-if="passwordModalOpen" class="settings-page__modal fixed inset-0 z-50 flex items-start justify-center bg-black/35 px-4 pt-10">
|
||||
<section class="settings-page__modal-panel w-full max-w-[520px] rounded-[12px] bg-[var(--site-bg)] text-[var(--site-text)] shadow-[0_22px_70px_rgba(15,23,42,0.25)]">
|
||||
<header class="flex items-center justify-between border-b border-[var(--site-line)] px-6 py-5">
|
||||
<h2 class="text-xl font-semibold">비밀번호 변경</h2>
|
||||
<button class="grid h-8 w-8 place-items-center rounded-[8px] site-muted hover:bg-[var(--site-panel)]" 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">
|
||||
<label class="grid gap-2 text-sm font-semibold">
|
||||
현재 비밀번호
|
||||
<input v-model="passwordForm.currentPassword" class="h-11 rounded-[8px] border border-[var(--site-line)] bg-[var(--site-panel)] px-4 text-sm outline-none focus:bg-[var(--site-bg)]" type="password" autocomplete="current-password">
|
||||
</label>
|
||||
<label class="grid gap-2 text-sm font-semibold">
|
||||
새 비밀번호
|
||||
<input v-model="passwordForm.nextPassword" class="h-11 rounded-[8px] border border-[var(--site-line)] bg-[var(--site-panel)] px-4 text-sm outline-none focus:bg-[var(--site-bg)]" type="password" autocomplete="new-password">
|
||||
</label>
|
||||
<label class="grid gap-2 text-sm font-semibold">
|
||||
새 비밀번호 확인
|
||||
<input v-model="passwordForm.nextPasswordConfirm" class="h-11 rounded-[8px] border border-[var(--site-line)] bg-[var(--site-panel)] px-4 text-sm outline-none focus:bg-[var(--site-bg)]" type="password" autocomplete="new-password">
|
||||
</label>
|
||||
<p v-if="passwordMessage" class="rounded-[8px] border border-red-400/30 bg-red-500/10 px-4 py-3 text-sm text-red-500">{{ passwordMessage }}</p>
|
||||
</div>
|
||||
<footer class="flex justify-end gap-2 border-t border-[var(--site-line)] px-6 py-4">
|
||||
<button class="h-10 rounded-[8px] border border-[var(--site-line)] px-4 text-sm font-semibold" type="button" @click="closePasswordModal">
|
||||
취소
|
||||
</button>
|
||||
<button class="site-accent-button h-10 rounded-[8px] px-4 text-sm font-semibold disabled:opacity-60" type="button" :disabled="savingPassword" @click="savePassword">
|
||||
{{ savingPassword ? '변경 중' : '변경' }}
|
||||
</button>
|
||||
</footer>
|
||||
</section>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<Teleport to="body">
|
||||
<div v-if="deleteModalOpen" class="settings-page__modal fixed inset-0 z-50 flex items-start justify-center bg-black/35 px-4 pt-10">
|
||||
<section class="settings-page__modal-panel w-full max-w-[520px] rounded-[12px] bg-[var(--site-bg)] text-[var(--site-text)] shadow-[0_22px_70px_rgba(15,23,42,0.25)]">
|
||||
<header class="flex items-center justify-between border-b border-[var(--site-line)] px-6 py-5">
|
||||
<h2 class="text-xl font-semibold">회원 탈퇴</h2>
|
||||
<button class="grid h-8 w-8 place-items-center rounded-[8px] site-muted hover:bg-[var(--site-panel)]" 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 site-muted">
|
||||
탈퇴하면 계정과 작성한 댓글이 삭제됩니다. 계속하려면 비밀번호를 입력해 주세요.
|
||||
</p>
|
||||
<input v-model="deleteForm.password" class="h-11 rounded-[8px] border border-[var(--site-line)] bg-[var(--site-panel)] px-4 text-sm outline-none focus:border-red-400 focus:bg-[var(--site-bg)]" type="password" autocomplete="current-password" placeholder="비밀번호 확인">
|
||||
<p v-if="deleteMessage" class="rounded-[8px] border border-red-400/30 bg-red-500/10 px-4 py-3 text-sm text-red-500">{{ deleteMessage }}</p>
|
||||
</div>
|
||||
<footer class="flex justify-end gap-2 border-t border-[var(--site-line)] px-6 py-4">
|
||||
<button class="h-10 rounded-[8px] border border-[var(--site-line)] px-4 text-sm font-semibold" type="button" @click="closeDeleteModal">
|
||||
취소
|
||||
</button>
|
||||
<button class="h-10 rounded-[8px] bg-red-500 px-4 text-sm font-semibold text-white disabled:opacity-60" type="button" :disabled="deletingAccount" @click="removeAccount">
|
||||
{{ deletingAccount ? '처리 중' : '탈퇴' }}
|
||||
</button>
|
||||
</footer>
|
||||
</section>
|
||||
</div>
|
||||
</Teleport>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -126,6 +126,11 @@ const submitSignIn = async () => {
|
||||
회원가입
|
||||
</NuxtLink>
|
||||
</p>
|
||||
<p class="mt-3 text-sm text-[#9ba3af]">
|
||||
<NuxtLink class="text-[#7eb8ff] hover:opacity-80" to="/forgot-password">
|
||||
비밀번호를 잊으셨나요?
|
||||
</NuxtLink>
|
||||
</p>
|
||||
<NuxtLink class="mt-2 inline-flex text-xs text-[#9ba3af] hover:opacity-80" to="/">
|
||||
홈으로 돌아가기
|
||||
</NuxtLink>
|
||||
|
||||
111
pages/signup.vue
111
pages/signup.vue
@@ -18,13 +18,15 @@ const { data: siteSettings } = await useFetch('/api/site-settings', {
|
||||
const { data: bootstrapStatus } = await useFetch('/api/auth/bootstrap-status', {
|
||||
default: () => ({
|
||||
hasUsers: true,
|
||||
needsAdminSetup: false
|
||||
needsAdminSetup: false,
|
||||
emailOtpConfigured: false
|
||||
})
|
||||
})
|
||||
|
||||
const form = reactive({
|
||||
username: '',
|
||||
email: '',
|
||||
emailOtp: '',
|
||||
password: '',
|
||||
passwordConfirm: ''
|
||||
})
|
||||
@@ -32,14 +34,20 @@ const form = reactive({
|
||||
const errors = reactive({
|
||||
username: '',
|
||||
email: '',
|
||||
emailOtp: '',
|
||||
password: '',
|
||||
passwordConfirm: ''
|
||||
})
|
||||
|
||||
const showSignupPassword = ref(false)
|
||||
const showSignupPasswordConfirm = ref(false)
|
||||
const otpRequestLoading = ref(false)
|
||||
const otpCooldownSeconds = ref(0)
|
||||
let otpCooldownTimerId = null
|
||||
|
||||
const isAdminBootstrapMode = computed(() => Boolean(bootstrapStatus.value?.needsAdminSetup))
|
||||
/** Resend 등 설정이 되어 있고 최초 관리자 모드가 아닐 때만 이메일 OTP 필요 */
|
||||
const emailOtpRequired = computed(() => Boolean(bootstrapStatus.value?.emailOtpConfigured) && !isAdminBootstrapMode.value)
|
||||
const welcomeTitle = computed(() => `Welcome to ${siteSettings.value?.title || 'AFFiNE'}`)
|
||||
const welcomeDescription = computed(() => siteSettings.value?.description || 'Configure your Self Host AFFiNE with a few simple settings.')
|
||||
const stepTwoTitle = computed(() => (isAdminBootstrapMode.value ? '관리자 등록' : '회원 가입'))
|
||||
@@ -54,6 +62,7 @@ const stepTwoDescription = computed(() => (isAdminBootstrapMode.value
|
||||
const resetErrors = () => {
|
||||
errors.username = ''
|
||||
errors.email = ''
|
||||
errors.emailOtp = ''
|
||||
errors.password = ''
|
||||
errors.passwordConfirm = ''
|
||||
}
|
||||
@@ -95,9 +104,65 @@ const validateStepTwo = () => {
|
||||
valid = false
|
||||
}
|
||||
|
||||
if (emailOtpRequired.value) {
|
||||
const digits = String(form.emailOtp || '').replace(/\D/g, '')
|
||||
if (digits.length !== 6) {
|
||||
errors.emailOtp = '이메일로 받은 6자리 인증번호를 입력해 주세요.'
|
||||
valid = false
|
||||
}
|
||||
}
|
||||
|
||||
return valid
|
||||
}
|
||||
|
||||
/**
|
||||
* 회원가입용 이메일 인증번호를 요청한다.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const requestSignupEmailOtp = async () => {
|
||||
submitErrorMessage.value = ''
|
||||
errors.email = ''
|
||||
errors.emailOtp = ''
|
||||
if (!form.email.trim() || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email.trim())) {
|
||||
errors.email = '인증번호를 받으려면 유효한 이메일을 먼저 입력해 주세요.'
|
||||
return
|
||||
}
|
||||
if (otpCooldownSeconds.value > 0 || otpRequestLoading.value) {
|
||||
return
|
||||
}
|
||||
otpRequestLoading.value = true
|
||||
try {
|
||||
await $fetch('/api/auth/email-otp/request', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
email: form.email.trim(),
|
||||
purpose: 'signup'
|
||||
}
|
||||
})
|
||||
otpCooldownSeconds.value = 60
|
||||
if (otpCooldownTimerId) {
|
||||
clearInterval(otpCooldownTimerId)
|
||||
}
|
||||
otpCooldownTimerId = setInterval(() => {
|
||||
otpCooldownSeconds.value -= 1
|
||||
if (otpCooldownSeconds.value <= 0 && otpCooldownTimerId) {
|
||||
clearInterval(otpCooldownTimerId)
|
||||
otpCooldownTimerId = null
|
||||
}
|
||||
}, 1000)
|
||||
} catch (error) {
|
||||
submitErrorMessage.value = error?.data?.message || '인증번호 요청에 실패했습니다.'
|
||||
} finally {
|
||||
otpRequestLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (otpCooldownTimerId) {
|
||||
clearInterval(otpCooldownTimerId)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 다음 단계로 이동한다.
|
||||
* @returns {Promise<void>}
|
||||
@@ -119,13 +184,17 @@ const goNextStep = async () => {
|
||||
isSubmitting.value = true
|
||||
|
||||
try {
|
||||
const signupBody = {
|
||||
username: form.username.trim(),
|
||||
email: form.email.trim(),
|
||||
password: form.password
|
||||
}
|
||||
if (emailOtpRequired.value) {
|
||||
signupBody.emailOtp = String(form.emailOtp || '').replace(/\D/g, '')
|
||||
}
|
||||
const signupResult = await $fetch('/api/auth/signup', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
username: form.username.trim(),
|
||||
email: form.email.trim(),
|
||||
password: form.password
|
||||
}
|
||||
body: signupBody
|
||||
})
|
||||
createdAdmin.value = Boolean(signupResult?.isAdmin)
|
||||
|
||||
@@ -207,6 +276,36 @@ const goPreviousStep = () => {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="emailOtpRequired" class="space-y-1.5">
|
||||
<label class="text-xs text-[#d8dee6]">이메일 인증번호</label>
|
||||
<div class="flex flex-col gap-2 sm:flex-row sm:items-stretch">
|
||||
<input
|
||||
v-model="form.emailOtp"
|
||||
class="auth-form-input h-10 min-w-0 flex-1 rounded-[8px] border border-[#1a212a] bg-transparent px-3 text-sm tracking-widest outline-none transition-colors focus:border-[#2f6feb]"
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
maxlength="6"
|
||||
pattern="[0-9]*"
|
||||
placeholder="6자리"
|
||||
autocomplete="one-time-code"
|
||||
>
|
||||
<button
|
||||
class="auth-signup__otp-send h-10 shrink-0 rounded-[8px] border border-[#1a212a] px-4 text-xs text-[#d8dee6] transition-opacity hover:opacity-80 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
type="button"
|
||||
:disabled="otpRequestLoading || otpCooldownSeconds > 0"
|
||||
@click="requestSignupEmailOtp"
|
||||
>
|
||||
{{ otpCooldownSeconds > 0 ? `${otpCooldownSeconds}초 후 재요청` : (otpRequestLoading ? '발송 중…' : '인증번호 받기') }}
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="errors.emailOtp" class="text-xs text-[#e05d67]">
|
||||
{{ errors.emailOtp }}
|
||||
</p>
|
||||
<p class="text-xs text-[#8c95a3]">
|
||||
Resend로 발송됩니다. 메일이 보이지 않으면 스팸함을 확인해 주세요.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<label class="text-xs text-[#d8dee6]">{{ isAdminBootstrapMode ? '관리자 비밀번호' : '비밀번호' }}</label>
|
||||
<div
|
||||
|
||||
@@ -29,8 +29,8 @@ const tagPosts = computed(() => posts.value
|
||||
|
||||
<template>
|
||||
<MainColumn>
|
||||
<section class="pt-4 sm:pt-5">
|
||||
<div class="mx-auto flex max-w-[720px] flex-col gap-4 border-b border-[var(--site-line)] pb-4 sm:pb-5">
|
||||
<section class="tag-posts-header site-section">
|
||||
<div class="tag-posts-header__inner site-section-header mx-auto flex max-w-[720px] flex-col gap-4">
|
||||
<div class="flex flex-col gap-1">
|
||||
<h1 class="text-lg font-medium leading-tight sm:text-xl">
|
||||
{{ tag?.name || slug.toUpperCase() }}
|
||||
@@ -42,93 +42,95 @@ const tagPosts = computed(() => posts.value
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<section class="tag-posts-list">
|
||||
<div class="mx-auto flex max-w-[720px] flex-col gap-8">
|
||||
<div class="flex flex-col divide-y divide-[var(--site-line)]" data-post-feed="latest">
|
||||
<div class="flex flex-col" data-post-feed="latest">
|
||||
<article
|
||||
v-for="post in tagPosts"
|
||||
:key="post.to"
|
||||
class="group relative flex flex-row gap-3 py-4 text-[var(--site-text)]"
|
||||
class="tag-posts-list__item site-section site-panel-hover group relative text-[var(--site-text)]"
|
||||
>
|
||||
<NuxtLink
|
||||
:to="post.to"
|
||||
class="relative aspect-square min-w-16 flex-1 sm:aspect-video"
|
||||
>
|
||||
<figure class="overflow-hidden rounded-[10px]">
|
||||
<img
|
||||
v-if="post.featuredImage"
|
||||
:src="post.featuredImage"
|
||||
:alt="post.title"
|
||||
class="aspect-square w-full rounded-[inherit] object-cover transition-opacity duration-200 group-hover:opacity-90 sm:aspect-video"
|
||||
loading="lazy"
|
||||
>
|
||||
<div
|
||||
v-else
|
||||
class="aspect-square w-full rounded-[inherit] bg-[linear-gradient(135deg,#253444,#8f9dad)] sm:aspect-video"
|
||||
/>
|
||||
</figure>
|
||||
</NuxtLink>
|
||||
|
||||
<div class="relative flex-[3] md:flex-[4]">
|
||||
<div class="flex h-full flex-col gap-1.5">
|
||||
<h2 class="max-w-[90%] text-sm font-medium leading-tight">
|
||||
<NuxtLink :to="post.to" class="flex items-center transition-opacity duration-200 hover:opacity-75">
|
||||
<span v-if="post.isFeatured" class="post-feed__featured-icon mr-1 inline-flex h-4 w-4 items-center justify-center text-[var(--site-accent)]">
|
||||
<svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M13 3v7h6l-8 11v-7H5l8-11" />
|
||||
</svg>
|
||||
</span>
|
||||
{{ post.title }}
|
||||
</NuxtLink>
|
||||
</h2>
|
||||
<p class="flex-1 line-clamp-2 text-[0.8rem] leading-tight site-muted">
|
||||
{{ post.excerpt }}
|
||||
</p>
|
||||
<div class="flex flex-wrap items-center gap-2 text-xs site-muted sm:gap-1.5">
|
||||
<time v-if="post.publishedAt" :datetime="post.publishedAtIso">{{ post.publishedAt }}</time>
|
||||
<span class="text-[var(--site-line)]">/</span>
|
||||
<span
|
||||
class="rounded-md px-1.5 py-px font-medium text-[var(--site-text)]"
|
||||
:style="{ backgroundColor: `${post.tagColor}1a` }"
|
||||
>
|
||||
{{ post.tag }}
|
||||
</span>
|
||||
<span class="text-[var(--site-line)]">/</span>
|
||||
<span class="flex items-center gap-0.75">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="-mt-px">
|
||||
<path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z" />
|
||||
</svg>
|
||||
<span>0</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
class="absolute top-0 right-0 flex cursor-pointer items-center gap-1 hover:opacity-75 md:top-auto md:right-0 md:bottom-0 md:invisible md:opacity-0 md:transition-[opacity,visibility] md:duration-200 md:group-hover:visible md:group-hover:opacity-100"
|
||||
type="button"
|
||||
aria-label="Share this post"
|
||||
<div class="tag-posts-list__body site-section-body flex flex-row gap-3">
|
||||
<NuxtLink
|
||||
:to="post.to"
|
||||
class="relative aspect-square min-w-16 flex-1 sm:aspect-video"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="h-4 w-4"
|
||||
<figure class="overflow-hidden rounded-[10px]">
|
||||
<img
|
||||
v-if="post.featuredImage"
|
||||
:src="post.featuredImage"
|
||||
:alt="post.title"
|
||||
class="aspect-square w-full rounded-[inherit] object-cover transition-opacity duration-200 group-hover:opacity-90 sm:aspect-video"
|
||||
loading="lazy"
|
||||
>
|
||||
<div
|
||||
v-else
|
||||
class="aspect-square w-full rounded-[inherit] bg-[linear-gradient(135deg,#253444,#8f9dad)] sm:aspect-video"
|
||||
/>
|
||||
</figure>
|
||||
</NuxtLink>
|
||||
|
||||
<div class="relative flex-[3] md:flex-[4]">
|
||||
<div class="flex h-full flex-col gap-1.5">
|
||||
<h2 class="max-w-[90%] text-sm font-medium leading-tight">
|
||||
<NuxtLink :to="post.to" class="flex items-center transition-opacity duration-200 hover:opacity-75">
|
||||
<span v-if="post.isFeatured" class="post-feed__featured-icon mr-1 inline-flex h-4 w-4 items-center justify-center text-[var(--site-accent)]">
|
||||
<svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M13 3v7h6l-8 11v-7H5l8-11" />
|
||||
</svg>
|
||||
</span>
|
||||
{{ post.title }}
|
||||
</NuxtLink>
|
||||
</h2>
|
||||
<p class="flex-1 line-clamp-2 text-[0.8rem] leading-tight site-muted">
|
||||
{{ post.excerpt }}
|
||||
</p>
|
||||
<div class="flex flex-wrap items-center gap-2 text-xs site-muted sm:gap-1.5">
|
||||
<time v-if="post.publishedAt" :datetime="post.publishedAtIso">{{ post.publishedAt }}</time>
|
||||
<span class="text-[var(--site-line)]">/</span>
|
||||
<span
|
||||
class="rounded-md px-1.5 py-px font-medium text-[var(--site-text)]"
|
||||
:style="{ backgroundColor: `${post.tagColor}1a` }"
|
||||
>
|
||||
{{ post.tag }}
|
||||
</span>
|
||||
<span class="text-[var(--site-line)]">/</span>
|
||||
<span class="flex items-center gap-0.75">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="-mt-px">
|
||||
<path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z" />
|
||||
</svg>
|
||||
<span>0</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
class="absolute top-0 right-0 flex cursor-pointer items-center gap-1 hover:opacity-75 md:top-auto md:right-0 md:bottom-0 md:invisible md:opacity-0 md:transition-[opacity,visibility] md:duration-200 md:group-hover:visible md:group-hover:opacity-100"
|
||||
type="button"
|
||||
aria-label="Share this post"
|
||||
>
|
||||
<path d="M17 7 7 17" />
|
||||
<path d="M8 7h9v9" />
|
||||
</svg>
|
||||
</button>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="h-4 w-4"
|
||||
>
|
||||
<path d="M17 7 7 17" />
|
||||
<path d="M8 7h9v9" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="tagPosts.length === 0" class="py-4">
|
||||
<div class="mx-auto max-w-[720px] text-sm site-muted">
|
||||
<section v-if="tagPosts.length === 0" class="tag-posts-empty site-section">
|
||||
<div class="tag-posts-empty__body site-section-body mx-auto max-w-[720px] text-sm site-muted">
|
||||
이 태그에 연결된 글이 없습니다.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
72
scripts/check-js-syntax.js
Normal file
72
scripts/check-js-syntax.js
Normal file
@@ -0,0 +1,72 @@
|
||||
import { readdirSync, statSync } from 'node:fs'
|
||||
import { join, relative } from 'node:path'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
|
||||
const ignoredDirectories = new Set([
|
||||
'.git',
|
||||
'.nuxt',
|
||||
'.output',
|
||||
'node_modules'
|
||||
])
|
||||
|
||||
const checkedExtensions = new Set(['.js', '.mjs', '.cjs'])
|
||||
|
||||
/**
|
||||
* 점검 대상 JavaScript 파일인지 확인한다.
|
||||
* @param {string} filePath - 파일 경로
|
||||
* @returns {boolean} 점검 대상 여부
|
||||
*/
|
||||
const isCheckedFile = (filePath) => {
|
||||
const extension = filePath.slice(filePath.lastIndexOf('.'))
|
||||
return checkedExtensions.has(extension)
|
||||
}
|
||||
|
||||
/**
|
||||
* 디렉터리에서 JavaScript 파일 목록을 재귀적으로 수집한다.
|
||||
* @param {string} directory - 탐색할 디렉터리
|
||||
* @returns {string[]} JavaScript 파일 경로 목록
|
||||
*/
|
||||
const collectJavaScriptFiles = (directory) => {
|
||||
const entries = readdirSync(directory)
|
||||
const files = []
|
||||
|
||||
for (const entry of entries) {
|
||||
if (ignoredDirectories.has(entry)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const fullPath = join(directory, entry)
|
||||
const stat = statSync(fullPath)
|
||||
|
||||
if (stat.isDirectory()) {
|
||||
files.push(...collectJavaScriptFiles(fullPath))
|
||||
continue
|
||||
}
|
||||
|
||||
if (stat.isFile() && isCheckedFile(fullPath)) {
|
||||
files.push(fullPath)
|
||||
}
|
||||
}
|
||||
|
||||
return files
|
||||
}
|
||||
|
||||
const rootDirectory = process.cwd()
|
||||
const files = collectJavaScriptFiles(rootDirectory)
|
||||
|
||||
for (const file of files) {
|
||||
const result = spawnSync(process.execPath, ['--check', file], {
|
||||
encoding: 'utf8'
|
||||
})
|
||||
|
||||
if (result.status !== 0) {
|
||||
const displayPath = relative(rootDirectory, file)
|
||||
process.stderr.write(`JavaScript 문법 오류: ${displayPath}\n`)
|
||||
if (result.stderr) {
|
||||
process.stderr.write(result.stderr)
|
||||
}
|
||||
process.exit(result.status || 1)
|
||||
}
|
||||
}
|
||||
|
||||
process.stdout.write(`JavaScript 문법 점검 완료: ${files.length}개 파일\n`)
|
||||
@@ -1,7 +1,8 @@
|
||||
import bcrypt from 'bcrypt'
|
||||
import { createError, readBody } from 'h3'
|
||||
import { z } from 'zod'
|
||||
import { deleteMember, getUserByIdWithPassword } from '../../repositories/member-repository'
|
||||
import { countOwnerMembers, deleteMember, getUserByIdWithPassword, MEMBER_ROLE } from '../../repositories/member-repository'
|
||||
import { clearAdminSession } from '../../utils/admin-auth'
|
||||
import { clearMemberSession, requireMemberSession } from '../../utils/member-auth'
|
||||
import { removeManagedAvatarAsset } from '../../utils/member-avatar'
|
||||
|
||||
@@ -41,13 +42,20 @@ export default defineEventHandler(async (event) => {
|
||||
})
|
||||
}
|
||||
|
||||
if (user.role === MEMBER_ROLE.OWNER && (await countOwnerMembers()) <= 1) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '최소 1명의 소유자 권한은 유지되어야 합니다.'
|
||||
})
|
||||
}
|
||||
|
||||
if (user.avatarUrl) {
|
||||
await removeManagedAvatarAsset(user.avatarUrl)
|
||||
}
|
||||
|
||||
await deleteMember(session.userId)
|
||||
clearMemberSession(event)
|
||||
clearAdminSession(event)
|
||||
|
||||
return { ok: true }
|
||||
})
|
||||
|
||||
|
||||
16
server/api/auth/bootstrap-status.get.js
vendored
16
server/api/auth/bootstrap-status.get.js
vendored
@@ -1,7 +1,17 @@
|
||||
import { getMemberBootstrapState } from '../../repositories/member-repository'
|
||||
import { isResendConfigured } from '../../utils/resend-mail'
|
||||
|
||||
/**
|
||||
* 최초 관리자 등록 필요 여부를 조회한다.
|
||||
* @returns {Promise<{ hasUsers: boolean, needsAdminSetup: boolean }>} 부트스트랩 상태
|
||||
* 최초 관리자 등록 필요 여부·이메일 OTP(Resend) 사용 가능 여부를 조회한다.
|
||||
* @returns {Promise<{ hasUsers: boolean, needsAdminSetup: boolean, emailOtpConfigured: boolean }>} 부트스트랩 상태
|
||||
*/
|
||||
export default defineEventHandler(async () => getMemberBootstrapState())
|
||||
export default defineEventHandler(async () => {
|
||||
const base = await getMemberBootstrapState()
|
||||
const config = useRuntimeConfig()
|
||||
const hasPepper = Boolean(String(config.emailOtpPepper || config.memberSessionSecret || '').trim())
|
||||
const emailOtpConfigured = isResendConfigured(config) && hasPepper
|
||||
return {
|
||||
...base,
|
||||
emailOtpConfigured
|
||||
}
|
||||
})
|
||||
|
||||
172
server/api/auth/email-otp/request.post.js
Normal file
172
server/api/auth/email-otp/request.post.js
Normal file
@@ -0,0 +1,172 @@
|
||||
import { randomBytes } from 'node:crypto'
|
||||
import { z } from 'zod'
|
||||
import { createError, getRequestIP, readBody } from 'h3'
|
||||
import { getPostgresClient } from '../../../repositories/postgres-client'
|
||||
import { getMemberBootstrapState, getUserByEmail } from '../../../repositories/member-repository'
|
||||
import {
|
||||
countOtpSendsLastHour,
|
||||
hasRecentOtpSend,
|
||||
insertOtpChallenge,
|
||||
deleteOtpChallengeById,
|
||||
invalidatePendingOtpChallenges,
|
||||
invalidatePendingOtpChallengesExcept
|
||||
} from '../../../repositories/email-otp-repository'
|
||||
import { generateSixDigitOtp, hashOtpCode, normalizeOtpEmail } from '../../../utils/email-otp'
|
||||
import { isResendConfigured, sendResendEmail } from '../../../utils/resend-mail'
|
||||
|
||||
const bodySchema = z.object({
|
||||
email: z.string().trim().email(),
|
||||
purpose: z.enum(['signup', 'password_reset'])
|
||||
})
|
||||
|
||||
const OTP_TTL_MS = 15 * 60 * 1000
|
||||
const MAX_SENDS_PER_HOUR = 5
|
||||
|
||||
/**
|
||||
* OTP 이메일 발송용 pepper
|
||||
* @param {import('nuxt/schema').RuntimeConfig} config - 런타임 설정
|
||||
* @returns {string}
|
||||
*/
|
||||
const resolveOtpPepper = (config) => {
|
||||
const pepper = String(config.emailOtpPepper || config.memberSessionSecret || '').trim()
|
||||
if (!pepper) {
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
message: '이메일 인증을 사용하려면 MEMBER_SESSION_SECRET 또는 EMAIL_OTP_PEPPER를 설정해 주세요.'
|
||||
})
|
||||
}
|
||||
return pepper
|
||||
}
|
||||
|
||||
/**
|
||||
* 인증번호 이메일 발송 요청
|
||||
* @param {import('h3').H3Event} event - 요청 이벤트
|
||||
* @returns {Promise<{ ok: boolean, message: string }>}
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const config = useRuntimeConfig()
|
||||
|
||||
if (!isResendConfigured(config)) {
|
||||
throw createError({
|
||||
statusCode: 503,
|
||||
message: '이메일 발송(Resend)이 서버에 설정되지 않았습니다. RESEND_API_KEY와 RESEND_FROM_EMAIL을 확인해 주세요.'
|
||||
})
|
||||
}
|
||||
|
||||
const parsed = bodySchema.safeParse(await readBody(event))
|
||||
if (!parsed.success) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '요청 형식이 올바르지 않습니다.'
|
||||
})
|
||||
}
|
||||
|
||||
const email = normalizeOtpEmail(parsed.data.email)
|
||||
const purpose = parsed.data.purpose
|
||||
const sql = getPostgresClient()
|
||||
if (!sql) {
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
message: '데이터베이스 설정이 필요합니다.'
|
||||
})
|
||||
}
|
||||
|
||||
const genericOk = {
|
||||
ok: true,
|
||||
message: '등록된 주소라면 인증 메일을 발송했습니다. 메일함을 확인해 주세요.'
|
||||
}
|
||||
|
||||
if (purpose === 'signup') {
|
||||
const bootstrap = await getMemberBootstrapState()
|
||||
if (bootstrap.needsAdminSetup) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '최초 관리자 등록 단계에서는 이메일 인증이 필요하지 않습니다.'
|
||||
})
|
||||
}
|
||||
const existing = await getUserByEmail(email)
|
||||
if (existing) {
|
||||
throw createError({
|
||||
statusCode: 409,
|
||||
message: '이미 사용 중인 이메일입니다.'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (await hasRecentOtpSend(sql, email, purpose)) {
|
||||
throw createError({
|
||||
statusCode: 429,
|
||||
message: '잠시 후 다시 인증번호를 요청해 주세요.'
|
||||
})
|
||||
}
|
||||
|
||||
if ((await countOtpSendsLastHour(sql, email, purpose)) >= MAX_SENDS_PER_HOUR) {
|
||||
throw createError({
|
||||
statusCode: 429,
|
||||
message: '인증번호 요청 횟수가 너무 많습니다. 1시간 후 다시 시도해 주세요.'
|
||||
})
|
||||
}
|
||||
|
||||
if (purpose === 'password_reset') {
|
||||
const user = await getUserByEmail(email)
|
||||
if (!user) {
|
||||
const dummyHash = randomBytes(32).toString('hex')
|
||||
const expiresAt = new Date(Date.now() + OTP_TTL_MS)
|
||||
const createdIp = String(getRequestIP(event) || '')
|
||||
await invalidatePendingOtpChallenges(sql, email, purpose)
|
||||
await insertOtpChallenge(sql, {
|
||||
email,
|
||||
purpose,
|
||||
codeHash: dummyHash,
|
||||
expiresAt,
|
||||
createdIp
|
||||
})
|
||||
return genericOk
|
||||
}
|
||||
}
|
||||
|
||||
const pepper = resolveOtpPepper(config)
|
||||
const code = generateSixDigitOtp()
|
||||
const codeHash = hashOtpCode({ pepper, email, purpose, code })
|
||||
const expiresAt = new Date(Date.now() + OTP_TTL_MS)
|
||||
const createdIp = String(getRequestIP(event) || '')
|
||||
|
||||
const challengeId = await insertOtpChallenge(sql, {
|
||||
email,
|
||||
purpose,
|
||||
codeHash,
|
||||
expiresAt,
|
||||
createdIp
|
||||
})
|
||||
|
||||
const siteTitle = String(config.public?.siteTitle || 'sori.studio')
|
||||
const subject = purpose === 'signup'
|
||||
? `[${siteTitle}] 회원가입 인증번호`
|
||||
: `[${siteTitle}] 비밀번호 재설정 인증번호`
|
||||
|
||||
const html = purpose === 'signup'
|
||||
? `<p>회원가입을 위한 인증번호입니다.</p><p style="font-size:24px;font-weight:700;letter-spacing:0.2em">${code}</p><p>15분 이내에 입력해 주세요.</p>`
|
||||
: `<p>비밀번호 재설정을 위한 인증번호입니다.</p><p style="font-size:24px;font-weight:700;letter-spacing:0.2em">${code}</p><p>15분 이내에 입력해 주세요.</p>`
|
||||
|
||||
try {
|
||||
await sendResendEmail({
|
||||
apiKey: String(config.resendApiKey).trim(),
|
||||
from: String(config.resendFromEmail).trim(),
|
||||
to: email,
|
||||
subject,
|
||||
html
|
||||
})
|
||||
} catch (error) {
|
||||
await deleteOtpChallengeById(sql, challengeId)
|
||||
throw error
|
||||
}
|
||||
|
||||
await invalidatePendingOtpChallengesExcept(sql, email, purpose, challengeId)
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
message: purpose === 'signup'
|
||||
? '인증 메일을 발송했습니다. 메일함을 확인해 주세요.'
|
||||
: '등록된 주소라면 인증 메일을 발송했습니다. 메일함을 확인해 주세요.'
|
||||
}
|
||||
})
|
||||
@@ -25,7 +25,8 @@ export default defineEventHandler(async (event) => {
|
||||
}
|
||||
|
||||
const body = parsedBody.data
|
||||
const user = await getUserByEmail(body.email)
|
||||
const emailNorm = body.email.trim().toLowerCase()
|
||||
const user = await getUserByEmail(emailNorm)
|
||||
|
||||
if (!user || !(await bcrypt.compare(body.password, user.passwordHash))) {
|
||||
throw createError({
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { getUserById, touchUserActivity } from '../../repositories/member-repository'
|
||||
import { getUserById } from '../../repositories/member-repository'
|
||||
import { requireMemberSession } from '../../utils/member-auth'
|
||||
import { getRequestIP } from 'h3'
|
||||
|
||||
/**
|
||||
* 회원 세션 조회 API
|
||||
@@ -9,10 +8,6 @@ import { getRequestIP } from 'h3'
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const session = requireMemberSession(event)
|
||||
await touchUserActivity({
|
||||
userId: session.userId,
|
||||
ip: String(getRequestIP(event) || '')
|
||||
})
|
||||
const user = await getUserById(session.userId)
|
||||
|
||||
if (!user) {
|
||||
@@ -31,4 +26,3 @@ export default defineEventHandler(async (event) => {
|
||||
avatarUrl: user.avatarUrl || ''
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
65
server/api/auth/password-reset/confirm.post.js
Normal file
65
server/api/auth/password-reset/confirm.post.js
Normal file
@@ -0,0 +1,65 @@
|
||||
import bcrypt from 'bcrypt'
|
||||
import { z } from 'zod'
|
||||
import { createError, readBody } from 'h3'
|
||||
import { updateMemberPasswordByEmail } from '../../../repositories/member-repository'
|
||||
import { verifyAndConsumeEmailOtp } from '../../../repositories/email-otp-repository'
|
||||
|
||||
const bodySchema = z.object({
|
||||
email: z.string().trim().email(),
|
||||
code: z.string().regex(/^\d{6}$/),
|
||||
newPassword: z.string().min(8).max(32)
|
||||
})
|
||||
|
||||
/**
|
||||
* 이메일 OTP로 비밀번호를 재설정한다.
|
||||
* @param {import('h3').H3Event} event - 요청 이벤트
|
||||
* @returns {Promise<{ ok: boolean }>}
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const parsed = bodySchema.safeParse(await readBody(event))
|
||||
if (!parsed.success) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '요청 형식이 올바르지 않습니다.'
|
||||
})
|
||||
}
|
||||
|
||||
const config = useRuntimeConfig()
|
||||
const pepper = String(config.emailOtpPepper || config.memberSessionSecret || '').trim()
|
||||
if (!pepper) {
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
message: '서버 인증 설정이 올바르지 않습니다.'
|
||||
})
|
||||
}
|
||||
|
||||
const email = parsed.data.email.trim().toLowerCase()
|
||||
const verify = await verifyAndConsumeEmailOtp({
|
||||
email,
|
||||
purpose: 'password_reset',
|
||||
code: parsed.data.code,
|
||||
pepper
|
||||
})
|
||||
|
||||
if (!verify.ok) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '인증번호가 올바르지 않거나 만료되었습니다.'
|
||||
})
|
||||
}
|
||||
|
||||
const nextHash = await bcrypt.hash(parsed.data.newPassword, 12)
|
||||
const updated = await updateMemberPasswordByEmail({
|
||||
email,
|
||||
passwordHash: nextHash
|
||||
})
|
||||
|
||||
if (!updated) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '계정을 찾을 수 없습니다. 다시 인증번호를 요청해 주세요.'
|
||||
})
|
||||
}
|
||||
|
||||
return { ok: true }
|
||||
})
|
||||
@@ -1,15 +1,15 @@
|
||||
import { getUserById } from '../../repositories/member-repository'
|
||||
import { getUserProfileById } from '../../repositories/member-repository'
|
||||
import { requireMemberSession } from '../../utils/member-auth'
|
||||
import { createError } from 'h3'
|
||||
|
||||
/**
|
||||
* 회원 프로필 조회 API
|
||||
* @param {import('h3').H3Event} event - 요청 이벤트
|
||||
* @returns {Promise<{ id: string, email: string, username: string, avatarUrl: string }>} 회원 프로필
|
||||
* @returns {Promise<{ id: string, email: string, username: string, avatarUrl: string, createdAt: string, updatedAt: string, lastSeenAt: string | null, previousLastSeenAt: string | null, previousLastSeenIp: string, commentCount: number }>} 회원 프로필
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const session = requireMemberSession(event)
|
||||
const user = await getUserById(session.userId)
|
||||
const user = await getUserProfileById(session.userId)
|
||||
|
||||
if (!user) {
|
||||
throw createError({
|
||||
@@ -22,7 +22,12 @@ export default defineEventHandler(async (event) => {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
username: user.username,
|
||||
avatarUrl: user.avatarUrl || ''
|
||||
avatarUrl: user.avatarUrl || '',
|
||||
createdAt: user.createdAt.toISOString(),
|
||||
updatedAt: user.updatedAt.toISOString(),
|
||||
lastSeenAt: user.lastSeenAt ? user.lastSeenAt.toISOString() : null,
|
||||
previousLastSeenAt: user.previousLastSeenAt ? user.previousLastSeenAt.toISOString() : null,
|
||||
previousLastSeenIp: user.previousLastSeenIp || '',
|
||||
commentCount: Number(user.commentCount || 0)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -1,16 +1,33 @@
|
||||
import bcrypt from 'bcrypt'
|
||||
import { z } from 'zod'
|
||||
import { createError, getRequestIP, readBody } from 'h3'
|
||||
import { createUser, getUserByEmail, isUsernameTaken, touchUserActivity } from '../../repositories/member-repository'
|
||||
import { createUser, getUserByEmail, getMemberBootstrapState, isUsernameTaken, touchUserActivity } from '../../repositories/member-repository'
|
||||
import { verifyAndConsumeEmailOtp } from '../../repositories/email-otp-repository'
|
||||
import { setMemberSession } from '../../utils/member-auth'
|
||||
import { setAdminSession } from '../../utils/admin-auth'
|
||||
import { isResendConfigured } from '../../utils/resend-mail'
|
||||
|
||||
const signupSchema = z.object({
|
||||
username: z.string().trim().min(1),
|
||||
email: z.string().trim().email(),
|
||||
password: z.string().min(8).max(32)
|
||||
password: z.string().min(8).max(32),
|
||||
emailOtp: z.string().regex(/^\d{6}$/).optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* 이메일 OTP가 회원가입에 필요한지
|
||||
* @param {import('nuxt/schema').RuntimeConfig} config - 런타임 설정
|
||||
* @param {{ needsAdminSetup: boolean }} bootstrap - 부트스트랩 상태
|
||||
* @returns {boolean}
|
||||
*/
|
||||
const isSignupOtpRequired = (config, bootstrap) => {
|
||||
if (bootstrap.needsAdminSetup) {
|
||||
return false
|
||||
}
|
||||
const hasPepper = Boolean(String(config.emailOtpPepper || config.memberSessionSecret || '').trim())
|
||||
return isResendConfigured(config) && hasPepper
|
||||
}
|
||||
|
||||
/**
|
||||
* 회원 가입 API
|
||||
* @param {import('h3').H3Event} event - 요청 이벤트
|
||||
@@ -27,6 +44,11 @@ export default defineEventHandler(async (event) => {
|
||||
}
|
||||
|
||||
const body = parsedBody.data
|
||||
const config = useRuntimeConfig()
|
||||
const bootstrap = await getMemberBootstrapState()
|
||||
const otpRequired = isSignupOtpRequired(config, bootstrap)
|
||||
const emailNorm = body.email.trim().toLowerCase()
|
||||
|
||||
const usernameTaken = await isUsernameTaken({
|
||||
username: body.username
|
||||
})
|
||||
@@ -38,7 +60,7 @@ export default defineEventHandler(async (event) => {
|
||||
})
|
||||
}
|
||||
|
||||
const existingUser = await getUserByEmail(body.email)
|
||||
const existingUser = await getUserByEmail(emailNorm)
|
||||
|
||||
if (existingUser) {
|
||||
throw createError({
|
||||
@@ -47,10 +69,33 @@ export default defineEventHandler(async (event) => {
|
||||
})
|
||||
}
|
||||
|
||||
if (otpRequired) {
|
||||
const otp = body.emailOtp
|
||||
if (!otp) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '이메일 인증번호를 입력해 주세요.'
|
||||
})
|
||||
}
|
||||
const pepper = String(config.emailOtpPepper || config.memberSessionSecret || '').trim()
|
||||
const verify = await verifyAndConsumeEmailOtp({
|
||||
email: emailNorm,
|
||||
purpose: 'signup',
|
||||
code: otp,
|
||||
pepper
|
||||
})
|
||||
if (!verify.ok) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '이메일 인증번호가 올바르지 않거나 만료되었습니다.'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const passwordHash = await bcrypt.hash(body.password, 12)
|
||||
const created = await createUser({
|
||||
username: body.username,
|
||||
email: body.email,
|
||||
username: body.username.trim(),
|
||||
email: emailNorm,
|
||||
passwordHash
|
||||
})
|
||||
|
||||
@@ -74,4 +119,3 @@ export default defineEventHandler(async (event) => {
|
||||
isAdmin: Boolean(created.isAdmin)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
34
server/middleware/admin-api-session.js
Normal file
34
server/middleware/admin-api-session.js
Normal file
@@ -0,0 +1,34 @@
|
||||
import { createError, getRequestURL } from 'h3'
|
||||
import { getAdminSession } from '../utils/admin-auth'
|
||||
import { isPrivilegedMember } from '../repositories/member-repository'
|
||||
|
||||
/**
|
||||
* 관리자 API 요청마다 현재 DB 권한을 다시 확인한다.
|
||||
* @param {import('h3').H3Event} event - 요청 이벤트
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const pathname = getRequestURL(event).pathname
|
||||
|
||||
if (!pathname.startsWith('/admin/api/') || pathname === '/admin/api/auth/login' || pathname === '/admin/api/auth/logout') {
|
||||
return
|
||||
}
|
||||
|
||||
const session = getAdminSession(event)
|
||||
|
||||
if (!session) {
|
||||
throw createError({
|
||||
statusCode: 401,
|
||||
message: '관리자 로그인이 필요합니다.'
|
||||
})
|
||||
}
|
||||
|
||||
const stillPrivileged = await isPrivilegedMember(session.userId)
|
||||
|
||||
if (!stillPrivileged) {
|
||||
throw createError({
|
||||
statusCode: 403,
|
||||
message: '현재 관리자 권한이 없습니다. 다시 로그인해 주세요.'
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -74,6 +74,8 @@ const mapSiteSettingsRow = (row) => ({
|
||||
description: row.description,
|
||||
siteUrl: row.site_url,
|
||||
logoText: row.logo_text,
|
||||
logoUrl: row.logo_url || '',
|
||||
faviconUrl: row.favicon_url || '',
|
||||
copyrightText: row.copyright_text,
|
||||
updatedAt: row.updated_at.toISOString()
|
||||
})
|
||||
@@ -749,6 +751,8 @@ export const updateSiteSettings = async (input) => {
|
||||
description,
|
||||
site_url,
|
||||
logo_text,
|
||||
logo_url,
|
||||
favicon_url,
|
||||
copyright_text,
|
||||
updated_at
|
||||
)
|
||||
@@ -757,7 +761,9 @@ export const updateSiteSettings = async (input) => {
|
||||
${input.title},
|
||||
${input.description},
|
||||
${input.siteUrl},
|
||||
${input.logoText},
|
||||
${input.logoText || '井'},
|
||||
${input.logoUrl || ''},
|
||||
${input.faviconUrl || ''},
|
||||
${input.copyrightText},
|
||||
now()
|
||||
)
|
||||
@@ -767,6 +773,8 @@ export const updateSiteSettings = async (input) => {
|
||||
description = EXCLUDED.description,
|
||||
site_url = EXCLUDED.site_url,
|
||||
logo_text = EXCLUDED.logo_text,
|
||||
logo_url = EXCLUDED.logo_url,
|
||||
favicon_url = EXCLUDED.favicon_url,
|
||||
copyright_text = EXCLUDED.copyright_text,
|
||||
updated_at = now()
|
||||
RETURNING *
|
||||
@@ -775,6 +783,42 @@ export const updateSiteSettings = async (input) => {
|
||||
return mapSiteSettingsRow(rows[0])
|
||||
}
|
||||
|
||||
/**
|
||||
* 사이트 로고 URL을 수정한다.
|
||||
* @param {{ logoUrl: string, faviconUrl: string }} input - 로고 URL
|
||||
* @returns {Promise<Object>} 수정된 사이트 설정
|
||||
*/
|
||||
export const updateSiteLogo = async (input) => {
|
||||
const sql = getPostgresClient()
|
||||
|
||||
if (!sql) {
|
||||
throw new Error('DATABASE_REQUIRED')
|
||||
}
|
||||
|
||||
const rows = await sql`
|
||||
INSERT INTO site_settings (
|
||||
id,
|
||||
logo_url,
|
||||
favicon_url,
|
||||
updated_at
|
||||
)
|
||||
VALUES (
|
||||
1,
|
||||
${input.logoUrl},
|
||||
${input.faviconUrl},
|
||||
now()
|
||||
)
|
||||
ON CONFLICT (id) DO UPDATE
|
||||
SET
|
||||
logo_url = EXCLUDED.logo_url,
|
||||
favicon_url = EXCLUDED.favicon_url,
|
||||
updated_at = now()
|
||||
RETURNING *
|
||||
`
|
||||
|
||||
return mapSiteSettingsRow(rows[0])
|
||||
}
|
||||
|
||||
/**
|
||||
* 네비게이션 항목 목록 조회
|
||||
* @param {Object} options - 조회 옵션
|
||||
|
||||
196
server/repositories/email-otp-repository.js
Normal file
196
server/repositories/email-otp-repository.js
Normal file
@@ -0,0 +1,196 @@
|
||||
import { createError } from 'h3'
|
||||
import { getPostgresClient } from './postgres-client'
|
||||
import { hashOtpCode, normalizeOtpEmail, timingSafeEqualHex } from '../utils/email-otp'
|
||||
|
||||
/** 최대 검증 시도 횟수(초과 시 해당 챌린지는 더 이상 사용 불가) */
|
||||
const MAX_OTP_VERIFY_ATTEMPTS = 8
|
||||
|
||||
/**
|
||||
* DB 클라이언트 조회 (필수)
|
||||
* @returns {ReturnType<typeof import('postgres').default>} postgres sql
|
||||
*/
|
||||
const requireSql = () => {
|
||||
const sql = getPostgresClient()
|
||||
if (!sql) {
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
message: '데이터베이스 설정이 필요합니다.'
|
||||
})
|
||||
}
|
||||
return sql
|
||||
}
|
||||
|
||||
/**
|
||||
* 동일 이메일·용도의 미소진 OTP를 무효화한다.
|
||||
* @param {import('postgres').TransactionSql} sql - sql 또는 트랜잭션
|
||||
* @param {string} email - 정규화된 이메일
|
||||
* @param {string} purpose - signup | password_reset
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export const invalidatePendingOtpChallenges = async (sql, email, purpose) => {
|
||||
await sql`
|
||||
UPDATE email_otp_challenges
|
||||
SET consumed_at = now()
|
||||
WHERE lower(email) = lower(${email})
|
||||
AND purpose = ${purpose}
|
||||
AND consumed_at IS NULL
|
||||
`
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 OTP를 제외한 동일 이메일·용도의 미소진 OTP를 무효화한다.
|
||||
* @param {import('postgres').TransactionSql} sql - sql 또는 트랜잭션
|
||||
* @param {string} email - 정규화된 이메일
|
||||
* @param {string} purpose - signup | password_reset
|
||||
* @param {string} keepId - 유지할 챌린지 ID
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export const invalidatePendingOtpChallengesExcept = async (sql, email, purpose, keepId) => {
|
||||
await sql`
|
||||
UPDATE email_otp_challenges
|
||||
SET consumed_at = now()
|
||||
WHERE lower(email) = lower(${email})
|
||||
AND purpose = ${purpose}
|
||||
AND id <> ${keepId}
|
||||
AND consumed_at IS NULL
|
||||
`
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 OTP 챌린지를 삭제한다.
|
||||
* @param {import('postgres').TransactionSql} sql - sql 또는 트랜잭션
|
||||
* @param {string} id - 챌린지 ID
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export const deleteOtpChallengeById = async (sql, id) => {
|
||||
await sql`
|
||||
DELETE FROM email_otp_challenges
|
||||
WHERE id = ${id}
|
||||
`
|
||||
}
|
||||
|
||||
/**
|
||||
* 최근 짧은 시간 내 동일 이메일·용도 발송이 있는지 확인한다.
|
||||
* @param {import('postgres').Sql} sql - sql
|
||||
* @param {string} email - 이메일
|
||||
* @param {string} purpose - 용도
|
||||
* @returns {Promise<boolean>} true면 재요청 쿨다운 중
|
||||
*/
|
||||
export const hasRecentOtpSend = async (sql, email, purpose) => {
|
||||
const rows = await sql`
|
||||
SELECT 1 AS "x"
|
||||
FROM email_otp_challenges
|
||||
WHERE lower(email) = lower(${email})
|
||||
AND purpose = ${purpose}
|
||||
AND created_at > now() - interval '55 seconds'
|
||||
LIMIT 1
|
||||
`
|
||||
return Boolean(rows?.[0])
|
||||
}
|
||||
|
||||
/**
|
||||
* 1시간 내 발송 횟수
|
||||
* @param {import('postgres').Sql} sql - sql
|
||||
* @param {string} email - 이메일
|
||||
* @param {string} purpose - 용도
|
||||
* @returns {Promise<number>}
|
||||
*/
|
||||
export const countOtpSendsLastHour = async (sql, email, purpose) => {
|
||||
const rows = await sql`
|
||||
SELECT COUNT(*)::int AS "c"
|
||||
FROM email_otp_challenges
|
||||
WHERE lower(email) = lower(${email})
|
||||
AND purpose = ${purpose}
|
||||
AND created_at > now() - interval '1 hour'
|
||||
`
|
||||
return Number(rows?.[0]?.c || 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* OTP 챌린지 행을 삽입한다.
|
||||
* @param {import('postgres').Sql} sql - sql
|
||||
* @param {{ email: string, purpose: string, codeHash: string, expiresAt: Date, createdIp: string }} input - 입력
|
||||
* @returns {Promise<string>} 삽입된 id
|
||||
*/
|
||||
export const insertOtpChallenge = async (sql, input) => {
|
||||
const rows = await sql`
|
||||
INSERT INTO email_otp_challenges (email, purpose, code_hash, expires_at, created_ip)
|
||||
VALUES (${input.email}, ${input.purpose}, ${input.codeHash}, ${input.expiresAt}, ${input.createdIp})
|
||||
RETURNING id
|
||||
`
|
||||
const id = rows?.[0]?.id
|
||||
if (!id) {
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
message: '인증 정보 저장에 실패했습니다.'
|
||||
})
|
||||
}
|
||||
return String(id)
|
||||
}
|
||||
|
||||
/**
|
||||
* 이메일 OTP를 검증하고 소진 처리한다.
|
||||
* @param {{ email: string, purpose: string, code: string, pepper: string }} input - 입력
|
||||
* @returns {Promise<{ ok: boolean, reason?: 'none' | 'expired' | 'locked' | 'mismatch' }>}
|
||||
*/
|
||||
export const verifyAndConsumeEmailOtp = async (input) => {
|
||||
const sql = requireSql()
|
||||
const email = normalizeOtpEmail(input.email)
|
||||
const purpose = String(input.purpose || '').trim()
|
||||
const code = String(input.code || '').trim()
|
||||
const pepper = String(input.pepper || '')
|
||||
|
||||
if (!email || !purpose || !/^\d{6}$/.test(code) || !pepper) {
|
||||
return { ok: false, reason: 'mismatch' }
|
||||
}
|
||||
|
||||
return await sql.begin(async (tx) => {
|
||||
const rows = await tx`
|
||||
SELECT id, code_hash AS "codeHash", verify_attempt_count AS "verifyAttemptCount", expires_at AS "expiresAt"
|
||||
FROM email_otp_challenges
|
||||
WHERE lower(email) = lower(${email})
|
||||
AND purpose = ${purpose}
|
||||
AND consumed_at IS NULL
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1
|
||||
FOR UPDATE
|
||||
`
|
||||
|
||||
const row = rows?.[0]
|
||||
if (!row) {
|
||||
return { ok: false, reason: 'none' }
|
||||
}
|
||||
|
||||
const expiresAt = new Date(row.expiresAt)
|
||||
if (Number.isNaN(expiresAt.getTime()) || expiresAt.getTime() < Date.now()) {
|
||||
await tx`
|
||||
UPDATE email_otp_challenges
|
||||
SET consumed_at = now()
|
||||
WHERE id = ${row.id}
|
||||
`
|
||||
return { ok: false, reason: 'expired' }
|
||||
}
|
||||
|
||||
if (Number(row.verifyAttemptCount || 0) >= MAX_OTP_VERIFY_ATTEMPTS) {
|
||||
return { ok: false, reason: 'locked' }
|
||||
}
|
||||
|
||||
const expected = hashOtpCode({ pepper, email, purpose, code })
|
||||
if (!timingSafeEqualHex(expected, row.codeHash)) {
|
||||
await tx`
|
||||
UPDATE email_otp_challenges
|
||||
SET verify_attempt_count = verify_attempt_count + 1
|
||||
WHERE id = ${row.id}
|
||||
`
|
||||
return { ok: false, reason: 'mismatch' }
|
||||
}
|
||||
|
||||
await tx`
|
||||
UPDATE email_otp_challenges
|
||||
SET consumed_at = now()
|
||||
WHERE id = ${row.id}
|
||||
`
|
||||
|
||||
return { ok: true }
|
||||
})
|
||||
}
|
||||
@@ -9,6 +9,51 @@ export const MEMBER_ROLE = {
|
||||
|
||||
const PRIVILEGED_ROLES = [MEMBER_ROLE.OWNER, MEMBER_ROLE.ADMIN]
|
||||
|
||||
/**
|
||||
* 회원 권한 표시 문자열을 반환한다.
|
||||
* @param {string} roleCode - 권한 코드
|
||||
* @returns {string} 권한 표시 문자열
|
||||
*/
|
||||
const getMemberRoleLabel = (roleCode) => roleCode === MEMBER_ROLE.OWNER
|
||||
? '소유자'
|
||||
: roleCode === MEMBER_ROLE.ADMIN
|
||||
? '관리자'
|
||||
: '멤버'
|
||||
|
||||
/**
|
||||
* 관리자 회원 행을 응답 객체로 변환한다.
|
||||
* @param {Object} row - DB 회원 행
|
||||
* @returns {Object} 관리자 회원 응답
|
||||
*/
|
||||
const mapAdminMemberRow = (row) => {
|
||||
const lastSeenAt = row.lastSeenAt ? row.lastSeenAt.toISOString() : null
|
||||
const previousLastSeenAt = row.previousLastSeenAt ? row.previousLastSeenAt.toISOString() : null
|
||||
const isActive = row.lastSeenAt
|
||||
? Date.now() - new Date(row.lastSeenAt).getTime() <= 1000 * 60 * 60 * 24 * 30
|
||||
: false
|
||||
const roleCode = String(row.roleCode || MEMBER_ROLE.MEMBER)
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
username: row.username,
|
||||
email: row.email,
|
||||
avatarUrl: row.avatarUrl || '',
|
||||
labels: Array.isArray(row.labels) ? row.labels : [],
|
||||
note: row.note || '',
|
||||
isAdmin: Boolean(row.isAdmin),
|
||||
roleCode,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
lastSeenAt,
|
||||
lastSeenIp: row.lastSeenIp || '',
|
||||
previousLastSeenAt,
|
||||
previousLastSeenIp: row.previousLastSeenIp || '',
|
||||
commentCount: Number(row.commentCount || 0),
|
||||
activityStatus: isActive ? '활성' : '비활성',
|
||||
role: getMemberRoleLabel(roleCode)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {Object} MemberUser
|
||||
* @property {string} id - 사용자 ID
|
||||
@@ -22,6 +67,8 @@ const PRIVILEGED_ROLES = [MEMBER_ROLE.OWNER, MEMBER_ROLE.ADMIN]
|
||||
* @property {string} updatedAt - 수정 시각(ISO)
|
||||
* @property {string | null} lastSeenAt - 최근 접속 시각(ISO)
|
||||
* @property {string} lastSeenIp - 최근 접속 IP
|
||||
* @property {string | null} previousLastSeenAt - 이전 로그인 시각(ISO)
|
||||
* @property {string} previousLastSeenIp - 이전 로그인 IP
|
||||
*/
|
||||
|
||||
/**
|
||||
@@ -58,9 +105,11 @@ export const getUserByEmail = async (email) => {
|
||||
created_at AS "createdAt",
|
||||
updated_at AS "updatedAt",
|
||||
last_seen_at AS "lastSeenAt",
|
||||
last_seen_ip AS "lastSeenIp"
|
||||
last_seen_ip AS "lastSeenIp",
|
||||
previous_last_seen_at AS "previousLastSeenAt",
|
||||
previous_last_seen_ip AS "previousLastSeenIp"
|
||||
FROM users
|
||||
WHERE email = ${email}
|
||||
WHERE lower(email) = lower(${email})
|
||||
LIMIT 1
|
||||
`
|
||||
|
||||
@@ -85,7 +134,9 @@ export const getUserById = async (id) => {
|
||||
created_at AS "createdAt",
|
||||
updated_at AS "updatedAt",
|
||||
last_seen_at AS "lastSeenAt",
|
||||
last_seen_ip AS "lastSeenIp"
|
||||
last_seen_ip AS "lastSeenIp",
|
||||
previous_last_seen_at AS "previousLastSeenAt",
|
||||
previous_last_seen_ip AS "previousLastSeenIp"
|
||||
FROM users
|
||||
WHERE id = ${id}
|
||||
LIMIT 1
|
||||
@@ -94,6 +145,38 @@ export const getUserById = async (id) => {
|
||||
return rows?.[0] || null
|
||||
}
|
||||
|
||||
/**
|
||||
* 사용자 설정 화면용 회원 프로필을 조회한다.
|
||||
* @param {string} id - 사용자 ID
|
||||
* @returns {Promise<(Omit<MemberUser, 'passwordHash'> & { commentCount: number }) | null>} 회원 프로필
|
||||
*/
|
||||
export const getUserProfileById = async (id) => {
|
||||
const sql = requireSql()
|
||||
const rows = await sql`
|
||||
SELECT
|
||||
users.id,
|
||||
users.username,
|
||||
users.email,
|
||||
users.avatar_url AS "avatarUrl",
|
||||
users.is_admin AS "isAdmin",
|
||||
users.user_role AS "role",
|
||||
users.created_at AS "createdAt",
|
||||
users.updated_at AS "updatedAt",
|
||||
users.last_seen_at AS "lastSeenAt",
|
||||
users.last_seen_ip AS "lastSeenIp",
|
||||
users.previous_last_seen_at AS "previousLastSeenAt",
|
||||
users.previous_last_seen_ip AS "previousLastSeenIp",
|
||||
COALESCE(count(comments.id), 0)::int AS "commentCount"
|
||||
FROM users
|
||||
LEFT JOIN comments ON comments.user_id = users.id AND comments.status = 'published'
|
||||
WHERE users.id = ${id}
|
||||
GROUP BY users.id
|
||||
LIMIT 1
|
||||
`
|
||||
|
||||
return rows?.[0] || null
|
||||
}
|
||||
|
||||
/**
|
||||
* ID로 회원 조회(비밀번호 포함)
|
||||
* @param {string} id - 사용자 ID
|
||||
@@ -113,7 +196,9 @@ export const getUserByIdWithPassword = async (id) => {
|
||||
created_at AS "createdAt",
|
||||
updated_at AS "updatedAt",
|
||||
last_seen_at AS "lastSeenAt",
|
||||
last_seen_ip AS "lastSeenIp"
|
||||
last_seen_ip AS "lastSeenIp",
|
||||
previous_last_seen_at AS "previousLastSeenAt",
|
||||
previous_last_seen_ip AS "previousLastSeenIp"
|
||||
FROM users
|
||||
WHERE id = ${id}
|
||||
LIMIT 1
|
||||
@@ -130,31 +215,37 @@ export const getUserByIdWithPassword = async (id) => {
|
||||
export const createUser = async (input) => {
|
||||
const sql = requireSql()
|
||||
|
||||
const rows = await sql`
|
||||
INSERT INTO users (username, email, password_hash, avatar_url, is_admin, user_role)
|
||||
VALUES (
|
||||
${input.username},
|
||||
${input.email},
|
||||
${input.passwordHash},
|
||||
'',
|
||||
NOT EXISTS (SELECT 1 FROM users),
|
||||
CASE
|
||||
WHEN NOT EXISTS (SELECT 1 FROM users) THEN ${MEMBER_ROLE.OWNER}
|
||||
ELSE ${MEMBER_ROLE.MEMBER}
|
||||
END
|
||||
)
|
||||
RETURNING
|
||||
id,
|
||||
username,
|
||||
email,
|
||||
avatar_url AS "avatarUrl",
|
||||
is_admin AS "isAdmin",
|
||||
user_role AS "role",
|
||||
created_at AS "createdAt",
|
||||
updated_at AS "updatedAt",
|
||||
last_seen_at AS "lastSeenAt",
|
||||
last_seen_ip AS "lastSeenIp"
|
||||
`
|
||||
const rows = await sql.begin(async (tx) => {
|
||||
await tx`LOCK TABLE users IN SHARE ROW EXCLUSIVE MODE`
|
||||
|
||||
return tx`
|
||||
INSERT INTO users (username, email, password_hash, avatar_url, is_admin, user_role)
|
||||
VALUES (
|
||||
${input.username},
|
||||
${input.email},
|
||||
${input.passwordHash},
|
||||
'',
|
||||
NOT EXISTS (SELECT 1 FROM users),
|
||||
CASE
|
||||
WHEN NOT EXISTS (SELECT 1 FROM users) THEN ${MEMBER_ROLE.OWNER}
|
||||
ELSE ${MEMBER_ROLE.MEMBER}
|
||||
END
|
||||
)
|
||||
RETURNING
|
||||
id,
|
||||
username,
|
||||
email,
|
||||
avatar_url AS "avatarUrl",
|
||||
is_admin AS "isAdmin",
|
||||
user_role AS "role",
|
||||
created_at AS "createdAt",
|
||||
updated_at AS "updatedAt",
|
||||
last_seen_at AS "lastSeenAt",
|
||||
last_seen_ip AS "lastSeenIp",
|
||||
previous_last_seen_at AS "previousLastSeenAt",
|
||||
previous_last_seen_ip AS "previousLastSeenIp"
|
||||
`
|
||||
})
|
||||
|
||||
const created = rows?.[0]
|
||||
if (!created) {
|
||||
@@ -177,6 +268,8 @@ export const touchUserActivity = async (input) => {
|
||||
await sql`
|
||||
UPDATE users
|
||||
SET
|
||||
previous_last_seen_at = last_seen_at,
|
||||
previous_last_seen_ip = last_seen_ip,
|
||||
last_seen_at = now(),
|
||||
last_seen_ip = ${input.ip},
|
||||
updated_at = now()
|
||||
@@ -208,12 +301,33 @@ export const updateMemberProfile = async (input) => {
|
||||
created_at AS "createdAt",
|
||||
updated_at AS "updatedAt",
|
||||
last_seen_at AS "lastSeenAt",
|
||||
last_seen_ip AS "lastSeenIp"
|
||||
last_seen_ip AS "lastSeenIp",
|
||||
previous_last_seen_at AS "previousLastSeenAt",
|
||||
previous_last_seen_ip AS "previousLastSeenIp"
|
||||
`
|
||||
|
||||
return rows?.[0] || null
|
||||
}
|
||||
|
||||
/**
|
||||
* 이메일(대소문자 무시)로 비밀번호 해시를 갱신한다.
|
||||
* @param {{ email: string, passwordHash: string }} input - 입력
|
||||
* @returns {Promise<boolean>} 갱신된 행이 있으면 true
|
||||
*/
|
||||
export const updateMemberPasswordByEmail = async (input) => {
|
||||
const sql = requireSql()
|
||||
const rows = await sql`
|
||||
UPDATE users
|
||||
SET
|
||||
password_hash = ${input.passwordHash},
|
||||
updated_at = now()
|
||||
WHERE lower(email) = lower(${input.email})
|
||||
RETURNING id
|
||||
`
|
||||
|
||||
return Boolean(rows?.[0])
|
||||
}
|
||||
|
||||
/**
|
||||
* 회원 비밀번호 변경
|
||||
* @param {{ userId: string, passwordHash: string }} input - 수정 값
|
||||
@@ -243,6 +357,53 @@ export const deleteMember = async (userId) => {
|
||||
`
|
||||
}
|
||||
|
||||
/**
|
||||
* 관리자 화면에서 회원을 삭제한다.
|
||||
* @param {{ actorUserId: string, targetUserId: string }} input - 삭제 정보
|
||||
* @returns {Promise<Object>} 삭제된 회원
|
||||
*/
|
||||
export const deleteMemberByAdmin = async (input) => {
|
||||
const target = await getMemberForAdmin(input.targetUserId)
|
||||
if (!target) {
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
message: '회원을 찾을 수 없습니다.'
|
||||
})
|
||||
}
|
||||
|
||||
if (target.id === input.actorUserId) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '현재 로그인한 계정은 여기서 삭제할 수 없습니다.'
|
||||
})
|
||||
}
|
||||
|
||||
if (target.roleCode === MEMBER_ROLE.OWNER && (await countOwnerMembers()) <= 1) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '최소 1명의 소유자 권한은 유지되어야 합니다.'
|
||||
})
|
||||
}
|
||||
|
||||
await deleteMember(input.targetUserId)
|
||||
return target
|
||||
}
|
||||
|
||||
/**
|
||||
* 소유자 권한 회원 수를 조회한다.
|
||||
* @returns {Promise<number>} 소유자 회원 수
|
||||
*/
|
||||
export const countOwnerMembers = async () => {
|
||||
const sql = requireSql()
|
||||
const rows = await sql`
|
||||
SELECT COUNT(*)::int AS "ownerCount"
|
||||
FROM users
|
||||
WHERE user_role = ${MEMBER_ROLE.OWNER}
|
||||
`
|
||||
|
||||
return Number(rows?.[0]?.ownerCount || 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* 사용자명 중복 확인
|
||||
* @param {{ username: string, excludeUserId?: string }} input - 사용자명과 제외 사용자 ID
|
||||
@@ -268,9 +429,34 @@ export const isUsernameTaken = async (input) => {
|
||||
return Boolean(rows?.[0])
|
||||
}
|
||||
|
||||
/**
|
||||
* 이메일 중복 확인
|
||||
* @param {{ email: string, excludeUserId?: string }} input - 이메일과 제외 사용자 ID
|
||||
* @returns {Promise<boolean>} 중복 여부
|
||||
*/
|
||||
export const isEmailTaken = async (input) => {
|
||||
const sql = requireSql()
|
||||
const rows = input.excludeUserId
|
||||
? await sql`
|
||||
SELECT id
|
||||
FROM users
|
||||
WHERE lower(email) = lower(${input.email})
|
||||
AND id <> ${input.excludeUserId}
|
||||
LIMIT 1
|
||||
`
|
||||
: await sql`
|
||||
SELECT id
|
||||
FROM users
|
||||
WHERE lower(email) = lower(${input.email})
|
||||
LIMIT 1
|
||||
`
|
||||
|
||||
return Boolean(rows?.[0])
|
||||
}
|
||||
|
||||
/**
|
||||
* 관리자용 회원 목록 조회(댓글 활동 포함)
|
||||
* @returns {Promise<Array<{ id: string, username: string, email: string, avatarUrl: string, isAdmin: boolean, roleCode: string, createdAt: string, updatedAt: string, lastSeenAt: string | null, lastSeenIp: string, commentCount: number, activityStatus: string, role: string }>>} 회원 목록
|
||||
* @returns {Promise<Array<{ id: string, username: string, email: string, avatarUrl: string, isAdmin: boolean, roleCode: string, createdAt: string, updatedAt: string, lastSeenAt: string | null, lastSeenIp: string, previousLastSeenAt: string | null, previousLastSeenIp: string, commentCount: number, activityStatus: string, role: string }>>} 회원 목록
|
||||
*/
|
||||
export const listMembersForAdmin = async () => {
|
||||
const sql = requireSql()
|
||||
@@ -280,12 +466,16 @@ export const listMembersForAdmin = async () => {
|
||||
users.username,
|
||||
users.email,
|
||||
users.avatar_url AS "avatarUrl",
|
||||
users.member_labels AS "labels",
|
||||
users.member_note AS "note",
|
||||
users.is_admin AS "isAdmin",
|
||||
users.user_role AS "roleCode",
|
||||
users.created_at AS "createdAt",
|
||||
users.updated_at AS "updatedAt",
|
||||
users.last_seen_at AS "lastSeenAt",
|
||||
users.last_seen_ip AS "lastSeenIp",
|
||||
users.previous_last_seen_at AS "previousLastSeenAt",
|
||||
users.previous_last_seen_ip AS "previousLastSeenIp",
|
||||
COALESCE(count(comments.id), 0)::int AS "commentCount"
|
||||
FROM users
|
||||
LEFT JOIN comments ON comments.user_id = users.id AND comments.status = 'published'
|
||||
@@ -293,32 +483,106 @@ export const listMembersForAdmin = async () => {
|
||||
ORDER BY users.created_at DESC
|
||||
`
|
||||
|
||||
return rows.map((row) => {
|
||||
const lastSeenAt = row.lastSeenAt ? row.lastSeenAt.toISOString() : null
|
||||
const isActive = row.lastSeenAt
|
||||
? Date.now() - new Date(row.lastSeenAt).getTime() <= 1000 * 60 * 60 * 24 * 30
|
||||
: false
|
||||
return rows.map(mapAdminMemberRow)
|
||||
}
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
username: row.username,
|
||||
email: row.email,
|
||||
avatarUrl: row.avatarUrl || '',
|
||||
isAdmin: Boolean(row.isAdmin),
|
||||
roleCode: String(row.roleCode || MEMBER_ROLE.MEMBER),
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
lastSeenAt,
|
||||
lastSeenIp: row.lastSeenIp || '',
|
||||
commentCount: Number(row.commentCount || 0),
|
||||
activityStatus: isActive ? '활성' : '비활성',
|
||||
role: row.roleCode === MEMBER_ROLE.OWNER
|
||||
? '소유자'
|
||||
: row.roleCode === MEMBER_ROLE.ADMIN
|
||||
? '관리자'
|
||||
: '멤버'
|
||||
}
|
||||
})
|
||||
/**
|
||||
* 관리자용 회원 상세 조회
|
||||
* @param {string} memberId - 회원 ID
|
||||
* @returns {Promise<Object | null>} 회원 상세
|
||||
*/
|
||||
export const getMemberForAdmin = async (memberId) => {
|
||||
const sql = requireSql()
|
||||
const rows = await sql`
|
||||
SELECT
|
||||
users.id,
|
||||
users.username,
|
||||
users.email,
|
||||
users.avatar_url AS "avatarUrl",
|
||||
users.member_labels AS "labels",
|
||||
users.member_note AS "note",
|
||||
users.is_admin AS "isAdmin",
|
||||
users.user_role AS "roleCode",
|
||||
users.created_at AS "createdAt",
|
||||
users.updated_at AS "updatedAt",
|
||||
users.last_seen_at AS "lastSeenAt",
|
||||
users.last_seen_ip AS "lastSeenIp",
|
||||
users.previous_last_seen_at AS "previousLastSeenAt",
|
||||
users.previous_last_seen_ip AS "previousLastSeenIp",
|
||||
COALESCE(count(comments.id), 0)::int AS "commentCount"
|
||||
FROM users
|
||||
LEFT JOIN comments ON comments.user_id = users.id AND comments.status = 'published'
|
||||
WHERE users.id = ${memberId}
|
||||
GROUP BY users.id
|
||||
LIMIT 1
|
||||
`
|
||||
|
||||
return rows?.[0] ? mapAdminMemberRow(rows[0]) : null
|
||||
}
|
||||
|
||||
/**
|
||||
* 관리자 화면에서 회원을 생성한다.
|
||||
* @param {{ username: string, email: string, passwordHash: string, avatarUrl: string, labels: string[], note: string }} input - 생성 값
|
||||
* @returns {Promise<Object>} 생성된 회원
|
||||
*/
|
||||
export const createMemberByAdmin = async (input) => {
|
||||
const sql = requireSql()
|
||||
const rows = await sql`
|
||||
INSERT INTO users (
|
||||
username,
|
||||
email,
|
||||
password_hash,
|
||||
avatar_url,
|
||||
member_labels,
|
||||
member_note,
|
||||
is_admin,
|
||||
user_role
|
||||
)
|
||||
VALUES (
|
||||
${input.username},
|
||||
${input.email},
|
||||
${input.passwordHash},
|
||||
${input.avatarUrl},
|
||||
${input.labels},
|
||||
${input.note},
|
||||
false,
|
||||
${MEMBER_ROLE.MEMBER}
|
||||
)
|
||||
RETURNING id
|
||||
`
|
||||
|
||||
const created = rows?.[0]
|
||||
if (!created) {
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
message: '회원 생성에 실패했습니다.'
|
||||
})
|
||||
}
|
||||
|
||||
return getMemberForAdmin(created.id)
|
||||
}
|
||||
|
||||
/**
|
||||
* 관리자 화면에서 회원 기본 정보를 수정한다.
|
||||
* @param {{ memberId: string, username: string, email: string, avatarUrl: string, labels: string[], note: string }} input - 수정 값
|
||||
* @returns {Promise<Object | null>} 수정된 회원
|
||||
*/
|
||||
export const updateMemberByAdmin = async (input) => {
|
||||
const sql = requireSql()
|
||||
const rows = await sql`
|
||||
UPDATE users
|
||||
SET
|
||||
username = ${input.username},
|
||||
email = ${input.email},
|
||||
avatar_url = ${input.avatarUrl},
|
||||
member_labels = ${input.labels},
|
||||
member_note = ${input.note},
|
||||
updated_at = now()
|
||||
WHERE id = ${input.memberId}
|
||||
RETURNING id
|
||||
`
|
||||
|
||||
return rows?.[0] ? getMemberForAdmin(rows[0].id) : null
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -340,7 +604,9 @@ export const getAdminUserByEmail = async (email) => {
|
||||
created_at AS "createdAt",
|
||||
updated_at AS "updatedAt",
|
||||
last_seen_at AS "lastSeenAt",
|
||||
last_seen_ip AS "lastSeenIp"
|
||||
last_seen_ip AS "lastSeenIp",
|
||||
previous_last_seen_at AS "previousLastSeenAt",
|
||||
previous_last_seen_ip AS "previousLastSeenIp"
|
||||
FROM users
|
||||
WHERE lower(email) = lower(${email})
|
||||
AND user_role = ANY(${PRIVILEGED_ROLES})
|
||||
@@ -481,4 +747,3 @@ export const updateMemberRoleByAdmin = async (input) => {
|
||||
isAdmin: Boolean(updated.isAdmin)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,12 @@ import postgres from 'postgres'
|
||||
|
||||
let client = null
|
||||
|
||||
/**
|
||||
* 현재 실행 환경이 운영인지 확인한다.
|
||||
* @returns {boolean} 운영 환경 여부
|
||||
*/
|
||||
const isProductionRuntime = () => process.env.NODE_ENV === 'production'
|
||||
|
||||
/**
|
||||
* PostgreSQL 클라이언트 조회
|
||||
* @returns {ReturnType<typeof postgres> | null} PostgreSQL 클라이언트
|
||||
@@ -10,6 +16,10 @@ export const getPostgresClient = () => {
|
||||
const config = useRuntimeConfig()
|
||||
|
||||
if (!config.databaseUrl) {
|
||||
if (isProductionRuntime()) {
|
||||
throw new Error('DATABASE_URL_REQUIRED')
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { z } from 'zod'
|
||||
import { createError, readBody } from 'h3'
|
||||
import { createError, getRequestIP, readBody } from 'h3'
|
||||
import bcrypt from 'bcrypt'
|
||||
import { setAdminSession } from '../../../../utils/admin-auth'
|
||||
import { getAdminUserByEmail } from '../../../../repositories/member-repository'
|
||||
import { getAdminUserByEmail, touchUserActivity } from '../../../../repositories/member-repository'
|
||||
import { setMemberSession } from '../../../../utils/member-auth'
|
||||
|
||||
const loginSchema = z.object({
|
||||
@@ -47,6 +47,10 @@ export default defineEventHandler(async (event) => {
|
||||
userId: adminUser.id,
|
||||
email: adminUser.email
|
||||
})
|
||||
await touchUserActivity({
|
||||
userId: adminUser.id,
|
||||
ip: String(getRequestIP(event) || '')
|
||||
})
|
||||
|
||||
return {
|
||||
userId: adminUser.id,
|
||||
|
||||
59
server/routes/admin/api/members.post.js
Normal file
59
server/routes/admin/api/members.post.js
Normal file
@@ -0,0 +1,59 @@
|
||||
import { randomBytes } from 'node:crypto'
|
||||
import bcrypt from 'bcrypt'
|
||||
import { createError, readBody } from 'h3'
|
||||
import { z } from 'zod'
|
||||
import { requireAdminSession } from '../../../utils/admin-auth'
|
||||
import { createMemberByAdmin, isEmailTaken, isUsernameTaken } from '../../../repositories/member-repository'
|
||||
|
||||
const memberInputSchema = z.object({
|
||||
username: z.string().trim().min(1).max(60),
|
||||
email: z.string().trim().email().max(254),
|
||||
avatarUrl: z.string().trim().max(500).optional().default(''),
|
||||
labels: z.array(z.string().trim().min(1).max(40)).max(20).optional().default([]),
|
||||
note: z.string().max(500).optional().default('')
|
||||
})
|
||||
|
||||
/**
|
||||
* 관리자 회원 생성 API
|
||||
* @param {import('h3').H3Event} event - 요청 이벤트
|
||||
* @returns {Promise<Object>} 생성된 회원
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
requireAdminSession(event)
|
||||
const parsedBody = memberInputSchema.safeParse(await readBody(event))
|
||||
|
||||
if (!parsedBody.success) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '회원 생성 요청 형식이 올바르지 않습니다.'
|
||||
})
|
||||
}
|
||||
|
||||
const body = parsedBody.data
|
||||
const email = body.email.toLowerCase()
|
||||
|
||||
if (await isUsernameTaken({ username: body.username })) {
|
||||
throw createError({
|
||||
statusCode: 409,
|
||||
message: '이미 사용 중인 이름입니다.'
|
||||
})
|
||||
}
|
||||
|
||||
if (await isEmailTaken({ email })) {
|
||||
throw createError({
|
||||
statusCode: 409,
|
||||
message: '이미 사용 중인 이메일입니다.'
|
||||
})
|
||||
}
|
||||
|
||||
const passwordHash = await bcrypt.hash(randomBytes(32).toString('hex'), 12)
|
||||
|
||||
return createMemberByAdmin({
|
||||
username: body.username,
|
||||
email,
|
||||
passwordHash,
|
||||
avatarUrl: body.avatarUrl,
|
||||
labels: [...new Set(body.labels)],
|
||||
note: body.note
|
||||
})
|
||||
})
|
||||
32
server/routes/admin/api/members/[id].delete.js
Normal file
32
server/routes/admin/api/members/[id].delete.js
Normal file
@@ -0,0 +1,32 @@
|
||||
import { createError, getRouterParam } from 'h3'
|
||||
import { requireAdminSession } from '../../../../utils/admin-auth'
|
||||
import { deleteMemberByAdmin } from '../../../../repositories/member-repository'
|
||||
import { removeManagedAvatarAsset } from '../../../../utils/member-avatar'
|
||||
|
||||
/**
|
||||
* 관리자 회원 삭제 API
|
||||
* @param {import('h3').H3Event} event - 요청 이벤트
|
||||
* @returns {Promise<{ ok: true }>} 삭제 결과
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const session = requireAdminSession(event)
|
||||
const memberId = String(getRouterParam(event, 'id') || '')
|
||||
|
||||
if (!memberId) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '회원 ID가 필요합니다.'
|
||||
})
|
||||
}
|
||||
|
||||
const deletedMember = await deleteMemberByAdmin({
|
||||
actorUserId: session.userId,
|
||||
targetUserId: memberId
|
||||
})
|
||||
|
||||
if (deletedMember.avatarUrl) {
|
||||
await removeManagedAvatarAsset(deletedMember.avatarUrl)
|
||||
}
|
||||
|
||||
return { ok: true }
|
||||
})
|
||||
30
server/routes/admin/api/members/[id].get.js
Normal file
30
server/routes/admin/api/members/[id].get.js
Normal file
@@ -0,0 +1,30 @@
|
||||
import { createError, getRouterParam } from 'h3'
|
||||
import { requireAdminSession } from '../../../../utils/admin-auth'
|
||||
import { getMemberForAdmin } from '../../../../repositories/member-repository'
|
||||
|
||||
/**
|
||||
* 관리자 회원 상세 조회 API
|
||||
* @param {import('h3').H3Event} event - 요청 이벤트
|
||||
* @returns {Promise<Object>} 회원 상세
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
requireAdminSession(event)
|
||||
const memberId = String(getRouterParam(event, 'id') || '')
|
||||
|
||||
if (!memberId) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '회원 ID가 필요합니다.'
|
||||
})
|
||||
}
|
||||
|
||||
const member = await getMemberForAdmin(memberId)
|
||||
if (!member) {
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
message: '회원을 찾을 수 없습니다.'
|
||||
})
|
||||
}
|
||||
|
||||
return member
|
||||
})
|
||||
80
server/routes/admin/api/members/[id].put.js
Normal file
80
server/routes/admin/api/members/[id].put.js
Normal file
@@ -0,0 +1,80 @@
|
||||
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'
|
||||
|
||||
const memberInputSchema = z.object({
|
||||
username: z.string().trim().min(1).max(60),
|
||||
email: z.string().trim().email().max(254),
|
||||
avatarUrl: z.string().trim().max(500).optional().default(''),
|
||||
labels: z.array(z.string().trim().min(1).max(40)).max(20).optional().default([]),
|
||||
note: z.string().max(500).optional().default('')
|
||||
})
|
||||
|
||||
/**
|
||||
* 관리자 회원 기본 정보 수정 API
|
||||
* @param {import('h3').H3Event} event - 요청 이벤트
|
||||
* @returns {Promise<Object>} 수정된 회원
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
requireAdminSession(event)
|
||||
const memberId = String(getRouterParam(event, 'id') || '')
|
||||
const parsedBody = memberInputSchema.safeParse(await readBody(event))
|
||||
|
||||
if (!memberId) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '회원 ID가 필요합니다.'
|
||||
})
|
||||
}
|
||||
|
||||
if (!parsedBody.success) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '회원 수정 요청 형식이 올바르지 않습니다.'
|
||||
})
|
||||
}
|
||||
|
||||
const existing = await getMemberForAdmin(memberId)
|
||||
if (!existing) {
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
message: '회원을 찾을 수 없습니다.'
|
||||
})
|
||||
}
|
||||
|
||||
const body = parsedBody.data
|
||||
const email = body.email.toLowerCase()
|
||||
|
||||
if (await isUsernameTaken({ username: body.username, excludeUserId: memberId })) {
|
||||
throw createError({
|
||||
statusCode: 409,
|
||||
message: '이미 사용 중인 이름입니다.'
|
||||
})
|
||||
}
|
||||
|
||||
if (await isEmailTaken({ email, excludeUserId: memberId })) {
|
||||
throw createError({
|
||||
statusCode: 409,
|
||||
message: '이미 사용 중인 이메일입니다.'
|
||||
})
|
||||
}
|
||||
|
||||
const updated = await updateMemberByAdmin({
|
||||
memberId,
|
||||
username: body.username,
|
||||
email,
|
||||
avatarUrl: body.avatarUrl,
|
||||
labels: [...new Set(body.labels)],
|
||||
note: body.note
|
||||
})
|
||||
|
||||
if (!updated) {
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
message: '회원 수정에 실패했습니다.'
|
||||
})
|
||||
}
|
||||
|
||||
return updated
|
||||
})
|
||||
50
server/routes/admin/api/members/[id]/password.put.js
Normal file
50
server/routes/admin/api/members/[id]/password.put.js
Normal file
@@ -0,0 +1,50 @@
|
||||
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'
|
||||
|
||||
const adminMemberPasswordSchema = z.object({
|
||||
password: z.string().min(8).max(32)
|
||||
})
|
||||
|
||||
/**
|
||||
* 관리자 회원 비밀번호 직접 변경 API
|
||||
* @param {import('h3').H3Event} event - 요청 이벤트
|
||||
* @returns {Promise<{ ok: true }>} 변경 결과
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
requireAdminSession(event)
|
||||
const memberId = String(getRouterParam(event, 'id') || '')
|
||||
const parsedBody = adminMemberPasswordSchema.safeParse(await readBody(event))
|
||||
|
||||
if (!memberId) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '회원 ID가 필요합니다.'
|
||||
})
|
||||
}
|
||||
|
||||
if (!parsedBody.success) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '비밀번호 변경 요청 형식이 올바르지 않습니다.'
|
||||
})
|
||||
}
|
||||
|
||||
const member = await getMemberForAdmin(memberId)
|
||||
if (!member) {
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
message: '회원을 찾을 수 없습니다.'
|
||||
})
|
||||
}
|
||||
|
||||
const passwordHash = await bcrypt.hash(parsedBody.data.password, 12)
|
||||
await updateMemberPassword({
|
||||
userId: memberId,
|
||||
passwordHash
|
||||
})
|
||||
|
||||
return { ok: true }
|
||||
})
|
||||
118
server/routes/admin/api/settings/logo.post.js
Normal file
118
server/routes/admin/api/settings/logo.post.js
Normal file
@@ -0,0 +1,118 @@
|
||||
import { mkdir, writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { createError, readMultipartFormData } from 'h3'
|
||||
import sharp from 'sharp'
|
||||
import { requireAdminSession } from '../../../../utils/admin-auth'
|
||||
import { updateSiteLogo } from '../../../../repositories/content-repository'
|
||||
import { upsertMediaMetadataCategory } from '../../../../utils/media-library'
|
||||
|
||||
const allowedImageTypes = new Set(['image/jpeg', 'image/png', 'image/webp'])
|
||||
|
||||
/**
|
||||
* 숫자 설정값을 최소/최대 범위로 보정한다.
|
||||
* @param {number} value - 원본 값
|
||||
* @param {number} minimum - 최소값
|
||||
* @param {number} maximum - 최대값
|
||||
* @returns {number} 보정된 값
|
||||
*/
|
||||
const clampNumber = (value, minimum, maximum) => {
|
||||
if (!Number.isFinite(value)) {
|
||||
return minimum
|
||||
}
|
||||
|
||||
if (value < minimum) {
|
||||
return minimum
|
||||
}
|
||||
|
||||
if (value > maximum) {
|
||||
return maximum
|
||||
}
|
||||
|
||||
return Math.round(value)
|
||||
}
|
||||
|
||||
/**
|
||||
* 사이트 로고 업로드 API
|
||||
* @param {import('h3').H3Event} event - 요청 이벤트
|
||||
* @returns {Promise<Object>} 수정된 사이트 설정
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
requireAdminSession(event)
|
||||
|
||||
const config = useRuntimeConfig()
|
||||
const maxFileSize = Number(config.maxFileSize || 10485760)
|
||||
const logoSize = clampNumber(Number(config.siteLogoSize || 512), 128, 2048)
|
||||
const formData = await readMultipartFormData(event)
|
||||
const file = (formData || []).find((part) => part.name === 'file' && part.filename)
|
||||
|
||||
if (!file) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '업로드할 로고 이미지가 없습니다.'
|
||||
})
|
||||
}
|
||||
|
||||
if (!allowedImageTypes.has(file.type)) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: 'JPG, PNG, WebP 이미지만 로고로 사용할 수 있습니다.'
|
||||
})
|
||||
}
|
||||
|
||||
if (file.data.length > maxFileSize) {
|
||||
throw createError({
|
||||
statusCode: 413,
|
||||
message: '업로드 가능한 파일 크기를 초과했습니다.'
|
||||
})
|
||||
}
|
||||
|
||||
const metadata = await sharp(file.data).metadata()
|
||||
if (!metadata.width || !metadata.height) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '이미지 메타데이터를 읽을 수 없습니다.'
|
||||
})
|
||||
}
|
||||
|
||||
const uploadBaseUrl = String(config.uploadDir || '/uploads').replace(/\/+$/g, '') || '/uploads'
|
||||
const publicBasePath = uploadBaseUrl.replace(/^\/+/, '')
|
||||
const directoryPath = join(process.cwd(), 'public', publicBasePath, 'system')
|
||||
const logoPath = join(directoryPath, 'logo.webp')
|
||||
const faviconPath = join(directoryPath, 'favicon.png')
|
||||
const logoUrl = `${uploadBaseUrl}/system/logo.webp`
|
||||
const faviconUrl = `${uploadBaseUrl}/system/favicon.png`
|
||||
|
||||
await mkdir(directoryPath, { recursive: true })
|
||||
|
||||
const logoBuffer = await sharp(file.data)
|
||||
.rotate()
|
||||
.resize({
|
||||
width: logoSize,
|
||||
height: logoSize,
|
||||
fit: 'cover',
|
||||
position: 'centre'
|
||||
})
|
||||
.webp({ quality: 90 })
|
||||
.toBuffer()
|
||||
|
||||
const faviconBuffer = await sharp(file.data)
|
||||
.rotate()
|
||||
.resize({
|
||||
width: 256,
|
||||
height: 256,
|
||||
fit: 'cover',
|
||||
position: 'centre'
|
||||
})
|
||||
.png()
|
||||
.toBuffer()
|
||||
|
||||
await writeFile(logoPath, logoBuffer)
|
||||
await writeFile(faviconPath, faviconBuffer)
|
||||
await upsertMediaMetadataCategory(logoUrl, '시스템')
|
||||
await upsertMediaMetadataCategory(faviconUrl, '시스템')
|
||||
|
||||
return updateSiteLogo({
|
||||
logoUrl,
|
||||
faviconUrl
|
||||
})
|
||||
})
|
||||
@@ -2,9 +2,11 @@ import { z } from 'zod'
|
||||
|
||||
export const adminSiteSettingsInputSchema = z.object({
|
||||
title: z.string().trim().min(1),
|
||||
description: z.string().trim().default(''),
|
||||
description: z.string().trim().min(1),
|
||||
siteUrl: z.string().trim().url(),
|
||||
logoText: z.string().trim().min(1).max(8),
|
||||
logoText: z.string().trim().max(8).optional().default('井'),
|
||||
logoUrl: z.string().trim().max(500).optional().default(''),
|
||||
faviconUrl: z.string().trim().max(500).optional().default(''),
|
||||
copyrightText: z.string().trim().min(1)
|
||||
})
|
||||
|
||||
|
||||
43
server/utils/email-otp.js
Normal file
43
server/utils/email-otp.js
Normal file
@@ -0,0 +1,43 @@
|
||||
import { createHash, randomInt, timingSafeEqual } from 'node:crypto'
|
||||
|
||||
/**
|
||||
* OTP용 이메일 정규화(소문자·trim)
|
||||
* @param {string} email - 원본
|
||||
* @returns {string}
|
||||
*/
|
||||
export const normalizeOtpEmail = (email) => String(email || '').trim().toLowerCase()
|
||||
|
||||
/**
|
||||
* 6자리 숫자 인증 코드 생성
|
||||
* @returns {string}
|
||||
*/
|
||||
export const generateSixDigitOtp = () => String(randomInt(0, 1_000_000)).padStart(6, '0')
|
||||
|
||||
/**
|
||||
* OTP 코드 해시(hex)
|
||||
* @param {{ pepper: string, email: string, purpose: string, code: string }} input - 입력
|
||||
* @returns {string} sha256 hex
|
||||
*/
|
||||
export const hashOtpCode = (input) => {
|
||||
const payload = `${String(input.pepper || '')}|${normalizeOtpEmail(input.email)}|${String(input.purpose || '')}|${String(input.code || '')}`
|
||||
return createHash('sha256').update(payload, 'utf8').digest('hex')
|
||||
}
|
||||
|
||||
/**
|
||||
* 두 sha256 hex 문자열을 상수 시간으로 비교한다.
|
||||
* @param {string} a - hex
|
||||
* @param {string} b - hex
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export const timingSafeEqualHex = (a, b) => {
|
||||
try {
|
||||
const ba = Buffer.from(String(a || ''), 'hex')
|
||||
const bb = Buffer.from(String(b || ''), 'hex')
|
||||
if (ba.length !== bb.length || ba.length === 0) {
|
||||
return false
|
||||
}
|
||||
return timingSafeEqual(ba, bb)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -10,13 +10,12 @@ const sessionMaxAge = 60 * 60 * 24 * 14
|
||||
*/
|
||||
const getSessionSecret = () => {
|
||||
const config = useRuntimeConfig()
|
||||
const fallbackSecret = String(config.adminPassword || '')
|
||||
const sessionSecret = String(config.memberSessionSecret || '').trim() || fallbackSecret
|
||||
const sessionSecret = String(config.memberSessionSecret || '').trim()
|
||||
|
||||
if (!sessionSecret) {
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
message: '회원 세션 비밀값 환경 변수가 없습니다. (MEMBER_SESSION_SECRET 또는 ADMIN_PASSWORD)'
|
||||
message: '회원 세션 비밀값 환경 변수가 없습니다. MEMBER_SESSION_SECRET을 설정해 주세요.'
|
||||
})
|
||||
}
|
||||
|
||||
@@ -149,4 +148,3 @@ export const requireMemberSession = (event) => {
|
||||
|
||||
return session
|
||||
}
|
||||
|
||||
|
||||
@@ -162,11 +162,38 @@ export const renumberSortOrderByTree = (items, location) => {
|
||||
|
||||
/**
|
||||
* 공개 API용 primary 트리(순환 참조 없음).
|
||||
* 동일 id가 평면 목록에 중복되거나(시드·이전 마이그레이션 등), 한 행은 루트·다른 행은 자식으로만 잡히면
|
||||
* 기존 한 루프 방식으로는 같은 노드가 roots와 children에 동시에 들어갈 수 있어, 자식으로 연결된 id는 루트에서 제외한다.
|
||||
* @param {Array<Object>} flatPrimary - location primary인 항목만
|
||||
* @returns {Array<Object>}
|
||||
*/
|
||||
export const buildPublicPrimaryTree = (flatPrimary) => {
|
||||
const list = flatPrimary.map((row) => ({
|
||||
const sorted = [...(flatPrimary || [])].sort((a, b) => {
|
||||
const sa = a.sortOrder || 0
|
||||
const sb = b.sortOrder || 0
|
||||
if (sa !== sb) {
|
||||
return sa - sb
|
||||
}
|
||||
const la = String(a.label || '')
|
||||
const lb = String(b.label || '')
|
||||
if (la !== lb) {
|
||||
return la.localeCompare(lb)
|
||||
}
|
||||
return String(a.id).localeCompare(String(b.id))
|
||||
})
|
||||
|
||||
const idSeen = new Set()
|
||||
const deduped = []
|
||||
for (const row of sorted) {
|
||||
const id = String(row.id)
|
||||
if (idSeen.has(id)) {
|
||||
continue
|
||||
}
|
||||
idSeen.add(id)
|
||||
deduped.push(row)
|
||||
}
|
||||
|
||||
const list = deduped.map((row) => ({
|
||||
id: row.id,
|
||||
label: row.label,
|
||||
url: row.url,
|
||||
@@ -177,16 +204,24 @@ export const buildPublicPrimaryTree = (flatPrimary) => {
|
||||
}))
|
||||
|
||||
const byId = new Map(list.map((i) => [String(i.id), { ...i, children: [] }]))
|
||||
const roots = []
|
||||
const attachedAsChild = new Set()
|
||||
|
||||
for (const row of list) {
|
||||
const id = String(row.id)
|
||||
const node = byId.get(id)
|
||||
const p = row.parentId
|
||||
if (p && byId.has(String(p))) {
|
||||
byId.get(String(p)).children.push(node)
|
||||
} else {
|
||||
roots.push(node)
|
||||
const pid = p != null && String(p).trim() !== '' ? String(p).trim() : ''
|
||||
if (pid && pid !== id && byId.has(pid)) {
|
||||
byId.get(pid).children.push(node)
|
||||
attachedAsChild.add(id)
|
||||
}
|
||||
}
|
||||
|
||||
const roots = []
|
||||
for (const row of list) {
|
||||
const id = String(row.id)
|
||||
if (!attachedAsChild.has(id)) {
|
||||
roots.push(byId.get(id))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
42
server/utils/resend-mail.js
Normal file
42
server/utils/resend-mail.js
Normal file
@@ -0,0 +1,42 @@
|
||||
import { createError } from 'h3'
|
||||
|
||||
/**
|
||||
* Resend가 서버 설정으로 사용 가능한지
|
||||
* @param {{ resendApiKey?: string, resendFromEmail?: string }} config - 런타임 설정
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export const isResendConfigured = (config) => {
|
||||
const key = String(config?.resendApiKey || '').trim()
|
||||
const from = String(config?.resendFromEmail || '').trim()
|
||||
return Boolean(key && from)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resend REST API로 이메일을 발송한다.
|
||||
* @param {{ apiKey: string, from: string, to: string, subject: string, html: string }} input - 발송 입력
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export const sendResendEmail = async (input) => {
|
||||
const res = await fetch('https://api.resend.com/emails', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${input.apiKey}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
from: input.from,
|
||||
to: [input.to],
|
||||
subject: input.subject,
|
||||
html: input.html
|
||||
})
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const detail = await res.text().catch(() => '')
|
||||
throw createError({
|
||||
statusCode: 502,
|
||||
message: '이메일 발송에 실패했습니다. Resend 발신 주소·도메인 설정을 확인해 주세요.',
|
||||
data: process.env.NODE_ENV === 'development' && detail ? { detail } : undefined
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,8 @@ export const getDefaultSiteSettings = () => {
|
||||
description: 'sori.studio 개인 블로그',
|
||||
siteUrl: config.public.siteUrl || 'https://sori.studio',
|
||||
logoText: '井',
|
||||
logoUrl: '',
|
||||
faviconUrl: '',
|
||||
copyrightText: `©${new Date().getFullYear()} ${title}`,
|
||||
updatedAt: null
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user