Compare commits
74 Commits
2f7ce64391
...
v1.1.9
| Author | SHA1 | Date | |
|---|---|---|---|
| 2768975752 | |||
| 59a50a0c97 | |||
| b4e4e37f5a | |||
| 536ee7079e | |||
| 9e544d97fa | |||
| 20b901d4a1 | |||
| 0ed848a2eb | |||
| 08f0aa0efa | |||
| 17dcd04339 | |||
| 36625de1eb | |||
| 62e501f8d0 | |||
| 849e86802f | |||
| 5da93b9aa4 | |||
| 113c974ee5 | |||
| f5bfb560e2 | |||
| 941355cae9 | |||
| 91b7369a07 | |||
| 5eb6c88381 | |||
| eab81697e5 | |||
| 88a0860078 | |||
| 35c378c8f5 | |||
| bd0e2ad120 | |||
| 1b035de16c | |||
| 4862b52b3a | |||
| 6367e62ef0 | |||
| 1487e9da76 | |||
| 3b331b8fe6 | |||
| 069d1bfbd4 | |||
| 965a8fd1f6 | |||
| 020471a1b8 | |||
| 52f22b4ff1 | |||
| bebf7ee1c9 | |||
| 6481f958f5 | |||
| 79d0a30475 | |||
| fb0dadb7b9 | |||
| b4f3fdb77d | |||
| 6cb6268b43 | |||
| b490d5b90f | |||
| ec9f9ea57f | |||
| 6e25cdfd60 | |||
| 5031b9de22 | |||
| 003fb86fad | |||
| 6a059a9a59 | |||
| 996965740f | |||
| 4e1056311d | |||
| 8b8a80034d | |||
| bcff96aa4c | |||
| 4de5589bcb | |||
| c1242e1409 | |||
| 16bb9370fa | |||
| 21024602b0 | |||
| 05176609ee | |||
| 9974e0d137 | |||
| 1d9a3e4527 | |||
| 79fb354d91 | |||
| bd71ca860c | |||
| cdc16c72b2 | |||
| b18aca4dcc | |||
| 080f76799a | |||
| a314c96c4d | |||
| ede272e7b1 | |||
| 65af30724c | |||
| eab800b6c1 | |||
| 6e8ca97779 | |||
| f5cd73b223 | |||
| 91573a31d6 | |||
| 2cb5c1a281 | |||
| 1b00dac21c | |||
| ff6526c997 | |||
| bcf3acd432 | |||
| 5141a63294 | |||
| 3f7f51ff86 | |||
| fd55d8af08 | |||
| ed7709ab59 |
12
.env.example
12
.env.example
@@ -9,14 +9,26 @@ DB_PORT=43119
|
||||
# Auth
|
||||
ADMIN_EMAIL=admin@example.com
|
||||
ADMIN_PASSWORD=replace-with-random-password
|
||||
MEMBER_SESSION_SECRET=replace-with-random-password
|
||||
|
||||
# Upload
|
||||
UPLOAD_DIR=/uploads
|
||||
MAX_FILE_SIZE=10485760
|
||||
AVATAR_MIN_WIDTH=96
|
||||
AVATAR_MIN_HEIGHT=96
|
||||
AVATAR_MAX_WIDTH=512
|
||||
AVATAR_MAX_HEIGHT=512
|
||||
AVATAR_WEBP_QUALITY=82
|
||||
|
||||
# Site
|
||||
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
|
||||
DOCKER_SUBNET=10.250.50.0/24
|
||||
|
||||
@@ -122,6 +122,7 @@
|
||||
- 작업이 끝나 변경사항을 커밋할 때마다 버전을 증가시킨다.
|
||||
- 버전은 `v0.0.1`, `v0.0.2`처럼 `v` 접두사를 포함해 순차 증가시킨다.
|
||||
- 원격 저장소에 푸시하기 전 민감 정보 포함 여부를 반드시 확인한다.
|
||||
- 커밋까지 완료한 작업은 사용자가 중단을 요청하지 않는 한 `git push`로 원격에 반영해 푸시 누락을 피한다.
|
||||
|
||||
민감 정보 예시:
|
||||
- 실명
|
||||
|
||||
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 />
|
||||
|
||||
@@ -60,6 +60,10 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
html.site-search-open {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
body {
|
||||
min-width: 320px;
|
||||
margin: 0;
|
||||
@@ -75,6 +79,18 @@
|
||||
}
|
||||
|
||||
@layer components {
|
||||
@keyframes site-search-modal-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-6px) scale(0.99);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.site-shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -96,6 +112,10 @@
|
||||
@apply px-6 py-4;
|
||||
}
|
||||
|
||||
.site-search-modal__panel--animate {
|
||||
animation: site-search-modal-in 0.18s ease-out;
|
||||
}
|
||||
|
||||
.post-prose {
|
||||
@apply max-w-none text-[17px] leading-8;
|
||||
color: var(--site-text);
|
||||
@@ -196,4 +216,44 @@
|
||||
background: color-mix(in srgb, var(--site-panel) 72%, var(--site-text));
|
||||
}
|
||||
|
||||
/**
|
||||
* 왼쪽 사이드바 1차 네비·태그 카테고리·테마 점 등 행 호버 — 라이트 테마에서 밝은 크림 톤, 다크는 패널 대비 유지
|
||||
*/
|
||||
.site-sidebar-nav-row {
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.site-sidebar-nav-row:hover {
|
||||
background-color: #f7f4ef;
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] .site-sidebar-nav-row:hover {
|
||||
background: color-mix(in srgb, var(--site-panel) 72%, var(--site-text));
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-theme='light']) .site-sidebar-nav-row:hover {
|
||||
background: color-mix(in srgb, var(--site-panel) 72%, var(--site-text));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 다크 인증 폼(signin/signup) 텍스트 입력 — UA가 부모 color를 상속하지 않는 경우 대비
|
||||
*/
|
||||
.auth-form-input {
|
||||
color: #f5f7fa;
|
||||
caret-color: #2f6feb;
|
||||
}
|
||||
|
||||
.auth-form-input::placeholder {
|
||||
color: #5c6570;
|
||||
}
|
||||
|
||||
.auth-form-input:-webkit-autofill,
|
||||
.auth-form-input:-webkit-autofill:hover,
|
||||
.auth-form-input:-webkit-autofill:focus {
|
||||
-webkit-text-fill-color: #f5f7fa;
|
||||
transition: background-color 9999s ease-out;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
1287
components/admin/AdminMarkdownEditor.vue
Normal file
1287
components/admin/AdminMarkdownEditor.vue
Normal file
File diff suppressed because it is too large
Load Diff
657
components/admin/AdminMemberForm.vue
Normal file
657
components/admin/AdminMemberForm.vue
Normal file
@@ -0,0 +1,657 @@
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
member: {
|
||||
type: Object,
|
||||
default: () => null
|
||||
},
|
||||
mode: {
|
||||
type: String,
|
||||
default: 'edit'
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['saved', 'deleted'])
|
||||
|
||||
const isNewMember = computed(() => props.mode === 'new')
|
||||
const saveMessage = ref('')
|
||||
const saveError = ref('')
|
||||
const isSaving = ref(false)
|
||||
const savedMemberSnapshot = ref('')
|
||||
const avatarInputRef = ref(null)
|
||||
const isUploadingAvatar = ref(false)
|
||||
const actionMenuOpen = ref(false)
|
||||
const passwordModalOpen = ref(false)
|
||||
const deleteModalOpen = ref(false)
|
||||
const isUpdatingPassword = ref(false)
|
||||
const isDeletingMember = ref(false)
|
||||
const actionMessage = ref('')
|
||||
const actionError = ref('')
|
||||
|
||||
const form = reactive({
|
||||
username: '',
|
||||
email: '',
|
||||
avatarUrl: '',
|
||||
labelsText: '',
|
||||
note: ''
|
||||
})
|
||||
|
||||
const passwordForm = reactive({
|
||||
password: '',
|
||||
passwordConfirm: ''
|
||||
})
|
||||
|
||||
const deleteForm = reactive({
|
||||
confirmText: ''
|
||||
})
|
||||
|
||||
/**
|
||||
* 회원 폼 값을 현재 회원 정보로 동기화한다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const syncMemberForm = () => {
|
||||
const member = props.member || {}
|
||||
form.username = member.username || ''
|
||||
form.email = member.email || ''
|
||||
form.avatarUrl = member.avatarUrl || ''
|
||||
form.labelsText = Array.isArray(member.labels) ? member.labels.join(', ') : ''
|
||||
form.note = member.note || ''
|
||||
}
|
||||
|
||||
watch(() => props.member, syncMemberForm, { immediate: true })
|
||||
|
||||
const pageTitle = computed(() => {
|
||||
if (isNewMember.value) {
|
||||
return '새 멤버'
|
||||
}
|
||||
|
||||
return form.username || props.member?.email || '멤버'
|
||||
})
|
||||
|
||||
const memberInitial = computed(() => String(form.username || form.email || '?').slice(0, 1).toUpperCase())
|
||||
const noteLength = computed(() => form.note.length)
|
||||
|
||||
const normalizedLabels = computed(() => [...new Set(
|
||||
form.labelsText
|
||||
.split(',')
|
||||
.map((label) => label.trim())
|
||||
.filter(Boolean)
|
||||
)])
|
||||
|
||||
/**
|
||||
* 회원 저장 요청 본문을 문자열로 직렬화한다.
|
||||
* @returns {string} 직렬화된 회원 입력값
|
||||
*/
|
||||
const serializeMemberPayload = () => JSON.stringify(getMemberPayload())
|
||||
|
||||
/**
|
||||
* 날짜 표시 형식 변환
|
||||
* @param {string | null} value - ISO 날짜 문자열
|
||||
* @returns {string} 화면 표시 날짜
|
||||
*/
|
||||
const formatDate = (value) => {
|
||||
if (!value) {
|
||||
return '-'
|
||||
}
|
||||
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return '-'
|
||||
}
|
||||
|
||||
const year = date.getFullYear()
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(date.getDate()).padStart(2, '0')
|
||||
|
||||
return `${year}.${month}.${day}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 최근 활동 시각을 상대 시간으로 표시한다.
|
||||
* @param {string | null} value - ISO 시각
|
||||
* @returns {string} 상대 시간
|
||||
*/
|
||||
const formatRelativeTime = (value) => {
|
||||
if (!value) {
|
||||
return '최근 활동 없음'
|
||||
}
|
||||
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return '최근 활동 없음'
|
||||
}
|
||||
|
||||
const diffMs = Date.now() - date.getTime()
|
||||
const minute = 1000 * 60
|
||||
const hour = minute * 60
|
||||
const day = hour * 24
|
||||
|
||||
if (diffMs < minute) {
|
||||
return '방금 전'
|
||||
}
|
||||
|
||||
if (diffMs < hour) {
|
||||
return `${Math.floor(diffMs / minute)}분 전`
|
||||
}
|
||||
|
||||
if (diffMs < day) {
|
||||
return `${Math.floor(diffMs / hour)}시간 전`
|
||||
}
|
||||
|
||||
if (diffMs < day * 30) {
|
||||
return `${Math.floor(diffMs / day)}일 전`
|
||||
}
|
||||
|
||||
return formatDate(value)
|
||||
}
|
||||
|
||||
/**
|
||||
* 회원 저장 요청 본문을 만든다.
|
||||
* @returns {{ username: string, email: string, avatarUrl: string, labels: string[], note: string }} 저장 본문
|
||||
*/
|
||||
const getMemberPayload = () => ({
|
||||
username: form.username.trim(),
|
||||
email: form.email.trim(),
|
||||
avatarUrl: form.avatarUrl.trim(),
|
||||
labels: normalizedLabels.value,
|
||||
note: form.note
|
||||
})
|
||||
|
||||
const hasUnsavedMemberChanges = computed(() => serializeMemberPayload() !== savedMemberSnapshot.value)
|
||||
|
||||
const {
|
||||
isUnsavedModalOpen,
|
||||
stayOnUnsavedPage,
|
||||
leaveUnsavedPage
|
||||
} = useAdminUnsavedChangesGuard(hasUnsavedMemberChanges)
|
||||
|
||||
/**
|
||||
* 썸네일 파일 선택창을 연다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const openAvatarFilePicker = () => {
|
||||
avatarInputRef.value?.click()
|
||||
}
|
||||
|
||||
/**
|
||||
* 회원 썸네일 파일을 업로드하고 폼에 반영한다.
|
||||
* @param {Event} event - 파일 선택 이벤트
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const uploadAvatar = async (event) => {
|
||||
const target = event.target instanceof HTMLInputElement ? event.target : null
|
||||
const file = target?.files?.[0]
|
||||
|
||||
if (!file || isUploadingAvatar.value) {
|
||||
return
|
||||
}
|
||||
|
||||
isUploadingAvatar.value = true
|
||||
saveError.value = ''
|
||||
saveMessage.value = ''
|
||||
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
const result = isNewMember.value
|
||||
? await $fetch('/admin/api/member-avatar', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
: await $fetch(`/admin/api/members/${props.member.id}/avatar`, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
form.avatarUrl = result.avatarUrl || ''
|
||||
|
||||
if (!isNewMember.value) {
|
||||
emit('saved', result)
|
||||
savedMemberSnapshot.value = serializeMemberPayload()
|
||||
saveMessage.value = '썸네일이 변경되었습니다.'
|
||||
}
|
||||
} catch (error) {
|
||||
saveError.value = error?.data?.message || '썸네일 업로드에 실패했습니다.'
|
||||
} finally {
|
||||
isUploadingAvatar.value = false
|
||||
if (target) {
|
||||
target.value = ''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 회원 썸네일 연결을 제거한다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const removeAvatar = () => {
|
||||
form.avatarUrl = ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 회원 작업 메뉴를 토글한다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const toggleActionMenu = () => {
|
||||
actionMenuOpen.value = !actionMenuOpen.value
|
||||
}
|
||||
|
||||
/**
|
||||
* 회원 작업 메뉴를 닫는다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const closeActionMenu = () => {
|
||||
actionMenuOpen.value = false
|
||||
}
|
||||
|
||||
/**
|
||||
* 비밀번호 변경 모달을 연다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const openPasswordModal = () => {
|
||||
passwordForm.password = ''
|
||||
passwordForm.passwordConfirm = ''
|
||||
actionMessage.value = ''
|
||||
actionError.value = ''
|
||||
passwordModalOpen.value = true
|
||||
closeActionMenu()
|
||||
}
|
||||
|
||||
/**
|
||||
* 회원 삭제 모달을 연다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const openDeleteModal = () => {
|
||||
deleteForm.confirmText = ''
|
||||
actionMessage.value = ''
|
||||
actionError.value = ''
|
||||
deleteModalOpen.value = true
|
||||
closeActionMenu()
|
||||
}
|
||||
|
||||
/**
|
||||
* 비밀번호 변경 모달을 닫는다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const closePasswordModal = () => {
|
||||
if (isUpdatingPassword.value) {
|
||||
return
|
||||
}
|
||||
|
||||
passwordModalOpen.value = false
|
||||
}
|
||||
|
||||
/**
|
||||
* 회원 삭제 모달을 닫는다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const closeDeleteModal = () => {
|
||||
if (isDeletingMember.value) {
|
||||
return
|
||||
}
|
||||
|
||||
deleteModalOpen.value = false
|
||||
}
|
||||
|
||||
/**
|
||||
* 관리자 권한으로 회원 비밀번호를 변경한다.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const updateMemberPassword = async () => {
|
||||
actionMessage.value = ''
|
||||
actionError.value = ''
|
||||
|
||||
if (!passwordForm.password || passwordForm.password.length < 8 || passwordForm.password.length > 32) {
|
||||
actionError.value = '새 비밀번호는 8~32자로 입력해 주세요.'
|
||||
return
|
||||
}
|
||||
|
||||
if (passwordForm.password !== passwordForm.passwordConfirm) {
|
||||
actionError.value = '새 비밀번호와 확인 값이 일치하지 않습니다.'
|
||||
return
|
||||
}
|
||||
|
||||
isUpdatingPassword.value = true
|
||||
try {
|
||||
await $fetch(`/admin/api/members/${props.member.id}/password`, {
|
||||
method: 'PUT',
|
||||
body: {
|
||||
password: passwordForm.password
|
||||
}
|
||||
})
|
||||
passwordModalOpen.value = false
|
||||
passwordForm.password = ''
|
||||
passwordForm.passwordConfirm = ''
|
||||
actionMessage.value = '비밀번호가 변경되었습니다.'
|
||||
} catch (error) {
|
||||
actionError.value = error?.data?.message || '비밀번호 변경에 실패했습니다.'
|
||||
} finally {
|
||||
isUpdatingPassword.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 관리자 권한으로 회원을 삭제한다.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const deleteMember = async () => {
|
||||
actionMessage.value = ''
|
||||
actionError.value = ''
|
||||
|
||||
if (deleteForm.confirmText !== form.email) {
|
||||
actionError.value = '삭제하려면 회원 이메일을 정확히 입력해 주세요.'
|
||||
return
|
||||
}
|
||||
|
||||
isDeletingMember.value = true
|
||||
try {
|
||||
await $fetch(`/admin/api/members/${props.member.id}`, {
|
||||
method: 'DELETE'
|
||||
})
|
||||
emit('deleted')
|
||||
} catch (error) {
|
||||
actionError.value = error?.data?.message || '회원 삭제에 실패했습니다.'
|
||||
} finally {
|
||||
isDeletingMember.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 회원 기본 정보를 저장한다.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const saveMember = async () => {
|
||||
if (isSaving.value) {
|
||||
return
|
||||
}
|
||||
|
||||
saveMessage.value = ''
|
||||
saveError.value = ''
|
||||
isSaving.value = true
|
||||
|
||||
try {
|
||||
const payload = getMemberPayload()
|
||||
const saved = isNewMember.value
|
||||
? await $fetch('/admin/api/members', {
|
||||
method: 'POST',
|
||||
body: payload
|
||||
})
|
||||
: await $fetch(`/admin/api/members/${props.member.id}`, {
|
||||
method: 'PUT',
|
||||
body: payload
|
||||
})
|
||||
|
||||
savedMemberSnapshot.value = serializeMemberPayload()
|
||||
emit('saved', saved)
|
||||
saveMessage.value = '저장되었습니다.'
|
||||
} catch (error) {
|
||||
saveError.value = error?.data?.message || '저장에 실패했습니다.'
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => props.member, () => {
|
||||
savedMemberSnapshot.value = serializeMemberPayload()
|
||||
}, { immediate: true, flush: 'post' })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="admin-member-form bg-paper p-6">
|
||||
<div class="admin-member-form__header sticky top-0 z-10 -mx-6 -mt-6 border-b border-line bg-paper/95 px-6 py-5 backdrop-blur">
|
||||
<div class="admin-member-form__header-inner flex flex-col gap-4 xl:flex-row xl:items-center xl:justify-between">
|
||||
<div class="admin-member-form__title-block">
|
||||
<div class="admin-member-form__breadcrumb flex items-center gap-2 text-sm text-[#8a95a5]">
|
||||
<NuxtLink class="admin-member-form__breadcrumb-link text-[#3f4650] hover:text-[#15171a]" to="/admin/members">
|
||||
멤버
|
||||
</NuxtLink>
|
||||
<svg class="h-3 w-3" viewBox="0 0 18 27" aria-hidden="true">
|
||||
<path d="M2.397 25.426l13.143-11.5-13.143-11.5" stroke-width="3" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
<span>{{ isNewMember ? '새 멤버' : '멤버 편집' }}</span>
|
||||
</div>
|
||||
<h1 class="admin-member-form__title mt-4 text-3xl font-semibold tracking-[-0.01em] text-[#15171a]">
|
||||
{{ pageTitle }}
|
||||
</h1>
|
||||
</div>
|
||||
<div class="admin-member-form__actions flex items-center gap-3">
|
||||
<div v-if="!isNewMember" class="admin-member-form__action-menu relative">
|
||||
<button class="admin-member-form__action-icon flex h-11 w-11 items-center justify-center rounded-md border border-line bg-white text-[#3f4650] transition hover:border-[#c5ccd5] hover:bg-[#f4f6f8]" type="button" aria-label="멤버 작업" :aria-expanded="actionMenuOpen" @click="toggleActionMenu">
|
||||
<svg class="h-5 w-5" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M10.546 2.438a1.957 1.957 0 002.908 0L14.4 1.4a1.959 1.959 0 013.41 1.413l-.071 1.4a1.958 1.958 0 002.051 2.054l1.4-.071a1.959 1.959 0 011.41 3.41l-1.042.94a1.96 1.96 0 000 2.909l1.042.94a1.959 1.959 0 01-1.413 3.41l-1.4-.071a1.958 1.958 0 00-2.056 2.056l.071 1.4A1.959 1.959 0 0114.4 22.6l-.941-1.041a1.959 1.959 0 00-2.908 0L9.606 22.6A1.959 1.959 0 016.2 21.192l.072-1.4a1.958 1.958 0 00-2.056-2.056l-1.4.071A1.958 1.958 0 011.4 14.4l1.041-.94a1.96 1.96 0 000-2.909L1.4 9.606A1.958 1.958 0 012.809 6.2l1.4.071a1.958 1.958 0 002.058-2.06L6.2 2.81A1.959 1.959 0 019.606 1.4z" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
|
||||
<circle cx="12" cy="12.001" r="4.5" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
|
||||
</svg>
|
||||
</button>
|
||||
<div v-if="actionMenuOpen" class="admin-member-form__action-popover absolute right-0 top-12 z-20 grid w-52 overflow-hidden rounded-xl border border-line bg-white py-2 text-sm text-[#3f4650] shadow-[0_16px_44px_rgba(15,23,42,0.16)]">
|
||||
<button class="admin-member-form__action-item px-4 py-2.5 text-left hover:bg-[#f3f5f7]" type="button" @click="openPasswordModal">
|
||||
비밀번호 변경
|
||||
</button>
|
||||
<button class="admin-member-form__action-item px-4 py-2.5 text-left text-[#d21a26] hover:bg-[#fff1f2]" type="button" @click="openDeleteModal">
|
||||
멤버 삭제
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button v-else class="admin-member-form__action-icon flex h-11 w-11 items-center justify-center rounded-md border border-line bg-white text-[#3f4650]" type="button" aria-label="멤버 작업" disabled>
|
||||
<svg class="h-5 w-5" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M10.546 2.438a1.957 1.957 0 002.908 0L14.4 1.4a1.959 1.959 0 013.41 1.413l-.071 1.4a1.958 1.958 0 002.051 2.054l1.4-.071a1.959 1.959 0 011.41 3.41l-1.042.94a1.96 1.96 0 000 2.909l1.042.94a1.959 1.959 0 01-1.413 3.41l-1.4-.071a1.958 1.958 0 00-2.056 2.056l.071 1.4A1.959 1.959 0 0114.4 22.6l-.941-1.041a1.959 1.959 0 00-2.908 0L9.606 22.6A1.959 1.959 0 016.2 21.192l.072-1.4a1.958 1.958 0 00-2.056-2.056l-1.4.071A1.958 1.958 0 011.4 14.4l1.041-.94a1.96 1.96 0 000-2.909L1.4 9.606A1.958 1.958 0 012.809 6.2l1.4.071a1.958 1.958 0 002.058-2.06L6.2 2.81A1.959 1.959 0 019.606 1.4z" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
|
||||
<circle cx="12" cy="12.001" r="4.5" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
|
||||
</svg>
|
||||
</button>
|
||||
<button class="admin-member-form__save h-11 rounded-md bg-[#15171a] px-5 text-sm font-semibold text-white transition hover:bg-[#2b2f35] disabled:opacity-50" type="button" :disabled="isSaving" @click="saveMember">
|
||||
{{ isSaving ? '저장 중' : '저장' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="admin-member-form__body grid gap-8 py-8 xl:grid-cols-3">
|
||||
<aside class="admin-member-form__summary">
|
||||
<div class="admin-member-form__identity flex items-center gap-4">
|
||||
<div class="admin-member-form__avatar-control group relative h-20 w-20 shrink-0">
|
||||
<button
|
||||
class="admin-member-form__avatar-button relative h-20 w-20 overflow-hidden rounded-full bg-[#15171a] text-white"
|
||||
type="button"
|
||||
:aria-label="form.avatarUrl ? '썸네일 변경' : '썸네일 등록'"
|
||||
@click="openAvatarFilePicker"
|
||||
>
|
||||
<img
|
||||
v-if="form.avatarUrl"
|
||||
class="admin-member-form__avatar h-full w-full object-cover"
|
||||
:src="form.avatarUrl"
|
||||
:alt="pageTitle"
|
||||
>
|
||||
<span v-else class="admin-member-form__avatar flex h-full w-full items-center justify-center text-2xl font-semibold">
|
||||
{{ memberInitial }}
|
||||
</span>
|
||||
<span class="admin-member-form__avatar-caption absolute inset-x-0 bottom-0 flex min-h-8 items-end justify-center bg-gradient-to-t from-black/80 via-black/35 to-transparent px-2 pb-2 text-center text-[11px] font-semibold leading-tight text-white opacity-0 transition-opacity group-hover:opacity-100 group-focus-within:opacity-100">
|
||||
{{ isUploadingAvatar ? '업로드 중' : form.avatarUrl ? '썸네일 변경' : '썸네일 등록' }}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="form.avatarUrl"
|
||||
class="admin-member-form__avatar-remove absolute right-0 top-0 grid size-6 -translate-y-1 translate-x-1 place-items-center rounded-full bg-black/85 text-white opacity-0 shadow-sm transition hover:bg-[#d21a26] group-hover:opacity-100 group-focus-within:opacity-100"
|
||||
type="button"
|
||||
aria-label="썸네일 제거"
|
||||
@click.stop="removeAvatar"
|
||||
>
|
||||
<svg class="h-3 w-3" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M12.707 12L23.854.854a.5.5 0 00-.707-.707L12 11.293.854.146a.5.5 0 00-.707.707L11.293 12 .146 23.146a.5.5 0 00.708.708L12 12.707l11.146 11.146a.5.5 0 10.708-.706L12.707 12z" fill="currentColor" />
|
||||
</svg>
|
||||
</button>
|
||||
<input ref="avatarInputRef" class="sr-only" type="file" accept="image/*" @change="uploadAvatar">
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<h2 class="truncate text-lg font-semibold text-[#15171a]">{{ pageTitle }}</h2>
|
||||
<p class="mt-1 truncate text-sm text-[#657080]">{{ form.email || '이메일 없음' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!isNewMember" class="admin-member-form__meta mt-10 space-y-3 text-sm text-[#4d5663]">
|
||||
<p class="flex items-center gap-2">
|
||||
<svg class="h-4 w-4 text-[#8a95a5]" fill="none" viewBox="0 0 24 26" aria-hidden="true">
|
||||
<path d="M12 14.75a4 4 0 100-8 4 4 0 000 8z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
<path d="M21 10.75c0 7.9-6.932 12.331-8.629 13.3a.751.751 0 01-.743 0C9.931 23.08 3 18.648 3 10.75a9 9 0 1118 0z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
{{ member?.lastSeenIp || '접속 IP 없음' }}
|
||||
</p>
|
||||
<p class="flex items-center gap-2">
|
||||
<svg class="h-4 w-4 text-[#8a95a5]" fill="none" viewBox="0 0 26 24" aria-hidden="true">
|
||||
<path d="M13 5.001c-4.03-.078-8.2 3.157-10.82 6.47-.276.35-.428.805-.428 1.277 0 .472.152.928.427 1.278C4.743 17.27 8.9 20.578 13 20.5c4.1.079 8.258-3.23 10.824-6.473.275-.35.428-.806.428-1.278s-.153-.927-.428-1.278C21.2 8.158 17.031 4.923 13 5.001z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
<path d="M16.75 12.751a3.75 3.75 0 11-7.5-.002 3.75 3.75 0 017.5.002z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
{{ formatRelativeTime(member?.lastSeenAt) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="!isNewMember" class="admin-member-form__side-section mt-12 border-t border-line pt-6">
|
||||
<h3 class="text-xs font-semibold uppercase tracking-[0.04em] text-[#15171a]">가입 정보</h3>
|
||||
<p class="mt-5 flex items-center gap-2 text-sm text-[#4d5663]">
|
||||
<svg class="h-4 w-4 text-[#8a95a5]" fill="none" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M11.5 12c-2.824 0-2.83.024-4.5.53-3.5 1.058-5 3.176-5 6.386V21h10m7-5v6m-3-3h6m-10.5-7a5.5 5.5 0 100-11 5.5 5.5 0 000 11z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
생성됨 — <strong>{{ formatDate(member?.createdAt) }}</strong>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="!isNewMember" class="admin-member-form__side-section mt-12 border-t border-line pt-6">
|
||||
<h3 class="text-xs font-semibold uppercase tracking-[0.04em] text-[#15171a]">참여도</h3>
|
||||
<p class="mt-5 text-sm leading-6 text-[#8a95a5]">
|
||||
댓글 작성 {{ member?.commentCount || 0 }}개
|
||||
</p>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div class="admin-member-form__content space-y-8 xl:col-span-2">
|
||||
<form class="admin-member-form__card rounded-xl border border-line bg-white p-5 md:p-6" @submit.prevent="saveMember">
|
||||
<div class="grid gap-5 md:grid-cols-2">
|
||||
<label class="admin-member-form__field block">
|
||||
<span class="admin-member-form__label mb-2 block text-sm font-semibold text-[#15171a]">이름</span>
|
||||
<input v-model="form.username" class="admin-member-form__input h-12 w-full rounded-md border border-transparent bg-[#eef1f4] px-4 text-sm text-[#15171a] outline-none focus:border-[#c5ccd5] focus:bg-white focus:ring-2 focus:ring-[#dce2e8]" type="text" maxlength="60" required>
|
||||
</label>
|
||||
<label class="admin-member-form__field block">
|
||||
<span class="admin-member-form__label mb-2 block text-sm font-semibold text-[#15171a]">이메일</span>
|
||||
<input v-model="form.email" class="admin-member-form__input h-12 w-full rounded-md border border-transparent bg-[#eef1f4] px-4 text-sm text-[#15171a] outline-none focus:border-[#c5ccd5] focus:bg-white focus:ring-2 focus:ring-[#dce2e8]" type="email" maxlength="254" required>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label class="admin-member-form__field mt-5 block">
|
||||
<span class="admin-member-form__label mb-2 block text-sm font-semibold text-[#15171a]">레이블</span>
|
||||
<input v-model="form.labelsText" class="admin-member-form__input h-12 w-full rounded-md border border-transparent bg-[#eef1f4] px-4 text-sm text-[#15171a] outline-none focus:border-[#c5ccd5] focus:bg-white focus:ring-2 focus:ring-[#dce2e8]" type="text" placeholder="쉼표로 구분">
|
||||
</label>
|
||||
|
||||
<label class="admin-member-form__field mt-5 block">
|
||||
<span class="admin-member-form__label mb-2 block text-sm font-semibold text-[#15171a]">
|
||||
노트 <span class="font-normal text-[#657080]">(멤버에게 보이지 않음)</span>
|
||||
</span>
|
||||
<textarea v-model="form.note" class="admin-member-form__textarea min-h-32 w-full resize-y rounded-md border border-transparent bg-[#eef1f4] px-4 py-3 text-sm text-[#15171a] outline-none focus:border-[#c5ccd5] focus:bg-white focus:ring-2 focus:ring-[#dce2e8]" maxlength="500" />
|
||||
<span class="admin-member-form__count mt-2 block text-sm text-[#8a95a5]">
|
||||
최대 500자. 현재 {{ noteLength }}자
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<p v-if="saveMessage" class="admin-member-form__message mt-5 rounded-md border border-green-200 bg-green-50 px-4 py-3 text-sm text-green-700">{{ saveMessage }}</p>
|
||||
<p v-if="saveError" class="admin-member-form__error mt-5 rounded-md border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{{ saveError }}</p>
|
||||
<p v-if="actionMessage" class="admin-member-form__message mt-5 rounded-md border border-green-200 bg-green-50 px-4 py-3 text-sm text-green-700">{{ actionMessage }}</p>
|
||||
<p v-if="actionError && !passwordModalOpen && !deleteModalOpen" class="admin-member-form__error mt-5 rounded-md border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{{ actionError }}</p>
|
||||
</form>
|
||||
|
||||
<section v-if="!isNewMember" class="admin-member-form__activity">
|
||||
<h2 class="admin-member-form__section-title mb-4 text-xs font-semibold uppercase tracking-[0.04em] text-[#15171a]">활동</h2>
|
||||
<div class="admin-member-form__activity-card rounded-xl border border-line bg-white px-5 md:px-6">
|
||||
<div class="admin-member-form__activity-row flex items-center justify-between gap-4 border-b border-line py-5 text-sm last:border-b-0">
|
||||
<span class="flex items-center gap-3 text-[#3f4650]">
|
||||
<svg class="h-5 w-5 text-[#6c747d]" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<path d="M4 12h10.31m-3.076-3.076L14.31 12l-3.076 3.077" stroke="currentColor" stroke-width="1.714" stroke-linecap="round" stroke-linejoin="round" />
|
||||
<path d="M4.998 16.308a7.69 7.69 0 003.733 3.182 7.238 7.238 0 004.8.189 7.608 7.608 0 003.949-2.88A8.283 8.283 0 0018.998 12c0-1.73-.533-3.414-1.518-4.798a7.607 7.607 0 00-3.949-2.88 7.237 7.237 0 00-4.8.188 7.69 7.69 0 00-3.733 3.182" stroke="currentColor" stroke-width="1.714" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
로그인
|
||||
</span>
|
||||
<span class="text-[#9aa4b2]">{{ formatRelativeTime(member?.lastSeenAt) }}</span>
|
||||
</div>
|
||||
<div class="admin-member-form__activity-row flex items-center justify-between gap-4 py-5 text-sm">
|
||||
<span class="flex items-center gap-3 text-[#3f4650]">
|
||||
<svg class="h-5 w-5 text-[#6c747d]" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<path d="M11.246 12.144a4.242 4.242 0 100-8.484 4.242 4.242 0 000 8.484zM4 18.761a8.484 8.484 0 0110.5-3.42" stroke="currentColor" stroke-width="1.714" stroke-linecap="round" stroke-linejoin="round" />
|
||||
<path d="M17.54 16.077V23m-3.463-3.46H21" stroke="#30CF43" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
가입
|
||||
</span>
|
||||
<span class="text-[#9aa4b2]">{{ formatRelativeTime(member?.createdAt) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AdminUnsavedChangesModal
|
||||
:open="isUnsavedModalOpen"
|
||||
@stay="stayOnUnsavedPage"
|
||||
@leave="leaveUnsavedPage"
|
||||
/>
|
||||
|
||||
<Teleport to="body">
|
||||
<div v-if="passwordModalOpen" class="admin-member-form__modal fixed inset-0 z-50 flex items-start justify-center bg-black/35 px-4 pt-10">
|
||||
<section class="admin-member-form__modal-panel w-full max-w-[520px] rounded-xl bg-white text-[#15171a] shadow-[0_22px_70px_rgba(15,23,42,0.25)]">
|
||||
<header class="flex items-center justify-between border-b border-line px-6 py-5">
|
||||
<h2 class="text-xl font-semibold">비밀번호 변경</h2>
|
||||
<button class="grid h-8 w-8 place-items-center rounded-md text-[#657080] hover:bg-[#f3f5f7]" type="button" aria-label="닫기" @click="closePasswordModal">
|
||||
<svg class="h-4 w-4" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M12.707 12L23.854.854a.5.5 0 00-.707-.707L12 11.293.854.146a.5.5 0 00-.707.707L11.293 12 .146 23.146a.5.5 0 00.708.708L12 12.707l11.146 11.146a.5.5 0 10.708-.706L12.707 12z" fill="currentColor" />
|
||||
</svg>
|
||||
</button>
|
||||
</header>
|
||||
<div class="grid gap-4 px-6 py-5">
|
||||
<p class="text-sm leading-6 text-[#657080]">
|
||||
이메일 전송이 불가능한 상황을 대비해 관리자가 직접 새 비밀번호를 설정합니다.
|
||||
</p>
|
||||
<label class="grid gap-2 text-sm font-semibold">
|
||||
새 비밀번호
|
||||
<input v-model="passwordForm.password" class="h-11 rounded-md border border-transparent bg-[#eef1f4] px-4 text-sm outline-none focus:border-[#c5ccd5] focus:bg-white focus:ring-2 focus:ring-[#dce2e8]" type="password" autocomplete="new-password">
|
||||
</label>
|
||||
<label class="grid gap-2 text-sm font-semibold">
|
||||
새 비밀번호 확인
|
||||
<input v-model="passwordForm.passwordConfirm" class="h-11 rounded-md border border-transparent bg-[#eef1f4] px-4 text-sm outline-none focus:border-[#c5ccd5] focus:bg-white focus:ring-2 focus:ring-[#dce2e8]" type="password" autocomplete="new-password">
|
||||
</label>
|
||||
<p v-if="actionError" class="rounded-md border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{{ actionError }}</p>
|
||||
</div>
|
||||
<footer class="flex justify-end gap-2 border-t border-line px-6 py-4">
|
||||
<button class="h-10 rounded-md border border-line bg-white px-4 text-sm font-semibold text-[#3f4650]" type="button" @click="closePasswordModal">
|
||||
취소
|
||||
</button>
|
||||
<button class="h-10 rounded-md bg-[#15171a] px-4 text-sm font-semibold text-white disabled:opacity-50" type="button" :disabled="isUpdatingPassword" @click="updateMemberPassword">
|
||||
{{ isUpdatingPassword ? '변경 중' : '변경' }}
|
||||
</button>
|
||||
</footer>
|
||||
</section>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<Teleport to="body">
|
||||
<div v-if="deleteModalOpen" class="admin-member-form__modal fixed inset-0 z-50 flex items-start justify-center bg-black/35 px-4 pt-10">
|
||||
<section class="admin-member-form__modal-panel w-full max-w-[520px] rounded-xl bg-white text-[#15171a] shadow-[0_22px_70px_rgba(15,23,42,0.25)]">
|
||||
<header class="flex items-center justify-between border-b border-line px-6 py-5">
|
||||
<h2 class="text-xl font-semibold">멤버 삭제</h2>
|
||||
<button class="grid h-8 w-8 place-items-center rounded-md text-[#657080] hover:bg-[#f3f5f7]" type="button" aria-label="닫기" @click="closeDeleteModal">
|
||||
<svg class="h-4 w-4" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M12.707 12L23.854.854a.5.5 0 00-.707-.707L12 11.293.854.146a.5.5 0 00-.707.707L11.293 12 .146 23.146a.5.5 0 00.708.708L12 12.707l11.146 11.146a.5.5 0 10.708-.706L12.707 12z" fill="currentColor" />
|
||||
</svg>
|
||||
</button>
|
||||
</header>
|
||||
<div class="grid gap-4 px-6 py-5">
|
||||
<p class="text-sm leading-6 text-[#657080]">
|
||||
삭제하면 멤버 계정과 작성 댓글이 함께 삭제됩니다. 계속하려면 아래에 <strong class="text-[#15171a]">{{ form.email }}</strong> 을 입력해 주세요.
|
||||
</p>
|
||||
<input v-model="deleteForm.confirmText" class="h-11 rounded-md border border-transparent bg-[#eef1f4] px-4 text-sm outline-none focus:border-[#c5ccd5] focus:bg-white focus:ring-2 focus:ring-[#dce2e8]" type="text" autocomplete="off">
|
||||
<p v-if="actionError" class="rounded-md border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{{ actionError }}</p>
|
||||
</div>
|
||||
<footer class="flex justify-end gap-2 border-t border-line px-6 py-4">
|
||||
<button class="h-10 rounded-md border border-line bg-white px-4 text-sm font-semibold text-[#3f4650]" type="button" @click="closeDeleteModal">
|
||||
취소
|
||||
</button>
|
||||
<button class="h-10 rounded-md bg-[#d21a26] px-4 text-sm font-semibold text-white disabled:opacity-50" type="button" :disabled="isDeletingMember" @click="deleteMember">
|
||||
{{ isDeletingMember ? '삭제 중' : '삭제' }}
|
||||
</button>
|
||||
</footer>
|
||||
</section>
|
||||
</div>
|
||||
</Teleport>
|
||||
</section>
|
||||
</template>
|
||||
214
components/admin/AdminNavPrimaryBranch.vue
Normal file
214
components/admin/AdminNavPrimaryBranch.vue
Normal file
@@ -0,0 +1,214 @@
|
||||
<script setup>
|
||||
import AdminNavPrimaryBranch from './AdminNavPrimaryBranch.vue'
|
||||
|
||||
const props = defineProps({
|
||||
/** buildNavigationEditorTree 결과 */
|
||||
wraps: {
|
||||
type: Array,
|
||||
required: true
|
||||
},
|
||||
/** 들여쓰기 단계 */
|
||||
depth: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
/** 루트면 `'root'`, 아니면 부모 항목 id */
|
||||
parentKey: {
|
||||
type: String,
|
||||
default: 'root'
|
||||
},
|
||||
/** 드래그 중인 항목 id */
|
||||
draggingId: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
/** 드롭 대상 위에 올린 항목 id */
|
||||
dragOverId: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits([
|
||||
'drag-start',
|
||||
'drag-over',
|
||||
'drag-end',
|
||||
'drop',
|
||||
'add-child',
|
||||
'remove'
|
||||
])
|
||||
|
||||
/**
|
||||
* 입력·버튼 위에서는 행 드래그를 시작하지 않는다.
|
||||
* @param {DragEvent} event - 이벤트
|
||||
* @returns {boolean} true면 드래그를 막는다
|
||||
*/
|
||||
const shouldBlockRowDrag = (event) => {
|
||||
const el = event.target
|
||||
if (!el || typeof el.closest !== 'function') {
|
||||
return false
|
||||
}
|
||||
return Boolean(el.closest('input, button, textarea, select, a'))
|
||||
}
|
||||
|
||||
/**
|
||||
* 드래그 시작
|
||||
* @param {DragEvent} event - 이벤트
|
||||
* @param {string} itemId - 항목 id
|
||||
* @returns {void}
|
||||
*/
|
||||
const onDragStart = (event, itemId) => {
|
||||
if (shouldBlockRowDrag(event)) {
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
if (!event.dataTransfer) {
|
||||
return
|
||||
}
|
||||
emit('drag-start', { parentKey: props.parentKey, itemId })
|
||||
event.dataTransfer.effectAllowed = 'move'
|
||||
}
|
||||
|
||||
/**
|
||||
* 드래그 오버
|
||||
* @param {DragEvent} event - 이벤트
|
||||
* @param {string} itemId - 항목 id
|
||||
* @returns {void}
|
||||
*/
|
||||
const onDragOver = (event, itemId) => {
|
||||
event.preventDefault()
|
||||
emit('drag-over', itemId)
|
||||
}
|
||||
|
||||
/**
|
||||
* 드롭
|
||||
* @param {DragEvent} event - 이벤트
|
||||
* @param {string} itemId - 대상 id
|
||||
* @returns {void}
|
||||
*/
|
||||
const onDrop = (event, itemId) => {
|
||||
event.preventDefault()
|
||||
emit('drop', { parentKey: props.parentKey, targetId: itemId })
|
||||
}
|
||||
|
||||
/**
|
||||
* 드래그 종료
|
||||
* @returns {void}
|
||||
*/
|
||||
const onDragEnd = () => {
|
||||
emit('drag-end')
|
||||
}
|
||||
|
||||
/**
|
||||
* 행 하이라이트 클래스(태그 관리 메인 태그 테이블과 동일 톤)
|
||||
* @param {string} itemId - 항목 id
|
||||
* @returns {string}
|
||||
*/
|
||||
const rowStateClass = (itemId) => {
|
||||
const id = String(itemId)
|
||||
if (props.dragOverId === id) {
|
||||
return 'bg-[#f9f9f7]'
|
||||
}
|
||||
if (props.draggingId === id) {
|
||||
return 'opacity-50'
|
||||
}
|
||||
return ''
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="admin-nav-primary-branch" :class="depth ? 'mt-2 border-l border-line pl-3' : ''">
|
||||
<div class="admin-nav-primary-branch__shell overflow-hidden rounded border border-line">
|
||||
<table class="admin-nav-primary-branch__table w-full border-collapse text-left text-sm">
|
||||
<thead v-if="depth === 0" class="admin-nav-primary-branch__head bg-[#f5f5f2] text-xs uppercase text-muted">
|
||||
<tr>
|
||||
<th class="admin-nav-primary-branch__cell px-4 py-3">
|
||||
#
|
||||
</th>
|
||||
<th class="admin-nav-primary-branch__cell px-4 py-3">
|
||||
라벨
|
||||
</th>
|
||||
<th class="admin-nav-primary-branch__cell px-4 py-3">
|
||||
URL
|
||||
</th>
|
||||
<th class="admin-nav-primary-branch__cell px-4 py-3">
|
||||
관리
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="admin-nav-primary-branch__body divide-y divide-line bg-white">
|
||||
<template v-for="(wrap, index) in wraps" :key="wrap.item.id">
|
||||
<tr
|
||||
class="admin-nav-primary-branch__row cursor-move"
|
||||
:class="rowStateClass(wrap.item.id)"
|
||||
draggable="true"
|
||||
@dragstart="onDragStart($event, wrap.item.id)"
|
||||
@dragover="onDragOver($event, wrap.item.id)"
|
||||
@drop="onDrop($event, wrap.item.id)"
|
||||
@dragend="onDragEnd"
|
||||
>
|
||||
<td class="admin-nav-primary-branch__cell px-4 py-3 align-middle text-muted">
|
||||
{{ index + 1 }}
|
||||
</td>
|
||||
<td class="admin-nav-primary-branch__cell px-4 py-3 align-middle">
|
||||
<input
|
||||
v-model="wrap.item.label"
|
||||
class="admin-nav-primary-branch__label w-full min-w-[8rem] rounded border border-line px-3 py-2 text-sm outline-none focus:border-[#8e9cac]"
|
||||
type="text"
|
||||
placeholder="라벨"
|
||||
required
|
||||
>
|
||||
</td>
|
||||
<td class="admin-nav-primary-branch__cell px-4 py-3 align-middle">
|
||||
<input
|
||||
v-model="wrap.item.url"
|
||||
class="admin-nav-primary-branch__url w-full min-w-[10rem] rounded border border-line px-3 py-2 font-mono text-sm outline-none focus:border-[#8e9cac]"
|
||||
type="text"
|
||||
placeholder="URL (# 또는 /경로)"
|
||||
required
|
||||
>
|
||||
</td>
|
||||
<td class="admin-nav-primary-branch__cell px-4 py-3 align-middle">
|
||||
<div class="admin-nav-primary-branch__actions flex flex-wrap gap-2">
|
||||
<button
|
||||
class="admin-nav-primary-branch__add-child rounded border border-line px-3 py-1.5 text-xs font-semibold"
|
||||
type="button"
|
||||
@click="emit('add-child', wrap.item.id)"
|
||||
>
|
||||
하위
|
||||
</button>
|
||||
<button
|
||||
class="admin-nav-primary-branch__remove rounded border border-red-200 px-3 py-1.5 text-xs font-semibold text-red-700"
|
||||
type="button"
|
||||
@click="emit('remove', wrap.item.id)"
|
||||
>
|
||||
삭제
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="wrap.children.length" class="admin-nav-primary-branch__nest bg-white">
|
||||
<td class="p-0" colspan="4">
|
||||
<div class="admin-nav-primary-branch__nest-inner border-t border-line bg-[#fafaf8] px-2 py-3">
|
||||
<AdminNavPrimaryBranch
|
||||
:wraps="wrap.children"
|
||||
:depth="depth + 1"
|
||||
:parent-key="String(wrap.item.id)"
|
||||
:dragging-id="draggingId"
|
||||
:drag-over-id="dragOverId"
|
||||
@drag-start="emit('drag-start', $event)"
|
||||
@drag-over="emit('drag-over', $event)"
|
||||
@drag-end="emit('drag-end')"
|
||||
@drop="emit('drop', $event)"
|
||||
@add-child="emit('add-child', $event)"
|
||||
@remove="emit('remove', $event)"
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,4 +1,6 @@
|
||||
<script setup>
|
||||
import { normalizeMarkdownContent } from '../../lib/markdown-content-normalizer.js'
|
||||
|
||||
const props = defineProps({
|
||||
initialPost: {
|
||||
type: Object,
|
||||
@@ -46,8 +48,16 @@ const autosaveStatus = ref('')
|
||||
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 입력값으로 변환
|
||||
@@ -93,10 +103,9 @@ const form = reactive({
|
||||
title: props.initialPost.title || '',
|
||||
slug: props.initialPost.slug || '',
|
||||
excerpt: props.initialPost.excerpt || '',
|
||||
content: props.initialPost.content || '',
|
||||
content: normalizeMarkdownContent(props.initialPost.content),
|
||||
featuredImage: props.initialPost.featuredImage || '',
|
||||
seoTitle: props.initialPost.seoTitle || '',
|
||||
seoDescription: props.initialPost.seoDescription || '',
|
||||
isFeatured: Boolean(props.initialPost.isFeatured),
|
||||
noindex: Boolean(props.initialPost.noindex),
|
||||
status: props.initialPost.status || 'draft',
|
||||
publishedAt: toDateTimeLocalValue(props.initialPost.publishedAt),
|
||||
@@ -107,19 +116,66 @@ const autosaveKey = computed(() => `${autosaveStoragePrefix}:${props.initialPost
|
||||
const postUrlLabel = computed(() => form.slug || toSlug(form.title) || '')
|
||||
const postUrlHint = computed(() => props.publicUrl || (postUrlLabel.value ? `/post/${postUrlLabel.value}/` : '/post/'))
|
||||
|
||||
/**
|
||||
* 한글 음절 1자를 영문 표기로 변환
|
||||
* @param {string} char - 변환할 문자
|
||||
* @returns {string} 영문 표기
|
||||
*/
|
||||
const romanizeHangulSyllable = (char) => {
|
||||
const syllableCode = char.charCodeAt(0)
|
||||
const hangulBase = 0xac00
|
||||
const hangulLast = 0xd7a3
|
||||
if (syllableCode < hangulBase || syllableCode > hangulLast) {
|
||||
return char
|
||||
}
|
||||
|
||||
const choseong = ['g', 'kk', 'n', 'd', 'tt', 'r', 'm', 'b', 'pp', 's', 'ss', '', 'j', 'jj', 'ch', 'k', 't', 'p', 'h']
|
||||
const jungseong = ['a', 'ae', 'ya', 'yae', 'eo', 'e', 'yeo', 'ye', 'o', 'wa', 'wae', 'oe', 'yo', 'u', 'wo', 'we', 'wi', 'yu', 'eu', 'ui', 'i']
|
||||
const jongseong = ['', 'k', 'k', 'ks', 'n', 'nj', 'nh', 't', 'l', 'lk', 'lm', 'lb', 'ls', 'lt', 'lp', 'lh', 'm', 'p', 'ps', 't', 't', 'ng', 't', 't', 'k', 't', 'p', 'h']
|
||||
|
||||
const offset = syllableCode - hangulBase
|
||||
const choseongIndex = Math.floor(offset / 588)
|
||||
const jungseongIndex = Math.floor((offset % 588) / 28)
|
||||
const jongseongIndex = offset % 28
|
||||
|
||||
return `${choseong[choseongIndex]}${jungseong[jungseongIndex]}${jongseong[jongseongIndex]}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 문자열을 URL 슬러그로 변환
|
||||
* @param {string} value - 원본 문자열
|
||||
* @returns {string} 슬러그
|
||||
*/
|
||||
const toSlug = (value) => value
|
||||
.normalize('NFC')
|
||||
.split('')
|
||||
.map((char) => romanizeHangulSyllable(char))
|
||||
.join('')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9가-힣\s-]/g, '')
|
||||
.replace(/[^a-z0-9\s-]/g, '')
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-|-$/g, '')
|
||||
|
||||
/**
|
||||
* 게시물 태그 입력 토큰 정규화(한글 유지, 공백은 하이픈으로)
|
||||
* @param {string} value - 원본 문자열
|
||||
* @returns {string} 정규화된 태그 문자열
|
||||
*/
|
||||
const normalizeTagToken = (value) => {
|
||||
const raw = String(value).normalize('NFC').trim().toLowerCase()
|
||||
if (!raw) {
|
||||
return ''
|
||||
}
|
||||
|
||||
return raw
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/[^a-z0-9가-힣-]+/g, '')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-|-$/g, '')
|
||||
}
|
||||
|
||||
watch(() => form.title, (title) => {
|
||||
if (!slugTouched.value) {
|
||||
form.slug = toSlug(title)
|
||||
@@ -136,14 +192,29 @@ const touchSlug = () => {
|
||||
}
|
||||
|
||||
/**
|
||||
* 쉼표 구분 태그 문자열을 슬러그 배열로 변환
|
||||
* 쉼표 구분 태그 문자열을 토큰 배열로 변환
|
||||
* @param {string} value - 태그 입력 문자열
|
||||
* @returns {Array<string>} 태그 슬러그 목록
|
||||
* @returns {Array<string>} 태그 토큰 목록
|
||||
*/
|
||||
const parseTags = (value) => [...new Set(value
|
||||
.split(',')
|
||||
.map((tag) => toSlug(tag))
|
||||
.filter(Boolean))]
|
||||
const parseTags = (value) => {
|
||||
const seen = new Set()
|
||||
const out = []
|
||||
|
||||
for (const part of value.split(',')) {
|
||||
const tag = normalizeTagToken(part)
|
||||
if (!tag) {
|
||||
continue
|
||||
}
|
||||
const dedupeKey = tag.toLowerCase()
|
||||
if (seen.has(dedupeKey)) {
|
||||
continue
|
||||
}
|
||||
seen.add(dedupeKey)
|
||||
out.push(tag)
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
const selectedTags = computed(() => parseTags(form.tagsText))
|
||||
|
||||
@@ -173,6 +244,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} 게시물 입력값
|
||||
@@ -186,10 +300,11 @@ const createPostPayload = () => {
|
||||
title: form.title.trim(),
|
||||
slug: toSlug(form.slug || form.title),
|
||||
excerpt: form.excerpt.trim(),
|
||||
content: form.content,
|
||||
content: normalizeMarkdownContent(form.content),
|
||||
featuredImage: form.featuredImage.trim() || null,
|
||||
seoTitle: form.seoTitle.trim(),
|
||||
seoDescription: form.seoDescription.trim(),
|
||||
isFeatured: form.isFeatured,
|
||||
seoTitle: form.title.trim(),
|
||||
seoDescription: form.excerpt.trim(),
|
||||
canonicalUrl: '',
|
||||
noindex: form.noindex,
|
||||
ogImage: null,
|
||||
@@ -199,6 +314,14 @@ const createPostPayload = () => {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 게시물 입력값을 문자열로 직렬화한다.
|
||||
* @returns {string} 직렬화된 게시물 입력값
|
||||
*/
|
||||
const serializePostPayload = () => JSON.stringify(createPostPayload())
|
||||
|
||||
const hasUnsavedPostChanges = computed(() => serializePostPayload() !== savedPostSnapshot.value)
|
||||
|
||||
/**
|
||||
* 자동 저장 데이터 생성
|
||||
* @returns {Object} 자동 저장 데이터
|
||||
@@ -207,10 +330,9 @@ const createAutosavePayload = () => ({
|
||||
title: form.title,
|
||||
slug: form.slug,
|
||||
excerpt: form.excerpt,
|
||||
content: form.content,
|
||||
content: normalizeMarkdownContent(form.content),
|
||||
featuredImage: form.featuredImage,
|
||||
seoTitle: form.seoTitle,
|
||||
seoDescription: form.seoDescription,
|
||||
isFeatured: form.isFeatured,
|
||||
noindex: form.noindex,
|
||||
status: form.status,
|
||||
publishedAt: form.publishedAt,
|
||||
@@ -228,9 +350,8 @@ const isEmptyAutosavePayload = (payload) => ![
|
||||
payload.excerpt,
|
||||
payload.content,
|
||||
payload.featuredImage,
|
||||
payload.seoTitle,
|
||||
payload.seoDescription,
|
||||
payload.tagsText
|
||||
payload.tagsText,
|
||||
payload.isFeatured ? 'featured' : ''
|
||||
].some((value) => String(value || '').trim())
|
||||
|
||||
/**
|
||||
@@ -292,7 +413,10 @@ const restoreAutosave = () => {
|
||||
}
|
||||
|
||||
isRestoringAutosave.value = true
|
||||
Object.assign(form, autosaveNotice.value.payload)
|
||||
Object.assign(form, {
|
||||
...autosaveNotice.value.payload,
|
||||
content: normalizeMarkdownContent(autosaveNotice.value.payload.content)
|
||||
})
|
||||
slugTouched.value = Boolean(form.slug)
|
||||
autosaveStatus.value = `${formatAutosaveTime(autosaveNotice.value.savedAt)} 자동 저장본 복원됨`
|
||||
autosaveNotice.value = null
|
||||
@@ -317,6 +441,15 @@ const discardAutosave = () => {
|
||||
autosaveStatus.value = ''
|
||||
}
|
||||
|
||||
const {
|
||||
isUnsavedModalOpen,
|
||||
stayOnUnsavedPage,
|
||||
leaveUnsavedPage,
|
||||
allowNextRouteLeave
|
||||
} = useAdminUnsavedChangesGuard(hasUnsavedPostChanges, {
|
||||
onLeaveConfirmed: discardAutosave
|
||||
})
|
||||
|
||||
/**
|
||||
* 미디어 라이브러리 목록 조회
|
||||
* @returns {Promise<void>}
|
||||
@@ -386,7 +519,7 @@ const removeFeaturedImage = () => {
|
||||
* @returns {void}
|
||||
*/
|
||||
const addTagFromInput = () => {
|
||||
const nextTag = toSlug(tagInput.value)
|
||||
const nextTag = normalizeTagToken(tagInput.value)
|
||||
|
||||
if (!nextTag) {
|
||||
tagInput.value = ''
|
||||
@@ -412,6 +545,10 @@ const removeTag = (tag) => {
|
||||
* @returns {void}
|
||||
*/
|
||||
const handleTagKeydown = (event) => {
|
||||
if (event.isComposing || isTagInputComposing.value || event.keyCode === 229) {
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === 'Enter' || event.key === ',') {
|
||||
event.preventDefault()
|
||||
addTagFromInput()
|
||||
@@ -483,9 +620,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()
|
||||
}
|
||||
|
||||
@@ -494,6 +637,7 @@ const focusContentEditor = () => {
|
||||
* @returns {void}
|
||||
*/
|
||||
const submitPost = () => {
|
||||
isPublishModalOpen.value = false
|
||||
emit('submit', createPostPayload())
|
||||
}
|
||||
|
||||
@@ -521,9 +665,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) {
|
||||
@@ -549,23 +807,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
|
||||
@@ -576,9 +858,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>
|
||||
@@ -621,32 +904,17 @@ defineExpose({
|
||||
|
||||
<input
|
||||
v-model="form.title"
|
||||
class="admin-post-form__title-input mb-2 w-full border-0 bg-transparent px-0 py-0 text-5xl font-bold leading-tight text-ink outline-none placeholder:text-[#8e9cac]"
|
||||
class="admin-post-form__title-input mb-2 w-full border-0 bg-transparent px-0 py-0 text-3xl font-bold leading-tight text-ink outline-none placeholder:text-[#8e9cac]"
|
||||
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" />
|
||||
<AdminMarkdownEditor ref="blockEditor" v-model="form.content" />
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
@@ -738,12 +1006,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
|
||||
@@ -753,48 +1023,46 @@ defineExpose({
|
||||
placeholder="태그 입력"
|
||||
@blur="addTagFromInput"
|
||||
@keydown="handleTagKeydown"
|
||||
@compositionstart="isTagInputComposing = true"
|
||||
@compositionend="isTagInputComposing = false"
|
||||
>
|
||||
<span class="admin-post-form__tag-chevron text-xs text-[#394047]" aria-hidden="true">⌄</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="admin-post-form__seo grid gap-3 border-t border-[#e3e6e8] pt-5 text-sm">
|
||||
<label class="admin-post-form__featured-toggle flex items-center justify-between gap-4 border-t border-[#e3e6e8] pt-5 text-sm">
|
||||
<span class="admin-post-form__featured-toggle-copy flex min-w-0 items-center gap-3">
|
||||
<span class="admin-post-form__featured-toggle-icon flex size-7 shrink-0 items-center justify-center text-[#15171a]" aria-hidden="true">
|
||||
<svg class="size-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 3.5l2.7 5.47 6.04.88-4.37 4.26 1.03 6.01L12 17.28l-5.4 2.84 1.03-6.01-4.37-4.26 6.04-.88L12 3.5z" />
|
||||
</svg>
|
||||
</span>
|
||||
<span class="admin-post-form__featured-toggle-label font-bold text-[#15171a]">
|
||||
추천 글
|
||||
</span>
|
||||
</span>
|
||||
<span class="admin-post-form__featured-toggle-control relative inline-flex h-7 w-12 shrink-0 items-center">
|
||||
<input
|
||||
v-model="form.isFeatured"
|
||||
class="peer sr-only"
|
||||
type="checkbox"
|
||||
aria-label="추천 글로 표시"
|
||||
>
|
||||
<span class="absolute inset-0 rounded-full bg-[#c8ced3] transition-colors peer-checked:bg-[#15171a]" aria-hidden="true" />
|
||||
<span class="relative ml-1 size-5 rounded-full bg-white shadow transition-transform peer-checked:translate-x-5" aria-hidden="true" />
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div class="admin-post-form__search-visibility grid gap-3 border-t border-[#e3e6e8] pt-5 text-sm">
|
||||
<div>
|
||||
<h2 class="admin-post-form__section-title text-sm font-semibold text-ink">
|
||||
SEO
|
||||
검색 노출
|
||||
</h2>
|
||||
<p class="admin-post-form__section-description mt-1 text-xs text-muted">
|
||||
검색 결과와 공유 미리보기에 사용할 기본 메타 정보를 설정합니다.
|
||||
메타 제목·설명은 저장 시 글 제목과 요약을 그대로 사용합니다.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<label class="admin-post-form__field grid gap-2">
|
||||
<span class="admin-post-form__label font-medium">SEO 제목</span>
|
||||
<input
|
||||
v-model="form.seoTitle"
|
||||
class="admin-post-form__input h-[38px] rounded border border-[#e3e6e8] bg-[#eff1f2] px-3 py-2 transition-colors hover:border-[#c8ced3] focus:border-[#8e9cac] focus:outline-none"
|
||||
type="text"
|
||||
maxlength="80"
|
||||
placeholder="비워두면 글 제목을 사용"
|
||||
>
|
||||
<span class="admin-post-form__hint text-xs text-muted">
|
||||
{{ form.seoTitle.length }}/80
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label class="admin-post-form__field grid gap-2">
|
||||
<span class="admin-post-form__label font-medium">SEO 설명</span>
|
||||
<textarea
|
||||
v-model="form.seoDescription"
|
||||
class="admin-post-form__textarea min-h-[108px] rounded border border-[#e3e6e8] bg-[#eff1f2] px-3 py-2 transition-colors hover:border-[#c8ced3] focus:border-[#8e9cac] focus:outline-none"
|
||||
maxlength="180"
|
||||
placeholder="비워두면 요약을 사용"
|
||||
/>
|
||||
<span class="admin-post-form__hint text-xs text-muted">
|
||||
{{ form.seoDescription.length }}/180
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label class="admin-post-form__checkbox flex items-start gap-2 text-sm">
|
||||
<input
|
||||
v-model="form.noindex"
|
||||
@@ -810,7 +1078,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"
|
||||
@@ -916,5 +1184,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>
|
||||
|
||||
@@ -11,6 +11,10 @@ const props = defineProps({
|
||||
saving: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
defaultTagType: {
|
||||
type: String,
|
||||
default: 'general'
|
||||
}
|
||||
})
|
||||
|
||||
@@ -22,7 +26,6 @@ const form = reactive({
|
||||
name: props.initialTag.name || '',
|
||||
slug: props.initialTag.slug || '',
|
||||
description: props.initialTag.description || '',
|
||||
sortOrder: props.initialTag.sortOrder ?? 0,
|
||||
color: props.initialTag.color || '#15171a'
|
||||
})
|
||||
|
||||
@@ -63,8 +66,9 @@ const submitTag = () => {
|
||||
name: form.name.trim(),
|
||||
slug: toSlug(form.slug || form.name),
|
||||
description: form.description.trim(),
|
||||
sortOrder: Number(form.sortOrder) || 0,
|
||||
color: form.color
|
||||
sortOrder: props.initialTag.sortOrder ?? 0,
|
||||
color: form.color,
|
||||
tagType: props.initialTag.tagType || props.defaultTagType
|
||||
})
|
||||
}
|
||||
</script>
|
||||
@@ -102,36 +106,23 @@ const submitTag = () => {
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div class="admin-tag-form__display grid gap-4 md:grid-cols-2">
|
||||
<label class="admin-tag-form__field grid gap-2 text-sm">
|
||||
<span class="admin-tag-form__label font-medium">정렬 순서</span>
|
||||
<label class="admin-tag-form__field grid gap-2 text-sm">
|
||||
<span class="admin-tag-form__label font-medium">색상 코드</span>
|
||||
<span class="admin-tag-form__color-row flex items-center gap-3">
|
||||
<input
|
||||
v-model.number="form.sortOrder"
|
||||
class="admin-tag-form__input rounded border border-line bg-white px-3 py-2"
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
v-model="form.color"
|
||||
class="admin-tag-form__color h-10 w-12 rounded border border-line bg-white p-1"
|
||||
type="color"
|
||||
>
|
||||
</label>
|
||||
|
||||
<label class="admin-tag-form__field grid gap-2 text-sm">
|
||||
<span class="admin-tag-form__label font-medium">색상 코드</span>
|
||||
<span class="admin-tag-form__color-row flex items-center gap-3">
|
||||
<input
|
||||
v-model="form.color"
|
||||
class="admin-tag-form__color h-10 w-12 rounded border border-line bg-white p-1"
|
||||
type="color"
|
||||
>
|
||||
<input
|
||||
v-model="form.color"
|
||||
class="admin-tag-form__input min-w-0 flex-1 rounded border border-line bg-white px-3 py-2"
|
||||
type="text"
|
||||
pattern="#[0-9a-fA-F]{6}"
|
||||
required
|
||||
>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<input
|
||||
v-model="form.color"
|
||||
class="admin-tag-form__input min-w-0 flex-1 rounded border border-line bg-white px-3 py-2"
|
||||
type="text"
|
||||
pattern="#[0-9a-fA-F]{6}"
|
||||
required
|
||||
>
|
||||
</span>
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<div class="admin-tag-form__actions flex justify-end gap-3">
|
||||
|
||||
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>
|
||||
106
components/auth/AuthPasswordVisibilityToggle.vue
Normal file
106
components/auth/AuthPasswordVisibilityToggle.vue
Normal file
@@ -0,0 +1,106 @@
|
||||
<script setup>
|
||||
/**
|
||||
* 비밀번호 필드 표시/숨김 토글(Material 스타일 눈 아이콘 SVG)
|
||||
*/
|
||||
const props = defineProps({
|
||||
/** 비밀번호를 평문으로 표시할 때 true */
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
required: true
|
||||
},
|
||||
/**
|
||||
* 스크린 리더용 필드 이름(예: 비밀번호 확인)
|
||||
*/
|
||||
fieldName: {
|
||||
type: String,
|
||||
default: '비밀번호'
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
/**
|
||||
* 접근성용 레이블 문자열
|
||||
* @param {'show' | 'hide'} kind - 보기 또는 숨기기
|
||||
* @returns {string}
|
||||
*/
|
||||
const labelFor = (kind) => {
|
||||
if (kind === 'show') {
|
||||
return `${props.fieldName} 보기`
|
||||
}
|
||||
|
||||
return `${props.fieldName} 숨기기`
|
||||
}
|
||||
|
||||
/**
|
||||
* 표시 상태를 반전한다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const toggle = () => {
|
||||
emit('update:modelValue', !props.modelValue)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
class="auth-password-visibility-toggle"
|
||||
type="button"
|
||||
:aria-label="modelValue ? labelFor('hide') : labelFor('show')"
|
||||
:aria-pressed="modelValue"
|
||||
@click="toggle"
|
||||
>
|
||||
<svg
|
||||
v-if="!modelValue"
|
||||
class="auth-password-visibility-toggle__icon"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M12 4.5C7 4.5 2.73 7.61 1 12c1.73 4.39 6 7.5 11 7.5s9.27-3.11 11-7.5c-1.73-4.39-6-7.5-11-7.5zM12 17c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5zm0-8c-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3-1.34-3-3-3z" />
|
||||
</svg>
|
||||
<svg
|
||||
v-else
|
||||
class="auth-password-visibility-toggle__icon"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M12 7c2.76 0 5 2.24 5 5 0 .65-.13 1.26-.36 1.83l2.92 2.92c1.51-1.26 2.7-2.89 3.43-4.75-1.73-4.39-6-7.5-11-7.5-1.4 0-2.74.25-3.98.7l2.16 2.16C10.74 7.13 11.35 7 12 7zM2 4.27l2.28 2.28.46.46C3.08 8.3 1.78 10.02 1 12c1.73 4.39 6 7.5 11 7.5 1.55 0 3.03-.3 4.38-.84l.42.42L19.73 22 22 19.73 3.27 3 2 4.27zM7.53 9.8l1.55 1.55c-.05.21-.08.43-.08.65 0 1.66 1.34 3 3 3 .22 0 .44-.03.65-.08l1.55 1.55c-.67.33-1.41.53-2.2.53-2.76 0-5-2.24-5-5 0-.79.2-1.53.53-2.2zm4.31-.78 3.15 3.15.02-.16c0-1.66-1.34-3-3-3l-.17.01z" />
|
||||
</svg>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.auth-password-visibility-toggle {
|
||||
display: flex;
|
||||
height: 2.5rem;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding-left: 0.625rem;
|
||||
padding-right: 0.625rem;
|
||||
margin: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #9ba3af;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.auth-password-visibility-toggle:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.auth-password-visibility-toggle:focus-visible {
|
||||
outline: 2px solid rgba(47, 111, 235, 0.55);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.auth-password-visibility-toggle__icon {
|
||||
display: block;
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
}
|
||||
</style>
|
||||
546
components/comments/PostComments.vue
Normal file
546
components/comments/PostComments.vue
Normal file
@@ -0,0 +1,546 @@
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
slug: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
|
||||
const comments = ref([])
|
||||
const member = ref(null)
|
||||
const loadingComments = ref(false)
|
||||
const submitting = ref(false)
|
||||
const submittingReplyId = ref('')
|
||||
const likingCommentIds = ref([])
|
||||
const errorMessage = ref('')
|
||||
const replyErrorMessage = ref('')
|
||||
const newCommentBody = ref('')
|
||||
const replyBody = ref('')
|
||||
const activeReplyTargetId = ref('')
|
||||
const sortOption = ref('best')
|
||||
const brokenAvatarCommentIds = ref([])
|
||||
|
||||
/**
|
||||
* 댓글 시간을 상대 시간 형식으로 변환한다.
|
||||
* @param {string} value - ISO 날짜 문자열
|
||||
* @returns {string} 표시용 문자열
|
||||
*/
|
||||
const formatCommentDate = (value) => {
|
||||
if (!value) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const now = Date.now()
|
||||
const diffMs = now - date.getTime()
|
||||
|
||||
if (diffMs < 60 * 1000) {
|
||||
return '방금 전'
|
||||
}
|
||||
|
||||
if (diffMs < 60 * 60 * 1000) {
|
||||
return `${Math.max(1, Math.floor(diffMs / (60 * 1000)))}분 전`
|
||||
}
|
||||
|
||||
if (diffMs < 24 * 60 * 60 * 1000) {
|
||||
return `${Math.max(1, Math.floor(diffMs / (60 * 60 * 1000)))}시간 전`
|
||||
}
|
||||
|
||||
const sameYear = date.getFullYear() === new Date(now).getFullYear()
|
||||
return date.toLocaleDateString('ko-KR', sameYear
|
||||
? { month: 'short', day: 'numeric' }
|
||||
: { year: 'numeric', month: 'short', day: 'numeric' })
|
||||
}
|
||||
|
||||
/**
|
||||
* 댓글 작성자 아바타 이니셜을 생성한다.
|
||||
* @param {{ username?: string, email?: string }} user - 작성자 정보
|
||||
* @returns {string} 아바타 이니셜
|
||||
*/
|
||||
const getAvatarInitials = (user) => {
|
||||
const baseText = String(user?.username || user?.email || '').trim()
|
||||
if (!baseText) {
|
||||
return '@'
|
||||
}
|
||||
const tokens = baseText.split(/\s+/g).filter(Boolean)
|
||||
if (tokens.length >= 2) {
|
||||
return `${tokens[0].slice(0, 1)}${tokens[1].slice(0, 1)}`.toUpperCase()
|
||||
}
|
||||
return baseText.slice(0, 2).toUpperCase()
|
||||
}
|
||||
|
||||
/**
|
||||
* 댓글 작성 시각 숫자값 반환
|
||||
* @param {string} value - ISO 날짜 문자열
|
||||
* @returns {number} 시간 숫자값
|
||||
*/
|
||||
const toTimeValue = (value) => {
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return 0
|
||||
}
|
||||
return date.getTime()
|
||||
}
|
||||
|
||||
/**
|
||||
* 아바타 이미지 깨짐 여부 확인
|
||||
* @param {string} commentId - 댓글 ID
|
||||
* @returns {boolean} 깨짐 여부
|
||||
*/
|
||||
const isAvatarBroken = (commentId) => brokenAvatarCommentIds.value.includes(commentId)
|
||||
|
||||
/**
|
||||
* 아바타 이미지 로드 실패를 기록한다.
|
||||
* @param {string} commentId - 댓글 ID
|
||||
* @returns {void}
|
||||
*/
|
||||
const markAvatarBroken = (commentId) => {
|
||||
if (isAvatarBroken(commentId)) {
|
||||
return
|
||||
}
|
||||
brokenAvatarCommentIds.value = [...brokenAvatarCommentIds.value, commentId]
|
||||
}
|
||||
|
||||
/**
|
||||
* 회원 세션을 조회한다.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const fetchMember = async () => {
|
||||
try {
|
||||
member.value = await $fetch('/api/auth/me')
|
||||
} catch {
|
||||
member.value = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 댓글 목록을 조회한다.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const fetchComments = async () => {
|
||||
loadingComments.value = true
|
||||
errorMessage.value = ''
|
||||
|
||||
try {
|
||||
const response = await $fetch(`/api/posts/${props.slug}/comments`)
|
||||
comments.value = response.comments || []
|
||||
} catch (error) {
|
||||
comments.value = []
|
||||
errorMessage.value = error?.data?.message || '댓글을 불러오지 못했습니다.'
|
||||
} finally {
|
||||
loadingComments.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 루트 댓글을 작성한다.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const submitComment = async () => {
|
||||
const body = newCommentBody.value.trim()
|
||||
if (!body || submitting.value) {
|
||||
return
|
||||
}
|
||||
|
||||
submitting.value = true
|
||||
errorMessage.value = ''
|
||||
|
||||
try {
|
||||
await $fetch(`/api/posts/${props.slug}/comments`, {
|
||||
method: 'POST',
|
||||
body: {
|
||||
body
|
||||
}
|
||||
})
|
||||
newCommentBody.value = ''
|
||||
await fetchComments()
|
||||
} catch (error) {
|
||||
errorMessage.value = error?.data?.message || '댓글 작성에 실패했습니다.'
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 답글 작성 UI를 연다.
|
||||
* @param {string} commentId - 대상 댓글 ID
|
||||
* @returns {void}
|
||||
*/
|
||||
const openReplyForm = (commentId) => {
|
||||
activeReplyTargetId.value = commentId
|
||||
replyBody.value = ''
|
||||
replyErrorMessage.value = ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 답글 작성 UI를 닫는다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const closeReplyForm = () => {
|
||||
activeReplyTargetId.value = ''
|
||||
replyBody.value = ''
|
||||
replyErrorMessage.value = ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 대댓글을 작성한다.
|
||||
* @param {string} parentId - 부모 댓글 ID
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const submitReply = async (parentId) => {
|
||||
const body = replyBody.value.trim()
|
||||
if (!body || submittingReplyId.value) {
|
||||
return
|
||||
}
|
||||
|
||||
submittingReplyId.value = parentId
|
||||
replyErrorMessage.value = ''
|
||||
|
||||
try {
|
||||
await $fetch(`/api/posts/${props.slug}/comments`, {
|
||||
method: 'POST',
|
||||
body: {
|
||||
body,
|
||||
parentId
|
||||
}
|
||||
})
|
||||
closeReplyForm()
|
||||
await fetchComments()
|
||||
} catch (error) {
|
||||
replyErrorMessage.value = error?.data?.message || '답글 작성에 실패했습니다.'
|
||||
} finally {
|
||||
submittingReplyId.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 댓글의 좋아요 요청 진행 여부
|
||||
* @param {string} commentId - 댓글 ID
|
||||
* @returns {boolean} 진행 여부
|
||||
*/
|
||||
const isLikingComment = (commentId) => likingCommentIds.value.includes(commentId)
|
||||
|
||||
/**
|
||||
* 댓글 좋아요를 토글한다.
|
||||
* @param {string} commentId - 댓글 ID
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const toggleLike = async (commentId) => {
|
||||
if (!member.value || isLikingComment(commentId)) {
|
||||
return
|
||||
}
|
||||
|
||||
likingCommentIds.value = [...likingCommentIds.value, commentId]
|
||||
errorMessage.value = ''
|
||||
|
||||
try {
|
||||
const result = await $fetch(`/api/posts/${props.slug}/comments/${commentId}/like`, {
|
||||
method: 'POST'
|
||||
})
|
||||
|
||||
comments.value = comments.value.map((item) => {
|
||||
if (item.id !== commentId) {
|
||||
return item
|
||||
}
|
||||
return {
|
||||
...item,
|
||||
likeCount: Number(result.likeCount || 0),
|
||||
likedByMe: Boolean(result.liked)
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
errorMessage.value = error?.data?.message || '좋아요 처리에 실패했습니다.'
|
||||
} finally {
|
||||
likingCommentIds.value = likingCommentIds.value.filter((id) => id !== commentId)
|
||||
}
|
||||
}
|
||||
|
||||
const rootComments = computed(() => comments.value.filter((item) => !item.parentId))
|
||||
const sortedRootComments = computed(() => {
|
||||
const copied = [...rootComments.value]
|
||||
|
||||
if (sortOption.value === 'latest') {
|
||||
return copied.sort((left, right) => toTimeValue(right.createdAt) - toTimeValue(left.createdAt))
|
||||
}
|
||||
|
||||
if (sortOption.value === 'oldest') {
|
||||
return copied.sort((left, right) => toTimeValue(left.createdAt) - toTimeValue(right.createdAt))
|
||||
}
|
||||
|
||||
return copied.sort((left, right) => {
|
||||
const likeDiff = Number(right.likeCount || 0) - Number(left.likeCount || 0)
|
||||
if (likeDiff !== 0) {
|
||||
return likeDiff
|
||||
}
|
||||
return toTimeValue(left.createdAt) - toTimeValue(right.createdAt)
|
||||
})
|
||||
})
|
||||
const repliesByParent = computed(() => {
|
||||
/** @type {Record<string, Array<any>>} */
|
||||
const grouped = {}
|
||||
|
||||
for (const item of comments.value) {
|
||||
if (!item.parentId) {
|
||||
continue
|
||||
}
|
||||
if (!grouped[item.parentId]) {
|
||||
grouped[item.parentId] = []
|
||||
}
|
||||
grouped[item.parentId].push(item)
|
||||
}
|
||||
|
||||
for (const parentId of Object.keys(grouped)) {
|
||||
grouped[parentId] = grouped[parentId].sort((left, right) => toTimeValue(left.createdAt) - toTimeValue(right.createdAt))
|
||||
}
|
||||
|
||||
return grouped
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([fetchMember(), fetchComments()])
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="post-comments text-sm">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<p class="font-medium"><span class="site-muted">{{ comments.length }}</span> Comments</p>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="mt-3 flex items-center gap-2 text-xs site-muted">
|
||||
<label for="comment-sort">Sort by:</label>
|
||||
<select
|
||||
id="comment-sort"
|
||||
v-model="sortOption"
|
||||
class="rounded-md border border-[var(--site-line)] bg-transparent px-2 py-1 text-xs font-semibold text-[var(--site-ink)] outline-none"
|
||||
>
|
||||
<option value="best">Best</option>
|
||||
<option value="latest">Latest</option>
|
||||
<option value="oldest">Oldest</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<div v-if="member" class="rounded-[10px] p-3">
|
||||
<p class="mb-2 text-xs site-muted">
|
||||
{{ member.username || member.email }} 님으로 댓글 작성
|
||||
</p>
|
||||
<textarea
|
||||
v-model="newCommentBody"
|
||||
rows="4"
|
||||
class="w-full rounded-[10px] border border-[var(--site-line)] bg-transparent px-3 py-2 outline-none focus-visible:border-[var(--site-accent)]"
|
||||
placeholder="댓글을 입력해 주세요."
|
||||
/>
|
||||
<div class="mt-2 flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
class="site-accent-button rounded-[10px] px-3 py-1.5 text-xs font-semibold disabled:opacity-60"
|
||||
:disabled="submitting"
|
||||
@click="submitComment"
|
||||
>
|
||||
{{ submitting ? '등록 중...' : '댓글 등록' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="rounded-[10px] border border-[var(--site-line)] bg-[var(--site-bg)] p-3">
|
||||
<p class="site-muted">
|
||||
댓글은 로그인한 회원만 작성할 수 있습니다.
|
||||
</p>
|
||||
<NuxtLink to="/signin" class="mt-2 inline-flex rounded-[10px] border border-[var(--site-line)] px-3 py-1.5 text-xs font-semibold hover:opacity-80">
|
||||
로그인하러 가기
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="errorMessage" class="mt-3 text-xs text-red-500">
|
||||
{{ errorMessage }}
|
||||
</p>
|
||||
|
||||
<div class="mt-5">
|
||||
<p v-if="loadingComments" class="text-xs site-muted">
|
||||
댓글을 불러오는 중입니다.
|
||||
</p>
|
||||
|
||||
<ul v-else-if="sortedRootComments.length > 0" class="flex flex-col divide-y divide-[var(--site-line)]">
|
||||
<li
|
||||
v-for="comment in sortedRootComments"
|
||||
:key="comment.id"
|
||||
class="py-4"
|
||||
>
|
||||
<div class="flex gap-3">
|
||||
<div class="flex w-8 flex-none flex-col items-center">
|
||||
<div class="h-8 w-8 min-h-8 min-w-8 overflow-hidden rounded-full border border-[var(--site-line)] bg-[var(--site-panel)]">
|
||||
<img
|
||||
v-if="comment.user.avatarUrl && !isAvatarBroken(comment.id)"
|
||||
:src="comment.user.avatarUrl"
|
||||
:alt="`${comment.user.username} 아바타`"
|
||||
class="block h-full w-full object-cover"
|
||||
@error="markAvatarBroken(comment.id)"
|
||||
>
|
||||
<span
|
||||
v-else
|
||||
class="grid h-full w-full place-items-center text-[11px] font-semibold site-muted"
|
||||
>
|
||||
{{ getAvatarInitials(comment.user) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<strong class="text-sm">{{ comment.user.username }}</strong>
|
||||
<span class="text-xs site-muted">{{ formatCommentDate(comment.createdAt) }}</span>
|
||||
</div>
|
||||
<p class="mt-2 whitespace-pre-line leading-relaxed">
|
||||
{{ comment.body }}
|
||||
</p>
|
||||
|
||||
<div class="mt-2 flex items-center gap-3 text-xs">
|
||||
<button
|
||||
type="button"
|
||||
class="group inline-flex items-center gap-1 site-muted hover:opacity-75 disabled:opacity-50"
|
||||
:disabled="!member || isLikingComment(comment.id)"
|
||||
@click="toggleLike(comment.id)"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 16 16"
|
||||
class="h-3.5 w-3.5"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M8 13.2 2.9 8.1a3.4 3.4 0 0 1 0-4.8 3.4 3.4 0 0 1 4.8 0L8 3.6l.3-.3a3.4 3.4 0 0 1 4.8 4.8L8 13.2Z"
|
||||
:fill="comment.likedByMe ? 'currentColor' : 'none'"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.2"
|
||||
/>
|
||||
</svg>
|
||||
<span>{{ comment.likeCount || 0 }}</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="member"
|
||||
type="button"
|
||||
class="group inline-flex items-center gap-1 site-muted hover:opacity-75"
|
||||
@click="openReplyForm(comment.id)"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 16 16"
|
||||
class="h-3.5 w-3.5"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="m1.5 7 4.7-4.2v2.6c4.7 0 8 2 8.3 6.5-1.5-2.3-3.3-3.1-8.3-3.1v2.4L1.5 7Z"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
답글
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="activeReplyTargetId === comment.id" class="mt-2 rounded-[10px] border border-[var(--site-line)] p-2">
|
||||
<textarea
|
||||
v-model="replyBody"
|
||||
rows="3"
|
||||
class="w-full rounded-[10px] border border-[var(--site-line)] bg-transparent px-3 py-2 outline-none focus-visible:border-[var(--site-accent)]"
|
||||
placeholder="답글을 입력해 주세요."
|
||||
/>
|
||||
<p v-if="replyErrorMessage" class="mt-2 text-xs text-red-500">
|
||||
{{ replyErrorMessage }}
|
||||
</p>
|
||||
<div class="mt-2 flex justify-end gap-2">
|
||||
<button type="button" class="rounded-[10px] border border-[var(--site-line)] px-3 py-1.5 text-xs" @click="closeReplyForm">
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="site-accent-button rounded-[10px] px-3 py-1.5 text-xs font-semibold disabled:opacity-60"
|
||||
:disabled="submittingReplyId === comment.id"
|
||||
@click="submitReply(comment.id)"
|
||||
>
|
||||
{{ submittingReplyId === comment.id ? '등록 중...' : '답글 등록' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul
|
||||
v-if="repliesByParent[comment.id]?.length"
|
||||
class="mt-3 ml-4 flex flex-col gap-3 border-l border-[var(--site-line)]/90 pl-4"
|
||||
>
|
||||
<li
|
||||
v-for="reply in repliesByParent[comment.id]"
|
||||
:key="reply.id"
|
||||
class="relative rounded-[10px] bg-[var(--site-panel)] p-2.5"
|
||||
>
|
||||
<span class="absolute -left-4 top-5 h-px w-3 bg-[var(--site-line)]/90" />
|
||||
<div class="flex gap-2.5">
|
||||
<div class="h-8 w-8 min-h-8 min-w-8 flex-none overflow-hidden rounded-full border border-[var(--site-line)] bg-[var(--site-bg)]">
|
||||
<img
|
||||
v-if="reply.user.avatarUrl && !isAvatarBroken(reply.id)"
|
||||
:src="reply.user.avatarUrl"
|
||||
:alt="`${reply.user.username} 아바타`"
|
||||
class="block h-full w-full object-cover"
|
||||
@error="markAvatarBroken(reply.id)"
|
||||
>
|
||||
<span
|
||||
v-else
|
||||
class="grid h-full w-full place-items-center text-[11px] font-semibold site-muted"
|
||||
>
|
||||
{{ getAvatarInitials(reply.user) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<strong class="text-sm">{{ reply.user.username }}</strong>
|
||||
<span class="text-xs site-muted">{{ formatCommentDate(reply.createdAt) }}</span>
|
||||
</div>
|
||||
<p class="mt-1 whitespace-pre-line leading-relaxed">
|
||||
{{ reply.body }}
|
||||
</p>
|
||||
<div class="mt-1.5 text-xs">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-1 site-muted hover:opacity-75 disabled:opacity-50"
|
||||
:disabled="!member || isLikingComment(reply.id)"
|
||||
@click="toggleLike(reply.id)"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 16 16"
|
||||
class="h-3.5 w-3.5"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M8 13.2 2.9 8.1a3.4 3.4 0 0 1 0-4.8 3.4 3.4 0 0 1 4.8 0L8 3.6l.3-.3a3.4 3.4 0 0 1 4.8 4.8L8 13.2Z"
|
||||
:fill="reply.likedByMe ? 'currentColor' : 'none'"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.2"
|
||||
/>
|
||||
</svg>
|
||||
<span>{{ reply.likeCount || 0 }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<p v-else class="text-xs site-muted">
|
||||
첫 댓글을 남겨보세요.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</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 - 마크다운 행
|
||||
@@ -52,6 +99,52 @@ const parseImageLine = (line) => {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 독립 블록으로 해석할 수 있는 마크다운 행인지 확인한다.
|
||||
* @param {string} line - 마크다운 행
|
||||
* @returns {boolean} 블록 시작 여부
|
||||
*/
|
||||
const isMarkdownBlockStart = (line) => {
|
||||
const trimmedLine = line.trim()
|
||||
|
||||
return trimmedLine === BLANK_PARAGRAPH_MARKER ||
|
||||
trimmedLine === '>>>' ||
|
||||
trimmedLine === ':::bookmark' ||
|
||||
trimmedLine === ':::signup' ||
|
||||
trimmedLine === ':::gallery' ||
|
||||
trimmedLine === ':::embed' ||
|
||||
trimmedLine.startsWith(':::callout') ||
|
||||
trimmedLine.startsWith(':::toggle') ||
|
||||
trimmedLine.startsWith('```') ||
|
||||
trimmedLine === '---' ||
|
||||
/^(#{1,6})\s+(.+)$/.test(trimmedLine) ||
|
||||
trimmedLine.startsWith('> ') ||
|
||||
/^- /.test(trimmedLine) ||
|
||||
/^\d+\.\s+/.test(trimmedLine) ||
|
||||
Boolean(parseImageLine(trimmedLine))
|
||||
}
|
||||
|
||||
/**
|
||||
* 마크다운 hard break 표식이 있는 행인지 확인한다.
|
||||
* @param {string} line - 마크다운 행
|
||||
* @returns {boolean} hard break 여부
|
||||
*/
|
||||
const hasMarkdownHardBreak = (line) => /( {2,}|\\)$/.test(line)
|
||||
|
||||
/**
|
||||
* 문단 행에서 hard break 표식을 제거한다.
|
||||
* @param {string} line - 마크다운 행
|
||||
* @returns {string} 정리된 문단 행
|
||||
*/
|
||||
const cleanParagraphLine = (line) => line.replace(/( {2,}|\\)$/, '').trim()
|
||||
|
||||
/**
|
||||
* 빈 줄 공백 블록 높이를 반환한다.
|
||||
* @param {Object} block - 렌더링 블록
|
||||
* @returns {string} Tailwind 높이 클래스
|
||||
*/
|
||||
const getSpacerHeightClass = (block) => block.meta?.legacy ? 'h-6' : 'h-8'
|
||||
|
||||
/**
|
||||
* 닫힘 표식까지의 행 목록을 반환
|
||||
* @param {Array<string>} lines - 전체 마크다운 행
|
||||
@@ -177,7 +270,14 @@ const parseMarkdownBlocks = (markdown) => {
|
||||
const line = lines[index]
|
||||
const trimmedLine = line.trim()
|
||||
|
||||
if (trimmedLine === BLANK_PARAGRAPH_MARKER) {
|
||||
blocks.push(createBlock('spacer', '', null, `block-${blocks.length}`, { meta: { legacy: true } }))
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
|
||||
if (!trimmedLine) {
|
||||
blocks.push(createBlock('spacer', '', null, `block-${blocks.length}`))
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
@@ -232,9 +332,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
|
||||
}
|
||||
@@ -326,8 +426,24 @@ const parseMarkdownBlocks = (markdown) => {
|
||||
continue
|
||||
}
|
||||
|
||||
blocks.push(createBlock('paragraph', trimmedLine, null, `block-${blocks.length}`))
|
||||
const paragraphLines = [cleanParagraphLine(line)]
|
||||
let shouldJoinNextLine = hasMarkdownHardBreak(line)
|
||||
index += 1
|
||||
|
||||
while (shouldJoinNextLine && index < lines.length) {
|
||||
const nextLine = lines[index]
|
||||
const nextTrimmedLine = nextLine.trim()
|
||||
|
||||
if (!nextTrimmedLine || isMarkdownBlockStart(nextTrimmedLine)) {
|
||||
break
|
||||
}
|
||||
|
||||
paragraphLines.push(cleanParagraphLine(nextLine))
|
||||
shouldJoinNextLine = hasMarkdownHardBreak(nextLine)
|
||||
index += 1
|
||||
}
|
||||
|
||||
blocks.push(createBlock('paragraph', paragraphLines.join('\n'), null, `block-${blocks.length}`))
|
||||
}
|
||||
|
||||
return blocks
|
||||
@@ -336,6 +452,72 @@ const parseMarkdownBlocks = (markdown) => {
|
||||
const blocks = computed(() => parseMarkdownBlocks(props.content))
|
||||
const activeLightboxImage = computed(() => activeLightboxImages.value[activeLightboxIndex.value])
|
||||
|
||||
/**
|
||||
* 인라인 마크다운을 표시 세그먼트로 변환한다.
|
||||
* @param {string} value - 원본 문자열
|
||||
* @returns {Array<{ type: string, text: string, href?: string }>} 인라인 세그먼트
|
||||
*/
|
||||
const parseInlineSegments = (value) => {
|
||||
const source = String(value || '')
|
||||
const segments = []
|
||||
const pattern = /(\[([^\]]+)\]\((https?:\/\/[^)\s]+|\/[^)\s]+)\)|\*\*([^*]+)\*\*|`([^`]+)`|\*([^*]+)\*)/g
|
||||
let lastIndex = 0
|
||||
let match = pattern.exec(source)
|
||||
|
||||
while (match) {
|
||||
if (match.index > lastIndex) {
|
||||
segments.push({
|
||||
type: 'text',
|
||||
text: source.slice(lastIndex, match.index)
|
||||
})
|
||||
}
|
||||
|
||||
if (match[2] && match[3]) {
|
||||
segments.push({
|
||||
type: 'link',
|
||||
text: match[2],
|
||||
href: match[3]
|
||||
})
|
||||
} else if (match[4]) {
|
||||
segments.push({
|
||||
type: 'strong',
|
||||
text: match[4]
|
||||
})
|
||||
} else if (match[5]) {
|
||||
segments.push({
|
||||
type: 'code',
|
||||
text: match[5]
|
||||
})
|
||||
} else if (match[6]) {
|
||||
segments.push({
|
||||
type: 'em',
|
||||
text: match[6]
|
||||
})
|
||||
}
|
||||
|
||||
lastIndex = pattern.lastIndex
|
||||
match = pattern.exec(source)
|
||||
}
|
||||
|
||||
if (lastIndex < source.length) {
|
||||
segments.push({
|
||||
type: 'text',
|
||||
text: source.slice(lastIndex)
|
||||
})
|
||||
}
|
||||
|
||||
return segments.length ? segments : [{ type: 'text', text: source }]
|
||||
}
|
||||
|
||||
/**
|
||||
* 줄바꿈이 포함된 인라인 마크다운을 줄 단위 세그먼트로 변환한다.
|
||||
* @param {string} value - 원본 문자열
|
||||
* @returns {Array<Array<{ type: string, text: string, href?: string }>>} 줄별 인라인 세그먼트
|
||||
*/
|
||||
const parseInlineSegmentLines = (value) => {
|
||||
return String(value || '').split('\n').map(parseInlineSegments)
|
||||
}
|
||||
|
||||
/**
|
||||
* 라이트박스를 연다
|
||||
* @param {Array<Object>} images - 이미지 목록
|
||||
@@ -378,25 +560,61 @@ const showNextImage = () => {
|
||||
<template>
|
||||
<div class="content-markdown-renderer">
|
||||
<template v-for="block in blocks" :key="block.id">
|
||||
<ProseHeading v-if="block.type === 'heading'" :level="block.level">
|
||||
{{ block.text }}
|
||||
<div v-if="block.type === 'spacer'" class="content-markdown-renderer__spacer" :class="getSpacerHeightClass(block)" aria-hidden="true" />
|
||||
<ProseHeading v-else-if="block.type === 'heading'" :level="block.level">
|
||||
<template v-for="(segment, segmentIndex) in parseInlineSegments(block.text)" :key="`${block.id}-heading-${segmentIndex}`">
|
||||
<strong v-if="segment.type === 'strong'">{{ segment.text }}</strong>
|
||||
<em v-else-if="segment.type === 'em'">{{ segment.text }}</em>
|
||||
<code v-else-if="segment.type === 'code'" class="rounded bg-[var(--site-panel)] px-1 py-0.5 text-[0.9em]">{{ segment.text }}</code>
|
||||
<a v-else-if="segment.type === 'link'" class="text-[var(--site-accent)] underline underline-offset-4" :href="segment.href" target="_blank" rel="noreferrer">{{ segment.text }}</a>
|
||||
<template v-else>{{ segment.text }}</template>
|
||||
</template>
|
||||
</ProseHeading>
|
||||
<ProseBlockquote v-else-if="block.type === 'quote'" :variant="block.variant || 'default'">
|
||||
{{ block.text }}
|
||||
<template v-for="(segment, segmentIndex) in parseInlineSegments(block.text)" :key="`${block.id}-quote-${segmentIndex}`">
|
||||
<strong v-if="segment.type === 'strong'">{{ segment.text }}</strong>
|
||||
<em v-else-if="segment.type === 'em'">{{ segment.text }}</em>
|
||||
<code v-else-if="segment.type === 'code'" class="rounded bg-[var(--site-panel)] px-1 py-0.5 text-[0.9em]">{{ segment.text }}</code>
|
||||
<a v-else-if="segment.type === 'link'" class="text-[var(--site-accent)] underline underline-offset-4" :href="segment.href" target="_blank" rel="noreferrer">{{ segment.text }}</a>
|
||||
<template v-else>{{ segment.text }}</template>
|
||||
</template>
|
||||
</ProseBlockquote>
|
||||
<ProseList v-else-if="block.type === 'list'" :ordered="block.ordered || false">
|
||||
<li v-for="(item, itemIndex) in block.text" :key="`${block.id}-${itemIndex}`">
|
||||
{{ item }}
|
||||
<template v-for="(segment, segmentIndex) in parseInlineSegments(item)" :key="`${block.id}-${itemIndex}-${segmentIndex}`">
|
||||
<strong v-if="segment.type === 'strong'">{{ segment.text }}</strong>
|
||||
<em v-else-if="segment.type === 'em'">{{ segment.text }}</em>
|
||||
<code v-else-if="segment.type === 'code'" class="rounded bg-[var(--site-panel)] px-1 py-0.5 text-[0.9em]">{{ segment.text }}</code>
|
||||
<a v-else-if="segment.type === 'link'" class="text-[var(--site-accent)] underline underline-offset-4" :href="segment.href" target="_blank" rel="noreferrer">{{ segment.text }}</a>
|
||||
<template v-else>{{ segment.text }}</template>
|
||||
</template>
|
||||
</li>
|
||||
</ProseList>
|
||||
<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'">
|
||||
{{ block.text }}
|
||||
<ProseCallout
|
||||
v-else-if="block.type === 'callout'"
|
||||
:emoji-enabled="block.calloutEmojiEnabled"
|
||||
:emoji="block.calloutEmoji"
|
||||
:background="block.calloutBackground"
|
||||
>
|
||||
<template v-for="(segment, segmentIndex) in parseInlineSegments(block.text)" :key="`${block.id}-callout-${segmentIndex}`">
|
||||
<strong v-if="segment.type === 'strong'">{{ segment.text }}</strong>
|
||||
<em v-else-if="segment.type === 'em'">{{ segment.text }}</em>
|
||||
<code v-else-if="segment.type === 'code'" class="rounded bg-black/5 px-1 py-0.5 text-[0.9em]">{{ segment.text }}</code>
|
||||
<a v-else-if="segment.type === 'link'" class="underline underline-offset-4" :href="segment.href" target="_blank" rel="noreferrer">{{ segment.text }}</a>
|
||||
<template v-else>{{ segment.text }}</template>
|
||||
</template>
|
||||
</ProseCallout>
|
||||
<ProseToggle v-else-if="block.type === 'toggle'" :title="block.title || '더 보기'">
|
||||
{{ block.text }}
|
||||
<template v-for="(segment, segmentIndex) in parseInlineSegments(block.text)" :key="`${block.id}-toggle-${segmentIndex}`">
|
||||
<strong v-if="segment.type === 'strong'">{{ segment.text }}</strong>
|
||||
<em v-else-if="segment.type === 'em'">{{ segment.text }}</em>
|
||||
<code v-else-if="segment.type === 'code'" class="rounded bg-[var(--site-panel)] px-1 py-0.5 text-[0.9em]">{{ segment.text }}</code>
|
||||
<a v-else-if="segment.type === 'link'" class="text-[var(--site-accent)] underline underline-offset-4" :href="segment.href" target="_blank" rel="noreferrer">{{ segment.text }}</a>
|
||||
<template v-else>{{ segment.text }}</template>
|
||||
</template>
|
||||
</ProseToggle>
|
||||
<ProseBookmark
|
||||
v-else-if="block.type === 'bookmark' && block.meta.url"
|
||||
@@ -429,8 +647,17 @@ const showNextImage = () => {
|
||||
class="content-markdown-renderer__code my-6 overflow-x-auto rounded bg-[#15171a] px-4 py-3 text-sm leading-6 text-white"
|
||||
><code>{{ block.text }}</code></pre>
|
||||
<hr v-else-if="block.type === 'divider'" class="content-markdown-renderer__divider my-10 border-line">
|
||||
<p v-else class="content-markdown-renderer__paragraph my-5 text-[15px] leading-8 text-[var(--site-text)]">
|
||||
{{ block.text }}
|
||||
<p v-else class="content-markdown-renderer__paragraph mb-2.5 text-base text-[var(--site-text)] last:mb-0">
|
||||
<template v-for="(lineSegments, lineIndex) in parseInlineSegmentLines(block.text)" :key="`${block.id}-paragraph-line-${lineIndex}`">
|
||||
<br v-if="lineIndex > 0">
|
||||
<template v-for="(segment, segmentIndex) in lineSegments" :key="`${block.id}-paragraph-${lineIndex}-${segmentIndex}`">
|
||||
<strong v-if="segment.type === 'strong'">{{ segment.text }}</strong>
|
||||
<em v-else-if="segment.type === 'em'">{{ segment.text }}</em>
|
||||
<code v-else-if="segment.type === 'code'" class="rounded bg-[var(--site-panel)] px-1 py-0.5 text-[0.9em]">{{ segment.text }}</code>
|
||||
<a v-else-if="segment.type === 'link'" class="text-[var(--site-accent)] underline underline-offset-4" :href="segment.href" target="_blank" rel="noreferrer">{{ segment.text }}</a>
|
||||
<template v-else>{{ segment.text }}</template>
|
||||
</template>
|
||||
</template>
|
||||
</p>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -12,7 +12,7 @@ const tagName = computed(() => `h${Math.min(Math.max(props.level, 1), 6)}`)
|
||||
<template>
|
||||
<component
|
||||
:is="tagName"
|
||||
class="prose-heading mt-12 font-semibold leading-[1.25] tracking-normal first:mt-0"
|
||||
class="prose-heading mb-2.5 font-semibold leading-[1.25] tracking-normal first:mt-0"
|
||||
:class="{
|
||||
'text-[clamp(1.35rem,1.25rem+0.35vw,1.6rem)] leading-[1.15]': level === 1,
|
||||
'text-[clamp(1.2rem,1.15rem+0.3vw,1.4rem)]': level === 2,
|
||||
|
||||
@@ -18,6 +18,123 @@ const { data: navigation } = await useFetch('/api/navigation', {
|
||||
footer: []
|
||||
})
|
||||
})
|
||||
|
||||
/** 저자 영역 공개 여부 */
|
||||
const showAuthorSection = false
|
||||
|
||||
const STORAGE_KEY = 'sori-primary-nav-expanded'
|
||||
|
||||
/**
|
||||
* 트리에서 하위가 있는 노드 id를 모은다.
|
||||
* @param {Array<Object>} list - 노드 목록
|
||||
* @returns {string[]} id 목록
|
||||
*/
|
||||
const collectBranchIds = (list) => {
|
||||
const out = []
|
||||
for (const node of list || []) {
|
||||
if (node.children?.length) {
|
||||
out.push(String(node.id))
|
||||
out.push(...collectBranchIds(node.children))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* localStorage에서 펼침 상태를 읽는다.
|
||||
* @returns {string[]|null} id 배열
|
||||
*/
|
||||
const readStoredExpanded = () => {
|
||||
if (typeof window === 'undefined') {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
const raw = window.localStorage.getItem(STORAGE_KEY)
|
||||
if (!raw) {
|
||||
return null
|
||||
}
|
||||
const parsed = JSON.parse(raw)
|
||||
return Array.isArray(parsed) ? parsed.map(String) : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 펼침 상태를 localStorage에 저장한다.
|
||||
* @param {Set<string>} set - id 집합
|
||||
* @returns {void}
|
||||
*/
|
||||
const persistExpanded = (set) => {
|
||||
if (typeof window === 'undefined') {
|
||||
return
|
||||
}
|
||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify([...set]))
|
||||
}
|
||||
|
||||
const primaryNavExpandedSet = ref(new Set())
|
||||
|
||||
/**
|
||||
* 트리 구조에 맞게 펼침 집합을 맞춘다.
|
||||
* @param {Array<Object>} nodes - primary 트리
|
||||
* @param {boolean} useStorage - 최초·복원 시 저장값 반영
|
||||
* @returns {void}
|
||||
*/
|
||||
const syncPrimaryNavExpanded = (nodes, useStorage = false) => {
|
||||
const allBranch = new Set(collectBranchIds(nodes))
|
||||
if (useStorage) {
|
||||
const stored = readStoredExpanded()
|
||||
if (stored && stored.length) {
|
||||
const next = new Set()
|
||||
for (const id of stored) {
|
||||
if (allBranch.has(id)) {
|
||||
next.add(id)
|
||||
}
|
||||
}
|
||||
primaryNavExpandedSet.value = next.size ? next : allBranch
|
||||
return
|
||||
}
|
||||
}
|
||||
const next = new Set()
|
||||
for (const id of primaryNavExpandedSet.value) {
|
||||
if (allBranch.has(id)) {
|
||||
next.add(id)
|
||||
}
|
||||
}
|
||||
primaryNavExpandedSet.value = next.size ? next : allBranch
|
||||
}
|
||||
|
||||
/**
|
||||
* 상단 네비 폴더 펼침 토글
|
||||
* @param {string} id - 노드 id
|
||||
* @returns {void}
|
||||
*/
|
||||
const togglePrimaryNavBranch = (id) => {
|
||||
const key = String(id)
|
||||
const next = new Set(primaryNavExpandedSet.value)
|
||||
if (next.has(key)) {
|
||||
next.delete(key)
|
||||
} else {
|
||||
next.add(key)
|
||||
}
|
||||
primaryNavExpandedSet.value = next
|
||||
persistExpanded(next)
|
||||
}
|
||||
|
||||
provide('sidebarPrimaryNavExpandedSet', primaryNavExpandedSet)
|
||||
provide('sidebarPrimaryNavToggle', togglePrimaryNavBranch)
|
||||
|
||||
watch(
|
||||
() => navigation.value?.primary,
|
||||
(nodes) => {
|
||||
syncPrimaryNavExpanded(nodes || [], false)
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
syncPrimaryNavExpanded(navigation.value?.primary || [], true)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -31,20 +148,7 @@ const { data: navigation } = await useFetch('/api/navigation', {
|
||||
<div class="left-sidebar__scroll site-sidebar-scroll min-h-0 flex-1">
|
||||
<div class="left-sidebar__block site-sidebar-section py-3 pl-4 pr-3 sm:pl-5 xl:pl-0">
|
||||
<nav class="left-sidebar__nav" data-nav="menu">
|
||||
<ul class="flex flex-col gap-[3px] text-[15px] text-[var(--site-text)]">
|
||||
<li
|
||||
v-for="item in navigation.primary"
|
||||
:key="item.id"
|
||||
class="group relative flex w-full items-center"
|
||||
>
|
||||
<NuxtLink
|
||||
class="left-sidebar__nav-link flex 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:bg-[#f2f2f2] hover:px-3 hover:before:h-2 hover:before:w-2 hover:before:rounded-full hover:before:bg-[rgba(17,17,17,0.25)]"
|
||||
:to="item.url"
|
||||
>
|
||||
<span class="left-sidebar__nav-link-label flex-1 transition-transform duration-200 group-hover:translate-x-[3px]">{{ item.label }}</span>
|
||||
</NuxtLink>
|
||||
</li>
|
||||
</ul>
|
||||
<SidebarPrimaryNavList :nodes="navigation.primary" />
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
@@ -57,7 +161,7 @@ const { data: navigation } = await useFetch('/api/navigation', {
|
||||
<NuxtLink
|
||||
v-for="tag in tags"
|
||||
:key="tag.id"
|
||||
class="left-sidebar__category group flex items-center gap-2 rounded-[10px] py-2 pr-3 pl-0 leading-tight transition-[padding,background-color,color] duration-200 hover:bg-[#f2f2f2] hover:px-3"
|
||||
class="left-sidebar__category site-sidebar-nav-row group flex items-center gap-2 rounded-[10px] py-2 pr-3 pl-0 leading-tight transition-[padding,background-color,color] duration-200 hover:px-3"
|
||||
:to="`/tag/${tag.slug}`"
|
||||
>
|
||||
<span class="left-sidebar__category-color h-4 w-1 rounded-sm rounded-l-none transition-all duration-200 group-hover:h-2 group-hover:w-2 group-hover:rounded-full" :style="{ backgroundColor: tag.color }" />
|
||||
@@ -72,7 +176,7 @@ const { data: navigation } = await useFetch('/api/navigation', {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="left-sidebar__block site-sidebar-section px-5 py-5 pr-3 xl:pl-0">
|
||||
<div v-if="showAuthorSection" class="left-sidebar__block site-sidebar-section px-5 py-5 pr-3 xl:pl-0">
|
||||
<div class="left-sidebar__section-title flex items-center justify-between text-xs font-semibold uppercase site-muted">
|
||||
<span>Authors</span>
|
||||
<span>⌃</span>
|
||||
@@ -90,19 +194,22 @@ const { data: navigation } = await useFetch('/api/navigation', {
|
||||
</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-sidebar-nav-row site-interactive grid h-7 w-7 shrink-0 place-items-center rounded-full"
|
||||
type="button"
|
||||
:aria-label="isDarkMode ? '라이트 모드로 전환' : '다크 모드로 전환'"
|
||||
:title="isDarkMode ? '라이트 모드' : '다크 모드'"
|
||||
|
||||
@@ -28,7 +28,7 @@ defineProps({
|
||||
{{ post.excerpt }}
|
||||
</p>
|
||||
<p class="post-card__meta mt-2 text-xs site-muted">
|
||||
{{ post.publishedAt }} / {{ post.tag }}
|
||||
{{ post.publishedAt }}<template v-if="post.tag"> / {{ post.tag }}</template>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -13,9 +13,13 @@ const { data: siteSettings } = await useFetch('/api/site-settings', {
|
||||
title: 'sori.studio',
|
||||
description: 'sori.studio 개인 블로그',
|
||||
logoText: '井',
|
||||
logoUrl: '',
|
||||
copyrightText: '©2026 sori.studio'
|
||||
})
|
||||
})
|
||||
|
||||
/** 소개 영역 공개 여부 */
|
||||
const showAboutSection = false
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -23,8 +27,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">
|
||||
@@ -169,7 +179,7 @@ const { data: siteSettings } = await useFetch('/api/site-settings', {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="right-sidebar__block site-sidebar-section py-5 pl-5 pr-0">
|
||||
<div v-if="showAboutSection" class="right-sidebar__block site-sidebar-section py-5 pl-5 pr-0">
|
||||
<p class="right-sidebar__about text-sm leading-6 site-muted">
|
||||
{{ siteSettings.description }}
|
||||
</p>
|
||||
|
||||
153
components/site/SidebarPrimaryNavList.vue
Normal file
153
components/site/SidebarPrimaryNavList.vue
Normal file
@@ -0,0 +1,153 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
/** 공개 API `primary` 트리 노드 */
|
||||
nodes: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
})
|
||||
|
||||
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-sidebar-nav-row, flex, 패딩 전환(가로 전체 호버 배경) */
|
||||
const navRowShell =
|
||||
'site-sidebar-nav-row 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
|
||||
* @returns {boolean}
|
||||
*/
|
||||
const isExpanded = (id) => expandedSet?.value?.has(String(id)) ?? true
|
||||
|
||||
/**
|
||||
* 부모 행(이름·행 전체) 클릭으로 하위 접기/펼치기
|
||||
* @param {string} id - 노드 id
|
||||
* @returns {void}
|
||||
*/
|
||||
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>
|
||||
<ul class="sidebar-primary-nav-list flex w-full flex-col gap-[3px] text-[15px] text-[var(--site-text)]">
|
||||
<template v-for="node in nodes" :key="node.id">
|
||||
<li
|
||||
v-if="node.children?.length"
|
||||
class="sidebar-primary-nav-list__branch w-full"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
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__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)]"
|
||||
:class="{ '-rotate-180': isExpanded(node.id) }"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<svg class="sidebar-primary-nav-list__chevron-svg h-3.5 w-3.5" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M2.5 4.25L6 7.75L9.5 4.25" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
<div
|
||||
class="sidebar-primary-nav-list__sub-grid grid min-h-0 w-full max-w-full transition-[grid-template-rows] duration-200 ease-out"
|
||||
:class="isExpanded(node.id) ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]'"
|
||||
>
|
||||
<div class="sidebar-primary-nav-list__sub-clip min-h-0 overflow-hidden">
|
||||
<ul class="sidebar-primary-nav-list__sub ml-0 border-l border-[var(--site-line)] pl-2 pt-0">
|
||||
<SidebarPrimaryNavList :nodes="node.children" />
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
<li
|
||||
v-else
|
||||
class="sidebar-primary-nav-list__leaf group relative flex w-full max-w-full items-center"
|
||||
>
|
||||
<NuxtLink
|
||||
v-if="node.url && String(node.url).trim() !== '' && String(node.url).trim() !== '#'"
|
||||
: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 group text-[var(--site-text)]"
|
||||
:class="`${navRowShell} ${navBarBeforeInactive}`"
|
||||
>
|
||||
<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>
|
||||
</ul>
|
||||
</template>
|
||||
@@ -3,6 +3,8 @@ const { menuOpen, toggleMenu, closeMenu } = useMenuState()
|
||||
const menuUserOpen = ref(false)
|
||||
const userMenuRef = ref(null)
|
||||
const userMenuToggleRef = ref(null)
|
||||
const searchOpen = ref(false)
|
||||
const member = ref(null)
|
||||
|
||||
const { data: siteSettings } = await useFetch('/api/site-settings', {
|
||||
default: () => ({
|
||||
@@ -18,6 +20,37 @@ const closeUserMenu = () => {
|
||||
menuUserOpen.value = false
|
||||
}
|
||||
|
||||
/**
|
||||
* 통합 검색 모달을 연다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const openSearchModal = () => {
|
||||
searchOpen.value = true
|
||||
}
|
||||
|
||||
/**
|
||||
* 입력 필드에 포커스가 있으면 `/` 검색 단축키를 무시한다.
|
||||
* @param {KeyboardEvent} event - 키보드 이벤트
|
||||
* @returns {boolean} 무시할 때 true
|
||||
*/
|
||||
const shouldIgnoreSearchHotkey = (event) => {
|
||||
if (event.ctrlKey || event.metaKey || event.altKey) {
|
||||
return true
|
||||
}
|
||||
const target = event.target
|
||||
if (!(target instanceof HTMLElement)) {
|
||||
return false
|
||||
}
|
||||
const tag = target.tagName
|
||||
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') {
|
||||
return true
|
||||
}
|
||||
if (target.isContentEditable) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* 사용자 메뉴를 토글한다.
|
||||
* @returns {void}
|
||||
@@ -26,6 +59,31 @@ const toggleUserMenu = () => {
|
||||
menuUserOpen.value = !menuUserOpen.value
|
||||
}
|
||||
|
||||
/**
|
||||
* 회원 세션 정보를 조회한다.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const fetchMember = async () => {
|
||||
try {
|
||||
member.value = await $fetch('/api/auth/me')
|
||||
} catch {
|
||||
member.value = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 회원 로그아웃을 처리한다.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const logoutMember = async () => {
|
||||
await $fetch('/api/auth/logout', {
|
||||
method: 'POST'
|
||||
})
|
||||
member.value = null
|
||||
closeUserMenu()
|
||||
await navigateTo('/')
|
||||
}
|
||||
|
||||
/**
|
||||
* 문서 클릭 시 사용자 메뉴 외부 영역이면 메뉴를 닫는다.
|
||||
* @param {MouseEvent} event - 클릭 이벤트
|
||||
@@ -47,27 +105,41 @@ const onDocumentClick = (event) => {
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape로 열린 패널을 닫는다(사용자 메뉴 우선, 이어서 모바일 좌측 메뉴).
|
||||
* Escape·`/` 키로 패널을 제어한다(검색 모달 → 사용자 메뉴 → 모바일 좌측 메뉴, `/`는 검색 열기).
|
||||
* @param {KeyboardEvent} event - 키보드 이벤트
|
||||
* @returns {void}
|
||||
*/
|
||||
const onGlobalKeydown = (event) => {
|
||||
if (event.key !== 'Escape') {
|
||||
if (event.key === 'Escape') {
|
||||
if (searchOpen.value) {
|
||||
searchOpen.value = false
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
if (menuUserOpen.value) {
|
||||
closeUserMenu()
|
||||
return
|
||||
}
|
||||
if (!menuOpen.value) {
|
||||
return
|
||||
}
|
||||
if (typeof window !== 'undefined' && window.matchMedia('(max-width: 1023px)').matches) {
|
||||
closeMenu()
|
||||
}
|
||||
return
|
||||
}
|
||||
if (menuUserOpen.value) {
|
||||
closeUserMenu()
|
||||
if (event.key !== '/') {
|
||||
return
|
||||
}
|
||||
if (!menuOpen.value) {
|
||||
if (shouldIgnoreSearchHotkey(event)) {
|
||||
return
|
||||
}
|
||||
if (typeof window !== 'undefined' && window.matchMedia('(max-width: 1023px)').matches) {
|
||||
closeMenu()
|
||||
}
|
||||
event.preventDefault()
|
||||
openSearchModal()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchMember()
|
||||
document.addEventListener('click', onDocumentClick)
|
||||
document.addEventListener('keydown', onGlobalKeydown)
|
||||
})
|
||||
@@ -80,8 +152,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"
|
||||
@@ -113,30 +186,39 @@ onBeforeUnmount(() => {
|
||||
</span>
|
||||
</button>
|
||||
<span class="min-w-0 truncate">{{ siteSettings.title }}</span>
|
||||
</NuxtLink>
|
||||
<div class="site-header__search site-header__search--responsive hidden h-9 min-w-0 flex-1 basis-0 items-center rounded-lg px-3 text-sm 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">
|
||||
</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 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>
|
||||
</div>
|
||||
<nav class="site-header__nav flex shrink-0 items-center gap-3 text-sm sm:gap-3.5">
|
||||
<NuxtLink class="site-header__buy site-accent-button shrink-0 rounded-lg px-3 py-1.5 text-xs font-semibold sm:px-4 sm:py-2 sm:text-sm" to="/pages/about">
|
||||
Subscribe
|
||||
</NuxtLink>
|
||||
<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"
|
||||
class="site-header__user-toggle relative flex h-7 w-7 items-center justify-center rounded-full transition-opacity duration-200 hover:opacity-75 md:h-8 md:w-8"
|
||||
class="site-header__user-toggle relative flex h-7 w-7 items-center justify-center rounded-full border transition-opacity duration-200 hover:opacity-75 md:h-8 md:w-8"
|
||||
type="button"
|
||||
aria-label="Toggle user menu"
|
||||
:aria-expanded="menuUserOpen.toString()"
|
||||
@click="toggleUserMenu"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" class="h-6 w-6 stroke-current stroke-[1.75] md:h-7 md:w-7 md:stroke-[1.5]">
|
||||
<path d="M12 12m-9 0a9 9 0 1 0 18 0a9 9 0 1 0 -18 0" />
|
||||
<path d="M12 10m-3 0a3 3 0 1 0 6 0a3 3 0 1 0 -6 0" />
|
||||
<path d="M6.168 18.849a4 4 0 0 1 3.832 -2.849h4a4 4 0 0 1 3.834 2.855" />
|
||||
</svg>
|
||||
<img
|
||||
v-if="member?.avatarUrl"
|
||||
:src="member.avatarUrl"
|
||||
:alt="member.username || '회원 아바타'"
|
||||
class="h-full w-full rounded-full object-cover"
|
||||
>
|
||||
<span v-else class="grid h-full w-full place-items-center rounded-full bg-[var(--site-panel)] text-[11px] font-semibold">
|
||||
{{ (member?.username || member?.email || '@').slice(0, 1).toUpperCase() }}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<Transition
|
||||
@@ -154,34 +236,67 @@ onBeforeUnmount(() => {
|
||||
>
|
||||
<div class="mb-2 flex items-center gap-2 border-b border-[var(--site-line)] pb-3">
|
||||
<div class="site-header__avatar-wrap flex h-8 w-8 items-center justify-center overflow-hidden rounded-full bg-[var(--site-panel)] md:h-10 md:w-10">
|
||||
<span class="text-base font-normal uppercase md:text-lg">@</span>
|
||||
<img
|
||||
v-if="member?.avatarUrl"
|
||||
:src="member.avatarUrl"
|
||||
:alt="member.username || '회원 아바타'"
|
||||
class="h-full w-full object-cover"
|
||||
>
|
||||
<span v-else class="text-base font-normal uppercase md:text-lg">
|
||||
{{ (member?.username || member?.email || '@').slice(0, 1) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<div class="max-w-xs truncate leading-[1.15]">Anonymous</div>
|
||||
<div class="max-w-xs truncate leading-[1.15]">
|
||||
{{ member?.username || 'Anonymous' }}
|
||||
</div>
|
||||
<div v-if="member?.email" class="max-w-xs truncate text-xs site-muted">
|
||||
{{ member.email }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<NuxtLink class="site-header__user-link flex items-center gap-1.5 rounded-[8px] px-2.5 py-1.5 transition-colors duration-150 hover:bg-[var(--site-panel)]" to="/signup/" @click="closeUserMenu">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="h-5 w-5">
|
||||
<path d="M3 12a9 9 0 1 0 18 0a9 9 0 0 0 -18 0" />
|
||||
<path d="M15 9l-6 6" />
|
||||
<path d="M15 15v-6h-6" />
|
||||
</svg>
|
||||
<span>Sign up</span>
|
||||
</NuxtLink>
|
||||
<template v-if="member">
|
||||
<NuxtLink class="site-header__user-link flex items-center gap-1.5 rounded-[8px] px-2.5 py-1.5 transition-colors duration-150 hover:bg-[var(--site-panel)]" to="/settings" @click="closeUserMenu">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="h-5 w-5">
|
||||
<path d="M12 15.5A3.5 3.5 0 1 0 12 8.5a3.5 3.5 0 0 0 0 7Z" />
|
||||
<path d="M19.4 15a1.7 1.7 0 0 0 .34 1.87l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06A1.7 1.7 0 0 0 15 19.4a1.7 1.7 0 0 0-1 .6 1.7 1.7 0 0 1-2 0 1.7 1.7 0 0 0-1-.6 1.7 1.7 0 0 0-1.87.34l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.7 1.7 0 0 0 4.6 15a1.7 1.7 0 0 0-.6-1 1.7 1.7 0 0 1 0-2 1.7 1.7 0 0 0 .6-1 1.7 1.7 0 0 0-.34-1.87l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.7 1.7 0 0 0 9 4.6a1.7 1.7 0 0 0 1-.6 1.7 1.7 0 0 1 2 0 1.7 1.7 0 0 0 1 .6 1.7 1.7 0 0 0 1.87-.34l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06A1.7 1.7 0 0 0 19.4 9c.24.36.48.69.6 1 .18.45.18 1.55 0 2-.12.31-.36.64-.6 1Z" />
|
||||
</svg>
|
||||
<span>설정</span>
|
||||
</NuxtLink>
|
||||
<button class="site-header__user-link flex items-center gap-1.5 rounded-[8px] px-2.5 py-1.5 text-left transition-colors duration-150 hover:bg-[var(--site-panel)]" type="button" @click="logoutMember">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="h-5 w-5">
|
||||
<path d="M15 8v-2a2 2 0 0 0 -2 -2h-7a2 2 0 0 0 -2 2v12a2 2 0 0 0 2 2h7a2 2 0 0 0 2 -2v-2" />
|
||||
<path d="M21 12h-13l3 -3" />
|
||||
<path d="M11 15l-3 -3" />
|
||||
</svg>
|
||||
<span>로그아웃</span>
|
||||
</button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<NuxtLink class="site-header__user-link flex items-center gap-1.5 rounded-[8px] px-2.5 py-1.5 transition-colors duration-150 hover:bg-[var(--site-panel)]" to="/signup/" @click="closeUserMenu">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="h-5 w-5">
|
||||
<path d="M3 12a9 9 0 1 0 18 0a9 9 0 0 0 -18 0" />
|
||||
<path d="M15 9l-6 6" />
|
||||
<path d="M15 15v-6h-6" />
|
||||
</svg>
|
||||
<span>Sign up</span>
|
||||
</NuxtLink>
|
||||
|
||||
<NuxtLink class="site-header__user-link flex items-center gap-1.5 rounded-[8px] px-2.5 py-1.5 transition-colors duration-150 hover:bg-[var(--site-panel)]" to="/signin/" @click="closeUserMenu">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="h-5 w-5">
|
||||
<path d="M15 8v-2a2 2 0 0 0 -2 -2h-7a2 2 0 0 0 -2 2v12a2 2 0 0 0 2 2h7a2 2 0 0 0 2 -2v-2" />
|
||||
<path d="M21 12h-13l3 -3" />
|
||||
<path d="M11 15l-3 -3" />
|
||||
</svg>
|
||||
<span>Sign in</span>
|
||||
</NuxtLink>
|
||||
<NuxtLink class="site-header__user-link flex items-center gap-1.5 rounded-[8px] px-2.5 py-1.5 transition-colors duration-150 hover:bg-[var(--site-panel)]" to="/signin/" @click="closeUserMenu">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="h-5 w-5">
|
||||
<path d="M15 8v-2a2 2 0 0 0 -2 -2h-7a2 2 0 0 0 -2 2v12a2 2 0 0 0 2 2h7a2 2 0 0 0 2 -2v-2" />
|
||||
<path d="M21 12h-13l3 -3" />
|
||||
<path d="M11 15l-3 -3" />
|
||||
</svg>
|
||||
<span>Sign in</span>
|
||||
</NuxtLink>
|
||||
</template>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
<SiteSearchModal v-model="searchOpen" />
|
||||
</template>
|
||||
|
||||
284
components/site/SiteSearchModal.vue
Normal file
284
components/site/SiteSearchModal.vue
Normal file
@@ -0,0 +1,284 @@
|
||||
<script setup>
|
||||
/**
|
||||
* @typedef {{ name: string, slug: string }} SearchTagHit
|
||||
* @typedef {{ slug: string, title: string, excerpt: string }} SearchPostHit
|
||||
*/
|
||||
|
||||
const open = defineModel({ type: Boolean, default: false })
|
||||
|
||||
const searchInputRef = ref(null)
|
||||
const query = ref('')
|
||||
const debouncedQuery = ref('')
|
||||
const isComposing = ref(false)
|
||||
|
||||
/** @type {ReturnType<typeof setTimeout> | null} */
|
||||
let debounceTimer = null
|
||||
|
||||
/**
|
||||
* 입력값을 디바운스로 검색어에 반영한다.
|
||||
* @param {string} value - 현재 입력값
|
||||
* @returns {void}
|
||||
*/
|
||||
const scheduleDebouncedQuery = (value) => {
|
||||
clearTimeout(debounceTimer)
|
||||
debounceTimer = setTimeout(() => {
|
||||
debouncedQuery.value = value
|
||||
}, 200)
|
||||
}
|
||||
|
||||
watch(query, (value) => {
|
||||
scheduleDebouncedQuery(value)
|
||||
})
|
||||
|
||||
const fetchKey = computed(() => `public-search:${debouncedQuery.value}`)
|
||||
|
||||
const { data, pending } = await useFetch('/api/search', {
|
||||
key: fetchKey,
|
||||
query: { q: debouncedQuery },
|
||||
watch: [debouncedQuery],
|
||||
default: () => ({ tags: /** @type {SearchTagHit[]} */ ([]), posts: /** @type {SearchPostHit[]} */ ([]) })
|
||||
})
|
||||
|
||||
/**
|
||||
* 정규식 특수 문자 이스케이프
|
||||
* @param {string} value - 원문
|
||||
* @returns {string} 이스케이프된 문자열
|
||||
*/
|
||||
const escapeRegExp = (value) => value.replace(/[\\^$.*+?()[\]{}|]/g, '\\$&')
|
||||
|
||||
/**
|
||||
* 검색어 일치 구간을 나누어 하이라이트 표시에 사용한다.
|
||||
* @param {string} text - 표시할 문자열
|
||||
* @param {string} needle - 검색어
|
||||
* @returns {Array<{ text: string, hit: boolean }>} 구간 목록
|
||||
*/
|
||||
const highlightParts = (text, needle) => {
|
||||
const base = String(text ?? '')
|
||||
const q = String(needle ?? '').trim()
|
||||
if (!q) {
|
||||
return [{ text: base, hit: false }]
|
||||
}
|
||||
const parts = base.split(new RegExp(`(${escapeRegExp(q)})`, 'gi'))
|
||||
return parts
|
||||
.filter((part) => part !== '')
|
||||
.map((part, index) => ({
|
||||
text: part,
|
||||
hit: index % 2 === 1
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* 목록용 요약 문자열을 한 줄로 자른다.
|
||||
* @param {string} text - 요약 원문
|
||||
* @param {number} max - 최대 글자 수
|
||||
* @returns {string} 잘린 문자열
|
||||
*/
|
||||
const clipExcerpt = (text, max = 140) => {
|
||||
const line = String(text ?? '').replace(/\s+/g, ' ').trim()
|
||||
if (line.length <= max) {
|
||||
return line
|
||||
}
|
||||
return `${line.slice(0, max)}…`
|
||||
}
|
||||
|
||||
/**
|
||||
* 배경 클릭 시 모달을 닫는다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const onBackdropPointerDown = () => {
|
||||
open.value = false
|
||||
}
|
||||
|
||||
/**
|
||||
* 검색어를 비운다(입력 포커스 유지).
|
||||
* @returns {void}
|
||||
*/
|
||||
const clearQuery = () => {
|
||||
query.value = ''
|
||||
debouncedQuery.value = ''
|
||||
nextTick(() => {
|
||||
const el = searchInputRef.value
|
||||
if (el instanceof HTMLInputElement) {
|
||||
el.focus()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 한글/일본어 등 IME 조합 시작 처리
|
||||
* @returns {void}
|
||||
*/
|
||||
const onCompositionStart = () => {
|
||||
isComposing.value = true
|
||||
}
|
||||
|
||||
/**
|
||||
* IME 조합 중에도 입력창 value를 기반으로 검색어를 갱신한다.
|
||||
* 일부 환경에서는 v-model 업데이트가 조합 종료까지 지연될 수 있다.
|
||||
* @param {CompositionEvent} event - 조합 업데이트 이벤트
|
||||
* @returns {void}
|
||||
*/
|
||||
const onCompositionUpdate = (event) => {
|
||||
const target = event.target
|
||||
if (!(target instanceof HTMLInputElement)) {
|
||||
return
|
||||
}
|
||||
const value = target.value
|
||||
query.value = value
|
||||
scheduleDebouncedQuery(value)
|
||||
}
|
||||
|
||||
/**
|
||||
* 한글/일본어 등 IME 조합 종료 처리(종료 시점에만 검색 갱신)
|
||||
* @returns {void}
|
||||
*/
|
||||
const onCompositionEnd = () => {
|
||||
isComposing.value = false
|
||||
clearTimeout(debounceTimer)
|
||||
debouncedQuery.value = query.value
|
||||
}
|
||||
|
||||
/**
|
||||
* 모달 열림·닫힘에 따라 스크롤 잠금과 입력을 동기화한다.
|
||||
* @returns {void}
|
||||
*/
|
||||
watch(open, (isOpen) => {
|
||||
if (typeof document === 'undefined') {
|
||||
return
|
||||
}
|
||||
document.documentElement.classList.toggle('site-search-open', Boolean(isOpen))
|
||||
if (isOpen) {
|
||||
nextTick(() => {
|
||||
const el = searchInputRef.value
|
||||
if (el instanceof HTMLInputElement) {
|
||||
el.focus()
|
||||
}
|
||||
})
|
||||
return
|
||||
}
|
||||
query.value = ''
|
||||
debouncedQuery.value = ''
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (typeof document !== 'undefined') {
|
||||
document.documentElement.classList.remove('site-search-open')
|
||||
}
|
||||
clearTimeout(debounceTimer)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-show="open"
|
||||
class="site-search-modal fixed inset-0 z-[60] flex justify-center bg-black/35 px-3 pt-14 pb-8 backdrop-blur-sm sm:px-4 sm:pt-20"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="사이트 검색"
|
||||
@pointerdown.self="onBackdropPointerDown"
|
||||
>
|
||||
<div
|
||||
class="site-search-modal__panel site-search-modal__panel--animate flex h-[min(78vh,640px)] w-full max-w-[95vw] flex-col overflow-hidden rounded-lg border border-[var(--site-line)] bg-[var(--site-bg)] text-[var(--site-text)] shadow-[0_20px_50px_rgba(0,0,0,0.18)] sm:max-w-lg"
|
||||
@pointerdown.stop
|
||||
>
|
||||
<div class="site-search-modal__header flex shrink-0 items-center gap-2 border-b border-[var(--site-line)] bg-[var(--site-bg)] px-3 py-3 sm:gap-3 sm:px-5 sm:py-4">
|
||||
<button
|
||||
type="button"
|
||||
class="site-search-modal__icon flex h-9 w-9 shrink-0 items-center justify-center rounded-md text-[var(--site-text)] transition-colors hover:bg-[var(--site-panel)]"
|
||||
:aria-label="query.trim() ? '검색어 지우기' : '검색'"
|
||||
:disabled="!query.trim()"
|
||||
@click="query.trim() ? clearQuery() : null"
|
||||
>
|
||||
<svg v-if="!query.trim()" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="18" height="18" fill="currentColor" aria-hidden="true">
|
||||
<path d="M23.38 21.62l-6.53-6.53a9.15 9.15 0 0 0 1.9-5.59 9.27 9.27 0 1 0-3.66 7.36l6.53 6.53a1.26 1.26 0 0 0 1.76 0 1.25 1.25 0 0 0 0-1.77ZM2.75 9.5A6.75 6.75 0 1 1 9.5 16.25 6.76 6.76 0 0 1 2.75 9.5Z" />
|
||||
</svg>
|
||||
<svg v-else xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true">
|
||||
<path d="M18 6 6 18" />
|
||||
<path d="m6 6 12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
<input
|
||||
ref="searchInputRef"
|
||||
v-model="query"
|
||||
type="text"
|
||||
inputmode="search"
|
||||
enterkeyhint="search"
|
||||
autocomplete="off"
|
||||
class="site-search-modal__input min-w-0 flex-1 bg-transparent py-2 text-lg outline-none placeholder:text-[var(--site-soft)] focus-visible:ring-0 sm:text-[1.35rem]"
|
||||
placeholder="글 제목, 본문, 태그 검색"
|
||||
@compositionstart="onCompositionStart"
|
||||
@compositionupdate="onCompositionUpdate"
|
||||
@compositionend="onCompositionEnd"
|
||||
/>
|
||||
<button type="button" class="site-search-modal__cancel shrink-0 rounded-md px-2 py-1 text-sm text-[var(--site-soft)] hover:text-[var(--site-text)] sm:hidden" @click="open = false">
|
||||
취소
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="site-search-modal__body min-h-0 flex-1 overflow-y-auto overscroll-contain px-3 py-3 sm:px-5 sm:py-4">
|
||||
<p v-if="!query.trim()" class="site-search-modal__hint text-sm text-[var(--site-soft)]">
|
||||
검색어를 입력하면 태그와 게시물이 섹션별로 표시됩니다.
|
||||
</p>
|
||||
|
||||
<template v-else>
|
||||
<div v-if="pending" class="text-sm text-[var(--site-soft)]">
|
||||
검색 중…
|
||||
</div>
|
||||
<template v-else>
|
||||
<section v-if="(data?.tags?.length ?? 0) > 0" class="site-search-modal__section mb-6">
|
||||
<h2 class="site-search-modal__section-title mb-2 text-[11px] font-semibold uppercase tracking-wide text-[var(--site-soft)]">
|
||||
Tags
|
||||
</h2>
|
||||
<ul class="space-y-0.5">
|
||||
<li v-for="tag in data.tags" :key="tag.slug">
|
||||
<NuxtLink
|
||||
class="site-search-modal__tag-link flex items-baseline gap-1 rounded-md px-2 py-2 text-[15px] transition-colors hover:bg-[var(--site-panel)]"
|
||||
:to="`/tag/${tag.slug}/`"
|
||||
@click="open = false"
|
||||
>
|
||||
<span class="text-[var(--site-soft)]">#</span>
|
||||
<span class="font-medium text-[var(--site-text)]">{{ tag.name }}</span>
|
||||
</NuxtLink>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section v-if="(data?.posts?.length ?? 0) > 0" class="site-search-modal__section">
|
||||
<h2 class="site-search-modal__section-title mb-2 text-[11px] font-semibold uppercase tracking-wide text-[var(--site-soft)]">
|
||||
Posts
|
||||
</h2>
|
||||
<ul class="space-y-1">
|
||||
<li v-for="post in data.posts" :key="post.slug">
|
||||
<NuxtLink
|
||||
class="site-search-modal__post-link block rounded-md px-2 py-2 transition-colors hover:bg-[var(--site-panel)]"
|
||||
:to="`/post/${post.slug}/`"
|
||||
@click="open = false"
|
||||
>
|
||||
<div class="text-[15px] font-semibold leading-snug text-[var(--site-text)]">
|
||||
<template v-for="(seg, i) in highlightParts(post.title, query)" :key="`t-${post.slug}-${i}`">
|
||||
<mark v-if="seg.hit" class="bg-transparent font-semibold text-[var(--site-text)]">{{ seg.text }}</mark>
|
||||
<span v-else>{{ seg.text }}</span>
|
||||
</template>
|
||||
</div>
|
||||
<div v-if="clipExcerpt(post.excerpt)" class="mt-0.5 line-clamp-2 text-sm leading-relaxed text-[var(--site-soft)]">
|
||||
<template v-for="(seg, i) in highlightParts(clipExcerpt(post.excerpt), query)" :key="`e-${post.slug}-${i}`">
|
||||
<mark v-if="seg.hit" class="bg-transparent font-semibold text-[var(--site-muted)]">{{ seg.text }}</mark>
|
||||
<span v-else>{{ seg.text }}</span>
|
||||
</template>
|
||||
</div>
|
||||
</NuxtLink>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<p v-if="!pending && (data?.tags?.length ?? 0) === 0 && (data?.posts?.length ?? 0) === 0" class="text-sm text-[var(--site-soft)]">
|
||||
일치하는 결과가 없습니다.
|
||||
</p>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
51
composables/useAdminToast.js
Normal file
51
composables/useAdminToast.js
Normal file
@@ -0,0 +1,51 @@
|
||||
import { onUnmounted, ref } from 'vue'
|
||||
|
||||
const TOAST_AUTO_HIDE_MS = 4000
|
||||
|
||||
/**
|
||||
* 관리자 화면 우측 상단 피드백 토스트. 모달(z-50 등)보다 위에 보이도록 사용처에서 `z-[100]` 클래스를 둔다.
|
||||
* @returns {{ toast: import('vue').Ref<null | { type: string, message: string }>, showToast: (type: string, message: string) => void, clearToast: () => void }}
|
||||
*/
|
||||
export const useAdminToast = () => {
|
||||
const toast = ref(null)
|
||||
let toastTimer = null
|
||||
|
||||
/**
|
||||
* 표시 중인 토스트를 즉시 제거한다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const clearToast = () => {
|
||||
window.clearTimeout(toastTimer)
|
||||
toastTimer = null
|
||||
toast.value = null
|
||||
}
|
||||
|
||||
/**
|
||||
* 토스트를 표시한다. 이전 타이머가 있으면 취소한다.
|
||||
* @param {'success' | 'error' | 'info'} type - 토스트 종류
|
||||
* @param {string} message - 본문
|
||||
* @returns {void}
|
||||
*/
|
||||
const showToast = (type, message) => {
|
||||
window.clearTimeout(toastTimer)
|
||||
const text = String(message || '').trim() || '알림'
|
||||
toast.value = {
|
||||
type,
|
||||
message: text
|
||||
}
|
||||
toastTimer = window.setTimeout(() => {
|
||||
toast.value = null
|
||||
toastTimer = null
|
||||
}, TOAST_AUTO_HIDE_MS)
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
window.clearTimeout(toastTimer)
|
||||
})
|
||||
|
||||
return {
|
||||
toast,
|
||||
showToast,
|
||||
clearToast
|
||||
}
|
||||
}
|
||||
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
|
||||
);
|
||||
|
||||
30
db/migrations/010_add_members_and_comments.sql
Normal file
30
db/migrations/010_add_members_and_comments.sql
Normal file
@@ -0,0 +1,30 @@
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
username TEXT NOT NULL,
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS users_email_idx
|
||||
ON users (email);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS comments (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
post_id UUID NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
parent_id UUID REFERENCES comments(id) ON DELETE CASCADE,
|
||||
body TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'published',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
CONSTRAINT comments_status_check CHECK (status IN ('published', 'pending', 'blocked'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS comments_post_id_created_at_idx
|
||||
ON comments (post_id, created_at ASC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS comments_parent_id_idx
|
||||
ON comments (parent_id);
|
||||
|
||||
25
db/migrations/011_add_member_profile_and_activity.sql
Normal file
25
db/migrations/011_add_member_profile_and_activity.sql
Normal file
@@ -0,0 +1,25 @@
|
||||
ALTER TABLE users
|
||||
ADD COLUMN IF NOT EXISTS avatar_url TEXT NOT NULL DEFAULT '';
|
||||
|
||||
ALTER TABLE users
|
||||
ADD COLUMN IF NOT EXISTS last_seen_at TIMESTAMPTZ;
|
||||
|
||||
ALTER TABLE users
|
||||
ADD COLUMN IF NOT EXISTS last_seen_ip TEXT NOT NULL DEFAULT '';
|
||||
|
||||
WITH deduplicated_users AS (
|
||||
SELECT
|
||||
id,
|
||||
username,
|
||||
ROW_NUMBER() OVER (PARTITION BY lower(username) ORDER BY created_at ASC, id ASC) AS row_number
|
||||
FROM users
|
||||
)
|
||||
UPDATE users
|
||||
SET username = deduplicated_users.username || '-' || deduplicated_users.row_number
|
||||
FROM deduplicated_users
|
||||
WHERE users.id = deduplicated_users.id
|
||||
AND deduplicated_users.row_number > 1;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS users_username_lower_unique_idx
|
||||
ON users (lower(username));
|
||||
|
||||
9
db/migrations/012_add_comment_likes.sql
Normal file
9
db/migrations/012_add_comment_likes.sql
Normal file
@@ -0,0 +1,9 @@
|
||||
CREATE TABLE IF NOT EXISTS comment_likes (
|
||||
comment_id UUID NOT NULL REFERENCES comments(id) ON DELETE CASCADE,
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (comment_id, user_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS comment_likes_user_id_idx
|
||||
ON comment_likes (user_id);
|
||||
12
db/migrations/013_add_user_admin_role.sql
Normal file
12
db/migrations/013_add_user_admin_role.sql
Normal file
@@ -0,0 +1,12 @@
|
||||
ALTER TABLE users
|
||||
ADD COLUMN IF NOT EXISTS is_admin BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
WITH first_user AS (
|
||||
SELECT id
|
||||
FROM users
|
||||
ORDER BY created_at ASC, id ASC
|
||||
LIMIT 1
|
||||
)
|
||||
UPDATE users
|
||||
SET is_admin = true
|
||||
WHERE id IN (SELECT id FROM first_user);
|
||||
27
db/migrations/014_add_user_role_levels.sql
Normal file
27
db/migrations/014_add_user_role_levels.sql
Normal file
@@ -0,0 +1,27 @@
|
||||
ALTER TABLE users
|
||||
ADD COLUMN IF NOT EXISTS user_role TEXT NOT NULL DEFAULT 'member';
|
||||
|
||||
UPDATE users
|
||||
SET user_role = CASE
|
||||
WHEN is_admin THEN 'admin'
|
||||
ELSE 'member'
|
||||
END
|
||||
WHERE user_role NOT IN ('owner', 'admin', 'member');
|
||||
|
||||
WITH first_user AS (
|
||||
SELECT id
|
||||
FROM users
|
||||
ORDER BY created_at ASC, id ASC
|
||||
LIMIT 1
|
||||
)
|
||||
UPDATE users
|
||||
SET user_role = 'owner'
|
||||
WHERE id IN (SELECT id FROM first_user)
|
||||
AND is_admin = true;
|
||||
|
||||
ALTER TABLE users
|
||||
DROP CONSTRAINT IF EXISTS users_user_role_check;
|
||||
|
||||
ALTER TABLE users
|
||||
ADD CONSTRAINT users_user_role_check
|
||||
CHECK (user_role IN ('owner', 'admin', 'member'));
|
||||
13
db/migrations/015_add_tag_type_and_reorder_support.sql
Normal file
13
db/migrations/015_add_tag_type_and_reorder_support.sql
Normal file
@@ -0,0 +1,13 @@
|
||||
ALTER TABLE tags
|
||||
ADD COLUMN IF NOT EXISTS tag_type TEXT NOT NULL DEFAULT 'managed';
|
||||
|
||||
UPDATE tags
|
||||
SET tag_type = 'managed'
|
||||
WHERE tag_type NOT IN ('managed', 'general');
|
||||
|
||||
ALTER TABLE tags
|
||||
DROP CONSTRAINT IF EXISTS tags_tag_type_check;
|
||||
|
||||
ALTER TABLE tags
|
||||
ADD CONSTRAINT tags_tag_type_check
|
||||
CHECK (tag_type IN ('managed', 'general'));
|
||||
12
db/migrations/016_media_category_normalize.sql
Normal file
12
db/migrations/016_media_category_normalize.sql
Normal file
@@ -0,0 +1,12 @@
|
||||
-- 게시물 업로드 경로 기본 분류(posts) 및 구 프로필 경로(회원/썸네일)를 논리 폴더 정책에 맞게 정리한다.
|
||||
UPDATE media_metadata
|
||||
SET
|
||||
category = '미분류',
|
||||
updated_at = now()
|
||||
WHERE category = 'posts';
|
||||
|
||||
UPDATE media_metadata
|
||||
SET
|
||||
category = '썸네일',
|
||||
updated_at = now()
|
||||
WHERE category = '회원/썸네일';
|
||||
9
db/migrations/017_navigation_hierarchy.sql
Normal file
9
db/migrations/017_navigation_hierarchy.sql
Normal file
@@ -0,0 +1,9 @@
|
||||
-- 상단(primary) 네비게이션 계층·폴더(접기) 지원, 하단(footer)은 평면 유지
|
||||
ALTER TABLE navigation_items
|
||||
ADD COLUMN IF NOT EXISTS parent_id UUID REFERENCES navigation_items (id) ON DELETE CASCADE,
|
||||
ADD COLUMN IF NOT EXISTS is_folder BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
ALTER TABLE navigation_items DROP CONSTRAINT IF EXISTS navigation_items_location_label_url_key;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS navigation_items_location_parent_sort_idx
|
||||
ON navigation_items (location, parent_id, sort_order ASC, label ASC);
|
||||
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 '';
|
||||
5
db/migrations/023_add_post_featured.sql
Normal file
5
db/migrations/023_add_post_featured.sql
Normal file
@@ -0,0 +1,5 @@
|
||||
ALTER TABLE posts
|
||||
ADD COLUMN IF NOT EXISTS is_featured BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS posts_is_featured_status_published_at_idx
|
||||
ON posts (is_featured, status, published_at DESC);
|
||||
@@ -12,6 +12,8 @@ services:
|
||||
- ./public/uploads:/app/public/uploads
|
||||
depends_on:
|
||||
- sori-studio-db
|
||||
networks:
|
||||
- sori-studio-network
|
||||
restart: unless-stopped
|
||||
|
||||
sori-studio-db:
|
||||
@@ -27,8 +29,18 @@ services:
|
||||
- "${DB_PORT:-43119}:5432"
|
||||
volumes:
|
||||
- sori-studio-postgres:/var/lib/postgresql/data
|
||||
# NAS 등: 호스트 db/migrations 가 다른 UID만 읽을 수 있으면 컨테이너에서 Permission denied → DB 재시작 루프. 프로젝트 루트에서 chmod -R a+rX db/migrations 및 상위 경로 통과 권한 확인.
|
||||
- ./db/migrations:/docker-entrypoint-initdb.d:ro
|
||||
networks:
|
||||
- sori-studio-network
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
sori-studio-postgres:
|
||||
|
||||
networks:
|
||||
sori-studio-network:
|
||||
driver: bridge
|
||||
ipam:
|
||||
config:
|
||||
- subnet: ${DOCKER_SUBNET:-10.250.50.0/24}
|
||||
|
||||
@@ -1,5 +1,105 @@
|
||||
# 업데이트 요약
|
||||
|
||||
## v1.1.9
|
||||
|
||||
- 관리자 글 목록에 상태·태그·정렬 필터와 댓글 수 표시를 추가.
|
||||
- 글쓰기 사이드바에 추천 글 토글을 추가하고, 홈 Featured와 번개 표시는 실제 추천 글만 기준으로 표시.
|
||||
- 공개 헤더는 텍스트 사이트 이름만 사용하고, 사이드바의 Authors/About 영역은 숨김 처리.
|
||||
|
||||
## v1.1.8
|
||||
|
||||
- 로고·파비콘 파일명 접미사를 년월+랜덤 문자열로 줄임.
|
||||
- 태그 추가 버튼을 일반 태그 영역으로 옮기고, 메인 태그 순서는 드래그 후 자동 저장되도록 개선.
|
||||
|
||||
## v1.1.7
|
||||
|
||||
- 사이트 로고와 파비콘을 교체할 때 새 고유 URL로 저장해 운영 환경에서 이전 이미지가 캐시에 남는 문제를 줄임.
|
||||
- 현재 사이트 설정에서 사용 중인 로고·파비콘은 미디어 라이브러리에서 사용 중인 파일로 표시하고 삭제·파일명 변경을 차단하도록 보강.
|
||||
|
||||
## v1.1.6
|
||||
|
||||
- 관리자 태그 관리 화면에서 일반 태그도 검색 없이 배지형 전체 목록으로 확인하고, 최근 사용순·많이 사용순·이름순으로 정렬할 수 있도록 수정.
|
||||
|
||||
## v1.1.5
|
||||
|
||||
- 운영 Docker에서 새로 업로드한 로고·게시물 이미지·회원 썸네일이 재시작 전에도 바로 표시되도록 업로드 파일 제공 방식을 보강.
|
||||
|
||||
## v1.1.4
|
||||
|
||||
- 관리자 멤버 썸네일 업로드가 회원 전용 `/uploads/members/avatars` 경로를 사용하도록 수정.
|
||||
- 관리자 계정과 일반 회원 모두 같은 회원 썸네일 저장 규칙(WebP 변환, 1:1 크롭)을 쓰도록 정리.
|
||||
- 태그 목록 카드 그리드 여백 수정 반영.
|
||||
|
||||
## v1.0.19
|
||||
|
||||
- Shift+Enter 줄바꿈이 수정 모드에서도 보이도록 줄끝 백슬래시 hard break 방식으로 변경.
|
||||
- 기존 공백 2개 hard break도 계속 렌더링되도록 호환 처리.
|
||||
|
||||
## v1.0.18
|
||||
|
||||
- 여러 줄을 비워둔 경우 미리보기와 공개 본문에서도 비운 만큼 공백이 보이도록 보강.
|
||||
- 미리보기 모드에서 편집 툴바와 카드형 패널 외곽을 숨겨 본문만 보이게 정리.
|
||||
- 줄 번호 영역의 스크롤바를 숨겨 작성 화면을 더 차분하게 정리.
|
||||
|
||||
## v1.0.17
|
||||
|
||||
- 글쓰기 영역의 보더와 카드형 배경을 제거해 본문 편집 화면을 더 가볍게 정리.
|
||||
- 줄 번호를 본문 바깥에 띄우고 현재 줄 액센트 배경을 제거.
|
||||
- Enter는 한 줄만 내려가는 새 문단으로, Shift+Enter는 같은 문단 안 줄바꿈으로 동작하도록 조정.
|
||||
- 문단과 제목 아래 기본 간격을 10px 기준으로 정리.
|
||||
|
||||
## v1.0.16
|
||||
|
||||
- 글쓰기에서 Enter는 새 문단, Shift+Enter는 같은 문단 안 줄바꿈으로 동작하도록 정리.
|
||||
- 미리보기 전환 후 작성 모드로 돌아오면 기존 커서 위치에서 계속 입력할 수 있도록 개선.
|
||||
- 공개 본문과 관리자 미리보기의 문단 간격을 24px 기준으로 통일.
|
||||
|
||||
## v1.0.15
|
||||
|
||||
- 본문 중간의 빈 줄이 공개 화면과 관리자 미리보기에서 사라지지 않도록 간격 보존을 보강.
|
||||
|
||||
## v1.0.14
|
||||
|
||||
- Markdown-first 전환 후 레거시 블록 본문이나 기존 자동 저장본 때문에 게시물 발행이 막히는 문제를 보강.
|
||||
|
||||
## v1.0.13
|
||||
|
||||
- 관리자 글쓰기에서 외부 웹 글 붙여넣기를 기본 마크다운으로 정리하고, 커서가 위치한 이미지·갤러리 블록을 바로 편집할 수 있도록 개선.
|
||||
|
||||
## v1.0.11
|
||||
|
||||
- 관리자 글쓰기 본문을 Markdown-first 에디터로 교체해 범위 선택, 복사/붙여넣기, 미디어 이미지·갤러리 삽입 흐름을 단순화.
|
||||
|
||||
## v1.0.5
|
||||
|
||||
- Docker 운영 컨테이너가 빌드 시점 설정 대신 `.env.production`의 런타임 환경 변수를 우선 읽도록 보강.
|
||||
|
||||
## v1.0.4
|
||||
|
||||
- owner/admin 계정이 없는 운영 DB에서도 환경 변수 관리자 계정으로 첫 owner를 생성하거나 기존 일반 회원을 승격할 수 있도록 보강.
|
||||
|
||||
## v1.0.3
|
||||
|
||||
- NAS에서 Postgres 초기 마이그레이션 디렉터리 권한 문제로 DB 컨테이너가 재시작될 때 확인할 배포 절차를 정리.
|
||||
|
||||
## v1.0.2
|
||||
|
||||
- 운영 DB 최초 상태에서 환경 변수 관리자 계정으로 첫 owner 계정을 만들고 로그인할 수 있도록 보강.
|
||||
- 배포 문서의 운영 환경 변수 생성 안내를 정리.
|
||||
|
||||
## v1.0.1
|
||||
|
||||
- Docker Compose 네트워크 충돌 대응을 위해 전용 브리지 네트워크와 `DOCKER_SUBNET` 설정 추가.
|
||||
|
||||
## v1.0.0
|
||||
|
||||
- 운영 시작 기준 버전.
|
||||
- 운영 환경 DB 설정 누락 시 샘플 콘텐츠 대신 즉시 실패하도록 보강.
|
||||
- 회원 세션 비밀값을 관리자 비밀번호와 분리.
|
||||
- JavaScript 문법 점검과 프로덕션 빌드를 묶은 검증 스크립트 추가.
|
||||
- Nitro 보안 권고 반영 및 취약점 0건 확인.
|
||||
- Docker compose 설정과 앱 이미지 빌드 검증 완료.
|
||||
|
||||
## v0.0.6
|
||||
|
||||
- `.env.example`을 실제 비밀값이 없는 공유 템플릿으로 정리.
|
||||
|
||||
@@ -24,8 +24,11 @@
|
||||
## 스타일
|
||||
|
||||
- TailwindCSS 기본 사용
|
||||
- 주요 요소: Tailwind + 고유 className 동시 적용
|
||||
- 관리자 글 에디터는 블록 단위 UI로 작성하되 저장 값은 기존 마크다운 문자열을 유지
|
||||
- 다크 인증(`signin`/`signup`)의 텍스트 입력에는 `auth-form-input` 클래스를 붙여 `main.css`의 글자색·캐럿·placeholder를 적용한다(폼 컨트롤은 부모 `color`를 상속하지 않는 경우가 많음).
|
||||
- Tailwind 엔트리는 `nuxt.config.js`의 `tailwindcss.cssPath: '~/assets/css/main.css'`로 통일한다(`@nuxtjs/tailwindcss` 기본 `assets/css/tailwind.css` 부재 시 패키지 `tailwind.css`가 중복 주입될 수 있음).
|
||||
- 관리자 글 에디터는 Markdown-first textarea 편집을 기준으로 하며 저장 값은 기존 마크다운 문자열을 유지
|
||||
- 관리자 미디어 화면에서 모달에 가릴 수 있는 오류·안내는 `useAdminToast`의 `showToast`로 우측 상단(`z-[100]`)에 표시한다.
|
||||
- 관리자 메뉴 관리는 상단/하단 탭으로 나누고, 순서는 드래그로 조정한다(상단은 동일 부모의 형제끼리만).
|
||||
|
||||
```html
|
||||
<main class="site-main w-full max-w-full lg:max-w-[720px]">
|
||||
@@ -52,3 +55,10 @@
|
||||
- 하드코딩 금지
|
||||
- 로컬 개발 설정과 NAS 운영 설정은 별도 환경 파일로 분리
|
||||
- 운영 DB 접속 정보는 개발용 `.env`에 기록하지 않음
|
||||
- 운영 환경에서는 `DATABASE_URL`과 `MEMBER_SESSION_SECRET` 누락을 허용하지 않음
|
||||
|
||||
## 검증
|
||||
|
||||
- `npm run lint`: JavaScript 파일 문법 점검
|
||||
- `npm run test`: Nuxt 프로덕션 빌드 기반 회귀 검증
|
||||
- `npm run verify`: 문법 점검과 빌드를 함께 실행
|
||||
|
||||
104
docs/deploy.md
104
docs/deploy.md
@@ -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 링크만 요약 출력한다.
|
||||
|
||||
@@ -116,7 +117,7 @@ ssh [NAS_IP]
|
||||
|
||||
```bash
|
||||
# 프로젝트 디렉토리로 이동
|
||||
cd /volume1/docker/sori.studio
|
||||
cd /volume1/docker/projects/apps/
|
||||
|
||||
# 프로젝트 클론
|
||||
git clone https://git.sori.studio/zenn/sori.studio.git
|
||||
@@ -128,21 +129,25 @@ cd sori.studio
|
||||
# .env.production은 Git에 올리지 않는 운영 전용 파일
|
||||
cp .env.example .env.production
|
||||
# .env.production 파일에 운영 DB 연결 정보, 운영 전용 랜덤 비밀번호, APP_PORT=43118 입력
|
||||
# 운영 DB에 owner/admin이 없으면 /admin/login에서 ADMIN_EMAIL/ADMIN_PASSWORD로 최초 owner 계정이 생성됨
|
||||
# MEMBER_SESSION_SECRET은 ADMIN_PASSWORD와 다른 긴 난수 문자열로 반드시 입력
|
||||
# Docker 네트워크 대역이 NAS 기존 컨테이너와 겹치면 DOCKER_SUBNET을 다른 사설 대역으로 변경
|
||||
# Docker 내부 앱에서 PostgreSQL에 접근할 때는 sori-studio-db:5432 사용
|
||||
|
||||
# Docker 빌드 및 실행
|
||||
docker compose --env-file .env.production up -d
|
||||
docker compose --env-file .env.production up -d --build
|
||||
|
||||
# 기존 운영 DB를 유지한 채 새 버전을 올릴 때 추천 글 컬럼 마이그레이션 적용
|
||||
docker compose --env-file .env.production exec sori-studio-db psql -U sori_studio -d sori_studio -f /docker-entrypoint-initdb.d/023_add_post_featured.sql
|
||||
```
|
||||
|
||||
### 프로덕션 빌드 (NAS에서)
|
||||
### Docker 네트워크 충돌 대응
|
||||
|
||||
NAS에 Docker 컨테이너가 많이 실행 중이면 `could not find an available, non-overlapping IPv4 address pool` 오류가 날 수 있다. 이 프로젝트는 기본 `DOCKER_SUBNET=10.250.50.0/24`를 사용한다. 해당 대역도 NAS 내부망 또는 다른 Docker 네트워크와 겹치면 `.env.production`에서 예를 들어 아래처럼 바꾼 뒤 다시 실행한다.
|
||||
|
||||
```bash
|
||||
# 프로덕션 빌드
|
||||
npm run build
|
||||
|
||||
# 또는 Docker로 빌드
|
||||
docker build -t sori.studio:latest .
|
||||
docker run -d -p 3000:3000 sori.studio:latest
|
||||
DOCKER_SUBNET=10.250.51.0/24
|
||||
docker compose --env-file .env.production up -d --build
|
||||
```
|
||||
|
||||
### 포트
|
||||
@@ -151,6 +156,7 @@ docker run -d -p 3000:3000 sori.studio:latest
|
||||
- NAS Docker 외부: 43118
|
||||
- 컨테이너 내부: 3000
|
||||
- PostgreSQL 외부: 43119
|
||||
- Docker 내부 네트워크 기본값: `10.250.50.0/24`
|
||||
- HTTPS: 3001 (SSL 설정 시)
|
||||
|
||||
---
|
||||
@@ -163,12 +169,32 @@ docker run -d -p 3000:3000 sori.studio:latest
|
||||
- NAS Docker 예시: `postgres://sori_studio:비밀번호@sori-studio-db:5432/sori_studio`
|
||||
- `.env.example`에는 실제 비밀번호나 개인 이메일을 기록하지 않음
|
||||
- 개발/운영 DB 비밀번호와 관리자 비밀번호는 서로 다른 랜덤 값을 사용
|
||||
- `ADMIN_EMAIL`/`ADMIN_PASSWORD`는 운영 DB에 owner/admin이 없는 최초 관리자 생성에만 사용한다. 같은 이메일의 일반 회원이 이미 있으면 owner로 승격하고 비밀번호를 `ADMIN_PASSWORD` 기준으로 갱신한다. 첫 owner 계정이 DB에 생성된 뒤에는 관리자 로그인도 DB의 bcrypt 비밀번호를 기준으로 검증한다.
|
||||
- 회원 세션 서명용 `MEMBER_SESSION_SECRET`은 관리자 비밀번호와 분리된 긴 난수 문자열을 사용
|
||||
- 개발 DB와 운영 DB는 반드시 별도 인스턴스 또는 별도 데이터베이스로 분리
|
||||
- 운영 DB는 로컬 개발 서버에서 직접 연결하지 않음
|
||||
- 운영 환경에서 `DATABASE_URL`이 없으면 샘플 콘텐츠로 대체하지 않고 서버 오류로 실패
|
||||
- Docker 운영 컨테이너는 `.env.production`의 서버 환경 변수를 런타임 `process.env`에서 우선 읽는다.
|
||||
|
||||
### 이메일 인증(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 분리 검증 절차
|
||||
|
||||
@@ -202,6 +228,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 연결 확인.
|
||||
@@ -240,12 +267,67 @@ git diff -- . ':!package-lock.json'
|
||||
- `.env.development`, `.env.production`이 변경 목록에 포함되지 않음
|
||||
- 문서와 코드 diff에 실제 DB 비밀번호, 관리자 비밀번호, 운영 접속 주소가 포함되지 않음
|
||||
|
||||
### 컨테이너가 `Restarting`일 때
|
||||
|
||||
`Error response from daemon: Container … is restarting, wait until the container is running`은 **프로세스가 곧바로 종료**되어 `restart: unless-stopped`가 반복 시도하는 상태다. 원인은 로그에 나온다.
|
||||
|
||||
1. **어느 서비스인지 확인** (`docker-compose.yml` 기준 이름은 `sori-studio`, `sori-studio-db`).
|
||||
|
||||
```bash
|
||||
docker ps -a --filter "name=sori-studio"
|
||||
```
|
||||
|
||||
2. **해당 컨테이너 로그** (가장 중요).
|
||||
|
||||
```bash
|
||||
docker logs sori-studio --tail 150
|
||||
docker logs sori-studio-db --tail 150
|
||||
```
|
||||
|
||||
Compose로 올렸다면:
|
||||
|
||||
```bash
|
||||
docker compose --env-file .env.production logs sori-studio --tail 200
|
||||
docker compose --env-file .env.production logs sori-studio-db --tail 200
|
||||
```
|
||||
|
||||
3. **자주 나오는 원인**
|
||||
|
||||
- **`sori-studio`**: `DATABASE_URL` 누락·오타, `MEMBER_SESSION_SECRET` 미설정, DB 호스트가 컨테이너 기준으로 잘못됨(예: 앱은 Docker 안인데 URL만 `127.0.0.1`로 DB를 가리킴), 애플리케이션 예외로 즉시 종료.
|
||||
- **`sori-studio-db`**: 이미 초기화된 볼륨과 다른 `POSTGRES_PASSWORD`로 다시 올린 경우, `docker-entrypoint-initdb.d` 마이그레이션 SQL 오류, 디스크/권한 문제.
|
||||
- **`sori-studio-db` 로그에 `ls: can't open '/docker-entrypoint-initdb.d/': Permission denied`**: 아래 **NAS·호스트에서 `db/migrations` 권한** 절차를 확인한다.
|
||||
|
||||
4. 로그를 고친 뒤에는 `docker compose --env-file .env.production up -d`로 다시 올리고, `docker ps`에서 `Up` 상태인지 확인한다.
|
||||
|
||||
### NAS·호스트에서 `db/migrations` 권한
|
||||
|
||||
`docker-compose.yml`은 `./db/migrations`를 Postgres 이미지의 `/docker-entrypoint-initdb.d`에 **읽기 전용**으로 붙인다. 공식 엔트리포인트는 이 디렉터리를 `ls`로 읽는데, NAS(UGREEN 등)나 SSH로 복사한 트리에서 **폴더·파일이 700/600만 허용**이거나 **상위 디렉터리에 실행(x) 비트가 없으면** 컨테이너 안 `postgres` 사용자가 경로를 통과하지 못해 `Permission denied`가 반복되고 DB 컨테이너가 재시작 루프에 들어갈 수 있다.
|
||||
|
||||
프로젝트 루트( `docker compose` 를 실행하는 디렉터리)에서 SSH로 다음을 적용한다. **비밀번호는 바꾸지 않으며**, 읽기·디렉터리 통과만 연다.
|
||||
|
||||
```bash
|
||||
cd /volume1/docker/projects/apps/sori.studio
|
||||
# 마이그레이션 디렉터리와 그 안 SQL: 모두 읽기, 디렉터리는 검색 가능
|
||||
sudo chmod -R a+rX db/migrations
|
||||
# 상위 db/, 프로젝트 루트가 다른 사용자만 rwx 인 경우 통과 허용
|
||||
sudo chmod a+x . db db/migrations
|
||||
```
|
||||
|
||||
그다음 DB 컨테이너만 재시작한다.
|
||||
|
||||
```bash
|
||||
docker compose --env-file .env.production restart sori-studio-db
|
||||
```
|
||||
|
||||
여전히 동일하면 프로젝트가 **SMB 공유 폴더 위**에만 있지 않은지 확인한다. Docker 데몬이 네이티브 경로(ext4 등)의 디렉터리를 마운트할 때 권한이 더 예측 가능하다.
|
||||
|
||||
## 업로드 파일
|
||||
|
||||
- 관리자 글쓰기에서 업로드한 이미지는 `/uploads/posts/YYYY/MM/` URL로 제공한다.
|
||||
- 로컬 개발에서는 실제 파일이 `public/uploads/posts/YYYY/MM/` 아래 저장된다.
|
||||
- `public/uploads/`는 Git에 포함하지 않는다.
|
||||
- NAS 운영에서는 업로드 파일이 컨테이너 재생성으로 사라지지 않도록 별도 볼륨 연결을 확정해야 한다.
|
||||
- NAS 운영에서는 `docker-compose.yml`의 `./public/uploads:/app/public/uploads` 볼륨으로 업로드 파일을 유지한다.
|
||||
- 운영 빌드에서는 `/uploads/**` 서버 라우트가 `.output/public`이 아니라 `/app/public/uploads` 볼륨의 실제 파일을 직접 제공한다.
|
||||
- `MAX_FILE_SIZE` 환경 변수로 관리자 이미지 업로드 최대 크기를 제한한다.
|
||||
- 관리자 미디어 화면은 현재 업로드 파일 시스템을 기준으로 목록, 파일명 변경, 삭제를 처리한다.
|
||||
|
||||
|
||||
545
docs/history.md
545
docs/history.md
@@ -1,5 +1,550 @@
|
||||
# 의사결정 이력
|
||||
|
||||
## 2026-05-15 v1.1.9
|
||||
|
||||
### 추천 글을 저장 필드로 분리
|
||||
|
||||
홈 Featured와 목록의 번개 표시는 최신 글 여부가 아니라 운영자가 명시한 추천 상태여야 한다. 기존처럼 첫 번째 글을 추천처럼 보이게 하면 추천 의도와 최신순 정렬이 섞이므로, 게시물에 `is_featured` 필드를 추가하고 글쓰기 사이드바 토글로 관리하도록 했다. 추천 글이 없으면 홈 Featured 영역도 숨겨 빈 운영 상태에서 불필요한 섹션이 보이지 않게 한다.
|
||||
|
||||
### 관리자 글 목록 필터를 클라이언트 우선으로 도입
|
||||
|
||||
현재 관리자 글 목록은 전체 글을 한 번에 조회하는 구조라 상태·태그·정렬 필터를 클라이언트에서 먼저 적용해 변경 범위를 줄였다. 목록 규모가 커지면 같은 필터 기준을 `/admin/api/posts` 쿼리 파라미터로 옮길 수 있도록 상태 키와 태그 필터 계산을 별도 함수로 분리했다.
|
||||
|
||||
## 2026-05-15 v1.1.8
|
||||
|
||||
### 태그 순서 저장을 드롭 즉시 자동화
|
||||
|
||||
메인 태그 정렬은 드래그 자체가 명확한 저장 의도를 가진 조작이므로 별도의 `정렬 저장` 버튼을 두면 화면의 책임이 나뉘어 보인다. 태그 추가 버튼도 화면 전체 제목 옆에 있으면 메인 태그 추가처럼 보일 수 있어, 새 태그가 기본적으로 일반 태그로 생성되는 현재 구조에 맞춰 일반 태그 섹션 헤더 오른쪽으로 옮겼다. 순서 저장 중에는 추가 드래그를 잠시 막아 서버 순서와 화면 순서가 어긋나지 않게 한다.
|
||||
|
||||
## 2026-05-15 v1.1.7
|
||||
|
||||
### 사이트 로고 파일명을 교체마다 고유하게 저장
|
||||
|
||||
사이트 로고 업로드는 미디어 라이브러리에 `시스템` 폴더 메타로 남지만, 기존 구현은 항상 `/uploads/system/logo.webp`와 `/uploads/system/favicon.png`를 덮어썼다. 운영 브라우저와 파비콘 캐시는 같은 URL의 이미지를 오래 보관할 수 있어 파일이 바뀌어도 이전 이미지처럼 보일 수 있다. 따라서 로고와 파비콘은 업로드마다 고유 파일명으로 저장하고 사이트 설정 URL 자체를 갱신한다. 현재 사이트 설정에서 참조 중인 로고·파비콘은 미디어 라이브러리에서 사용 중인 파일로 표시해 실수로 이름을 바꾸거나 삭제하지 못하게 했다.
|
||||
|
||||
## 2026-05-15 v1.1.6
|
||||
|
||||
### 일반 태그도 검색 없이 보이는 관리 화면
|
||||
|
||||
메인 태그는 공개 카테고리 노출용이고 일반 태그는 게시물 분류 보조용이므로, 새 태그를 무조건 메인 태그로 올리면 공개 노출 의도가 섞인다. 다만 일반 태그를 검색해야만 볼 수 있으면 방금 만든 태그가 저장되지 않은 것처럼 느껴진다. 따라서 새 태그는 일반 태그 기본값을 유지하되, 관리자 태그 화면에서 일반 태그 전체 목록을 배지 형태로 항상 보여주고 필요할 때 메인 태그로 전환하도록 정리했다. 배지는 최근 사용순을 기본으로 하되 운영 판단에 따라 많이 사용순·이름순으로 바꿀 수 있게 했다.
|
||||
|
||||
## 2026-05-15 v1.1.5
|
||||
|
||||
### 운영 업로드 파일을 런타임 볼륨에서 직접 제공
|
||||
|
||||
Nuxt 운영 빌드는 `public/`을 빌드 시점에 `.output/public`으로 복사해 정적 파일로 제공한다. 반면 Docker 운영 업로드는 컨테이너 실행 중 `/app/public/uploads` 볼륨에 기록되므로, 새 파일이 `.output/public` 스냅샷에 없으면 업로드 직후 이미지가 깨져 보일 수 있다. 업로드 파일은 사용자 콘텐츠이자 런타임 데이터이므로 빌드 산출물에 의존하지 않고 `/uploads/**` 요청을 `public/uploads`에서 직접 스트리밍하도록 결정했다.
|
||||
|
||||
## 2026-05-15 v1.1.4
|
||||
|
||||
### 관리자 멤버 썸네일 업로드 경로 분리
|
||||
|
||||
회원 프로필 썸네일은 관리자 계정인지 일반 회원인지와 무관하게 회원 자산이므로 `/uploads/members/avatars`에 저장해야 한다. 관리자 멤버 편집 화면이 공용 게시물 이미지 업로드 API를 사용하면 `/uploads/posts`에 저장되어 미디어 분류와 썸네일 생명주기 규칙이 어긋난다. 회원 설정 업로드와 관리자 멤버 업로드가 같은 검증·WebP 변환·1:1 크롭 로직을 쓰도록 공통 유틸로 분리하고, 관리자 멤버 화면은 회원 전용 업로드 API를 사용하도록 정리했다.
|
||||
|
||||
## 2026-05-13 v1.1.3
|
||||
|
||||
### 사이드바 행 호버 배경 분리
|
||||
|
||||
전역 `site-panel-hover`는 패널과 텍스트 색을 `color-mix`해 라이트에서도 호버가 진하게 느껴진다. 카드·태그 목록 등 다른 패널은 기존 대비를 유지하고, 왼쪽 사이드바 네비·카테고리·테마 점만 `site-sidebar-nav-row`로 분리해 라이트에서 `#F7F4EF`로 완화했다. 다크에서는 가독성을 위해 기존과 동일한 `color-mix` 호버를 유지한다.
|
||||
|
||||
## 2026-05-14 v1.1.2
|
||||
|
||||
### 태그 없을 때 “POST” 더미 표시 제거
|
||||
|
||||
태그 배열이 비어 있을 때 UI 폴백으로 `POST` 문자열을 넣어 두어, 사용자는 실제 태그가 붙은 것으로 오해했다. 저장 데이터와 무관한 표시이므로 슬러그가 있을 때만 첫 태그를 노출하고 없으면 태그 영역을 렌더하지 않는다.
|
||||
|
||||
## 2026-05-14 v1.1.1
|
||||
|
||||
### 공개 문단 행간 기본값으로 복귀
|
||||
|
||||
문단 글자 크기만 16px(`text-base`)로 고정하고 행간은 `leading-7` 대신 Tailwind·브라우저 기본(`leading-normal` 계열)에 맡긴다.
|
||||
|
||||
## 2026-05-14 v1.1.0
|
||||
|
||||
### 관리자 제목·공개 본문 타이포 마이너 조정
|
||||
|
||||
관리자 글 제목 입력은 전체 화면 폼에서 과도하게 커 보일 수 있어 `text-3xl`로 낮췄다. 공개 본문 문단은 15px·좁은 행간 조합보다 `text-base`·`leading-7`로 읽기 리듬을 맞추고, 제목 블록의 고정 `mt-12`를 제거해 본문 첫 블록과의 간격을 렌더 트리에 맡긴다.
|
||||
|
||||
## 2026-05-14 v1.0.19
|
||||
|
||||
### 수정 모드에서 보이는 hard break 표식
|
||||
|
||||
마크다운 표준의 공백 2개 hard break는 렌더링 결과는 맞지만 textarea 수정 모드에서는 공백이 보이지 않아 일반 Enter와 Shift+Enter를 구분할 수 없다. Markdown-first 에디터가 아직 plaintext textarea 기반인 동안에는 작성자가 줄바꿈 의미를 눈으로 확인할 수 있어야 하므로, Shift+Enter는 줄끝 백슬래시 hard break를 삽입하도록 바꾼다. 렌더러는 새 백슬래시 방식과 이전 공백 2개 방식을 모두 지원해 기존 저장 콘텐츠와 호환한다.
|
||||
|
||||
## 2026-05-14 v1.0.18
|
||||
|
||||
### 빈 줄 공백 보존과 미리보기 집중 모드
|
||||
|
||||
일반 Enter를 단일 줄 이동으로 유지하더라도, 작성자가 의도적으로 여러 줄을 비우면 미리보기와 공개 본문에서도 그만큼의 공백이 보여야 한다. 빈 줄을 문단 경계로만 소비하면 제목 아래나 문단 사이에 넣은 여백이 모두 사라지므로, 내용 없는 줄은 다시 spacer 블록으로 렌더링해 줄 수 정보를 보존한다. 다만 기본 문단 간격은 10px로 유지해 일반 문단 흐름과 의도적 공백을 분리한다.
|
||||
|
||||
미리보기 모드는 작성 도구가 아니라 결과 확인 화면이므로 툴바와 카드형 패널 외곽을 숨긴다. 작성 모드 줄 번호 거터는 본문 바깥 위치 안내로만 쓰고 스크롤바는 숨겨 편집 화면의 시각 잡음을 줄인다.
|
||||
|
||||
## 2026-05-14 v1.0.17
|
||||
|
||||
### textarea 기준 문단 입력 재조정
|
||||
|
||||
일반 Enter를 `\\n\\n`으로 저장하면 마크다운 문단 구분은 명확하지만, 작성 화면에서는 커서가 두 줄 내려가 보여 긴 글 작성 리듬이 어색해진다. textarea 기반 편집에서는 Affine처럼 일반 Enter를 단일 줄 이동의 새 문단으로 보고, Shift+Enter만 같은 문단 안 hard break로 구분하는 쪽이 더 편하다. 따라서 일반 Enter는 브라우저 기본 입력을 유지하고, Shift+Enter만 마크다운 hard break(`공백 2개 + \\n`)를 삽입한다. 렌더러는 hard break가 있는 행만 같은 문단으로 묶고, 일반 줄은 각각 10px 간격의 문단으로 렌더링한다.
|
||||
|
||||
### 작성 영역 카드감 축소
|
||||
|
||||
본문 textarea에 외곽 보더와 배경 카드가 있으면 관리자 화면의 다른 패널과 겹쳐 편집 영역이 과하게 박스처럼 보인다. 작성 영역의 보더와 배경을 제거하고, 줄 번호 거터는 본문 흐름 바깥 absolute 영역으로 분리한다. 현재 줄 강조 액센트는 제거하고 줄 번호 자체만 위치 안내로 사용한다.
|
||||
|
||||
## 2026-05-14 v1.0.16
|
||||
|
||||
### 문단과 줄바꿈 의미 분리
|
||||
|
||||
Markdown-first 에디터에서 빈 줄을 그대로 spacer로 보존하면 세로 간격은 조절할 수 있지만, 글쓰기 경험이 옵시디언·고스트처럼 “문단”과 “줄바꿈”으로 나뉘지 않는다. 운영 글쓰기에서는 일반 Enter를 새 문단, Shift+Enter를 같은 문단 안 줄바꿈으로 보는 Ghost형 규칙이 더 예측 가능하다. 따라서 에디터는 일반 Enter 입력 시 `\\n\\n`을 넣고, Shift+Enter는 브라우저 기본 단일 줄바꿈을 유지한다. 렌더러는 빈 줄을 별도 spacer가 아니라 문단 경계로만 쓰며, 연속 텍스트 줄은 한 문단으로 묶어 단일 줄바꿈을 `<br>`로 표시한다. 문단 간 구분감은 개별 spacer가 아니라 문단 하단 24px 간격으로 통일한다.
|
||||
|
||||
### 미리보기 전환 후 커서 복원
|
||||
|
||||
`Cmd/Ctrl+E`로 미리보기를 확인한 뒤 작성 모드로 돌아왔을 때 포커스가 사라지면 긴 글을 이어 쓰는 흐름이 끊긴다. 작성 textarea의 선택 시작·끝 위치와 스크롤 위치를 기억하고, 미리보기에서 돌아올 때 같은 위치로 포커스와 선택 영역을 복원하도록 했다.
|
||||
|
||||
## 2026-05-14 v1.0.15
|
||||
|
||||
### 마크다운 빈 줄 간격 보존
|
||||
|
||||
Markdown-first 에디터에서는 작성자가 빈 줄을 넣어 문단 사이 호흡을 직접 조절할 수 있어야 한다. 기존 `ContentMarkdownRenderer`는 빈 줄을 파싱 단계에서 건너뛰어 1줄과 2줄의 차이가 모두 사라졌다. 빈 줄과 레거시 빈 문단 마커를 `spacer` 블록으로 렌더링해 공개 본문과 관리자 미리보기에서 작성자가 넣은 세로 간격을 보존한다. 세부 높이는 이후 본문 스타일 QA에서 조정하되, 우선 줄 수 정보가 사라지지 않는 것을 기준으로 한다.
|
||||
|
||||
## 2026-05-14 v1.0.14
|
||||
|
||||
### Markdown-first 전환 후 레거시 본문 정규화
|
||||
|
||||
Markdown-first 에디터로 전환한 뒤에도 브라우저 자동 저장본이나 이전 블록 에디터 상태가 배열·객체 형태로 남아 있으면, 저장 API의 `content: string` 검증에서 “게시물 입력 형식” 오류가 날 수 있다. 데이터베이스 저장 형식은 마크다운 문자열로 유지하되, 클라이언트 복원 단계와 서버 입력 검증 단계에 공통 정규화 유틸을 두어 레거시 블록 값을 마크다운으로 변환한다. 이렇게 하면 사용자가 기존 자동 저장본을 복원해도 발행 흐름이 끊기지 않고, 이후 페이지 편집 쪽도 같은 기준을 공유한다.
|
||||
|
||||
## 2026-05-14 v1.0.13
|
||||
|
||||
### Markdown 에디터 붙여넣기와 미디어 편집 보강
|
||||
|
||||
textarea 기반 Markdown-first 편집은 범위 선택과 복사/붙여넣기 문제를 줄였지만, 외부 웹 글을 붙여넣을 때 HTML 구조가 일반 텍스트로 무너지거나 이미지·갤러리 마크다운을 직접 고쳐야 하는 불편이 남았다. 우선 브라우저 클립보드의 `text/html`을 제목·문단·목록·링크·강조·이미지 중심의 마크다운 조각으로 변환하고, 커서가 이미지 또는 갤러리 블록 안에 있을 때 별도 편집 패널을 보여 alt·URL·너비·갤러리 순서를 수정하도록 했다. 작성과 미리보기 전환은 반복 작업이므로 `Cmd/Ctrl+E` 단축키로 접근하게 하고, 관리자 미리보기는 공개 렌더러를 쓰되 밝은 관리자 패널에 맞는 색상 변수를 별도로 고정한다. 완전한 옵시디언식 토큰 숨김 Live Preview와 표준 마크다운 파서는 더 큰 편집 엔진 선택이 필요하므로 후속 단계로 둔다.
|
||||
|
||||
## 2026-05-13 v1.0.12
|
||||
|
||||
### Markdown 에디터 줄 번호 거터
|
||||
|
||||
옵시디언·CodeMirror는 편집 줄 왼쪽에 줄 번호와 현재 줄 하이라이트를 둔다. 본문 편집이 textarea 단일 호스트로 바뀐 뒤에도 동일한 방향의 안내가 있으면 긴 본문에서 위치 파악이 쉬워진다. CodeMirror 수준의 **시각 줄**(줄바꿈 wrap) 단위 번호는 별도 미러 레이아웃 없이는 맞추기 어렵고, 우선 `\\n` 기준 **논리 줄** 번호와 캐럿이 속한 논리 줄의 거터 셀 배경 강조, 스크롤 동기화만 구현했다.
|
||||
|
||||
## 2026-05-14 v1.0.11
|
||||
|
||||
### 관리자 글쓰기를 Markdown-first로 전환
|
||||
|
||||
블록형 에디터는 이미지·갤러리 같은 카드형 입력에는 편하지만, 여러 문단 범위 선택, 외부 블로그/옵시디언 복사·붙여넣기, 다중 블록 복사 같은 기본 글쓰기 동작이 브라우저 텍스트 편집 모델과 계속 충돌했다. 저장 포맷은 이미 마크다운 문자열이므로 본문 편집의 원본도 마크다운 문자열로 되돌리고, textarea 기반 작성 모드와 공개 렌더러 기반 미리보기를 제공한다. 이미지와 갤러리는 기존 업로드·미디어 라이브러리 API를 유지하고 커서 위치에 마크다운으로 삽입한다. 옵시디언식 토큰 숨김 Live Preview는 이 기반이 안정화된 뒤 별도 단계로 확장한다.
|
||||
|
||||
## 2026-05-13 v1.0.10
|
||||
|
||||
### 관리자 블록 에디터를 v1.0.5 파일 기준으로 복원
|
||||
|
||||
v1.0.6부터 적용했던 다중 줄 붙여넣기 분할, Cmd/Ctrl+A로 전체 마크다운 복사, 블록 단위 범위 선택·레인 드래그·복사 가로채기 등이 실제 사용에서 어색하다는 피드백이 있어, `AdminBlockEditor.vue`는 Git 태그 `v1.0.5` 시점 내용으로 되돌렸다. Docker·부트스트랩 등 v1.0.5 이후 서버/배포 변경은 유지하고 에디터 파일만 이전 동작으로 맞춘다.
|
||||
|
||||
## 2026-05-14 v1.0.5
|
||||
|
||||
### Docker 런타임 환경 변수 우선
|
||||
|
||||
Nuxt `runtimeConfig`에 `process.env.*`를 직접 대입하면 Docker 이미지 빌드 시점에 값이 비어 있는 상태로 번들에 들어갈 수 있다. 운영 컨테이너는 `.env.production`을 런타임에 주입하므로, 서버 전용 비밀값과 DB 연결값은 `useRuntimeConfig()`보다 `process.env`를 우선 조회하도록 공통 유틸을 추가했다. 이 기준은 관리자 최초 로그인, 세션 서명, DB 연결, 이메일 OTP 설정에 적용한다.
|
||||
|
||||
## 2026-05-14 v1.0.4
|
||||
|
||||
### 최초 관리자 기준을 owner/admin 존재 여부로 변경
|
||||
|
||||
운영 DB에 일반 회원이 먼저 생성되면 기존의 “사용자 0명” 기준 부트스트랩이 더 이상 동작하지 않아 관리자 계정이 없는 잠금 상태가 생길 수 있다. 최초 관리자 필요 여부를 전체 사용자 수가 아니라 `owner`/`admin` 권한 보유자 존재 여부로 판단하고, `ADMIN_EMAIL`과 같은 일반 회원이 이미 있으면 해당 회원을 `owner`로 승격하면서 `ADMIN_PASSWORD` 기준 비밀번호 해시를 갱신해 운영 복구 경로를 보장한다. 이미 `owner`/`admin`이 있으면 환경 변수 로그인은 우회 권한으로 쓰지 않는다.
|
||||
|
||||
## 2026-05-14 v1.0.3
|
||||
|
||||
### NAS에서 Postgres init 디렉터리 Permission denied
|
||||
|
||||
Docker가 호스트의 `db/migrations`를 `postgres:16-alpine`의 `/docker-entrypoint-initdb.d`에 마운트할 때, NAS 파일 시스템이나 복사 시 기본 umask 때문에 디렉터리가 `700`·파일이 `600`만 되면 컨테이너 내부 `postgres` UID로는 목록을 읽지 못한다. 엔트리포인트가 `ls /docker-entrypoint-initdb.d`에서 실패하면 DB가 즉시 종료되고 `restart: unless-stopped`로 루프에 들어간다. 배포 문서에 `chmod -R a+rX db/migrations` 및 상위 경로 `a+x` 절차를 명시하고, compose에 주석으로 동일 원인을 남겨 재발 시 빠르게 대응할 수 있게 했다.
|
||||
|
||||
## 2026-05-14 v1.0.1
|
||||
|
||||
### Docker Compose 전용 네트워크 대역 명시
|
||||
|
||||
NAS에서 이미 실행 중인 Docker 서비스가 많으면 Docker가 자동으로 고르는 기본 브리지 네트워크 주소 풀이 기존 네트워크와 겹쳐 Compose 실행이 실패할 수 있다. 기존 네트워크를 정리할 수 없는 운영 환경을 고려해 이 프로젝트 전용 브리지 네트워크와 기본 subnet을 명시하고, 필요 시 `.env.production`의 `DOCKER_SUBNET`으로 다른 사설 대역을 지정할 수 있게 했다.
|
||||
|
||||
## 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
|
||||
|
||||
chevron 전용 버튼과 펼침 배경은 원본 Ghost류 UI와 어긋난다. 부모 한 줄을 단일 컨트롤로 두고 chevron·패널 높이에 전환을 줘서 끊김을 줄였다.
|
||||
|
||||
## 2026-05-12 v0.0.95
|
||||
|
||||
### 메뉴 관리 단순화와 드래그 UX
|
||||
|
||||
`is_visible` 체크는 “숨기기” 용도였으나 목록에 두면 모두 표시해야 한다는 운영 기대와 맞지 않아 제거하고 항상 공개로 저장한다. `is_folder`는 자식 존재로만 서버가 채운다. 드래그는 태그 관리와 같은 행 단위 시각 피드백으로 맞췄다.
|
||||
|
||||
## 2026-05-12 v0.0.94
|
||||
|
||||
### 메뉴 관리 UX와 상단 네비 트리
|
||||
|
||||
위치를 셀렉트로 바꾸면 실수로 상·하단을 오가기 쉽고, 인덱스 입력은 모달·레이아웃과 겹칠 때 피드백이 약하다. 미디어처럼 탭으로 영역을 나누고 순서는 드래그로 통일했다. Ghost형 상단 그룹은 `parent_id`와 공개 트리 API, 사이드바에서 chevron 접기로 맞췄다.
|
||||
|
||||
## 2026-05-12 v0.0.93
|
||||
|
||||
### 관리자 미디어 오류 표시를 토스트로
|
||||
|
||||
상세·폴더 모달이 `z-50`~`z-[60]`일 때 본문 상단 배너는 모달 뒤에 깔려 사용자가 API 오류(예: 동일 파일명 409)를 볼 수 없었다. `useAdminToast`로 우측 상단 고정·높은 z-index에 두어 레이아웃과 무관하게 피드백이 보이게 했다.
|
||||
|
||||
## 2026-05-12 v0.0.92
|
||||
|
||||
### 프로필 썸네일 해제와 다운로드
|
||||
|
||||
서버는 이미 디스크를 지우지 않지만, 설정 화면이 `PUT /api/auth/profile`로만 `avatarUrl`을 비울 때는 메타 분리가 빠져 관리자 목록과 체감이 어긋날 수 있어 `DELETE /api/auth/avatar`와 같은 `removeManagedAvatarAsset` 호출을 맞췄다. 관리자 미디어 모달에 다운로드를 넣어 원본 확인을 쉽게 했다.
|
||||
|
||||
## 2026-05-12 v0.0.91
|
||||
|
||||
### 썸네일 미사용 자산과 업로드 파일명
|
||||
|
||||
프로필에서 바뀐 옛 썸네일을 바로 디스크에서 지우면 관리자가 누가 올린 자산인지 추적하기 어렵다. 메타만 끊고 파일은 남겨 썸네일 탭에서 정리하도록 바꿨다. 삭제·이름 변경 차단은 `avatar_url`이 가리키는 경우로 한정했다. 게시물 업로드는 UUID 접미 대신 원본명과 넘버링으로 검색 가능성을 높였다.
|
||||
|
||||
## 2026-05-12 v0.0.90
|
||||
|
||||
### 관리자 미디어 라이브러리와 썸네일 탭 분리
|
||||
|
||||
게시물 이미지는 디스크상 `posts/`에 두더라도 논리 분류는 `미분류`로 통일해 `posts` 트리와 이중 표기를 없앤다. 회원 프로필 이미지는 디스크 경로는 유지하되 논리 폴더를 예약명 `썸네일`로 고정하고, 관리자 화면에서는 탭을 나눠 검색·탐색 대상을 분리했다. 썸네일 파일은 URL이 회원 콘텐츠와 직결되므로 관리자에서 임의 삭제·이름 변경이 되면 프로필이 깨지기 쉬워 API·UI에서 막는다.
|
||||
|
||||
## 2026-05-12 v0.0.89
|
||||
|
||||
### 미디어 선택 토글 가시성
|
||||
|
||||
네이티브 체크박스는 배경·브라우저 기본 스타일 때문에 흰 썸네일 위에서 거의 보이지 않는 사례가 있어, 동일 hit 영역을 유지한 채 명암이 큰 커스텀 토글로 바꿨다. 폴더 트리에 나오는 `posts`·`미분류` 등은 디스크 경로와 DB 메타 규칙을 문서에 적어 운영자가 혼동하지 않도록 했다.
|
||||
|
||||
## 2026-05-12 v0.0.88
|
||||
|
||||
### 관리자 미디어 선택·폴더 UX
|
||||
|
||||
썸네일 전체 클릭이 선택과 미리보기를 겹쳐 쓰기 어렵다. 본문 클릭은 미리보기 모달, 좌측 체크박스만 선택으로 분리했다. 폴더 추가는 사용 빈도가 낮아 상시 입력 대신 모달로 받고, 비어 있지 않은 분류 트리를 위해 폴더 삭제 API와 행 단위 삭제를 추가했다(물리 파일은 건드리지 않고 메타만 미분류로).
|
||||
|
||||
## 2026-05-12 v0.0.87
|
||||
|
||||
### 저장·로그인 버튼 기본 비활성과 글 목록 삭제 아이콘
|
||||
|
||||
정렬 저장·메뉴 저장은 변경이 없을 때 연속 클릭이 의미 없으므로, 서버 스냅샷과 비교해 dirty일 때만 활성화한다. 로그인은 빈 제출을 막기 위해 필수 필드가 채워진 뒤에만 버튼을 켠다. 글 목록 삭제는 보조 액션이므로 텍스트 강조 대신 휴지통 아이콘과 낮은 기본 대비, 호버 시 강조로 시선 부담을 줄였다.
|
||||
|
||||
## 2026-05-12 v0.0.86
|
||||
|
||||
### 게시물 URL 로마자화와 태그 표기 분리
|
||||
|
||||
게시물 슬러그는 URL 안정성을 위해 한글 음절을 로마자로 바꾸는 기존 방식을 유지한다. 반면 태그는 사용자가 입력한 한글을 그대로 쓰는 경우가 많고, 동일 로마자화를 적용하면 배지·DB `name`이 기대와 달라지므로 태그 토큰은 한글·영문·숫자와 하이픈만 정리하는 별도 정규화로 분리했다. 저장소에서는 태그 슬러그에 한글이 포함되면 하이픈을 공백으로 바꾼 문자열을 표시명으로 쓰고, 순수 라틴 하이픈 슬러그는 기존처럼 단어별 이니셜 대문자 규칙을 유지한다.
|
||||
|
||||
### 관리자 글 SEO 입력 단순화
|
||||
|
||||
공개 상세는 이미 SEO 필드가 비어 있으면 제목·요약을 메타 기본값으로 쓰므로, 관리자 폼에서 별도 SEO 제목·설명 입력을 두면 중복 편집만 늘어난다. 저장 시점에 제목·요약을 `seo_title`·`seo_description`에 동기화하고 폼에서는 `noindex`만 노출해 입력 부담을 줄였다.
|
||||
|
||||
### 태그 관리 피드백을 토스트로
|
||||
|
||||
순서 저장 등 성공 메시지를 본문 위에 블록으로 넣으면 레이아웃이 밀려 체감 품질이 떨어지므로, 네비게이션 저장과 동일하게 우측 상단 고정 토스트로 통일했다.
|
||||
|
||||
## 2026-05-11 v0.0.85
|
||||
|
||||
### 의도한 빈 문단 저장 보존
|
||||
|
||||
블록 에디터는 마지막 보조 빈 문단을 자동으로 유지하는 구조라서, 저장 시 모든 빈 문단을 제거하면 사용자가 의도적으로 만든 2~3줄 공백도 함께 사라진다. 이를 구분하기 위해 빈 문단 전용 마커를 저장 포맷에 도입하고, 에디터 파서와 공개 렌더러 파서가 동일하게 해석하도록 맞춰 공백 의도를 보존했다.
|
||||
|
||||
## 2026-05-11 v0.0.84
|
||||
|
||||
### 방향키 문단 이동과 슬래시 메뉴 스크롤 고정
|
||||
|
||||
관리자 에디터는 블록 단위 편집이므로 일반 텍스트 에디터처럼 위/아래 방향키로 인접 문단으로 자연스럽게 넘어가야 한다. 커서가 블록 경계에 있을 때만 인접 블록으로 이동하도록 보완해 기존 블록 내부 이동과 충돌을 줄였다. 슬래시 메뉴는 명령 수가 많아도 화면을 넘기지 않도록 최대 높이+내부 스크롤로 제한하고, 방향키 하이라이트 항목을 항상 가시 영역으로 자동 스크롤해 선택 맥락을 유지하도록 정리했다.
|
||||
|
||||
## 2026-05-11 v0.0.83
|
||||
|
||||
### 슬래시 메뉴 방향키 상태 유지
|
||||
|
||||
슬래시 메뉴 강조 인덱스를 검색어 동기화 때마다 0으로 초기화하면 방향키를 여러 번 눌러도 체감상 1회만 이동하는 것처럼 보이므로, 검색어가 바뀐 경우에만 초기화하도록 분리했다. 또한 슬래시 입력 상태가 아닌 일반 본문 블록에서는 메뉴 방향키 핸들러를 즉시 빠져나오게 해 기본 커서 이동 동작을 최대한 유지하도록 조정했다.
|
||||
|
||||
## 2026-05-11 v0.0.82
|
||||
|
||||
### 메인 태그는 강등, 일반 태그는 검색 삭제
|
||||
|
||||
메인 태그는 홈 카테고리 노출 자산이므로 목록에서 실수 삭제보다 일반 태그로 되돌리는 강등 동작이 안전하다고 판단했다. 반대로 일반 태그는 수량이 많아 전체 목록 노출 대신 검색 중심으로 다루고, 삭제도 검색 결과 문맥에서만 허용해 운영 화면의 복잡도를 줄였다. 태그 수정 화면의 정렬/유형 입력은 목록 액션과 역할이 겹치므로 제거했다.
|
||||
|
||||
## 2026-05-11 v0.0.81
|
||||
|
||||
### 태그 입력 IME 안정화와 메인 태그 전환 흐름
|
||||
|
||||
관리자 글 작성 태그 입력은 한글 조합 중 Enter 이벤트가 완성 전/완성 후로 중복 처리될 수 있어, 조합 상태에서는 태그 추가를 막고 조합 완료 후에만 확정하도록 정리했다. 또한 게시물 작성에서 자연스럽게 늘어나는 태그는 기본적으로 일반 태그로 생성하고, 카테고리로 노출할 태그만 별도 검색 후 메인 태그로 승격하도록 운영 흐름을 분리했다.
|
||||
|
||||
## 2026-05-11 v0.0.80
|
||||
|
||||
### 태그를 관리용/일반용으로 분리하고 관리용만 정렬
|
||||
|
||||
태그 수가 많아질수록 모든 태그에 순번을 강제하면 운영 비용이 커지므로, 홈페이지 카테고리로 쓰는 태그만 `managed`로 분리해 순서를 관리하고 나머지는 `general`로 분리했다. 관리용 태그는 드래그 앤 드롭으로 순서를 바꾸고 일괄 저장 API로 반영해 중복 숫자 입력 문제를 제거했다.
|
||||
공개 `GET /api/tags`는 관리용 태그만 반환하도록 바꿔, 카테고리 노출 목적과 일반 배지 태그 목적이 섞이지 않게 했다.
|
||||
|
||||
## 2026-05-11 v0.0.79
|
||||
|
||||
### 최초 사용자 관리자 부트스트랩 전환
|
||||
|
||||
관리자 계정을 환경변수로 고정하면 실제 운영에서 초기 세팅 흐름이 불명확하고, 관리자 프로필/권한 관리가 회원 데이터와 분리되어 확장성이 떨어진다. 따라서 최초 사용자가 회원가입을 시도하는 시점에 `관리자 등록` 모드로 안내하고, 첫 계정에 `is_admin=true`를 부여하는 부트스트랩 방식으로 전환했다. 관리자 로그인도 동일한 `users` 인증 체계를 사용하도록 맞춰, 관리자/회원 계정 모델을 일원화했다.
|
||||
관리자 화면에서 썸네일/이름을 바로 수정할 수 있도록, 관리자 로그인 시 회원 세션도 함께 발급해 기존 회원 프로필 API를 재사용하는 방향을 선택했다.
|
||||
권한은 향후 기능 확장을 위해 `owner`/`admin`/`member` 3단계로 먼저 분리하고, 현재 단계에서는 관리자 멤버 화면에서 권한 값을 변경할 수 있게 준비했다.
|
||||
|
||||
## 2026-05-11 v0.0.79
|
||||
|
||||
### 댓글 아바타/좋아요/상대시간 표시 정렬
|
||||
|
||||
댓글 영역은 텍스트 중심 구조만으로는 SNS형 피드백 흐름이 약해 참여 지표를 확인하기 어려웠다. 작성자 썸네일과 좋아요 토글을 기본 액션으로 배치하고, 시간 표기는 최근 24시간 동안 상대 시간으로 보여 즉시성을 높였다. 24시간 이후에는 날짜로 전환해 장기 글에서도 타임라인 문맥을 유지한다.
|
||||
|
||||
## 2026-05-11 v0.0.78
|
||||
|
||||
### 관리자 미디어에서 회원 썸네일 가시성 복구
|
||||
|
||||
회원 썸네일을 미디어 목록에서 완전히 제외하면 운영자가 업로드 결과를 확인하거나 정리할 수 없어 관리성이 떨어진다. 경로 분리는 유지하되 관리자 미디어에서는 `회원/썸네일` 카테고리로 조회되도록 바꿔, 일반 콘텐츠 미디어와 논리적으로 구분하면서도 관리 화면에서 추적 가능하게 했다.
|
||||
|
||||
## 2026-05-11 v0.0.77
|
||||
|
||||
### 회원 설정 썸네일 표시 방식 전환
|
||||
|
||||
설정 화면에서 썸네일 URL 문자열을 직접 보여 주는 방식은 실제 사용자에게 의미가 낮고, 업로드 결과를 직관적으로 확인하기 어려웠다. 프로필 카드에서 아바타 미리보기와 이미지 변경/제거 액션을 바로 제공해 확인-수정 흐름을 단순화했다.
|
||||
|
||||
## 2026-05-11 v0.0.76
|
||||
|
||||
### 회원 썸네일 중앙 1:1 강제 크롭
|
||||
|
||||
회원이 세로형/가로형 이미지를 올려도 헤더와 설정 화면의 아바타는 동일한 비율이어야 UI가 안정적이므로, 업로드 시점에 중앙 기준으로 1:1 정사각형 크롭을 강제했다. 이렇게 하면 클라이언트별 개별 크롭 로직 없이 서버 저장본 자체가 일관된 아바타 규격을 가진다.
|
||||
|
||||
## 2026-05-11 v0.0.75
|
||||
|
||||
### 회원 썸네일 최소 해상도/설정 방어 강화
|
||||
|
||||
회원 썸네일은 너무 작은 원본이 올라오면 헤더/설정 화면에서 품질 저하가 크게 보이므로 최소 해상도 제한을 추가했다. 또한 운영 환경 변수 오입력으로 리사이즈/품질 값이 비정상이어도 업로드가 깨지지 않도록 서버에서 값 범위를 보정(clamp)해 안정성을 높였다.
|
||||
|
||||
## 2026-05-11 v0.0.74
|
||||
|
||||
### 회원 썸네일 업로드 표준화(WebP + 리사이즈)
|
||||
|
||||
회원 아바타는 사용자 입력 원본 포맷과 해상도가 제각각이므로, 업로드 시점에 서버에서 WebP로 통일 변환하고 최대 가로/세로를 제한해 저장하기로 했다. 이 방식은 저장 공간과 전송 용량을 줄이고, 너무 큰 원본 이미지 업로드로 인한 렌더링·트래픽 비용 증가를 줄이면서도 기존 URL 분리 정책(`/uploads/members/avatars/`)을 유지할 수 있다.
|
||||
|
||||
## 2026-05-11 v0.0.73
|
||||
|
||||
### 회원 썸네일 생명주기 정리
|
||||
|
||||
회원 아바타는 교체가 빈번해 파일이 누적되기 쉬우므로, 업로드 성공 후 이전 회원 전용 썸네일 자산을 자동 정리하도록 했다. 또한 회원이 직접 썸네일을 제거하거나 탈퇴할 때도 동일한 정리 로직을 재사용해 고아 파일과 불필요한 `media_metadata` 레코드가 남지 않게 했다.
|
||||
|
||||
## 2026-05-11 v0.0.72
|
||||
|
||||
### 회원 썸네일 미디어 분리
|
||||
|
||||
회원 썸네일은 운영자(작가) 콘텐츠 제작용 미디어와 목적이 다르므로, 업로드 경로를 `/uploads/members/avatars/YYYY/MM`로 분리했다. 관리자 미디어 목록에서는 해당 경로를 숨겨, 작가용 미디어 라이브러리와 회원 프로필 자산이 섞이지 않도록 했다.
|
||||
|
||||
## 2026-05-11 v0.0.71
|
||||
|
||||
### 회원 UX를 헤더 중심으로 전환
|
||||
|
||||
기존 헤더는 로그인 여부와 무관하게 Anonymous 메뉴를 고정으로 보여 실제 로그인 상태가 사용자에게 전달되지 않았다. 구독 버튼 대신 우측 아바타만 남기고, 로그인 상태에서는 설정/로그아웃 메뉴를 제공해 계정 액션을 한 위치로 정리했다. 비로그인 상태에서는 기존 Sign up/Sign in을 유지한다.
|
||||
|
||||
### 회원 설정/활동 추적과 관리자 멤버 관측
|
||||
|
||||
회원 기능이 들어오면서 운영 관점에서 사용자 정보와 활동 추적이 필요해졌다. `users`에 `avatar_url`, `last_seen_at`, `last_seen_ip`를 추가하고 로그인/세션조회/댓글작성 시 최근 활동을 갱신한다. 관리자는 `/admin/members`에서 닉네임, 이메일, 최근 접속, IP, 댓글 수를 확인해 운영 판단을 할 수 있다.
|
||||
|
||||
### 닉네임 유니크 정책
|
||||
|
||||
사용자 설정에서 닉네임 변경 시 중복 체크가 필요하므로 DB 레벨에서 `lower(username)` 유니크 인덱스를 도입했다. 기존 중복 데이터로 마이그레이션이 막히지 않도록, 인덱스 생성 전 중복 닉네임은 `-2`, `-3` 접미사를 붙여 자동 정리한 뒤 인덱스를 생성한다.
|
||||
|
||||
## 2026-05-11 v0.0.65
|
||||
|
||||
### 통합 검색 모달과 `GET /api/search`
|
||||
|
||||
헤더 검색은 장식이 아니라 Ghost류 UX로 `/` 단축키·모달·태그·게시물 섹션 구분이 필요했다. `INPUT`/`TEXTAREA` 등에 포커스가 있을 때는 브라우저 입력과 충돌하지 않도록 `/`를 무시한다. 검색은 저장소 `searchPublicContent`에 모아 `LIKE` 대신 `position(lower(q) in lower(column))`로 부분 일치를 구현해 `%`·`_` 이스케이프 이슈를 줄였다. 저자(author) 검색은 현재 도메인 모델에 없어 제외했다.
|
||||
|
||||
## 2026-05-11 v0.0.63
|
||||
|
||||
### Tailwind 엔트리 단일화
|
||||
|
||||
`@nuxtjs/tailwindcss` 기본 `cssPath`는 `assets/css/tailwind.css`인데 저장소에 해당 파일이 없으면 모듈이 패키지 내 `tailwind.css`를 `nuxt.options.css` 앞에 끼워 넣는다. 프로젝트는 이미 `main.css`에 `@tailwind`와 커스텀 `@layer`를 두고 있어 두 엔트리가 겹치면 유틸·레이어 순서가 기대와 달라질 수 있다. `tailwindcss.cssPath`를 `main.css`로 고정하고, JIT `content`에 composables·modules·plugins를 포함해 클래스 수집을 보강했다.
|
||||
|
||||
## 2026-05-11 v0.0.62
|
||||
|
||||
### 인증 폼 다크 스타일이 안 보이던 현상
|
||||
|
||||
`layout/page.vue`의 `text-ink`는 본문에 전달되지만, 폼 컨트롤은 UA 스타일로 `color`를 상속하지 않는 경우가 많아 다크 배경에서 입력 글자와 `currentColor` SVG가 사실상 사라질 수 있다. 전역 `.auth-form-input`으로 텍스트·캐럿·placeholder·WebKit autofill 글자색을 고정하고, 토글 버튼은 SFC `scoped` 스타일로 동일하게 맞췄다. `color-scheme: dark`는 네이티브 컨트롤 테마를 맞추기 위해 섹션에 추가했다.
|
||||
|
||||
## 2026-05-11 v0.0.61
|
||||
|
||||
### 인증 폼 비밀번호 토글 아이콘화
|
||||
|
||||
보기/숨기기 텍스트는 좁은 모바일에서 시각적 잡음이 되고 다국어·아이콘 일관성도 떨어져, Material 스타일 단일 경로 SVG(눈 열림·가림)를 공통 컴포넌트로 두었다. `aria-label`은 필드명(`field-name`)을 받아 회원가입의 확인 필드와 구분한다.
|
||||
|
||||
## 2026-05-11 v0.0.60
|
||||
|
||||
### 홈 Featured 모바일 스크롤·화살표 상태
|
||||
|
||||
가로 오버플로 트랙은 기본적으로 스크롤 가능하지만, 카드 전체가 링크일 때 브라우저가 세로 제스처에 가깝게 해석하거나 체인 스크롤이 나는 경우가 있어 `touch-pan-x`와 `overscroll-x-contain`으로 가로 우선·부모 스크롤 전파를 줄였다. 화살표는 스크롤 한계에서 의미 없는 클릭을 막기 위해 `scrollLeft`와 `scrollWidth - clientWidth` 비교로 `disabled`를 두고, 레이아웃 변화에 맞추기 위해 `ResizeObserver`를 함께 썼다.
|
||||
|
||||
## 2026-05-11 v0.0.59
|
||||
|
||||
### Nuxt `#internal/nuxt/paths` Node 해석 오류
|
||||
|
||||
Nuxt 3.21과 `@nuxt/vite-builder`는 SSR 엔트리에서 `#internal/nuxt/paths`를 롤업 외부 모듈로 남기는데, 동일 경로의 `paths.mjs` 템플릿은 기본적으로 VFS에만 있어 디스크 파일이 없다. Node는 프로젝트 루트 `package.json`의 `imports`로만 서브패스를 해석하므로, 템플릿을 디스크에 쓰도록(`write: true`) 훅하는 로컬 모듈과 루트 `imports` 매핑을 추가했다. `nitro-server` 경로만으로 브리지하면 `nitropack/runtime` 쪽 내부 specifier가 끌려와 단독 해석이 깨지므로, Nuxt가 생성하는 `paths.mjs` 본문을 그대로 두는 방식을 택했다.
|
||||
|
||||
## 2026-05-11 v0.0.58
|
||||
|
||||
### 중앙 본문과 우측 사이드 가로 넘침
|
||||
|
||||
154
docs/map.md
154
docs/map.md
@@ -8,91 +8,138 @@
|
||||
|------|------|
|
||||
| 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
|
||||
|
||||
| 파일 | 용도 |
|
||||
|------|------|
|
||||
| composables/formatPostDate.js | 공개 화면 게시일 `YYYY.MM.DD` 포맷 |
|
||||
| composables/useAdminToast.js | 관리자 우측 상단 토스트(성공·오류·안내; 모달보다 위 z-index) |
|
||||
|
||||
## 공유 라이브러리(서버·클라이언트 공통)
|
||||
|
||||
| 파일 | 용도 |
|
||||
|------|------|
|
||||
| lib/navigation-editor-tree.js | 관리자 메뉴 UI·서버 `renumberSortOrderByTree`가 쓰는 `buildNavigationEditorTree` |
|
||||
| lib/markdown-content-normalizer.js | 관리자 Markdown-first 전환 후 레거시 블록 배열·객체 본문 값을 저장용 마크다운 문자열로 변환 |
|
||||
|
||||
## Nuxt 모듈
|
||||
|
||||
| 파일 | 용도 |
|
||||
|------|------|
|
||||
| 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`) 재확인 |
|
||||
| server/routes/uploads/[...path].get.js | 런타임 업로드 파일 제공 API(`/app/public/uploads` 볼륨 파일을 `/uploads/**`로 스트리밍) |
|
||||
|
||||
## 사이트 컴포넌트
|
||||
|
||||
| 파일 | 화면 위치 |
|
||||
|------|-----------|
|
||||
| components/site/SiteHeader.vue | 모든 공개 페이지 상단, 우측 사용자 아바타 드롭다운(Anonymous/Sign up/Sign in), `lg`~`xl` 헤더 여백·반응형 검색창 폭 |
|
||||
| components/site/LeftSidebar.vue | 왼쪽 사이드바, `lg+`는 `sticky`+고정 높이+내부 무스크롤바 스크롤, `lg` 미만은 고정 슬라이드 패널, 하단 푸터 `px-4`/`sm:px-5` |
|
||||
| components/site/RightSidebar.vue | 오른쪽 사이드바, `lg+`는 고정 열 높이·스티키, 모바일은 본문 아래 전체 너비, 하단 푸터 `pr-3` |
|
||||
| components/auth/AuthPasswordVisibilityToggle.vue | 로그인·회원가입 비밀번호 표시/숨김 토글(SVG, scoped 스타일·`field-name`으로 접근성 레이블 구분) |
|
||||
| components/site/SiteHeader.vue | 모든 공개 페이지 상단, 사이트 이름 텍스트 브랜드, `grid-cols-3`로 검색 패널 중앙 정렬(`md+`), 우측 사용자 아바타 드롭다운, `/`·`SiteSearchModal` |
|
||||
| components/site/SiteSearchModal.vue | `Teleport`·전체 화면 딤·Tags/Posts 결과·일치 구간 강조, 열림 시 `html.site-search-open` 스크롤 잠금 |
|
||||
| components/site/LeftSidebar.vue | 왼쪽 사이드바, `lg+`는 `sticky`+고정 높이+내부 무스크롤바 스크롤, `lg` 미만은 고정 슬라이드 패널, 상단 메뉴는 `SidebarPrimaryNavList`+`provide`로 트리·펼침 상태(`sori-primary-nav-expanded`), Authors 영역은 비공개, 푸터 `footer` 링크는 `flex-wrap`·테마 버튼 `shrink-0`, 태그 카테고리·테마 점은 `site-sidebar-nav-row` 호버 |
|
||||
| components/site/SidebarPrimaryNavList.vue | 상단 네비: 부모·리프 동일 `before` 막대/호버 원형, 내부 현재 경로 `--site-accent`, 행 `w-full`+`site-sidebar-nav-row` 호버(`#F7F4EF` 라이트), `inject`·`localStorage` 펼침 |
|
||||
| components/site/RightSidebar.vue | 오른쪽 사이드바, 공개 사이트 이미지 로고 fallback, About 영역은 비공개, `lg+`는 고정 열 높이·스티키, 모바일은 본문 아래 전체 너비, 하단 푸터 `pr-3` |
|
||||
| components/site/MainColumn.vue | 메인 화면 중앙, `lg:max-w-[720px]`로 본문 상한 |
|
||||
| components/site/PostCard.vue | 목록의 게시물 카드, 대표 이미지 썸네일, 카드 hover 인터랙션 |
|
||||
| components/site/PostCard.vue | 목록의 게시물 카드, 대표 이미지 썸네일, 카드 hover 인터랙션, 태그는 있을 때만 메타에 표시 |
|
||||
| components/site/TagHeader.vue | 태그 페이지 헤더 |
|
||||
| components/comments/PostComments.vue | 게시물 상세 `#comments` 영역, 회원 댓글/답글(1단) 작성 및 목록 표시, 작성자 썸네일/좋아요/상대시간 표시 |
|
||||
|
||||
## 관리자 컴포넌트
|
||||
|
||||
| 파일 | 화면 위치 |
|
||||
|------|-----------|
|
||||
| components/admin/AdminPostForm.vue | 관리자 글 작성/수정 폼, Ghost 스타일 전체 화면 에디터, 좌우 분할 설정 패널, 설정 패널 전환 애니메이션, Post URL 보기 액션, 하단 삭제 액션, 대표 이미지 hover 액션과 업로드/미디어 선택 확정, 배지형 태그 입력, 로컬 자동 저장, 예약 발행 시각 입력, SEO 설정, 미리보기 요청 |
|
||||
| components/admin/AdminPostForm.vue | 관리자 글 작성/수정 폼, 제목 입력 text-3xl, 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/AdminTagForm.vue | 관리자 태그 생성/수정 폼 |
|
||||
| components/admin/AdminMarkdownEditor.vue | 관리자 글 Markdown-first 에디터, textarea 기반 범위 선택·복사/붙여넣기, HTML 클립보드 마크다운 변환, Enter 새 문단·Shift+Enter 백슬래시 hard break 줄바꿈 입력, 작성 모드 왼쪽 바깥 absolute 논리 줄 번호 거터·거터 스크롤 동기화·스크롤바 숨김, 작성/미리보기 전환(`Cmd/Ctrl+E`)과 커서 복원, 작성 모드 툴바 마크다운 삽입, 미리보기 모드 툴바 숨김, 이미지·갤러리 업로드 및 미디어 라이브러리 삽입, 현재 이미지·갤러리 편집 패널 |
|
||||
| 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` 연결 |
|
||||
|
||||
## 콘텐츠 컴포넌트
|
||||
|
||||
| 파일 | 화면 위치 |
|
||||
|------|-----------|
|
||||
| components/content/ContentRenderer.vue | 게시물/페이지 본문 |
|
||||
| components/content/ContentMarkdownRenderer.vue | 마크다운 문자열 기반 본문 렌더링, 확장 블록 파싱 |
|
||||
| components/content/ProseHeading.vue | h1~h6 제목 |
|
||||
| components/content/ContentMarkdownRenderer.vue | 마크다운 문자열 기반 본문 렌더링, 문단 text-base(16px), 빈 줄 spacer 보존·hard break `<br>` 처리, 확장 블록 파싱 |
|
||||
| components/content/ProseHeading.vue | h1~h6 제목, 기본 mt-12 제거 |
|
||||
| components/content/ProseImage.vue | 본문 내 이미지 |
|
||||
| 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)` 외부 링크 |
|
||||
|
||||
## 관리자 페이지
|
||||
|
||||
| 파일 | 화면 |
|
||||
|------|------|
|
||||
| pages/admin/index.vue | 대시보드 |
|
||||
| pages/admin/login.vue | 관리자 로그인 |
|
||||
| pages/admin/posts/index.vue | 글 목록, 예약 발행 상태 표시 |
|
||||
| pages/admin/login.vue | 관리자 로그인(이메일·비밀번호 모두 입력 시에만 제출 버튼 활성) |
|
||||
| pages/admin/posts/index.vue | 글 목록, 상태·태그·최신순/오래된순 필터, 예약/초안/발행 텍스트 상태 표시, 제목 옆 댓글 수, 읽기 전용 태그 배지, 삭제는 휴지통 아이콘·기본 낮은 강조 |
|
||||
| pages/admin/posts/new.vue | 글 작성, Ghost 스타일 작성 폼, 저장 토스트 |
|
||||
| pages/admin/posts/[id].vue | 글 수정, Ghost 스타일 작성 폼, 저장/삭제 토스트 |
|
||||
| pages/admin/posts/preview.vue | 글 미리보기 |
|
||||
| pages/admin/posts/preview.vue | 글 미리보기(공개 상세와 동일한 중앙 컬럼·수평 패딩) |
|
||||
| pages/admin/pages/index.vue | 페이지 목록 |
|
||||
| pages/admin/pages/new.vue | 페이지 작성, 저장 토스트 |
|
||||
| pages/admin/pages/[id].vue | 페이지 수정, 저장/삭제 토스트 |
|
||||
| pages/admin/media/index.vue | 미디어 관리, 폴더 트리, 복수 선택, 드래그 이동 |
|
||||
| pages/admin/navigation/index.vue | 메뉴/네비게이션 관리 |
|
||||
| pages/admin/tags/index.vue | 태그 관리 |
|
||||
| pages/admin/media/index.vue | 미디어 관리, **미디어 라이브러리/썸네일** 탭, 검색은 파일명·게시물/페이지 사용처 제목만, 라이브러리: 폴더 트리·드래그 이동 등, 현재 사이트 설정 로고·파비콘은 사용 중 파일로 잠금, 썸네일: 미참조 파일 삭제·이름 변경 가능, 상세 모달(연결 회원·폴더 편집·**다운로드**) |
|
||||
| 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/settings/index.vue | 사이트 설정(이름, 설명, URL, 1:1 로고 업로드·고유 URL 파비콘 생성, 저작권 문구) |
|
||||
| pages/admin/members/index.vue | 관리자 멤버 목록(Ghost형 테이블, 검색, 조건 필터, 멤버 추가 버튼, 닉네임+이메일, 가입일+최근 활동, IP, 댓글 수, 활동 상태 텍스트) |
|
||||
| pages/admin/members/new.vue | 관리자 멤버 추가(썸네일 URL, 이름, 이메일, 레이블, 관리자 노트) |
|
||||
| pages/admin/members/[id].vue | 관리자 멤버 상세/수정(회원 요약, 가입 정보, 활동 요약, 기본 정보 저장, 삭제 후 목록 이동) |
|
||||
|
||||
## 공개 페이지
|
||||
|
||||
| 파일 | 화면 |
|
||||
|------|------|
|
||||
| pages/index.vue | 홈, 중앙 Hero/Featured/Latest 섹션의 내부 컨테이너 보더 정렬과 리스트형 latest 카드 |
|
||||
| pages/posts/index.vue | 게시물 전체 목록 |
|
||||
| pages/index.vue | 홈, 중앙 Hero/Featured/Latest 섹션의 내부 컨테이너 보더 정렬과 리스트형 latest 카드, Latest 메타는 태그가 있을 때만 태그 배지 표시, Featured는 추천 글이 있을 때만 표시하며 모바일 터치 가로 스크롤·스냅과 끝에서 화살표 비활성 |
|
||||
| pages/posts/index.vue | 게시물 전체 목록, 태그는 있을 때만 카드 메타에 표시 |
|
||||
| pages/posts/[slug].vue | `/post/:slug` 리다이렉트 |
|
||||
| pages/post/[slug].vue | 블로그 글 상세, 게시물 SEO/OG 메타 출력, 공유 모달(X/Bluesky/Facebook/LinkedIn/Email/링크복사) |
|
||||
| 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단계 화면(환영/입력/이메일 확인, 재전송) |
|
||||
| pages/signin.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
|
||||
|
||||
@@ -103,8 +150,29 @@
|
||||
| server/api/pages.get.js | 고정 페이지 목록 샘플 API |
|
||||
| server/api/pages/[slug].get.js | 고정 페이지 상세 샘플 API |
|
||||
| server/api/tags.get.js | 태그 목록 샘플 API |
|
||||
| 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(선택 `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 |
|
||||
| server/api/auth/profile.get.js | 회원 프로필 조회 API |
|
||||
| server/api/auth/profile.put.js | 회원 프로필 수정 API(닉네임·`avatarUrl`; 관리 썸네일 URL 교체 시 메타만 분리) |
|
||||
| server/api/auth/avatar.post.js | 회원 썸네일 업로드 API(WebP 변환, 최소 해상도 검증, 중앙 1:1 강제 크롭, 품질 보정, `media_metadata` 논리 폴더 `썸네일`) |
|
||||
| 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(마지막 `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 |
|
||||
| server/routes/admin/api/auth/login.post.js | 관리자 로그인 API |
|
||||
| server/routes/admin/api/auth/logout.post.js | 관리자 로그아웃 API |
|
||||
| server/routes/admin/api/auth/me.get.js | 관리자 세션 조회 API |
|
||||
@@ -123,29 +191,44 @@
|
||||
| server/routes/admin/api/media.delete.js | 관리자 미디어 삭제 API |
|
||||
| server/routes/admin/api/media-folders.get.js | 관리자 미디어 폴더 목록 API |
|
||||
| server/routes/admin/api/media-folders.post.js | 관리자 미디어 폴더 생성 API |
|
||||
| server/routes/admin/api/uploads.post.js | 관리자 이미지 업로드 API |
|
||||
| server/routes/admin/api/tags.get.js | 관리자 태그 목록 API |
|
||||
| server/routes/admin/api/uploads.post.js | 관리자 게시물·페이지용 이미지 업로드 API(원본명 기반 파일명·충돌 시 넘버링, `/uploads/posts` 저장, 성공 시 `media_metadata`를 `미분류`로 기록) |
|
||||
| server/routes/admin/api/member-avatar.post.js | 관리자 새 회원 생성 전 썸네일 사전 업로드 API(`/uploads/members/avatars` 저장, WebP 변환·1:1 크롭) |
|
||||
| server/routes/admin/api/tags.get.js | 관리자 태그 목록 API(`tagType`, `q`, `limit` 검색 옵션) |
|
||||
| server/routes/admin/api/tags.post.js | 관리자 태그 생성 API |
|
||||
| server/routes/admin/api/tags/[id].get.js | 관리자 태그 상세 API |
|
||||
| server/routes/admin/api/tags/[id].put.js | 관리자 태그 수정 API |
|
||||
| server/routes/admin/api/tags/[id].delete.js | 관리자 태그 삭제 API |
|
||||
| server/routes/admin/api/tags/reorder.put.js | 관리자 메인 태그 순서 일괄 저장 API |
|
||||
| server/routes/admin/api/settings.get.js | 관리자 사이트 설정 조회 API |
|
||||
| server/routes/admin/api/settings.put.js | 관리자 사이트 설정 수정 API |
|
||||
| server/routes/admin/api/settings/logo.post.js | 관리자 사이트 로고 업로드 API(`/uploads/system/logo-YYYYMM-*.webp`, `/uploads/system/favicon-YYYYMM-*.png` 생성, `시스템` 미디어 메타 저장) |
|
||||
| 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]/avatar.post.js | 관리자 멤버 썸네일 업로드 및 즉시 반영 API |
|
||||
| server/routes/admin/api/members/[id]/role.put.js | 관리자 멤버 권한 변경 API |
|
||||
| server/utils/content-schema.js | Zod 콘텐츠 스키마 |
|
||||
| server/utils/sample-content.js | 샘플 콘텐츠 저장소 |
|
||||
| server/utils/admin-auth.js | 관리자 세션 쿠키 인증 유틸리티 |
|
||||
| server/utils/member-auth.js | 회원 세션 쿠키 인증 유틸리티 |
|
||||
| server/utils/member-avatar.js | 회원 전용 썸네일 경로 검증·프로필 분리 시 `media_metadata`만 제거(디스크는 관리자 미디어에서 정리) |
|
||||
| server/utils/member-avatar-upload.js | 회원 썸네일 공통 업로드 검증·WebP 변환·중앙 1:1 크롭·저장 유틸 |
|
||||
| server/utils/admin-post-input.js | 관리자 게시물 입력값 검증 스키마 |
|
||||
| server/utils/admin-page-input.js | 관리자 고정 페이지 입력값 검증 스키마 |
|
||||
| server/utils/admin-site-settings-input.js | 관리자 사이트 설정 입력값 검증 스키마 |
|
||||
| server/utils/admin-navigation-input.js | 관리자 네비게이션 입력값 검증 스키마 |
|
||||
| server/utils/admin-tag-input.js | 관리자 태그 입력값 검증 스키마 |
|
||||
| server/utils/site-settings.js | 사이트 설정 기본값 유틸리티 |
|
||||
| server/utils/navigation-items.js | 네비게이션 기본값과 그룹 유틸리티 |
|
||||
| server/utils/media-library.js | 업로드 미디어 파일과 폴더 메타데이터 관리 유틸리티 |
|
||||
| server/utils/navigation-items.js | DB 없을 때 기본 네비 항목(UUID id·parentId·isFolder) |
|
||||
| server/utils/navigation-tree.js | 네비 검증·삽입 순서·공개 primary 트리·DFS sort_order 재부여 |
|
||||
| server/utils/media-library.js | 업로드 미디어·논리 폴더(`미분류`, 예약 `썸네일`)·`avatarOwner` 부착·아바타 삭제/이름변경 차단 |
|
||||
| server/repositories/postgres-client.js | PostgreSQL 클라이언트 |
|
||||
| server/repositories/content-repository.js | 콘텐츠 조회 저장소 |
|
||||
| server/repositories/member-repository.js | 회원 조회/생성 저장소 |
|
||||
| server/repositories/comment-repository.js | 댓글 조회/생성 저장소 |
|
||||
|
||||
## 데이터베이스
|
||||
|
||||
@@ -155,18 +238,27 @@
|
||||
| db/migrations/002_seed_development.sql | 개발용 샘플 데이터 |
|
||||
| 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 | 미디어 폴더 테이블 추가 |
|
||||
| db/migrations/016_media_category_normalize.sql | `media_metadata` 레거시 `posts`→`미분류`, `회원/썸네일`→`썸네일` 정리 |
|
||||
| db/migrations/008_add_post_seo_fields.sql | 게시물 SEO 필드 추가 |
|
||||
| db/migrations/009_add_post_og_image.sql | 게시물 OG 이미지 필드 추가 |
|
||||
| db/migrations/010_add_members_and_comments.sql | 회원/댓글 테이블 추가 |
|
||||
| db/migrations/011_add_member_profile_and_activity.sql | 회원 아바타/최근 활동 컬럼 추가 및 닉네임 유니크 인덱스 추가 |
|
||||
| db/migrations/012_add_comment_likes.sql | 댓글 좋아요 테이블 추가 |
|
||||
| db/migrations/014_add_user_role_levels.sql | 회원 권한 3단계(owner/admin/member) 컬럼 추가 |
|
||||
| db/migrations/013_add_user_admin_role.sql | 회원 관리자 권한 컬럼 추가 및 첫 사용자 관리자 승격 |
|
||||
| db/migrations/015_add_tag_type_and_reorder_support.sql | 태그 유형(`managed`/`general`) 컬럼 추가 |
|
||||
|
||||
## 설정/배포
|
||||
|
||||
| 파일 | 기능 |
|
||||
|------|------|
|
||||
| package.json | Nuxt 실행 스크립트와 의존성 |
|
||||
| nuxt.config.js | Nuxt 앱 설정, Tailwind 모듈 연결, 관리자 QA를 위한 개발 도구 비활성화 |
|
||||
| nuxt.config.js | Nuxt 앱 설정, `tailwindcss.cssPath`로 `main.css` 단일 엔트리, Tailwind 모듈, 관리자 QA를 위한 개발 도구 비활성화, 회원 썸네일 최소/최대 해상도·품질 런타임 설정 |
|
||||
| tailwind.config.js | Tailwind 테마 설정 |
|
||||
| assets/css/main.css | 전역 스타일, `#fcfcfc` 단일 배경 기준, 좌측 사이드바/그리드 전환 애니메이션, 네비게이션 세로 바 hover 효과 |
|
||||
| composables/useMenuState.js | 좌측 메뉴 열림 상태·`closeMenu`(모바일 백드롭 등) |
|
||||
|
||||
319
docs/spec.md
319
docs/spec.md
@@ -6,8 +6,9 @@
|
||||
- **유형**: 커스텀 블로그/CMS
|
||||
- **목표**: 개인 블로그 중심 운영, 기존 포털성 링크와 서비스 진입점은 블로그 내부 구조에 통합
|
||||
- **참조**: Ghost(관리자 UX/글쓰기), Thred 테마(사용자 화면)
|
||||
- **현재 상태**: Nuxt 3 초기 스캐폴딩과 PostgreSQL 저장소 계층 구성 완료
|
||||
- **현재 상태**: Nuxt 3.21(SSR)·PostgreSQL 저장소 계층 구성 완료. Node가 SSR 번들의 `#internal/nuxt/paths`를 해석하도록 루트 `package.json` `imports`와 `modules/nuxt-ssr-paths-write.mjs`(`.nuxt/paths.mjs` 디스크 기록)을 둔다.
|
||||
- **원격 저장소**: https://git.sori.studio/zenn/sori.studio.git
|
||||
- **스타일**: Tailwind 엔트리는 `assets/css/main.css` 한 곳(`nuxt.config`의 `tailwindcss.cssPath`)이며, `tailwind.config.js`의 `content`가 Vue·composables·modules·plugins를 스캔한다.
|
||||
|
||||
---
|
||||
|
||||
@@ -17,13 +18,13 @@
|
||||
|
||||
| 요소 | 크기/속성 |
|
||||
|------|-----------|
|
||||
| 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`, `column-gap`(`lg:gap-x-4`, `xl:gap-x-5`)으로 열 사이 간격. 중앙 열은 `minmax(0,1fr)`로 패딩이 있어도 가로 합이 넘치지 않게 함 — **문서(`html`/`body`) 스크롤**로 긴 본문 처리(스크롤바는 브라우저 오른쪽) |
|
||||
| 그리드(데스크톱 `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` 없음. 좌측 메뉴 닫힘 시 `gap` 제거에 맞춰 `padding-right`로 우측만 여백 유지 — 뷰포트와 동일한 문서 스크롤에 포함 |
|
||||
| Main | 중앙 열 안에서 `max-width: 720px`·`justify-self: start`, 별도 `overflow-y` 없음. 공개 페이지의 가로 패딩은 레이아웃 그리드(`public-layout__grid`)의 `px-*` 한 번만 사용하고, 본문 섹션의 `px-*`는 두지 않는다. |
|
||||
| Right Aside | Left와 동일 패턴(스티키·고정 높이·내부 무스크롤바 스크롤·하단 카피라이트) |
|
||||
| Right Aside(모바일) | 본문 아래에 전체 너비로 배치, 상단 보더로 본문과 구분, 좌우 안전 여백(`px-4`) 적용 |
|
||||
|
||||
@@ -34,8 +35,9 @@
|
||||
- 브라우저에서는 `localStorage.MENU_STATE`에 `open` 또는 `closed` 저장
|
||||
- 닫힘 상태에서는 왼쪽 사이드바 폭을 0으로 줄이는 전환 애니메이션을 적용
|
||||
- `lg` 미만에서는 왼쪽 사이드바를 화면 좌측 고정 슬라이드 패널로 표시하고, 열린 동안 백드롭을 탭하면 `closeMenu`로 닫는다.
|
||||
- `Escape` 키는 사용자 드롭다운이 열려 있으면 우선 닫고, 그렇지 않으면 모바일에서만 좌측 슬라이드 메뉴를 닫는다.
|
||||
- 헤더 우측 사용자 아이콘 버튼은 비로그인 기준 사용자 메뉴(Anonymous, Sign up, Sign in)만 표시한다.
|
||||
- `Escape` 키는 통합 검색 모달이 열려 있으면 최우선으로 닫고, 그다음 사용자 드롭다운, 이어서 모바일에서만 좌측 슬라이드 메뉴를 닫는다.
|
||||
- `/` 키는 `INPUT`·`TEXTAREA`·`SELECT`·`contenteditable`에 포커스가 없고 `Ctrl`/`Meta`/`Alt`와 함께 눌리지 않을 때 통합 검색 모달을 연다. 헤더 검색 영역(`md+`) 클릭으로도 동일하게 연다.
|
||||
- 헤더 우측 사용자 아이콘 버튼은 로그인 상태면 회원 아바타/닉네임과 설정·로그아웃 메뉴를, 비로그인 상태면 Anonymous·Sign up·Sign in 메뉴를 표시한다.
|
||||
|
||||
### 공개 화면 색상
|
||||
|
||||
@@ -46,9 +48,23 @@
|
||||
- 사용자 수동 테마 전환은 `html[data-theme]`와 `localStorage.SITE_THEME`로 관리
|
||||
- Thred 참고 화면처럼 사이드바와 본문은 같은 화면 안에서 구분되는 패널과 라인으로 표현
|
||||
|
||||
### 홈 Featured (인덱스)
|
||||
|
||||
- 가로 카드 트랙은 `overflow-x-auto`와 `snap-x`/`snap-mandatory`로 슬라이드 느낌을 낸다.
|
||||
- Featured 영역은 추천 글(`isFeatured=true`)이 1개 이상 있을 때만 표시한다.
|
||||
- 모바일 터치: `touch-pan-x`, `-webkit-overflow-scrolling: touch`, `overscroll-x-contain`으로 가로 스크롤 우선·부모로의 스크롤 전파 완화.
|
||||
- 헤더의 이전·다음 화살표는 `scrollLeft`와 최대 스크롤 거리로 양 끝에서 `disabled` 처리하며, `scroll` 이벤트와 `ResizeObserver`로 동기화한다.
|
||||
|
||||
### Post 페이지
|
||||
|
||||
- Main 좌우 패딩: 24px → 20px
|
||||
- 게시물 화면은 레이아웃 그리드의 좌우 패딩을 기본으로 사용하고, 섹션 단위 `px-*`는 내부 래퍼로만 두어 패딩이 2중 적용되지 않게 한다.
|
||||
- 댓글 시작 섹션의 구분선(`border-y`)은 패딩 없이 전체 폭으로 표시하고, 내용만 내부 래퍼에 패딩을 둔다.
|
||||
- 본문 끝과 댓글 섹션 시작 사이 간격은 `48px`(`mt-12`)로 유지한다.
|
||||
- 댓글은 로그인 회원만 작성 가능하며, 대댓글은 1단까지만 허용한다.
|
||||
- 댓글은 작성자 썸네일(없으면 이니셜)과 좋아요 수를 표시한다.
|
||||
- 댓글 시간은 24시간 이내일 때 상대 시간(분/시간 전), 이후에는 날짜로 표시한다.
|
||||
- 댓글 정렬은 `Best`(좋아요 우선), `Latest`, `Oldest`를 제공한다.
|
||||
- 댓글 아바타 이미지 로드 실패 시 이니셜 아바타로 즉시 대체한다.
|
||||
- 공개 게시물 본문은 콘텐츠 타입별 컴포넌트로 분리해 추후 스타일 변경이 쉽도록 구성
|
||||
- 제목 우측 공유 버튼을 누르면 게시물 공유 모달을 연다.
|
||||
- 공유 모달은 게시물 썸네일/제목/요약 미리보기, X/Bluesky/Facebook/LinkedIn/Email 링크, 링크 복사 액션을 제공한다.
|
||||
@@ -72,8 +88,10 @@
|
||||
- `/post/:slug` - 개별 게시물 상세
|
||||
- `/tags` - 태그 전체 목록
|
||||
- `/tag/:slug` - 태그별 게시물 목록
|
||||
- `/tag/:slug` 화면의 헤더와 게시물 목록은 공개 목록 공통 섹션 패딩(`site-section-header`, `site-section-body`)을 사용해 메인 컬럼 좌우 여백을 다른 목록 화면과 맞춘다.
|
||||
- `/signup` - 회원가입(3단계: 환영/입력/이메일 확인)
|
||||
- `/signin` - 로그인
|
||||
- `/settings` - 회원 설정(썸네일 미리보기/변경/제거, 닉네임, 비밀번호, 회원 탈퇴)
|
||||
- 기존 `/posts/:slug`, `/tags/:slug` 상세 경로는 새 단수형 상세 경로로 리다이렉트한다.
|
||||
|
||||
### 공개 인증 화면(초기)
|
||||
@@ -82,9 +100,10 @@
|
||||
- 3단계는 인증 메일 발송 안내와 재전송 버튼(쿨다운)을 제공한다.
|
||||
- 이메일 링크 확인 전에는 회원가입이 완료되지 않으며, 인증 완료 액션 이후 로그인 화면으로 이동한다.
|
||||
- 로그인 화면은 동일한 다크 톤 폼 레이아웃을 사용한다.
|
||||
- 로그인 비밀번호 입력은 보기/숨기기 토글을 제공한다.
|
||||
- 로그인·회원가입(2단계) 비밀번호 입력은 `AuthPasswordVisibilityToggle` SVG(눈 열림/가림) 토글로 표시 여부를 바꾼다. 스크린 리더용 `aria-label`은 필드별 `field-name`으로 구분한다. 텍스트 입력은 `.auth-form-input`으로 글자색·캐럿 등을 보정한다.
|
||||
- 인증 화면 상태 메시지는 오류/안내를 분리해 `aria-live`로 노출한다.
|
||||
- 회원가입 1단계의 타이틀/설명은 `GET /api/site-settings`의 `title`, `description` 값을 우선 사용한다.
|
||||
- 회원 세션 쿠키 서명에는 `MEMBER_SESSION_SECRET`만 사용하며, 관리자 비밀번호를 fallback으로 쓰지 않는다.
|
||||
|
||||
### 레이아웃 파일
|
||||
|
||||
@@ -95,6 +114,17 @@ layouts/
|
||||
└── admin.vue # 관리자 화면
|
||||
```
|
||||
|
||||
### 관리자 레이아웃
|
||||
|
||||
- 관리자 사이드바는 밝은 Ghost형 톤을 기준으로 하며, 메뉴 행은 아이콘+라벨 구조를 사용한다.
|
||||
- 관리자 사이드바는 데스크톱에서 320px 너비를 사용한다.
|
||||
- 관리자 우측 캔버스는 기본 `min-h-screen`과 `bg-paper`를 유지해 콘텐츠 높이가 짧아도 배경이 화면 전체를 채운다. 일반 관리자 화면은 레이아웃 레벨에서 넉넉한 외부 여백을 둔다.
|
||||
- 게시글 메뉴 라벨은 `게시글`로 표시하고, 우측 `+` 아이콘은 `/admin/posts/new`로 바로 이동한다.
|
||||
- 메뉴 관리 항목은 `네비게이션`으로 표시한다.
|
||||
- 게시글·페이지·태그·미디어·네비게이션·멤버·설정 메뉴는 전용 SVG 아이콘을 사용한다.
|
||||
- 멤버 메뉴는 우측에 현재 총 멤버 수를 표시한다.
|
||||
- 로그아웃은 사이드바 상단 메뉴가 아니라 하단 사용자 썸네일 드롭다운 안에서 제공한다. 하단에는 사용자 썸네일 트리거와 설정 아이콘을 둔다.
|
||||
|
||||
---
|
||||
|
||||
## 컴포넌트 구조
|
||||
@@ -104,6 +134,7 @@ layouts/
|
||||
```
|
||||
components/site/
|
||||
├── SiteHeader.vue # 상단 헤더
|
||||
├── SiteSearchModal.vue # 통합 검색 모달(`/`·헤더 검색 영역, Tags·Posts 결과)
|
||||
├── LeftSidebar.vue # 왼쪽 사이드바
|
||||
├── RightSidebar.vue # 오른쪽 사이드바
|
||||
├── MainColumn.vue # 메인 컬럼
|
||||
@@ -150,6 +181,13 @@ components/content/
|
||||
- 이미지 갤러리
|
||||
- `:::gallery` ~ `:::` fenced block 내부에 이미지 마크다운 행을 여러 개 작성
|
||||
- 렌더링: `ContentMarkdownRenderer.vue` (그리드 + 라이트박스)
|
||||
- 문단과 줄바꿈
|
||||
- 관리자 Markdown-first 에디터에서 일반 Enter는 브라우저 기본 단일 줄 이동으로 새 문단을 만든다.
|
||||
- Shift+Enter는 같은 문단 안 줄바꿈을 위해 수정 모드에서 보이는 마크다운 hard break(`\\ + 줄바꿈`)를 삽입한다.
|
||||
- 공개 본문 렌더러는 줄끝 백슬래시 또는 공백 2개 hard break가 있는 행만 같은 문단으로 묶고 `<br>`로 표시한다.
|
||||
- 내용 없는 빈 줄과 레거시 빈 문단 마커(`<!--sori:blank-paragraph-->`)는 spacer 블록으로 렌더링해 작성자가 비운 줄 수만큼 공백을 보존한다.
|
||||
- 문단 하단 기본 간격은 10px(`mb-2.5`) 기준이며, 문단 글자 크기는 `ContentMarkdownRenderer` 문단에 `text-base`(16px·`1rem`)만 지정하고 행간은 Tailwind·브라우저 기본에 맡긴다.
|
||||
- 제목은 `ProseHeading`에서 단계별 크기·굵기를 적용하고, 첫 제목(`first:`)을 제외한 상단 추가 여백은 컴포넌트 스타일로만 조정한다.
|
||||
- 카드류
|
||||
- Callout: `:::callout` ~ `:::` (왼쪽 강조선은 `var(--site-accent)`)
|
||||
- Toggle: `:::toggle 제목` ~ `:::`
|
||||
@@ -168,6 +206,8 @@ components/content/
|
||||
- 로컬 개발 환경과 NAS 운영 환경은 서로 다른 DB를 사용
|
||||
- 로컬 개발 서버는 개발 DB만 연결
|
||||
- NAS 배포 환경은 운영 DB만 연결
|
||||
- 운영 환경(`NODE_ENV=production`)에서는 `DATABASE_URL` 누락 시 샘플 콘텐츠로 대체하지 않고 서버 오류로 즉시 실패
|
||||
- Docker Compose는 전용 브리지 네트워크를 사용하며 기본 subnet은 `DOCKER_SUBNET`(`10.250.50.0/24`)으로 관리
|
||||
- 운영 DB 접속 정보는 로컬 기본 `.env`에 기록하지 않음
|
||||
- DB 관리 도구는 CloudBeaver 등을 사용할 수 있도록 접속 정보를 환경별로 분리
|
||||
|
||||
@@ -181,6 +221,7 @@ components/content/
|
||||
| content | Text | 마크다운 콘텐츠 |
|
||||
| excerpt | String | 요약 |
|
||||
| featured_image | String nullable | 대표 이미지 |
|
||||
| is_featured | Boolean | 홈 Featured 및 목록 번개 표시용 추천 글 여부 |
|
||||
| seo_title | String | SEO 제목 |
|
||||
| seo_description | String | SEO 설명 |
|
||||
| canonical_url | String | canonical URL |
|
||||
@@ -191,6 +232,45 @@ components/content/
|
||||
| created_at | DateTime | 생성일 |
|
||||
| updated_at | DateTime | 수정일 |
|
||||
|
||||
> API 응답의 게시물 객체는 `isFeatured`와 `commentCount`를 함께 반환한다. `commentCount`는 `published` 상태 댓글 수를 기준으로 한다.
|
||||
|
||||
### Users
|
||||
|
||||
| 필드 | 타입 | 설명 |
|
||||
|------|------|------|
|
||||
| id | UUID | Primary Key |
|
||||
| username | String | 사용자명 |
|
||||
| email | String | 로그인 이메일(유니크) |
|
||||
| password_hash | String | bcrypt 해시 비밀번호 |
|
||||
| avatar_url | String | 프로필 썸네일 URL |
|
||||
| is_admin | Boolean | 관리자 권한 여부 |
|
||||
| user_role | Enum | 권한 단계(`owner`/`admin`/`member`) |
|
||||
| last_seen_at | DateTime nullable | 마지막 접속 시각 |
|
||||
| last_seen_ip | String | 마지막 접속 IP |
|
||||
| created_at | DateTime | 생성일 |
|
||||
| updated_at | DateTime | 수정일 |
|
||||
|
||||
### Comments
|
||||
|
||||
| 필드 | 타입 | 설명 |
|
||||
|------|------|------|
|
||||
| id | UUID | Primary Key |
|
||||
| post_id | UUID | FK → Posts |
|
||||
| user_id | UUID | FK → Users |
|
||||
| parent_id | UUID nullable | FK → Comments, 대댓글 1단 |
|
||||
| body | Text | 댓글 본문 |
|
||||
| status | Enum | published/pending/blocked |
|
||||
| created_at | DateTime | 생성일 |
|
||||
| updated_at | DateTime | 수정일 |
|
||||
|
||||
### CommentLikes
|
||||
|
||||
| 필드 | 타입 | 설명 |
|
||||
|------|------|------|
|
||||
| comment_id | UUID | FK → Comments |
|
||||
| user_id | UUID | FK → Users |
|
||||
| created_at | DateTime | 생성일 |
|
||||
|
||||
### Pages (고정 페이지)
|
||||
|
||||
| 필드 | 타입 | 설명 |
|
||||
@@ -211,8 +291,9 @@ components/content/
|
||||
| name | String | 태그명 |
|
||||
| slug | String | URL 슬러그 |
|
||||
| description | String | 설명 |
|
||||
| sort_order | Integer | 사용자 화면 표시 순서 |
|
||||
| sort_order | Integer | 메인 태그 정렬 순서 |
|
||||
| color | String | 태그 색상 코드 |
|
||||
| tag_type | Enum | 태그 유형(`managed`/`general`) |
|
||||
| created_at | DateTime | 생성일 |
|
||||
| updated_at | DateTime | 수정일 |
|
||||
|
||||
@@ -224,7 +305,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 | 수정일 |
|
||||
|
||||
@@ -246,7 +329,7 @@ components/content/
|
||||
| 필드 | 타입 | 설명 |
|
||||
|------|------|------|
|
||||
| url | String | 업로드 미디어 URL |
|
||||
| category | String | 관리자 미디어 폴더 경로 |
|
||||
| category | String | 논리 폴더 경로(게시물 업로드 이미지는 `미분류`, 회원 아바타는 예약값 `썸네일` 등) |
|
||||
| created_at | DateTime | 생성일 |
|
||||
| updated_at | DateTime | 수정일 |
|
||||
|
||||
@@ -286,11 +369,39 @@ components/content/
|
||||
|
||||
- `GET /api/posts` - 게시물 목록
|
||||
- `GET /api/posts/:slug` - 게시물 상세
|
||||
- `GET /api/posts/:slug/comments` - 게시물 댓글 목록
|
||||
- `POST /api/posts/:slug/comments` - 게시물 댓글 작성(회원 세션 필요, 대댓글 1단)
|
||||
- `POST /api/posts/:slug/comments/:commentId/like` - 댓글 좋아요 토글(회원 세션 필요)
|
||||
- `GET /api/pages` - 고정 페이지 목록
|
||||
- `GET /api/pages/:slug` - 고정 페이지 상세
|
||||
- `GET /api/tags` - 태그 목록
|
||||
- `GET /api/search?q=` - 통합 검색(태그 `name`·`slug`, 게시물 `title`·`excerpt`·`content` 부분 일치, 각 최대 12건, 발행 게시물만)
|
||||
- `GET /api/site-settings` - 공개 사이트 설정
|
||||
- `GET /api/navigation` - 공개 네비게이션
|
||||
- `GET /api/navigation` - 공개 네비게이션(`primary`는 트리·`footer`는 평면, 상세는 위 메뉴/네비게이션 절)
|
||||
- `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` - 회원 로그아웃
|
||||
- `GET /api/auth/profile` - 회원 설정 조회
|
||||
- `PUT /api/auth/profile` - 회원 프로필 수정(닉네임, `avatarUrl`). 이전 값이 `/uploads/members/avatars/` URL이고 새 값과 달라지면 `removeManagedAvatarAsset`으로 **메타만** 끊고 디스크 파일은 유지한다(`DELETE /api/auth/avatar`와 동일한 자산 정리 규칙).
|
||||
- `POST /api/auth/avatar` - 회원 썸네일 이미지 업로드
|
||||
- `DELETE /api/auth/avatar` - 회원 썸네일 제거
|
||||
- `GET /api/auth/check-username?username=` - 닉네임 중복 확인
|
||||
- `PUT /api/auth/password` - 회원 비밀번호 변경
|
||||
- `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` 정사각형으로 맞춘다.
|
||||
> `AVATAR_WEBP_QUALITY`는 1~100 범위로 보정하며, 최대 해상도 설정이 최소 해상도보다 작으면 서버에서 최소값 이상으로 자동 보정한다.
|
||||
> 회원 썸네일을 새로 업로드하거나 제거·탈퇴할 때, 이전 썸네일 URL에 대한 `media_metadata` 행은 `removeManagedAvatarAsset`으로 제거하되 **디스크 파일은 삭제하지 않는다.** 관리자 미디어 **썸네일** 탭에서 미사용 파일을 확인·삭제한다.
|
||||
> 관리자 미디어 화면의 **썸네일** 탭에서만 회원 썸네일을 목록·검색하며, `GET /admin/api/media` 응답에 `avatarOwner`(닉네임·이메일·마지막 접속·IP)가 포함될 수 있다.
|
||||
|
||||
### 업로드 파일 제공
|
||||
|
||||
- `GET /uploads/**` - 런타임 업로드 파일 제공. 운영 Docker에서는 빌드 산출물 `.output/public`이 아니라 `public/uploads` 볼륨의 실제 파일을 읽어 로고·게시물 이미지·회원 썸네일을 즉시 제공한다.
|
||||
|
||||
### 관리자 API (`/admin/api/`)
|
||||
|
||||
@@ -307,61 +418,77 @@ components/content/
|
||||
- `GET /admin/api/pages/:id` - 고정 페이지 상세
|
||||
- `PUT /admin/api/pages/:id` - 고정 페이지 수정
|
||||
- `DELETE /admin/api/pages/:id` - 고정 페이지 삭제
|
||||
- `GET /admin/api/media` - 업로드 미디어 목록
|
||||
- `PUT /admin/api/media` - 업로드 미디어 파일명 변경 또는 단일/복수 미디어 폴더 변경
|
||||
- `DELETE /admin/api/media` - 업로드 미디어 삭제
|
||||
- `GET /admin/api/media` - 업로드 미디어 목록(게시물용 이미지와 회원 아바타 포함; 회원 아바타에는 `avatarOwner` 요약이 붙을 수 있음)
|
||||
- `PUT /admin/api/media` - 업로드 미디어 파일명 변경 또는 단일/복수 미디어 폴더 변경(회원 아바타 URL은 논리 폴더 `썸네일`만 허용, 일반 미디어를 `썸네일`로 옮기는 것은 거부; 썸네일 파일명 변경은 `users.avatar_url`이 해당 URL을 참조할 때만 거부)
|
||||
- `DELETE /admin/api/media` - 업로드 미디어 삭제(게시물·페이지에서 사용 중이면 거부; `/members/avatars/` URL은 `users.avatar_url`이 해당 URL을 참조할 때만 거부)
|
||||
- `GET /admin/api/media-folders` - 미디어 폴더 목록
|
||||
- `POST /admin/api/media-folders` - 미디어 폴더 생성
|
||||
- `POST /admin/api/uploads` - 관리자 이미지 업로드
|
||||
- `GET /admin/api/tags` - 태그 목록
|
||||
- `DELETE /admin/api/media-folders` - 미디어 폴더 삭제(해당 경로·하위 경로로 분류된 `media_metadata`는 `미분류`로 되돌림)
|
||||
- `POST /admin/api/uploads` - 관리자 게시물·페이지용 이미지 업로드(저장 파일명은 원본명 기반·동일 월 폴더 내 충돌 시 `이름-2` 등 넘버링, `/uploads/posts/YYYY/MM` 저장)
|
||||
- `POST /admin/api/member-avatar` - 관리자 새 회원 생성 전 썸네일 사전 업로드(`/uploads/members/avatars/YYYY/MM`, WebP 변환·중앙 1:1 크롭)
|
||||
- `GET /admin/api/tags` - 태그 목록(옵션: `tagType`, `q`, `limit`)
|
||||
- `POST /admin/api/tags` - 태그 생성
|
||||
- `GET /admin/api/tags/:id` - 태그 상세
|
||||
- `PUT /admin/api/tags/:id` - 태그 수정
|
||||
- `DELETE /admin/api/tags/:id` - 태그 삭제
|
||||
- `PUT /admin/api/tags/reorder` - 메인 태그 순서 일괄 저장
|
||||
- `GET /admin/api/settings` - 사이트 설정 조회
|
||||
- `PUT /admin/api/settings` - 사이트 설정 수정
|
||||
- `GET /admin/api/navigation` - 네비게이션 항목 목록
|
||||
- `PUT /admin/api/navigation` - 네비게이션 항목 일괄 저장
|
||||
- `GET /admin/api/members` - 회원 목록(권한 코드, 최근 접속, 접속 IP, 댓글 수 포함)
|
||||
- `POST /admin/api/members` - 관리자 회원 생성. 본문: `username`, `email`, 선택 `avatarUrl`, `labels`, `note`. 생성된 회원은 `member` 권한이며 초기 비밀번호는 임의 해시로 저장한다.
|
||||
- `GET /admin/api/members/:id` - 관리자 회원 상세(썸네일, 이름, 이메일, 레이블, 관리자 노트, 활동 요약 포함)
|
||||
- `PUT /admin/api/members/:id` - 관리자 회원 기본 정보 수정. 본문: `username`, `email`, 선택 `avatarUrl`, `labels`, `note`. 이전 값이 회원 전용 썸네일 URL이고 새 값과 달라지면 `media_metadata` 연결을 분리한다.
|
||||
- `POST /admin/api/members/:id/avatar` - 관리자 회원 썸네일 업로드 및 즉시 반영(`/uploads/members/avatars/YYYY/MM`, WebP 변환·중앙 1:1 크롭)
|
||||
- `PUT /admin/api/members/:id/role` - 회원 권한 변경(`owner`/`admin`/`member`)
|
||||
|
||||
> 글 발행/초안/비공개 전환은 현재 `PUT /admin/api/posts/:id`의 `status` 값으로 처리한다.
|
||||
> 관리자 글 목록의 삭제 액션은 휴지통 아이콘으로 표시하며, 기본은 낮은 강조로 두고 호버 시에만 색·불투명도를 올린다.
|
||||
> 태그 삭제 시 `post_tags` 연결도 데이터베이스 외래 키 규칙에 따라 함께 삭제된다.
|
||||
> 태그 목록은 `sort_order ASC, name ASC` 기준으로 정렬한다.
|
||||
> 공개 `GET /api/tags`는 `managed`(메인 태그)만 반환한다.
|
||||
> 관리자 태그 목록 응답은 각 태그의 `postCount`, `lastUsedAt`, `updatedAt`을 포함한다.
|
||||
> 관리자 태그 목록은 `managed` 우선, `sort_order ASC, 최근 사용/수정 DESC, name ASC` 기준으로 정렬한다.
|
||||
> 메인 태그 순서 저장은 드래그 드롭 직후 자동으로 실행되며, 드래그 순서를 받아 `sort_order`를 순차 값으로 다시 저장한다.
|
||||
> 메인 태그 순서 저장 중에는 추가 드래그를 잠시 막고 저장 상태를 표시한다.
|
||||
> 관리자 태그 추가 화면에서 직접 생성한 태그는 기본적으로 `general`(일반 태그)로 생성하고, 태그 관리 화면의 일반 태그 목록에 바로 표시한다.
|
||||
> 게시물 작성에서 새로 생기는 태그는 기본적으로 `general`(일반 태그)로 생성한다.
|
||||
> 메인 태그는 목록에서 `일반 태그로 변경` 액션으로 강등하며, 일반 태그는 배지형 전체 목록에서 확인·필터·최근 사용순·많이 사용순·이름순 정렬·메인 전환·삭제를 수행한다.
|
||||
> 태그 관리 화면에서 순서 저장·메인 전환·강등·삭제 등의 성공·실패 피드백은 우측 상단 토스트로 표시한다.
|
||||
> 태그 `color`는 `#RRGGBB` 형식이며 사용자 화면 태그 색상 표시와 배지 배경색에 사용한다.
|
||||
|
||||
### 관리자 글 편집
|
||||
|
||||
- 글 작성/수정 화면은 Ghost 스타일을 참고한 블록형 에디터를 사용한다.
|
||||
- 저장 데이터는 기존 `content` 필드의 마크다운 문자열을 유지한다.
|
||||
- `/` 입력 시 블록 선택 메뉴를 표시한다.
|
||||
- `/` 명령 메뉴는 화면 하단 공간이 부족하면 현재 블록 위쪽으로 표시한다.
|
||||
- `/` 명령 메뉴가 열린 블록 행은 아래 블록보다 위 stacking 순서로 표시해 메뉴와 본문 텍스트가 겹쳐 보이지 않게 한다.
|
||||
- `/` 명령 메뉴가 열린 상태에서 Enter를 누르면 현재 강조된 메뉴 항목을 적용한다.
|
||||
- `/` 명령 메뉴가 열린 상태에서 위/아래 방향키로 강조 항목을 이동한다.
|
||||
- `/` 명령 메뉴 필터는 한글 조합 입력 완료와 방향키/Enter 입력 직전에 현재 DOM 텍스트를 기준으로 동기화한다.
|
||||
- `/갤`처럼 필터 결과가 하나로 좁혀진 상태에서 Enter를 누르면 해당 블록 명령을 적용한다.
|
||||
- 블록 메뉴는 문단, 제목 2, 제목 3, 이미지, 갤러리, 콜아웃, 토글, 임베드, 인용, 목록, 코드, 구분선을 제공한다.
|
||||
- `#`, `##`, `###`, `>`, `-` 입력 후 공백을 누르거나 ` ``` `을 입력하면 현재 블록 타입을 즉시 변환한다.
|
||||
- 한글 등 조합형 입력 중에는 단축 문법과 슬래시 메뉴 상태를 확정하지 않고 조합 종료 뒤 반영한다.
|
||||
- 한글 등 조합형 입력 직후 확정된 텍스트로 슬래시 메뉴 필터와 Enter 블록 이동을 반영한다.
|
||||
- 한글 등 조합형 입력 중 Shift+Enter가 들어오면 조합 완료 직후 줄바꿈을 예약 적용한다.
|
||||
- Shift+Enter는 같은 텍스트 블록 안에 줄바꿈 문자를 직접 삽입하고 커서를 줄바꿈 뒤로 유지하며, Enter는 다음 문단 블록을 생성한다.
|
||||
- 빈 문단에서 Enter를 누르면 다음 빈 문단 블록을 생성한다.
|
||||
- 문단 간 기본 간격은 다음 블록의 `margin-top: 32px` 기준으로 조정한다.
|
||||
- 블록 왼쪽 핸들은 hover/focus 상태에서 AFFiNE 참고 스타일의 세로 막대로 표시되며, hover 시 해당 블록 높이만큼 확장해 선택 범위를 드러낸다.
|
||||
- 블록 왼쪽 핸들을 클릭하면 블록을 선택하고 Delete 또는 Backspace로 해당 블록을 삭제할 수 있다.
|
||||
- 블록 왼쪽 핸들을 드래그하면 블록 순서를 이동할 수 있다.
|
||||
- 블록 드래그 중에는 현재 포인터 위치 기준으로 대상 블록 위 또는 아래에 삽입선을 표시하고, 드롭 또는 드래그 종료 시 표시 위치와 같은 곳으로 이동한다.
|
||||
- 빈 블록 placeholder는 현재 활성 블록 또는 첫 빈 블록에만 표시한다.
|
||||
- 에디터 마지막에는 클릭 가능한 빈 문단 블록을 항상 유지하며, 해당 블록이 비어 있으면 저장 콘텐츠에는 포함하지 않는다.
|
||||
- 글 작성/수정 화면은 Markdown-first 에디터(`AdminMarkdownEditor`)를 사용한다.
|
||||
- 작성 모드 textarea 왼쪽 바깥에 **논리 줄 번호** 거터(`\\n` 기준 줄 수, 빈 본문은 1줄)를 absolute 영역으로 두고 textarea와 거터의 세로 스크롤을 동기화한다. 거터 스크롤바는 숨긴다. 한 논리 줄이 화면에서 여러 줄로 줄바꿈될 때는 옵시디언·CodeMirror처럼 시각 줄마다 번호가 늘지 않으며, 논리 줄 단위로만 맞춘다.
|
||||
- 저장 데이터는 기존 `content` 필드의 마크다운 문자열을 그대로 유지한다.
|
||||
- 관리자 게시물/페이지 저장 API는 레거시 블록 배열·객체 본문 값이 들어와도 마크다운 문자열로 정규화한 뒤 저장한다.
|
||||
- 본문 작성 모드는 textarea 기반으로 범위 선택, 다중 문단 복사/붙여넣기, 외부 마크다운 붙여넣기를 브라우저 기본 동작에 가깝게 처리한다.
|
||||
- 본문 작성 모드에서 일반 Enter는 새 문단, Shift+Enter는 같은 문단 안 줄바꿈으로 처리한다. 일반 Enter는 단일 줄 이동으로 보여야 하며, Shift+Enter는 수정 모드에서도 보이는 줄끝 백슬래시 hard break를 남긴다.
|
||||
- 클립보드에 `text/html`이 있으면 제목, 문단, 목록, 인용, 코드, 링크, 굵게, 기울임, 이미지를 기본 마크다운 조각으로 변환해 삽입한다.
|
||||
- 본문 미리보기 모드는 공개 본문과 같은 `ContentMarkdownRenderer`를 사용하며, 툴바와 카드형 패널 외곽을 숨겨 본문만 표시한다.
|
||||
- 툴바는 제목 1/2/3, 굵게, 기울임, 인라인 코드, 인용, 목록, 코드 블록, 구분선 삽입을 제공한다.
|
||||
- `Cmd/Ctrl+B`, `Cmd/Ctrl+I`는 현재 선택 텍스트에 각각 굵게, 기울임 마크다운을 적용한다.
|
||||
- `Cmd/Ctrl+E`는 작성 모드와 미리보기 모드를 전환하며, 미리보기에서 작성 모드로 돌아올 때 기존 커서 위치와 textarea 스크롤을 복원한다.
|
||||
- 관리자 미리보기 패널은 공개 렌더러를 쓰되 밝은 관리자 배경 기준의 본문 색상 변수를 별도로 지정한다.
|
||||
- 이미지 파일을 붙여넣거나 드롭하면 관리자 업로드 API로 저장한 뒤 현재 커서 위치에 이미지 또는 갤러리 마크다운을 삽입한다.
|
||||
- 미디어 라이브러리에서 단일 이미지를 선택하면 `{width=...}` 형식으로 삽입한다.
|
||||
- 미디어 라이브러리에서 여러 이미지를 선택하면 `:::gallery` fenced block으로 삽입한다.
|
||||
- 이미지 너비 옵션은 `regular`, `wide`, `full` 값을 사용하며 `regular`는 width 옵션을 생략한다.
|
||||
- 작성 모드에서 커서가 이미지 마크다운 줄 또는 `:::gallery` 블록 안에 있으면 현재 미디어 블록 편집 패널을 표시한다.
|
||||
- 현재 미디어 블록 편집 패널은 alt, URL, 너비 값을 수정하고 갤러리 이미지 순서 변경, 삭제, 미디어 라이브러리 이미지 추가를 지원한다.
|
||||
- 옵시디언식 토큰 숨김/백스페이스 복원 Live Preview는 후속 단계로 둔다.
|
||||
- 제목은 별도 라벨 영역이 아니라 에디터 상단의 큰 제목 입력으로 표시한다.
|
||||
- 글 작성/수정 화면은 페이지 제목 헤더를 별도로 두지 않고, 폼 상단의 Ghost 스타일 도구막대에서 목록 이동, 상태, 미리보기, 저장, 설정 패널 토글을 제공한다.
|
||||
- 글 작성/수정 화면의 저장 버튼은 즉시 저장하지 않고, 전체 화면 발행 모달에서 상태(발행/초안/비공개)와 발행 시점(즉시/예약)을 최종 선택한 뒤 확정한다. 모달에서는 행마다 접힌 상태로 현재 선택 요약만 보이고, 행을 눌러 펼친 뒤 버튼으로 값을 고른다. 게시 상태 행에는 Ghost와 같은 종이비행기 아이콘·펼침 화살표 SVG를 쓰고, 발행 시점 행에는 시계 아이콘·동일 화살표를 쓴다. 발행이 아닌 상태에서는 발행 시점 행 자체를 숨긴다.
|
||||
- 글 작성/수정 화면의 저장 버튼은 현재 입력값이 마지막 저장 기준점과 다를 때만 활성화한다.
|
||||
- 글 작성/수정 화면은 관리자 사이드바 네비게이션을 숨기고, 관리자 공통 내부 패딩 없이 전체 화면 편집 모드로 표시한다.
|
||||
- 글 작성/수정 화면은 브라우저 문서가 아니라 에디터 작업 영역 내부에서만 세로 스크롤한다.
|
||||
- 글 작성/수정 폼은 에디터 작업 영역과 우측 설정 패널을 먼저 좌우로 나누고, 에디터 작업 영역 안에 상단 도구막대와 본문 스크롤 영역을 배치한다.
|
||||
- 본문 에디터는 데스크톱에서 좌우 32px 패딩을 포함한 804px 중앙 컬럼으로 배치해 실제 입력 영역 최대 폭을 740px로 유지한다.
|
||||
- 게시물 설정은 데스크톱에서 420px 우측 패널로 표시하며, 도구막대 버튼으로 열고 닫을 수 있고 너비 전환 애니메이션을 적용한다.
|
||||
- 공개 가능한 글의 보기 액션은 게시물 설정 패널의 Post URL 라벨 오른쪽에 표시한다.
|
||||
- 글 삭제 액션은 게시물 설정 패널 하단의 빨간 outline 버튼으로 제공한다.
|
||||
- 글 삭제 액션은 게시물 설정 패널 하단에 기본 중립 톤 버튼으로 제공하고 hover 시 위험 색상으로 강조한다.
|
||||
- 대표 이미지는 본문 제목 위의 에디터 흐름 안에서 추가, 변경, 삭제한다.
|
||||
- 대표 이미지가 설정된 상태의 변경/삭제 액션은 실제 이미지 레이아웃을 바꾸지 않도록 이미지 hover 또는 focus 오버레이로 표시한다.
|
||||
- 대표 이미지 선택 모달은 이미지 업로드 탭과 미디어 라이브러리 탭을 제공한다.
|
||||
@@ -369,22 +496,29 @@ components/content/
|
||||
- 새 글 작성처럼 본문이 비어 있는 경우에도 빈 문단 블록을 먼저 생성한다.
|
||||
- 글 작성/수정 중인 입력값은 브라우저 `localStorage`에 자동 저장한다.
|
||||
- 자동 저장 키는 `SORI_ADMIN_POST_AUTOSAVE:new` 또는 `SORI_ADMIN_POST_AUTOSAVE:{postId}` 형식을 사용한다.
|
||||
- 자동 저장본이 있으면 작성 화면에서 복원 또는 삭제를 선택할 수 있다.
|
||||
- 자동 저장본이 있으면 상단 툴바의 상태 문구 옆에서 복원 또는 무시(로컬 초안 삭제)를 선택할 수 있다.
|
||||
- 글 저장 성공 시 해당 자동 저장본은 삭제한다.
|
||||
- 미리보기 버튼은 현재 작성 폼 값을 `SORI_ADMIN_POST_PREVIEW` 브라우저 저장소에 기록한 뒤 `/admin/posts/preview` 새 탭에서 렌더링한다.
|
||||
- 미리보기 화면은 공개 게시물 상세와 같은 본문 렌더링 컴포넌트를 사용하되, 데이터베이스에는 임시 글을 저장하지 않는다.
|
||||
- 미리보기 본문·헤로 영역은 공개 상세와 동일하게 중앙 `max-w-[720px]` 컬럼과 `px-4 sm:px-5` 수평 패딩을 적용한다.
|
||||
- 글 저장/수정/삭제 진행과 성공/실패 상태는 화면 우측 상단 토스트로 표시한다.
|
||||
- 발행 상태에서 발행 시각을 미래로 지정하면 예약 발행으로 저장한다.
|
||||
- 예약 발행 글은 관리자 목록에서 예약 상태로 표시하되 공개 게시물 목록과 상세 API에는 발행 시각 이후부터 노출한다.
|
||||
- 관리자 글 에디터의 실제 입력 텍스트 색상은 placeholder보다 진하게 표시한다.
|
||||
- 관리자 작성 화면과 공개 본문은 같은 마크다운 렌더링 기준을 사용한다.
|
||||
- 태그 입력은 배지형 입력으로 제공하며 Enter 또는 쉼표 입력 시 태그를 추가하고, 배지의 x 버튼으로 삭제한다.
|
||||
- 태그 배지 삭제 버튼은 SVG 닫기 아이콘으로 표시한다.
|
||||
- 태그 토큰은 게시물 URL용 `toSlug`(한글 로마자화)와 분리하여 한글을 유지하고, 공백은 하이픈으로만 정리하며 `a-z0-9가-힣` 및 하이픈만 허용한다.
|
||||
- 제목 입력에서 한글 IME 조합 중 Enter는 조합 확정으로만 처리하고 본문 에디터 포커스 이동을 실행하지 않는다.
|
||||
- 관리자 게시글 목록의 태그는 쉼표 구분 문자열이 아니라 읽기 전용 배지 목록으로 표시한다.
|
||||
- 대표 이미지는 URL 직접 입력이 아니라 미디어 선택 또는 이미지 업로드 탭으로 설정한다.
|
||||
- 대표 이미지 선택 모달에서 미디어를 클릭하면 선택 상태만 표시하고, 하단 대표 이미지로 적용 버튼을 눌렀을 때 글 폼에 반영한다.
|
||||
- 대표 이미지가 설정되면 관리자 글 폼에 썸네일과 hover 오버레이 삭제/변경 액션을 표시한다.
|
||||
- 글 SEO 설정은 SEO 제목, SEO 설명, 검색엔진 노출 제외 여부를 저장한다.
|
||||
- 글 SEO 메타(`seo_title`, `seo_description`)는 별도 입력 없이 저장 시 글 제목·요약과 동일하게 기록한다.
|
||||
- 관리자 폼에서는 검색엔진 노출 제외(`noindex`)만 설정할 수 있다.
|
||||
- Canonical URL은 별도 입력을 받지 않고 기본 글 주소를 사용한다.
|
||||
- 공개 게시물 상세 화면은 SEO 제목이 없으면 글 제목, SEO 설명이 없으면 요약을 메타 태그 기본값으로 사용한다.
|
||||
- 저장된 태그가 없는 게시물에는 상세 메타 행·홈 Latest·`/posts` 목록 카드에 태그 배지나 더미 라벨을 표시하지 않는다.
|
||||
- 검색엔진 노출 제외가 켜진 글은 robots 메타를 `noindex, nofollow`로 출력한다.
|
||||
- 공개 상세 화면의 `og:image`와 Twitter large image 카드는 대표 이미지를 기본값으로 사용한다.
|
||||
- 이미지 블록은 관리자 업로드 API로 이미지를 업로드하고 `{width=wide}` 형식으로 저장한다.
|
||||
@@ -392,9 +526,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으로 렌더링한다.
|
||||
@@ -413,24 +553,58 @@ components/content/
|
||||
### 사이트 설정
|
||||
|
||||
- 사이트 설정은 `site_settings` 테이블의 단일 레코드로 관리한다.
|
||||
- 관리자는 사이트 이름, 설명, 사이트 URL, 텍스트 로고, 저작권 문구를 수정할 수 있다.
|
||||
- 관리자는 사이트 이름, 설명, 사이트 URL, 로고 이미지, 저작권 문구를 수정할 수 있다.
|
||||
- 로고 이미지는 1:1 비율로 저장하며 `/admin/api/settings/logo` 업로드 시 `/uploads/system/logo-YYYYMM-random.webp`와 `/uploads/system/favicon-YYYYMM-random.png`를 고유 파일명으로 함께 생성한다. 같은 URL 덮어쓰기로 인한 브라우저·운영 정적 캐시 문제를 피하기 위해 로고 교체마다 새 URL을 저장한다.
|
||||
- 공개 헤더와 오른쪽 사이드바는 공개 사이트 설정 API 값을 사용한다.
|
||||
- 공개 헤더와 오른쪽 사이드바는 `logo_url`이 있으면 이미지 로고를 표시하고, 없으면 `logo_text` fallback을 쓴다. `favicon_url`은 head의 icon 링크로 연결한다.
|
||||
- DB 연결이 없는 환경에서는 환경 변수와 기본값 기반 설정을 사용한다.
|
||||
|
||||
### 메뉴/네비게이션
|
||||
|
||||
- 네비게이션은 `navigation_items` 테이블로 관리한다.
|
||||
- 관리자는 메뉴 라벨, URL, 위치, 순서, 표시 여부를 수정할 수 있다.
|
||||
- 공개 왼쪽 사이드바의 상단 메뉴는 `primary` 위치 항목을 사용한다.
|
||||
- 공개 왼쪽 사이드바 하단 메뉴는 `footer` 위치 항목을 사용한다.
|
||||
- URL은 `/`로 시작하는 내부 경로 또는 `http://`, `https://` 외부 URL을 허용한다.
|
||||
- 컬럼: `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)` 유니크 제약은 제거되었다.
|
||||
- 동일 `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) 클릭**으로 접기/펼친다. 부모·리프 모두 왼쪽 장식은 **기본 세로 막대(`--site-line`)**, **호버 시에만** 리프와 동일하게 **작은 원형**으로 전환한다. **내부 경로**(`/`로 시작, `//`·`http(s)` 제외)이고 현재 `route.path`와 정규화한 경로가 같으면 장식 색을 **`--site-accent`(브랜드 오렌지)**로 둔다. 외부 URL은 비교하지 않는다. 펼침 상태는 `localStorage` 키 `sori-primary-nav-expanded`에 저장된다. 상단·리프 링크·부모 버튼 행은 **`w-full`**로 `site-sidebar-nav-row` 호버 배경이 가로 전체를 쓴다(라이트 `#F7F4EF`, 다크는 `site-panel-hover`와 동일한 `color-mix` 패턴).
|
||||
- 관리자 메뉴 관리 화면에서 저장 API로 보내는 직렬화 결과가 서버에서 불러온 직후와 동일하면 `메뉴 저장` 버튼이 비활성화된다.
|
||||
|
||||
### 관리자 인증
|
||||
|
||||
- 초기 관리자 인증은 `ADMIN_EMAIL`, `ADMIN_PASSWORD` 환경 변수를 사용
|
||||
- 로그인 성공 시 httpOnly 세션 쿠키를 `/admin` 경로에 설정
|
||||
- 관리자 페이지 접근은 `/admin/api/auth/me` 확인 후 허용
|
||||
- 세션 토큰은 `ADMIN_PASSWORD` 기반 HMAC 서명으로 검증
|
||||
- 관리자 인증은 `users.is_admin=true` 회원의 이메일/비밀번호(bcrypt 해시) 기반으로 처리한다.
|
||||
- DB에 사용자가 없으면 `/signup`에서 관리자 등록 모드(관리자 이름/이메일/비밀번호/비밀번호 확인)로 첫 계정을 생성한다.
|
||||
- DB에 owner/admin 계정이 없는 최초 상태에서 `/admin/login`에 `ADMIN_EMAIL`/`ADMIN_PASSWORD`와 같은 값을 입력하면 첫 owner 계정을 DB에 생성한 뒤 로그인한다. 같은 이메일의 일반 회원이 이미 있으면 해당 회원을 owner로 승격하고 비밀번호를 `ADMIN_PASSWORD` 기준 bcrypt 해시로 갱신한다. 이미 owner/admin 계정이 있으면 환경 변수 계정으로 우회 로그인하지 않고 DB의 관리자 계정만 사용한다.
|
||||
- 최초 owner 생성 시 `is_admin=true`, `user_role=owner`를 자동 부여한다. 최초 관리자 판정은 동시 요청에서 중복 owner가 생기지 않도록 `users` 테이블 잠금 안에서 처리한다.
|
||||
- 관리자 로그인 성공 시 httpOnly 세션 쿠키를 `/admin` 경로에 설정한다.
|
||||
- `/admin/login` 로그인 제출 버튼은 이메일·비밀번호가 모두 입력된 경우에만 활성화한다.
|
||||
- 관리자 로그인 성공 시 관리자/회원 세션 쿠키를 함께 설정하고 `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`, `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`만 사용하며, 값이 없으면 서버 오류로 실패한다.
|
||||
- Docker 운영 서버 환경 변수는 이미지 빌드 시점 `runtimeConfig`보다 컨테이너 런타임 `process.env` 값을 우선한다.
|
||||
|
||||
---
|
||||
|
||||
@@ -441,23 +615,34 @@ components/content/
|
||||
```
|
||||
/uploads/posts/YYYY/MM/filename.webp
|
||||
/uploads/pages/YYYY/MM/filename.webp
|
||||
/uploads/system/logo.png
|
||||
/uploads/system/favicon.png
|
||||
/uploads/members/avatars/YYYY/MM/filename.webp
|
||||
/uploads/system/logo-YYYYMM-random.webp
|
||||
/uploads/system/favicon-YYYYMM-random.png
|
||||
```
|
||||
|
||||
- 관리자 에디터 이미지 업로드 API는 디스크상 `public/uploads/posts/YYYY/MM/`에 저장하지만, DB가 있을 때 `media_metadata`에는 논리 폴더 **`미분류`**로 기록한다. 메타가 없을 때도 서버 목록에서는 `posts/...` 경로를 **`미분류`**로 표시한다(디스크 1단계 `posts`와 이중 표기하지 않음). 저장 파일명은 업로드 원본 파일명(안전 문자만 유지)을 우선 쓰고, 같은 월 디렉터리에 동일 이름이 있으면 `이름-2`, `이름-3` 식으로 넘버링한다.
|
||||
- `미분류`는 예약된 논리 폴더이며, 사용자가 폴더를 지정하지 않았거나 폴더 삭제 후 되돌림 등으로 `media_metadata.category`가 `미분류`로 저장된 항목이 여기에 모인다.
|
||||
- 회원 썸네일은 `public/uploads/members/avatars/YYYY/MM/`에 WebP로 저장되며, 저장 파일명은 원본명 기반(동일 폴더 충돌 시 `-2` 넘버링)이다. 업로드 시 `media_metadata.category`는 논리 폴더 **`썸네일`**로 저장한다. `썸네일`은 예약 폴더로 `media_folders`에 수동 생성·삭제할 수 없다.
|
||||
- 사이트 로고와 파비콘은 `public/uploads/system/`에 저장되며, 업로드 시 `media_metadata.category`는 논리 폴더 **`시스템`**으로 저장한다. 현재 사이트 설정의 `logo_url` 또는 `favicon_url`이 가리키는 파일은 사용 중인 미디어로 표시하고 파일명 변경·삭제를 차단한다.
|
||||
- 레거시 메타 값 `posts`, `회원/썸네일`은 마이그레이션 `016_media_category_normalize.sql` 및 서버 정규화로 각각 `미분류`, `썸네일`에 맞춘다.
|
||||
|
||||
- 관리자 이미지 업로드 API는 `image/jpeg`, `image/png`, `image/webp`, `image/gif`만 허용한다.
|
||||
- 업로드 파일 크기 제한은 `MAX_FILE_SIZE` 환경 변수를 따른다.
|
||||
- 로컬 개발 업로드 파일은 `public/uploads/posts/YYYY/MM/` 아래 저장하고 `/uploads/posts/YYYY/MM/filename` URL로 제공한다.
|
||||
- 관리자 미디어 화면은 왼쪽 폴더 트리와 오른쪽 고밀도 썸네일 갤러리, 검색, 파일명 변경, 개별 삭제를 제공한다.
|
||||
- 관리자는 폴더 트리에서 새 폴더와 하위 폴더를 만들 수 있다.
|
||||
- 미디어는 Ctrl/Command 클릭으로 복수 선택하고 Shift 클릭으로 범위 선택할 수 있다.
|
||||
- 선택한 미디어를 폴더로 드래그하면 해당 미디어들의 폴더 경로가 일괄 변경된다.
|
||||
- 미디어 파일 경로, 사용 현황, 용량 등 세부 정보는 썸네일 더블 클릭 시 열리는 상세 모달에서 표시한다.
|
||||
- 관리자 미디어 화면 상단에 **미디어 라이브러리** 탭과 **썸네일** 탭을 두어, 라이브러리 탭에서는 게시물·기타 이미지만, 썸네일 탭에서는 `/members/avatars/` 파일만 검색·탐색한다.
|
||||
- 미디어 라이브러리 탭은 왼쪽 폴더 트리와 오른쪽 고밀도 썸네일 갤러리, 검색, 파일명 변경, 개별 삭제를 제공한다.
|
||||
- 관리자는 폴더 추가 버튼으로 모달에서 새 폴더·하위 폴더 이름을 입력해 만들 수 있으며, `미분류`·`썸네일`(및 그 하위)을 제외한 폴더는 목록에서 삭제할 수 있다. 폴더 삭제 시 해당 경로 및 하위 경로로 분류돼 있던 미디어 메타는 모두 `미분류`로 되돌린다.
|
||||
- 썸네일 본문(이미지·파일명) 한 번 클릭 시 상세(미리보기) 모달이 열리고, 썸네일 좌측 상단 **선택 토글**로 개별 선택한다. Shift+클릭으로 범위 선택이 가능하다.
|
||||
- 미디어 라이브러리 탭에서만 선택한 미디어를 폴더로 드래그하면 해당 미디어들의 폴더 경로가 일괄 변경된다.
|
||||
- 관리자 미디어 화면 검색은 **저장 파일명**과 게시물·페이지 **사용처 제목**만 대상으로 한다(URL 경로·논리 폴더명 문자열은 검색에 쓰지 않는다).
|
||||
- 미디어 파일 경로, 사용 현황(라이브러리), 연결 회원(썸네일 탭), 용량 등 세부 정보는 상세 모달에서 표시한다.
|
||||
- API 실패·클라이언트 검증 실패 등 사용자 피드백은 본문 상단 고정 배너가 아니라 `useAdminToast` 우측 상단 토스트로 표시해 모달에 가리지 않는다.
|
||||
- 상세 모달의 **다운로드**는 공개 `/uploads/...` URL을 `download` 속성으로 브라우저에 내려받는다(썸네일·게시물 이미지 공통).
|
||||
- 미디어 폴더는 실제 파일 경로를 옮기지 않고 `media_metadata` 테이블에 URL별 경로 메타데이터로 저장한다.
|
||||
- 글쓰기 미디어 선택 창은 업로드 미디어 목록에서 이미지를 선택해 단일 이미지 또는 갤러리에 삽입한다.
|
||||
- 미디어 사용 현황은 게시물/페이지의 대표 이미지와 본문 내 URL을 기준으로 표시한다.
|
||||
- 미디어 사용 현황은 게시물/페이지의 대표 이미지·본문 내 URL과 사이트 설정의 로고·파비콘 URL을 기준으로 표시한다.
|
||||
- 사용 중인 미디어는 저장된 콘텐츠 URL이 깨지지 않도록 파일명 변경과 삭제를 차단한다.
|
||||
- 향후 미디어 라이브러리는 프로필/사이트 설정 이미지 사용처 추적을 제공한다.
|
||||
- 회원 프로필 썸네일 파일은 `users.avatar_url`이 해당 URL을 가리킬 때만 관리자 화면에서 파일명 변경·삭제를 차단한다. 프로필에서 교체·해제된 파일은 디스크에 남으며 썸네일 탭에서 정리할 수 있다.
|
||||
|
||||
---
|
||||
|
||||
@@ -477,10 +662,16 @@ DB_PORT=43119
|
||||
# Auth
|
||||
ADMIN_EMAIL=admin@example.com
|
||||
ADMIN_PASSWORD=replace-with-random-password
|
||||
MEMBER_SESSION_SECRET=replace-with-random-password
|
||||
|
||||
# Upload
|
||||
UPLOAD_DIR=/uploads
|
||||
MAX_FILE_SIZE=10485760
|
||||
AVATAR_MIN_WIDTH=96
|
||||
AVATAR_MIN_HEIGHT=96
|
||||
AVATAR_MAX_WIDTH=512
|
||||
AVATAR_MAX_HEIGHT=512
|
||||
AVATAR_WEBP_QUALITY=82
|
||||
|
||||
# Site
|
||||
NUXT_PUBLIC_SITE_URL=https://sori.studio
|
||||
@@ -517,6 +708,6 @@ APP_PORT=43118
|
||||
|
||||
## 버전 관리
|
||||
|
||||
- 현재 버전: v0.0.58
|
||||
- 현재 버전: v0.0.85
|
||||
- 첫 커밋 이후 변경사항을 커밋할 때마다 패치 버전 증가
|
||||
- 메이저/마이너 버전은 구조 변경 또는 기능 묶음 단위로 결정
|
||||
|
||||
12
docs/todo.md
12
docs/todo.md
@@ -2,17 +2,18 @@
|
||||
|
||||
## 1차 관리자 개발
|
||||
|
||||
- [ ] 블록 핸들 액션 메뉴 확장: 잘라내기, 복사, 붙여넣기, 블록 타입 변경, 선택 블록 서식 적용
|
||||
- [ ] Markdown-first 에디터 3차 개선: 옵시디언식 Live Preview(마크다운 토큰 숨김/백스페이스 복원), 표준 마크다운 파서 도입 검토, HTML 붙여넣기 변환 범위 확대
|
||||
|
||||
## 2차 관리자 개발
|
||||
|
||||
- [ ] 미디어 라이브러리 프로필/사이트 설정 이미지 사용처 추적
|
||||
- [ ] 관리자 멤버 추가 후 초대 메일 또는 비밀번호 설정 안내 플로우 연결
|
||||
|
||||
## 프론트엔드 개발
|
||||
|
||||
- [ ] Thred 참고 화면 기준 시각 QA
|
||||
- [ ] 사이드바 토글 애니메이션 세부 조정
|
||||
- [ ] 사용자 화면 테마 전환 초기 로드 깜빡임(FART) 최소화
|
||||
- [ ] 회원 설정 썸네일 수동 크롭 UI(선택 영역 지정) 도입 검토
|
||||
|
||||
## 콘텐츠 스타일 구현
|
||||
|
||||
@@ -29,6 +30,7 @@
|
||||
- [ ] ProseFile 실제 파일 데이터 연결
|
||||
- [ ] ProseProduct 실제 상품 카드 데이터 연결
|
||||
- [ ] ProseHeaderCard Simple/Wide/Full-width/Split 변형 구현
|
||||
|
||||
## 데이터베이스
|
||||
|
||||
- [ ] NAS 운영 DB 연결 설정 실제 값 작성
|
||||
@@ -36,7 +38,5 @@
|
||||
|
||||
## 배포
|
||||
|
||||
- [ ] UGREEN NAS Docker 배포 가이드 작성
|
||||
- [ ] Docker 빌드 검증
|
||||
- [ ] `.env.production` 작성 후 `docker compose --env-file .env.production config` 검증
|
||||
- [ ] NAS 운영 환경 변수 작성
|
||||
- [ ] NAS 운영 환경 변수 최종 점검
|
||||
- [ ] NAS 실제 컨테이너 기동 및 도메인/프록시 접속 QA
|
||||
|
||||
618
docs/update.md
618
docs/update.md
@@ -1,5 +1,623 @@
|
||||
# 업데이트 이력
|
||||
|
||||
## v1.1.9
|
||||
|
||||
- 관리자 글 목록에 상태·태그·최신순/오래된순 필터 추가.
|
||||
- 관리자 글 목록 상태 표시를 배지에서 단순 텍스트 색상 기준으로 정리하고 제목 옆 댓글 수 표시 추가.
|
||||
- 게시물 추천 여부(`is_featured`) 저장 필드와 글쓰기 사이드바 추천 토글 추가.
|
||||
- 홈 Featured 영역을 추천 글이 있을 때만 표시하고, 최신 글의 번개 표시는 실제 추천 글에만 나오도록 수정.
|
||||
- 공개 목록·상세의 댓글 수 표시를 API 댓글 집계값 기준으로 정리.
|
||||
- 공개 헤더의 이미지 로고 주석 코드를 제거하고 사이트 이름 텍스트만 표시하도록 정리.
|
||||
- 왼쪽 사이드바 Authors 영역과 오른쪽 사이드바 About 영역을 비공개 처리.
|
||||
- 패키지 버전 `1.1.9`로 갱신.
|
||||
|
||||
## v1.1.8
|
||||
|
||||
- 사이트 로고·파비콘 고유 파일명 접미사를 년월+랜덤 문자열 형식으로 간소화.
|
||||
- 관리자 태그 관리 화면의 `태그 추가` 버튼을 일반 태그 섹션 헤더 오른쪽으로 이동.
|
||||
- 메인 태그 `정렬 저장` 버튼을 제거하고 드래그 드롭 직후 자동 저장되도록 수정.
|
||||
- 메인 태그 순서 자동 저장 중 추가 드래그를 막고 저장 상태를 표시하도록 정리.
|
||||
- 패키지 버전 `1.1.8`로 갱신.
|
||||
|
||||
## v1.1.7
|
||||
|
||||
- 사이트 로고 업로드가 고정 `/uploads/system/logo.webp` 덮어쓰기 대신 고유 파일명 URL을 저장하도록 수정.
|
||||
- 사이트 파비콘도 로고와 같은 고유 접미사 파일명으로 생성해 브라우저 캐시로 이전 이미지가 남는 문제를 완화.
|
||||
- 미디어 라이브러리 사용 현황에 사이트 설정 로고·파비콘 참조를 포함하고, 현재 사용 중인 시스템 이미지는 파일명 변경·삭제가 잠기도록 수정.
|
||||
- 미디어 라이브러리 프로필/사이트 설정 이미지 사용처 추적 todo 정리.
|
||||
- 패키지 버전 `1.1.7`로 갱신.
|
||||
|
||||
## v1.1.6
|
||||
|
||||
- 관리자 태그 관리 화면에서 일반 태그를 검색 전에도 전체 목록으로 확인할 수 있도록 수정.
|
||||
- 일반 태그 목록을 배지형 flex-wrap UI로 표시하고, 최근 사용/수정 태그가 앞에 오도록 정렬.
|
||||
- 일반 태그 배지 목록에 최근 사용순·많이 사용순·이름순 정렬 전환을 추가.
|
||||
- 일반 태그 배지에서 이름·슬러그 로컬 필터, 메인 태그 전환, 삭제를 바로 수행하도록 정리.
|
||||
- 관리자 태그 추가 화면에서 새 태그는 일반 태그로 유지하되, 생성 후 태그 관리 화면에서 누락처럼 보이지 않게 정리.
|
||||
- 패키지 버전 `1.1.6`으로 갱신.
|
||||
|
||||
## v1.1.5
|
||||
|
||||
- 운영 빌드에서 런타임 업로드 파일을 `/app/public/uploads` 볼륨에서 직접 제공하는 `/uploads/**` 서버 라우트 추가.
|
||||
- Docker 운영 이미지의 `.output/public` 빌드 시점 스냅샷에 의존하지 않고 새로 업로드한 로고·게시물 이미지·회원 썸네일이 즉시 표시되도록 수정.
|
||||
- 패키지 버전 `1.1.5`로 갱신.
|
||||
|
||||
## v1.1.4
|
||||
|
||||
- 관리자 멤버 썸네일 업로드가 게시물용 `/uploads/posts`가 아니라 회원 전용 `/uploads/members/avatars` 경로를 사용하도록 수정.
|
||||
- 회원 썸네일 업로드 검증·WebP 변환·1:1 크롭 로직을 공통 유틸로 분리.
|
||||
- 관리자 멤버 편집 전용 썸네일 업로드 API와 새 멤버 생성 전 썸네일 사전 업로드 API 추가.
|
||||
- 관리자 회원 기본 정보 저장에서 기존 회원 전용 썸네일 URL이 교체·제거되면 `media_metadata` 연결을 분리하도록 정리.
|
||||
- 태그 목록 카드 그리드에 사용자 수정 `px-6` 반영.
|
||||
- 패키지 버전 `1.1.4`로 갱신.
|
||||
|
||||
## v1.1.3
|
||||
|
||||
- 왼쪽 사이드바 1차 네비·태그 카테고리·테마 점 행 호버를 `site-sidebar-nav-row`로 분리하고, 라이트 테마에서 배경 `#F7F4EF`로 완화. 다크 테마는 기존 `color-mix` 패널 호버 유지.
|
||||
- 패키지 버전 `1.1.3`으로 갱신.
|
||||
|
||||
## v1.1.2
|
||||
|
||||
- 태그가 없는 게시물에 기본값으로 보이던 `POST` 표기 제거: 공개 상세·홈 Latest·게시물 목록 카드에서 태그가 있을 때만 배지·메타에 표시.
|
||||
- 패키지 버전 `1.1.2`로 갱신.
|
||||
|
||||
## v1.1.1
|
||||
|
||||
- 공개 본문 `ContentMarkdownRenderer` 문단에서 `leading-7`을 제거하고 `text-base`(16px)만 적용.
|
||||
- 패키지 버전 `1.1.1`로 갱신.
|
||||
|
||||
## v1.1.0
|
||||
|
||||
- 관리자 글 작성 폼 제목 입력 타이포를 `text-5xl`에서 `text-3xl`로 조정.
|
||||
- 공개 본문 `ContentMarkdownRenderer` 문단을 `text-base`·`leading-7` 기준으로 조정(기존 `text-[15px] leading-4` 대비 크기·행간 정리).
|
||||
- `ProseHeading`에서 제목 블록 상단 `mt-12` 제거로 제목·본문 간 세로 리듬 정리.
|
||||
- 패키지 버전 `1.1.0`으로 갱신.
|
||||
|
||||
## v1.0.19
|
||||
|
||||
- 관리자 `AdminMarkdownEditor`에서 Shift+Enter가 보이지 않는 `공백 2개 + \\n` 대신 줄끝 백슬래시 hard break(`\\ + 줄바꿈`)를 삽입하도록 수정.
|
||||
- 공개 본문/관리자 미리보기 공통 `ContentMarkdownRenderer`가 줄끝 백슬래시 hard break와 기존 공백 2개 hard break를 모두 같은 문단 안 줄바꿈으로 렌더링하도록 보강.
|
||||
- 패키지 버전 `1.0.19`로 갱신.
|
||||
|
||||
## v1.0.18
|
||||
|
||||
- 공개 본문/관리자 미리보기 공통 `ContentMarkdownRenderer`가 내용 없는 빈 줄을 다시 spacer 블록으로 렌더링해 여러 줄을 비우면 비운 줄 수만큼 공백이 보이도록 수정.
|
||||
- 관리자 `AdminMarkdownEditor` 미리보기 모드에서 툴바를 숨기도록 수정.
|
||||
- 관리자 미리보기 패널의 보더·라운드·흰 배경 카드 처리를 제거.
|
||||
- 작성 모드 줄 번호 거터의 스크롤바를 숨기도록 정리.
|
||||
- 패키지 버전 `1.0.18`로 갱신.
|
||||
|
||||
## v1.0.17
|
||||
|
||||
- 관리자 `AdminMarkdownEditor` 작성 영역의 외곽 보더와 배경 카드 처리를 제거.
|
||||
- 작성 영역 줄 번호 거터를 본문 textarea 바깥의 absolute 영역으로 분리하고 활성 줄 액센트 배경을 제거.
|
||||
- 일반 Enter는 브라우저 기본 단일 줄 이동으로 되돌리고, Shift+Enter만 마크다운 hard break(`공백 2개 + \\n`)로 저장하도록 수정.
|
||||
- 공개 본문/관리자 미리보기 공통 `ContentMarkdownRenderer`가 hard break 행만 같은 문단 안 줄바꿈으로 묶고, 일반 줄은 각각 문단으로 렌더링하도록 정리.
|
||||
- 문단과 제목 하단 기본 간격을 10px 기준으로 조정.
|
||||
- 패키지 버전 `1.0.17`로 갱신.
|
||||
|
||||
## v1.0.16
|
||||
|
||||
- 관리자 `AdminMarkdownEditor`에서 일반 Enter는 새 문단(`\\n\\n`), Shift+Enter는 같은 문단 안 줄바꿈(`\\n`)으로 입력되도록 수정.
|
||||
- `Cmd/Ctrl+E`로 미리보기에서 작성 모드로 돌아올 때 기존 커서 위치와 스크롤을 복원하도록 보강.
|
||||
- 공개 본문/관리자 미리보기 공통 `ContentMarkdownRenderer`가 연속 텍스트 줄을 한 문단으로 묶고, 단일 줄바꿈은 `<br>`, 빈 줄은 문단 경계로 렌더링하도록 정리.
|
||||
- 본문 문단 하단 간격을 24px 기준으로 조정.
|
||||
- 패키지 버전 `1.0.16`으로 갱신.
|
||||
|
||||
## v1.0.15
|
||||
|
||||
- 공개 본문/관리자 미리보기 공통 `ContentMarkdownRenderer`가 빈 줄을 버리지 않고 spacer 블록으로 렌더링하도록 수정.
|
||||
- 레거시 빈 문단 마커(`<!--sori:blank-paragraph-->`)도 동일한 spacer 블록으로 표시하도록 정리.
|
||||
- 패키지 버전 `1.0.15`로 갱신.
|
||||
|
||||
## v1.0.14
|
||||
|
||||
- 관리자 게시물/페이지 입력 스키마에서 레거시 블록 배열·객체 본문 값을 저장용 마크다운 문자열로 정규화하도록 보강.
|
||||
- `AdminMarkdownEditor`와 `AdminPostForm`에서 기존 자동 저장본 또는 레거시 블록 본문을 복원할 때 마크다운 문자열로 변환하도록 수정.
|
||||
- 공통 `normalizeMarkdownContent` 유틸 추가.
|
||||
- 패키지 버전 `1.0.14`로 갱신.
|
||||
|
||||
## v1.0.13
|
||||
|
||||
- 관리자 `AdminMarkdownEditor`에 HTML 클립보드 붙여넣기 기본 변환을 추가해 외부 블로그/웹 문서를 붙여넣을 때 제목·문단·목록·링크·굵게·기울임·이미지를 마크다운 조각으로 정리.
|
||||
- 작성 모드에서 커서가 이미지 줄 또는 `:::gallery` 블록 안에 있을 때 현재 미디어 블록 편집 패널을 표시하고 alt·URL·너비 수정, 갤러리 순서 변경·삭제·이미지 추가를 지원.
|
||||
- 관리자 `AdminMarkdownEditor`에서 `Cmd/Ctrl+E`로 작성/미리보기 모드를 전환하도록 변경하고, 관리자 미리보기의 본문 색상 변수를 밝은 배경 기준으로 고정.
|
||||
- 패키지 버전 `1.0.13`으로 갱신.
|
||||
|
||||
## v1.0.12
|
||||
|
||||
- 관리자 `AdminMarkdownEditor` 작성 모드에 왼쪽 줄 번호 거터(`\\n` 기준 논리 줄)와 현재 줄 배경 강조 추가, textarea와 거터 세로 스크롤 동기화.
|
||||
- 패키지 버전 `1.0.12`로 갱신.
|
||||
|
||||
## v1.0.11
|
||||
|
||||
- 관리자 글 본문 에디터를 블록형 `AdminBlockEditor`에서 Markdown-first `AdminMarkdownEditor`로 교체.
|
||||
- 새 에디터에 textarea 기반 범위 선택·복사/붙여넣기, 작성/미리보기 전환, 마크다운 툴바, 이미지·갤러리 업로드 및 미디어 라이브러리 삽입 추가.
|
||||
- 공개 본문 렌더러에 굵게, 기울임, 인라인 코드, 링크 인라인 마크다운 표시 추가.
|
||||
- 패키지 버전 `1.0.11`로 갱신.
|
||||
|
||||
## v1.0.10
|
||||
|
||||
- 관리자 `AdminBlockEditor.vue`를 저장소 태그 `v1.0.5` 시점과 동일한 내용으로 복원(다중 줄·마크다운 붙여넣기 분할, Cmd/Ctrl+A 전체 MD 복사 안내, 블록 단위 범위 선택 등 v1.0.6 이후 에디터 UX 변경 제거). 동작 불만에 따른 되돌림.
|
||||
- 패키지 버전 `1.0.10`으로 갱신.
|
||||
|
||||
## v1.0.5
|
||||
|
||||
- Docker 운영 이미지에서 빌드 시점 `runtimeConfig`가 비어도 컨테이너 런타임 환경 변수(`DATABASE_URL`, `ADMIN_EMAIL`, `ADMIN_PASSWORD`, `MEMBER_SESSION_SECRET`, Resend 설정)를 우선 읽도록 수정.
|
||||
- 서버 런타임 환경 변수 조회 유틸 추가.
|
||||
- 패키지 버전 `1.0.5`로 갱신.
|
||||
|
||||
## v1.0.4
|
||||
|
||||
- 최초 관리자 부트스트랩 기준을 전체 사용자 수가 아니라 owner/admin 존재 여부로 변경.
|
||||
- owner/admin이 없는 상태에서 `ADMIN_EMAIL`과 같은 일반 회원이 이미 있으면 해당 회원을 owner로 승격하고 `ADMIN_PASSWORD`로 비밀번호를 갱신하도록 수정.
|
||||
- 패키지 버전 `1.0.4`로 갱신.
|
||||
|
||||
## v1.0.3
|
||||
|
||||
- NAS 등에서 `db/migrations` 바인드 마운트 권한 부족 시 `docker-entrypoint-initdb.d` Permission denied로 DB 컨테이너가 재시작하는 경우를 배포 문서에 정리.
|
||||
- `docker-compose.yml`에 동일 이슈용 주석 추가.
|
||||
- 패키지 버전 `1.0.3`으로 갱신.
|
||||
|
||||
## v1.0.2
|
||||
|
||||
- 운영 DB가 비어 있을 때 `/admin/login`에서 `ADMIN_EMAIL`/`ADMIN_PASSWORD`로 최초 owner 계정을 생성하도록 수정.
|
||||
- 배포 문서의 `.env.production` 생성 명령과 최초 관리자 생성 기준 정리.
|
||||
- 배포 문서에 Docker 컨테이너 `Restarting` 루프 시 로그 확인 절차 추가.
|
||||
- 패키지 버전 `1.0.2`로 갱신.
|
||||
|
||||
## v1.0.1
|
||||
|
||||
- Docker Compose 기본 네트워크 주소 풀 충돌을 피하기 위해 전용 브리지 네트워크와 `DOCKER_SUBNET` 설정 추가.
|
||||
- 배포 문서에 NAS Docker 네트워크 충돌 시 `DOCKER_SUBNET` 변경 기준 추가.
|
||||
- 패키지 버전 `1.0.1`로 갱신.
|
||||
|
||||
## 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 **회전 애니메이션**, 하위 영역 **높이 전환 애니메이션**, 펼침 전용 배경 제거·너비·간격 리프와 통일.
|
||||
|
||||
## v0.0.95
|
||||
|
||||
- 메뉴 관리: `표시`·`폴더` 체크 제거(항목은 항상 공개, `is_folder`는 저장 시 자식 유무로 서버 설정). 테이블+행 드래그 UX를 태그 메인 태그와 동일 톤(`bg-[#f9f9f7]`·`opacity-50`, 입력 위에서는 드래그 시작 안 함).
|
||||
- 태그 관리 메인 태그: 드래그 중인 행에 `opacity-50` 적용(메뉴 관리와 동일한 이탈 피드백).
|
||||
- `PUT /admin/api/navigation`: `parent_id`/`is_folder` 컬럼 부재 시 한국어 안내(503) 및 화면에 마이그레이션 안내 블록.
|
||||
|
||||
## v0.0.94
|
||||
|
||||
- 메뉴 관리: 상단/하단 탭 분리, 순서는 드래그(태그 관리와 유사). 상단은 `parent_id` 트리·하위 추가·폴더(`is_folder`)·동일 부모 내 순서 변경.
|
||||
- `GET /api/navigation`의 `primary`는 트리 응답, 좌측 사이드바는 `SidebarPrimaryNavList`로 접기/펼치기(`localStorage` 유지).
|
||||
- 마이그레이션 `017_navigation_hierarchy.sql`, 공유 `lib/navigation-editor-tree.js`, `server/utils/navigation-tree.js` 검증·저장 순서.
|
||||
|
||||
## v0.0.93
|
||||
|
||||
- `composables/useAdminToast.js` 추가: 관리자 우측 상단 토스트(자동 숨김).
|
||||
- 관리자 미디어(`pages/admin/media/index.vue`): 본문 상단 `errorMessage` 배너 제거, 폴더·이름 변경·삭제 등 실패 피드백을 토스트로 통일해 모달에 가리지 않게 함.
|
||||
|
||||
## v0.0.92
|
||||
|
||||
- 회원 `PUT /api/auth/profile`에서 관리 썸네일 URL이 바뀌거나 비워질 때도 `removeManagedAvatarAsset`으로 메타만 분리해, 해제 후에도 디스크·썸네일 탭 목록과 일치하도록 정리.
|
||||
- 관리자 미디어 상세 모달에 **다운로드** 버튼 추가.
|
||||
- 썸네일 탭 안내: 프로필 해제 시에도 파일이 삭제되지 않음·목록 갱신은 새로고침을 명시.
|
||||
|
||||
## v0.0.91
|
||||
|
||||
- 회원 썸네일 교체·삭제·탈퇴 시 이전 파일은 디스크에 남기고 `media_metadata`만 제거해, 관리자 썸네일 탭에서 미사용 자산을 구분·삭제할 수 있게 함.
|
||||
- 관리자 미디어: 프로필이 참조 중인 썸네일만 삭제·이름 변경 차단(미참조 파일은 허용).
|
||||
- `POST /admin/api/uploads`·`POST /api/auth/avatar`: 저장 파일명은 원본명 기반, 동일 폴더 충돌 시 `-2` 넘버링.
|
||||
- 관리자 미디어 검색: 파일명·게시물 사용처 제목만; 모달에서 폴더 요약 중복 행 제거.
|
||||
- `renameMediaItem`: 대상 폴더에 동일 파일명이 있으면 409.
|
||||
|
||||
## v0.0.90
|
||||
|
||||
- 관리자 미디어: 상단 탭으로 **미디어 라이브러리**와 **썸네일**(회원 `/members/avatars/`만) 분리, 썸네일 검색에 닉네임·이메일·IP 반영.
|
||||
- 게시물용 관리자 업로드는 디스크 `posts/연/월` 유지하되 `media_metadata` 및 목록 논리 폴더는 **`미분류`**로 통일; 회원 아바타 메타는 **`썸네일`**.
|
||||
- `listMediaItems`에 `avatarOwner` 부착, 썸네일 파일의 관리자 삭제·이름 변경·임의 논리 폴더 이동 차단, 예약 폴더 `썸네일`의 `media_folders` 생성·삭제 차단.
|
||||
- 마이그레이션 `016_media_category_normalize.sql`로 레거시 `posts`/`회원/썸네일` 카테고리 문자열 정리.
|
||||
|
||||
## v0.0.89
|
||||
|
||||
- 관리자 미디어 썸네일 선택 컨트롤을 네이티브 체크박스에서 대비가 분명한 토글 버튼(미선택: 흰 배경·진한 테두리, 선택: 진한 배경·흰 체크)으로 교체.
|
||||
- `docs/spec.md`에 미디어 디스크 경로와 `media_metadata` 논리 폴더 관계를 명시(이후 v0.0.90에서 `미분류`/`썸네일` 정책으로 갱신).
|
||||
|
||||
## v0.0.88
|
||||
|
||||
- 관리자 미디어: 썸네일 본문 클릭 시 상세 모달, 좌측 상단 체크박스로 다중 선택(Shift 범위 유지), 툴바 `현재 폴더로 이동` 제거.
|
||||
- 폴더 추가는 상시 입력 대신 모달로 이름 입력.
|
||||
- `DELETE /admin/api/media-folders` 및 폴더 행 삭제 UI 추가(삭제 시 해당 분류 메타는 `미분류`로).
|
||||
|
||||
## v0.0.87
|
||||
|
||||
- 메인 태그 `정렬 저장`·메뉴 `메뉴 저장`은 서버에서 받은 상태와 비교해 변경이 있을 때만 버튼이 활성화되도록 조정.
|
||||
- 관리자 로그인·회원 로그인(`signin`) 제출 버튼은 이메일·비밀번호가 모두 입력된 경우에만 활성화.
|
||||
- 관리자 글 목록의 삭제는 휴지통 아이콘으로 바꾸고, 기본은 낮은 불투명도·호버 시에만 강조.
|
||||
- `AGENTS.md`에 커밋 후 원격 반영 시 `git push` 생략 방지 지침을 추가.
|
||||
|
||||
## v0.0.86
|
||||
|
||||
- 관리자 게시물 미리보기 본문 영역을 공개 상세와 동일한 `max-w-[720px]`·좌우 패딩으로 감싸 여백을 맞춤.
|
||||
- 글 작성 폼에서 태그 토큰은 로마자 슬러그 변환 대신 한글 유지 정규화를 사용하고, 저장 시 태그 `name`은 한글 슬러그에 맞게 표시되도록 저장소 `normalizeTagSlugs`·`getTagNameFromSlug`를 조정.
|
||||
- 관리자 게시물 폼에서 SEO 제목·설명 입력을 제거하고 저장 시 제목·요약을 메타 필드로 기록하도록 변경. `admin-post-input`의 SEO 문자열 길이 제한을 완화.
|
||||
- 관리자 태그 관리 화면의 성공·오류 안내를 본문 상단 블록 대신 우측 상단 고정 토스트로 표시.
|
||||
|
||||
## v0.0.85
|
||||
|
||||
- 관리자 블록 에디터 저장 시 의도적으로 만든 빈 문단(연속 Enter)을 제거하지 않도록 빈 문단 마커(`<!--sori:blank-paragraph-->`) 직렬화/복원 로직을 추가.
|
||||
- 공개 본문 마크다운 렌더러에서도 빈 문단 마커를 문단 블록으로 해석해 저장 후에도 문단 간 여백 의도를 유지하도록 맞춤.
|
||||
|
||||
## v0.0.84
|
||||
|
||||
- 관리자 블록 에디터 본문에서 위/아래 방향키 입력 시 커서가 블록 시작/끝 경계에 있으면 이전/다음 문단(또는 구조형 블록)으로 이동하도록 보완.
|
||||
- 슬래시 메뉴를 최대 높이 제한 + 내부 스크롤 구조로 변경해 명령 개수가 많아도 화면을 넘기지 않도록 조정.
|
||||
- 슬래시 메뉴 방향키 이동 시 현재 하이라이트 항목이 항상 스크롤 영역 안에 보이도록 자동 스크롤을 추가.
|
||||
|
||||
## v0.0.83
|
||||
|
||||
- 관리자 블록 에디터 슬래시 메뉴에서 방향키 이동 시 하이라이트 인덱스가 매번 초기화되던 문제를 수정해 연속 이동이 가능하도록 보정.
|
||||
- 슬래시 메뉴가 열린 블록(`text`가 `/`로 시작)에서만 위/아래 방향키 메뉴 이동 로직이 동작하도록 분기해, 일반 본문 블록의 방향키 입력 간섭을 줄임.
|
||||
|
||||
## v0.0.82
|
||||
|
||||
- 메인 태그 목록의 `삭제` 버튼을 제거하고 `일반 태그로 변경`(강등) 버튼으로 교체.
|
||||
- 일반 태그 삭제는 일반 태그 검색 결과 영역에서만 가능하도록 변경.
|
||||
- 태그 생성/수정 폼에서 정렬 순서 입력과 태그 유형 선택을 제거해, 메인/일반 전환은 태그 목록 액션으로만 처리하도록 단순화.
|
||||
|
||||
## v0.0.81
|
||||
|
||||
- 관리자 글 작성 태그 입력에서 한글 조합 중 Enter 입력 시 중복 태그가 생성되지 않도록 IME 조합 상태 가드를 추가.
|
||||
- 게시물 저장 중 새로 생성되는 태그는 기본값을 `general`(일반 태그)로 저장하도록 수정.
|
||||
- 관리자 태그 화면에서 `관리용 태그` 명칭을 `메인 태그`로 변경.
|
||||
- 관리자 태그 화면의 일반 태그 전체 목록 테이블을 제거하고, 일반 태그 검색 후 `메인 태그로 전환`하는 흐름으로 개편.
|
||||
- 관리자 태그 목록 API에 `tagType`, `q`, `limit` 조회 옵션을 추가해 일반 태그 검색을 지원.
|
||||
|
||||
## v0.0.80
|
||||
|
||||
- 태그에 유형(`managed`/`general`) 컬럼을 추가하는 마이그레이션(`015_add_tag_type_and_reorder_support.sql`)을 추가.
|
||||
- 관리자 태그 입력 폼에 태그 유형 선택을 추가하고, 일반 태그는 정렬 순서를 사용하지 않도록 정리.
|
||||
- 관리자 태그 목록을 `관리용 태그`/`일반 태그`로 분리.
|
||||
- 관리용 태그 목록에 드래그 앤 드롭 정렬 UI를 추가하고, 저장 버튼으로 순서를 일괄 반영하도록 개선.
|
||||
- `PUT /admin/api/tags/reorder` API를 추가해 관리용 태그의 `sort_order`를 순차 재정렬하도록 확장.
|
||||
- 공개 태그 API(`GET /api/tags`)는 관리용 태그만 반환하도록 변경해 좌측 카테고리 영역과 태그 관리 목적을 분리.
|
||||
|
||||
## v0.0.79
|
||||
|
||||
- 댓글 목록에 작성자 썸네일(`avatar_url`)을 노출하고, 썸네일이 없으면 이니셜 아바타를 표시하도록 UI를 개선.
|
||||
- 댓글/대댓글에 좋아요 토글을 추가하고, 좋아요 수/내 좋아요 상태를 함께 반환하도록 API와 저장소를 확장.
|
||||
- 댓글 시간 표기를 `n분 전`, `n시간 전`, 24시간 이후 날짜 형식으로 변경.
|
||||
- `comment_likes` 테이블 마이그레이션(`012_add_comment_likes.sql`)을 추가.
|
||||
- 댓글 정렬 옵션(`Best`, `Latest`, `Oldest`)을 실제 동작하도록 연결.
|
||||
- 댓글/대댓글 카드 보더를 최소화하고 간격·아이콘 스타일을 정리해 더 단정한 레이아웃으로 조정.
|
||||
- 대댓글 아바타가 깨질 때 이니셜로 안전하게 대체되도록 이미지 로드 실패 처리 보강.
|
||||
- 공개 레이아웃(`default`, `post`)의 데스크톱 3열 `gap-x`를 제거해 본문-사이드 사이가 이중 패딩처럼 보이던 간격을 정리.
|
||||
- 좌측 사이드바 네비게이션/카테고리 hover 배경을 `site-panel-hover` 기반으로 통일해 다크 모드에서 텍스트 가독성 저하를 수정.
|
||||
- 관리자 멤버 목록에 권한 컬럼을 추가하고, 환경변수 관리자 계정을 `관리자` 권한으로 함께 표시하도록 보강.
|
||||
- 사용자 테이블에 관리자 권한 컬럼(`is_admin`)을 추가하는 마이그레이션(`013_add_user_admin_role.sql`)을 추가.
|
||||
- 최초 사용자 생성 시 관리자 권한을 자동 부여하고, 가입 응답에 관리자 여부를 함께 반환하도록 수정.
|
||||
- 관리자 로그인은 환경변수 고정 계정 대신 `is_admin` 회원 계정 인증으로 전환.
|
||||
- `GET /api/auth/bootstrap-status`를 추가해 최초 관리자 등록 필요 여부를 조회하도록 확장.
|
||||
- 회원가입 화면에서 최초 사용자일 때 `관리자 등록` 타이틀/설명/필드 라벨을 노출하도록 수정.
|
||||
- 관리자 로그인 시 회원 세션도 함께 설정하고, 관리자 로그아웃 시 회원 세션도 함께 정리하도록 수정.
|
||||
- 관리자 설정 화면에 관리자 프로필(썸네일 업로드/제거, 이름 저장) 섹션을 추가.
|
||||
- 관리자 설정 화면에서 현재 비밀번호 확인 기반 관리자 비밀번호 변경을 지원하도록 추가.
|
||||
- 회원 권한 3단계(`owner`/`admin`/`member`)를 위한 마이그레이션(`014_add_user_role_levels.sql`)을 추가.
|
||||
- 관리자 멤버 화면에서 권한 선택/저장 UI와 `PUT /admin/api/members/:id/role` 권한 변경 API를 추가.
|
||||
|
||||
## v0.0.78
|
||||
|
||||
- 관리자 미디어 목록에서 회원 썸네일 경로(`/uploads/members/avatars/`)를 다시 포함해 `회원/썸네일` 폴더에서 확인 가능하도록 수정.
|
||||
- 회원 썸네일 파일도 일반 미디어와 동일하게 폴더 트리/검색/카운트 집계에 반영되도록 정리.
|
||||
|
||||
## v0.0.77
|
||||
|
||||
- 회원 설정 프로필 영역에서 썸네일 URL 입력을 제거하고, 썸네일 미리보기 + 이미지 변경/제거 버튼 중심 UI로 개편.
|
||||
- 썸네일 업로드 완료 안내 문구를 실제 동작과 맞게 간소화.
|
||||
- 썸네일 수동 크롭 UI는 우선순위 낮은 후속 작업으로 `docs/todo.md`에 기록.
|
||||
|
||||
## v0.0.76
|
||||
|
||||
- 회원 썸네일 업로드 시 원본 비율과 관계없이 중앙 기준 1:1 정사각형으로 강제 크롭하도록 변경.
|
||||
- 크롭 결과 해상도는 `AVATAR_MAX_WIDTH`, `AVATAR_MAX_HEIGHT` 중 작은 값을 사용해 정사각형(`N x N`)으로 저장하도록 정리.
|
||||
|
||||
## v0.0.75
|
||||
|
||||
- 회원 썸네일 업로드 시 최소 해상도 제한(`AVATAR_MIN_WIDTH`, `AVATAR_MIN_HEIGHT`)을 추가해 너무 작은 이미지를 차단.
|
||||
- 썸네일 품질과 리사이즈 설정값이 비정상일 때 서버에서 안전 범위로 보정(clamp)하도록 방어 로직을 추가.
|
||||
- 최대 해상도 설정이 최소 해상도보다 작게 들어와도 자동 보정되도록 처리.
|
||||
|
||||
## v0.0.74
|
||||
|
||||
- 회원 썸네일 업로드 시 원본 포맷과 관계없이 WebP로 변환 저장하도록 수정.
|
||||
- 회원 썸네일 업로드 시 `AVATAR_MAX_WIDTH`, `AVATAR_MAX_HEIGHT` 기준으로 자동 리사이즈(`fit: inside`)를 적용.
|
||||
- 썸네일 품질을 `AVATAR_WEBP_QUALITY` 환경 변수로 제어하도록 설정을 추가.
|
||||
|
||||
## v0.0.73
|
||||
|
||||
- 회원 썸네일 교체 시 기존 회원 전용 썸네일 파일/메타데이터를 자동 정리하는 공통 유틸을 추가.
|
||||
- `DELETE /api/auth/avatar`를 추가해 회원 설정에서 썸네일 제거를 지원하고, 제거 시 프로필 `avatarUrl`을 비움.
|
||||
- 회원 탈퇴 시에도 회원 전용 썸네일 파일/메타데이터가 함께 정리되도록 처리.
|
||||
|
||||
## v0.0.72
|
||||
|
||||
- 회원 썸네일 업로드 API(`POST /api/auth/avatar`)를 추가하고 업로드 경로를 `/uploads/members/avatars/YYYY/MM`으로 분리.
|
||||
- 회원 설정 페이지에서 썸네일 파일 업로드를 직접 처리하고, 업로드 후 프로필 저장 흐름으로 연결.
|
||||
- 관리자 미디어 목록에서 회원 썸네일은 전용 카테고리(`회원/썸네일`)로 구분해 관리.
|
||||
|
||||
## v0.0.71
|
||||
|
||||
- 헤더 사용자 영역에서 구독 버튼을 제거하고, 로그인 상태 기반 아바타/드롭다운(설정, 로그아웃 / 비로그인 시 Sign up, Sign in)으로 정리.
|
||||
- 회원 설정 페이지(`/settings`)를 추가하고 닉네임 변경(중복 확인), 썸네일 URL 변경, 비밀번호 변경, 회원 탈퇴 기능을 연결.
|
||||
- 관리자 멤버 화면(`/admin/members`)과 회원 목록 API를 추가해 닉네임, 이메일, 최근 접속 시각/IP, 댓글 개수, 활동 현황을 확인할 수 있게 구성.
|
||||
- 회원 활동 컬럼(`last_seen_at`, `last_seen_ip`)과 아바타 컬럼(`avatar_url`)을 DB에 추가하고, 로그인/세션 조회/댓글 작성 시 최근 활동을 갱신.
|
||||
|
||||
## v0.0.70
|
||||
|
||||
- 회원 인증 API(`POST /api/auth/signup`, `POST /api/auth/login`, `GET /api/auth/me`, `POST /api/auth/logout`)와 회원 세션 쿠키(`sori_member_session`)를 추가.
|
||||
- 댓글 DB 스키마(`users`, `comments`)와 게시물 댓글 API(`GET/POST /api/posts/:slug/comments`)를 추가하고, 대댓글은 1단까지만 허용하도록 검증을 적용.
|
||||
- 게시물 상세 `#comments` 영역에 `PostComments` UI를 연결해 로그인 회원 댓글/답글 작성과 댓글 목록 표시를 구현.
|
||||
- `signin`/`signup` 화면을 시뮬레이션에서 실제 API 연동으로 전환.
|
||||
|
||||
## v0.0.66
|
||||
|
||||
- 태그 검색은 `description`을 제외하고 `name`·`slug`만 부분 일치하도록 조정해, `p` 같은 한 글자 입력으로 의미 없는 태그가 뜨는 혼선을 줄임.
|
||||
- 검색 모달 헤더 아이콘은 입력 비어있으면 돋보기, 입력이 있으면 X(클리어)로 전환하고 클릭 시 입력값을 비운다. 좌측/우측 닫기 X는 제거하고 `Esc`·백드롭 클릭·모바일 취소로 닫는다.
|
||||
- 검색 입력은 IME(한글 조합) 중에도 디바운스로 검색을 갱신해 `워`처럼 조합 상태가 유지되는 입력에서도 결과가 나오게 하고, 조합 종료 시점에는 확정값으로 즉시 한 번 더 갱신한다.
|
||||
|
||||
## v0.0.67
|
||||
|
||||
- 공개 검색에서 `/uploads/...` 파일 경로와 마크다운 이미지 토큰(``)은 노이즈로 간주해 매칭 대상에서 제거, 이미지 파일명 때문에 숫자 검색이 걸리던 문제를 해결.
|
||||
|
||||
## v0.0.68
|
||||
|
||||
- 게시물 상세(`pages/post/[slug].vue`) 섹션 패딩을 내부 래퍼로 이동해 레이아웃 그리드 패딩과 중복되던 2중 패딩을 제거.
|
||||
- 댓글 섹션 구분선(`border-y`)은 패딩 없이 전체 폭으로 표시하고, 내용만 내부 래퍼에 패딩을 적용.
|
||||
|
||||
## v0.0.69
|
||||
|
||||
- 공개 레이아웃(`layouts/default.vue`, `layouts/post.vue`)에서 메뉴 닫힘 시 추가로 주던 메인 우측 패딩을 제거해 가로 패딩이 1회만 적용되도록 정리.
|
||||
- 홈/태그 목록/태그 상세 공개 페이지 섹션의 `px-*`를 제거하고 레이아웃 그리드 패딩만 사용하도록 통일해 2중 패딩을 해소.
|
||||
|
||||
## v0.0.65
|
||||
|
||||
- 헤더 `/` 단축키·검색 영역 클릭으로 통합 검색 모달(`SiteSearchModal`)을 연다. `INPUT`·`TEXTAREA`·`SELECT`·`contenteditable` 포커스일 때는 `/`를 가로채지 않는다.
|
||||
- `GET /api/search?q=`와 `searchPublicContent`(저장소)로 태그·게시물(제목·요약·본문) 부분 일치 검색, 모달에서 Tags·Posts 섹션·일치 구간 강조·`html.site-search-open` 스크롤 잠금.
|
||||
|
||||
## v0.0.64
|
||||
|
||||
- 비개발용 `paths.mjs`가 `#internal/nitro`를 import하는데 루트 `package.json` `imports`에 없어 `Package import specifier "#internal/nitro" is not defined`가 나던 문제를, `scripts/node-paths-nitro-shim.mjs`로 최소 `useRuntimeConfig().app`만 제공하고 `#internal/nitro`를 매핑해 해결.
|
||||
|
||||
## v0.0.63
|
||||
|
||||
- `tailwindcss.cssPath`를 `~/assets/css/main.css`로 지정해, 없는 기본 경로 때문에 `node_modules/tailwindcss/tailwind.css`가 추가로 주입되던 이중 `@tailwind` 로딩을 제거.
|
||||
- `tailwind.config.js` `content`에 `composables`·`modules`·`plugins` 스캔 경로 추가.
|
||||
|
||||
## v0.0.62
|
||||
|
||||
- 다크 인증 페이지에서 `input`이 UA 기본색으로 남아 글자·아이콘이 안 보이던 문제를 `main.css`의 `.auth-form-input`(글자색·캐럿·placeholder·autofill)로 보정.
|
||||
- `AuthPasswordVisibilityToggle`은 Tailwind 의존을 줄이고 `scoped` CSS로 버튼·아이콘 크기·포커스 링을 고정.
|
||||
- signin/signup 루트에 `[color-scheme:dark]`, signup 패널에 `border`·`bg-[#0d1116]`(`auth-signup__panel`)로 카드 대비 보강.
|
||||
|
||||
## v0.0.61
|
||||
|
||||
- 로그인·회원가입 비밀번호 표시 토글을 `AuthPasswordVisibilityToggle`(Material 스타일 SVG 눈 아이콘)으로 통일, 텍스트 보기/숨기기 제거.
|
||||
- 회원가입 비밀번호·비밀번호 확인 각각 독립 토글·포커스 링을 가진 입력 행으로 정리.
|
||||
|
||||
## v0.0.60
|
||||
|
||||
- 홈 Featured 가로 트랙에 `touch-pan-x`·`-webkit-overflow-scrolling:touch`·`overscroll-x-contain`을 두어 모바일에서 손가락으로 가로 슬라이드(스크롤·스냅)가 잘 먹도록 함.
|
||||
- Featured 이전/다음 화살표는 스크롤 시작·끝에 따라 `disabled`와 시각적 비활성 처리, `ResizeObserver`·`scroll`로 상태 동기화.
|
||||
|
||||
## v0.0.59
|
||||
|
||||
- Nuxt 3.21 SSR이 `#internal/nuxt/paths`를 외부 import로 두는데 `.nuxt/paths.mjs`가 기본적으로 디스크에 쓰이지 않아 Node가 루트 `package.json`에서 해석하지 못하던 오류 수정.
|
||||
- `modules/nuxt-ssr-paths-write.mjs`에서 `paths.mjs` 템플릿에 `write: true`를 부여하고, 루트 `package.json` `imports`로 `./.nuxt/paths.mjs`를 매핑.
|
||||
- `nuxt` 의존성을 `^3.21.2`로 올려 lockfile과 정렬.
|
||||
|
||||
## v0.0.58
|
||||
|
||||
- 공개 3열 그리드 중앙을 `minmax(0,1fr)`로 바꾸고 `lg:gap-x-4`·`xl:gap-x-5`를 두어 본문과 오른쪽 사이드 사이 시각적 간격 확보.
|
||||
|
||||
@@ -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,51 +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/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 />
|
||||
|
||||
@@ -45,12 +45,12 @@ onBeforeUnmount(() => {
|
||||
/>
|
||||
</Transition>
|
||||
<div
|
||||
class="public-layout__grid mx-auto flex w-full max-w-[1294px] flex-1 flex-col bg-[var(--site-bg)] px-4 lg:grid lg:grid-cols-[287px_minmax(0,1fr)_287px] lg:items-start lg:gap-x-4 lg:px-5 lg:transition-[grid-template-columns,max-width,gap] lg:duration-300 lg:ease-out xl:gap-x-5 xl:px-6 2xl:px-0"
|
||||
class="public-layout__grid mx-auto flex w-full max-w-[1294px] flex-1 flex-col bg-[var(--site-bg)] px-4 lg:grid lg:grid-cols-[287px_minmax(0,1fr)_287px] lg:items-start lg:gap-x-0 lg:px-5 lg:transition-[grid-template-columns,max-width,gap] lg:duration-300 lg:ease-out xl:gap-x-0 xl:px-6 2xl:px-0"
|
||||
:class="menuOpen ? '' : 'lg:max-w-[1007px] lg:!gap-x-0 lg:[grid-template-columns:0_minmax(0,1fr)_287px]'"
|
||||
>
|
||||
<main
|
||||
class="site-main min-w-0 w-full max-w-full overflow-x-hidden lg:col-start-2 lg:row-start-1 lg:max-w-[720px] lg:justify-self-start"
|
||||
:class="[{ 'site-main--menu-closed': !menuOpen }, !menuOpen ? 'lg:pr-4 xl:pr-5' : '']"
|
||||
:class="[{ 'site-main--menu-closed': !menuOpen }]"
|
||||
>
|
||||
<slot />
|
||||
</main>
|
||||
|
||||
@@ -45,12 +45,12 @@ onBeforeUnmount(() => {
|
||||
/>
|
||||
</Transition>
|
||||
<div
|
||||
class="post-layout__grid mx-auto flex w-full max-w-[1294px] flex-1 flex-col bg-[var(--site-bg)] px-4 lg:grid lg:grid-cols-[287px_minmax(0,1fr)_287px] lg:items-start lg:gap-x-4 lg:px-5 lg:transition-[grid-template-columns,max-width,gap] lg:duration-300 lg:ease-out xl:gap-x-5 xl:px-6 2xl:px-0"
|
||||
class="post-layout__grid mx-auto flex w-full max-w-[1294px] flex-1 flex-col bg-[var(--site-bg)] px-4 lg:grid lg:grid-cols-[287px_minmax(0,1fr)_287px] lg:items-start lg:gap-x-0 lg:px-5 lg:transition-[grid-template-columns,max-width,gap] lg:duration-300 lg:ease-out xl:gap-x-0 xl:px-6 2xl:px-0"
|
||||
:class="menuOpen ? '' : 'lg:max-w-[1007px] lg:!gap-x-0 lg:[grid-template-columns:0_minmax(0,1fr)_287px]'"
|
||||
>
|
||||
<main
|
||||
class="site-main min-w-0 w-full max-w-full overflow-x-hidden lg:col-start-2 lg:row-start-1 lg:max-w-[720px] lg:justify-self-start"
|
||||
:class="[{ 'site-main--menu-closed': !menuOpen }, !menuOpen ? 'lg:pr-4 xl:pr-5' : '']"
|
||||
:class="[{ 'site-main--menu-closed': !menuOpen }]"
|
||||
>
|
||||
<slot />
|
||||
</main>
|
||||
|
||||
172
lib/markdown-content-normalizer.js
Normal file
172
lib/markdown-content-normalizer.js
Normal file
@@ -0,0 +1,172 @@
|
||||
const BLANK_PARAGRAPH_MARKER = '<!--sori:blank-paragraph-->'
|
||||
|
||||
const blockSpacingTypes = new Set(['list'])
|
||||
|
||||
/**
|
||||
* 이미지 블록을 마크다운 문자열로 변환한다.
|
||||
* @param {Object} image - 이미지 데이터
|
||||
* @returns {string} 이미지 마크다운
|
||||
*/
|
||||
const serializeImageBlock = (image = {}) => {
|
||||
const url = String(image.url || '').trim()
|
||||
|
||||
if (!url) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const width = image.width && image.width !== 'regular'
|
||||
? `{width=${image.width}}`
|
||||
: ''
|
||||
|
||||
return `${width}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 레거시 블록 하나를 마크다운 조각으로 변환한다.
|
||||
* @param {Object} block - 레거시 에디터 블록
|
||||
* @param {number} index - 블록 인덱스
|
||||
* @param {number} total - 전체 블록 수
|
||||
* @returns {{ type: string, value: string }|null} 마크다운 조각
|
||||
*/
|
||||
const serializeLegacyBlock = (block = {}, index = 0, total = 1) => {
|
||||
if (typeof block.value === 'string') {
|
||||
return block.value.trim()
|
||||
? { type: block.type || 'paragraph', value: block.value }
|
||||
: null
|
||||
}
|
||||
|
||||
const type = block.type || 'paragraph'
|
||||
const rawText = String(block.text || '')
|
||||
const text = rawText.trim()
|
||||
|
||||
if (type === 'divider') {
|
||||
return { type, value: '---' }
|
||||
}
|
||||
|
||||
if (type === 'image') {
|
||||
const image = serializeImageBlock(block)
|
||||
return image ? { type, value: image } : null
|
||||
}
|
||||
|
||||
if (type === 'gallery') {
|
||||
const images = Array.isArray(block.images)
|
||||
? block.images.map(serializeImageBlock).filter(Boolean)
|
||||
: []
|
||||
|
||||
return images.length
|
||||
? { type, value: [':::gallery', ...images, ':::'].join('\n') }
|
||||
: null
|
||||
}
|
||||
|
||||
if (type === 'callout') {
|
||||
const emoji = block.calloutEmojiEnabled === false
|
||||
? 'none'
|
||||
: (block.calloutEmoji || '💡')
|
||||
const background = block.calloutBackground || 'blue'
|
||||
|
||||
return text
|
||||
? { type, value: `:::callout emoji=${emoji} bg=${background}\n${text}\n:::` }
|
||||
: null
|
||||
}
|
||||
|
||||
if (type === 'toggle') {
|
||||
const title = String(block.title || '').trim()
|
||||
|
||||
return title || text
|
||||
? { type, value: `:::toggle ${title || '더 보기'}\n${text}\n:::` }
|
||||
: null
|
||||
}
|
||||
|
||||
if (type === 'embed') {
|
||||
const url = String(block.url || '').trim()
|
||||
|
||||
return url
|
||||
? { type, value: `:::embed\n${url}\n:::` }
|
||||
: null
|
||||
}
|
||||
|
||||
if (type === 'paragraph' && !text) {
|
||||
return index === total - 1
|
||||
? null
|
||||
: { type, value: BLANK_PARAGRAPH_MARKER }
|
||||
}
|
||||
|
||||
if (!text) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (type === 'heading') {
|
||||
return { type, value: `${'#'.repeat(block.level || 2)} ${text}` }
|
||||
}
|
||||
|
||||
if (type === 'quote') {
|
||||
return { type, value: `> ${text}` }
|
||||
}
|
||||
|
||||
if (type === 'list') {
|
||||
return { type, value: `- ${text}` }
|
||||
}
|
||||
|
||||
if (type === 'code') {
|
||||
return { type, value: `\`\`\`\n${rawText}\n\`\`\`` }
|
||||
}
|
||||
|
||||
return { type, value: text }
|
||||
}
|
||||
|
||||
/**
|
||||
* 레거시 블록 배열을 저장용 마크다운 문자열로 변환한다.
|
||||
* @param {Array<Object>} blocks - 레거시 블록 목록
|
||||
* @returns {string} 마크다운 문자열
|
||||
*/
|
||||
const serializeLegacyBlocks = (blocks) => blocks
|
||||
.map((block, index) => serializeLegacyBlock(block, index, blocks.length))
|
||||
.filter(Boolean)
|
||||
.reduce((markdown, block, index, blocksList) => {
|
||||
if (index === 0) {
|
||||
return block.value
|
||||
}
|
||||
|
||||
const previousBlock = blocksList[index - 1]
|
||||
const joiner = blockSpacingTypes.has(previousBlock.type) && blockSpacingTypes.has(block.type)
|
||||
? '\n'
|
||||
: '\n\n'
|
||||
|
||||
return `${markdown}${joiner}${block.value}`
|
||||
}, '')
|
||||
|
||||
/**
|
||||
* 게시물/페이지 본문 값을 저장 가능한 마크다운 문자열로 정규화한다.
|
||||
* @param {unknown} value - 본문 값
|
||||
* @returns {string} 마크다운 문자열
|
||||
*/
|
||||
export const normalizeMarkdownContent = (value) => {
|
||||
if (typeof value === 'string') {
|
||||
return value
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return serializeLegacyBlocks(value)
|
||||
}
|
||||
|
||||
if (value && typeof value === 'object') {
|
||||
if (typeof value.content === 'string') {
|
||||
return value.content
|
||||
}
|
||||
|
||||
if (Array.isArray(value.blocks)) {
|
||||
return serializeLegacyBlocks(value.blocks)
|
||||
}
|
||||
|
||||
if (typeof value.markdown === 'string') {
|
||||
return value.markdown
|
||||
}
|
||||
|
||||
if (typeof value.type === 'string') {
|
||||
const block = serializeLegacyBlock(value)
|
||||
return block?.value || ''
|
||||
}
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
44
lib/navigation-editor-tree.js
Normal file
44
lib/navigation-editor-tree.js
Normal file
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* 관리자 UI용: 동일 location 항목을 부모 기준 트리 래퍼로 만든다(항목 객체는 원본 참조).
|
||||
* @param {Array<Object>} flat - 전체 항목
|
||||
* @param {'primary'|'footer'} location - 위치
|
||||
* @returns {Array<{ item: Object, children: Array<{ item: Object, children: any[] }> }>}
|
||||
*/
|
||||
export const buildNavigationEditorTree = (flat, location) => {
|
||||
const filtered = flat.filter((i) => i.location === location)
|
||||
const nodeMap = new Map()
|
||||
|
||||
for (const item of filtered) {
|
||||
nodeMap.set(String(item.id).trim(), { item, children: [] })
|
||||
}
|
||||
|
||||
const roots = []
|
||||
|
||||
for (const item of filtered) {
|
||||
const id = String(item.id).trim()
|
||||
const wrap = nodeMap.get(id)
|
||||
const p = item.parentId
|
||||
if (p != null && String(p).trim() !== '') {
|
||||
const pid = String(p).trim()
|
||||
if (nodeMap.has(pid)) {
|
||||
nodeMap.get(pid).children.push(wrap)
|
||||
continue
|
||||
}
|
||||
}
|
||||
roots.push(wrap)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Array<{ item: Object, children: any[] }>} nodes - 노드
|
||||
* @returns {void}
|
||||
*/
|
||||
const sortRec = (nodes) => {
|
||||
nodes.sort((a, b) => (a.item.sortOrder || 0) - (b.item.sortOrder || 0))
|
||||
for (const n of nodes) {
|
||||
sortRec(n.children)
|
||||
}
|
||||
}
|
||||
|
||||
sortRec(roots)
|
||||
return roots
|
||||
}
|
||||
25
modules/nuxt-ssr-paths-write.mjs
Normal file
25
modules/nuxt-ssr-paths-write.mjs
Normal file
@@ -0,0 +1,25 @@
|
||||
import { defineNuxtModule } from '@nuxt/kit'
|
||||
|
||||
/**
|
||||
* Nuxt 3.21 SSR 번들이 `#internal/nuxt/paths`를 외부 모듈로 두는데,
|
||||
* 기본 `paths.mjs` 템플릿은 `write: true`가 아니어서 `.nuxt/paths.mjs`가 디스크에 없고
|
||||
* Node가 `package.json`의 `imports`로 해석할 실제 파일이 없어 오류가 난다.
|
||||
* 동일 템플릿을 빌드 디렉터리에 기록하도록 한다.
|
||||
* @param {unknown} _options - 모듈 옵션(미사용)
|
||||
* @param {import('@nuxt/schema').Nuxt} nuxt - Nuxt 인스턴스
|
||||
* @returns {void}
|
||||
*/
|
||||
export default defineNuxtModule({
|
||||
meta: {
|
||||
name: 'nuxt-ssr-paths-write'
|
||||
},
|
||||
setup(_options, nuxt) {
|
||||
nuxt.hook('app:templates', (app) => {
|
||||
for (const template of app.templates) {
|
||||
if (template.filename === 'paths.mjs') {
|
||||
template.write = true
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -7,7 +7,14 @@ export default defineNuxtConfig({
|
||||
devtools: {
|
||||
enabled: false
|
||||
},
|
||||
modules: ['@nuxtjs/tailwindcss'],
|
||||
modules: ['./modules/nuxt-ssr-paths-write.mjs', '@nuxtjs/tailwindcss'],
|
||||
/**
|
||||
* 기본 cssPath(assets/css/tailwind.css)가 없으면 패키지의 tailwind.css가 먼저 주입되어
|
||||
* main.css와 @tailwind 이중 로딩·스타일 꼬임이 날 수 있어 엔트리를 main.css로 통일한다.
|
||||
*/
|
||||
tailwindcss: {
|
||||
cssPath: '~/assets/css/main.css'
|
||||
},
|
||||
components: [
|
||||
{
|
||||
path: '~/components',
|
||||
@@ -43,8 +50,17 @@ export default defineNuxtConfig({
|
||||
databaseName: process.env.DATABASE_NAME || '',
|
||||
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),
|
||||
avatarMinHeight: Number(process.env.AVATAR_MIN_HEIGHT || 96),
|
||||
avatarMaxWidth: Number(process.env.AVATAR_MAX_WIDTH || 512),
|
||||
avatarMaxHeight: Number(process.env.AVATAR_MAX_HEIGHT || 512),
|
||||
avatarWebpQuality: Number(process.env.AVATAR_WEBP_QUALITY || 82),
|
||||
public: {
|
||||
siteUrl: process.env.NUXT_PUBLIC_SITE_URL || 'https://sori.studio',
|
||||
siteTitle: process.env.NUXT_PUBLIC_SITE_TITLE || 'sori.studio'
|
||||
|
||||
1030
package-lock.json
generated
1030
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
15
package.json
15
package.json
@@ -1,19 +1,30 @@
|
||||
{
|
||||
"name": "sori.studio",
|
||||
"version": "0.0.58",
|
||||
"version": "1.1.9",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"imports": {
|
||||
"#internal/nuxt/paths": "./.nuxt/paths.mjs",
|
||||
"#internal/nitro": {
|
||||
"node": "./scripts/node-paths-nitro-shim.mjs"
|
||||
}
|
||||
},
|
||||
"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"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||
"nuxt": "^3.16.2",
|
||||
"bcrypt": "^6.0.0",
|
||||
"nuxt": "^3.21.2",
|
||||
"postgres": "^3.4.9",
|
||||
"sharp": "^0.34.5",
|
||||
"vue": "^3.5.13",
|
||||
"vue-router": "^4.5.0",
|
||||
"zod": "^3.24.2"
|
||||
|
||||
@@ -11,6 +11,18 @@ const form = reactive({
|
||||
const pending = ref(false)
|
||||
const errorMessage = ref('')
|
||||
|
||||
/**
|
||||
* 로그인 제출 가능 여부(이메일·비밀번호가 모두 채워졌는지)
|
||||
* @returns {boolean} 제출 가능 여부
|
||||
*/
|
||||
const canSubmitAdminLogin = computed(() => Boolean(form.email.trim()) && Boolean(form.password))
|
||||
const { data: bootstrapStatus } = await useFetch('/api/auth/bootstrap-status', {
|
||||
default: () => ({
|
||||
hasUsers: true,
|
||||
needsAdminSetup: false
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* 관리자 로그인 제출
|
||||
* @returns {Promise<void>} 로그인 처리 결과
|
||||
@@ -42,6 +54,16 @@ const submitLogin = async () => {
|
||||
<h1 class="admin-login__title mt-2 text-3xl font-semibold">
|
||||
로그인
|
||||
</h1>
|
||||
<p
|
||||
v-if="bootstrapStatus?.needsAdminSetup"
|
||||
class="mt-3 rounded border border-[#ff4f2e]/30 bg-[#ff4f2e]/10 px-3 py-2 text-xs text-[#b63a23]"
|
||||
>
|
||||
등록된 관리자가 없습니다.
|
||||
<NuxtLink class="font-semibold underline-offset-2 hover:underline" to="/signup">
|
||||
관리자 등록으로 이동
|
||||
</NuxtLink>
|
||||
</p>
|
||||
|
||||
<form class="admin-login__form mt-8 grid gap-4" @submit.prevent="submitLogin">
|
||||
<label class="admin-login__field grid gap-2 text-sm">
|
||||
<span class="admin-login__label font-medium">이메일</span>
|
||||
@@ -67,9 +89,9 @@ const submitLogin = async () => {
|
||||
{{ errorMessage }}
|
||||
</p>
|
||||
<button
|
||||
class="admin-login__button rounded bg-[#15171a] px-4 py-2 text-sm font-semibold text-white disabled:opacity-50"
|
||||
class="admin-login__button rounded bg-[#15171a] px-4 py-2 text-sm font-semibold text-white disabled:cursor-not-allowed disabled:opacity-50"
|
||||
type="submit"
|
||||
:disabled="pending"
|
||||
:disabled="pending || !canSubmitAdminLogin"
|
||||
>
|
||||
{{ pending ? '확인 중' : '로그인' }}
|
||||
</button>
|
||||
|
||||
@@ -3,23 +3,115 @@ definePageMeta({
|
||||
layout: 'admin'
|
||||
})
|
||||
|
||||
/** 서버 `MEDIA_THUMBNAIL_ROOT`와 동일한 썸네일 논리 폴더 라벨 */
|
||||
const MEDIA_THUMBNAIL_ROOT = '썸네일'
|
||||
|
||||
/**
|
||||
* 논리 폴더 경로가 썸네일 전용 루트인지 확인한다.
|
||||
* @param {string} folder - 폴더 경로
|
||||
* @returns {boolean} 썸네일 전용이면 true
|
||||
*/
|
||||
const isThumbnailFolderPath = (folder) => folder === MEDIA_THUMBNAIL_ROOT || String(folder).startsWith(`${MEDIA_THUMBNAIL_ROOT}/`)
|
||||
|
||||
const activeTab = ref('library')
|
||||
const searchText = ref('')
|
||||
const activeFolder = ref('')
|
||||
const newFolderName = ref('')
|
||||
const isCreateFolderModalOpen = ref(false)
|
||||
const createFolderModalName = ref('')
|
||||
const deletingFolder = ref('')
|
||||
const editingUrl = ref('')
|
||||
const editingName = ref('')
|
||||
const editingCategory = ref('')
|
||||
const deletingUrl = ref('')
|
||||
const errorMessage = ref('')
|
||||
const selectedMediaUrl = ref('')
|
||||
const selectedMediaUrls = ref([])
|
||||
const lastSelectedIndex = ref(-1)
|
||||
const draggingUrls = ref([])
|
||||
|
||||
const { toast, showToast } = useAdminToast()
|
||||
|
||||
const { data: mediaItems, refresh } = await useFetch('/admin/api/media', {
|
||||
default: () => []
|
||||
})
|
||||
|
||||
/**
|
||||
* 썸네일 디스크 경로 여부
|
||||
* @param {Object} item - 미디어 항목
|
||||
* @returns {boolean} 회원 아바타 경로이면 true
|
||||
*/
|
||||
const isThumbnailDiskItem = (item) => Boolean(item?.url?.includes('/members/avatars/'))
|
||||
|
||||
/**
|
||||
* 파일명 변경·삭제·드래그 이동이 제한되는지 여부
|
||||
* @param {Object} item - 미디어 항목
|
||||
* @returns {boolean} 잠금이면 true
|
||||
*/
|
||||
const isMediaItemLocked = (item) => Boolean(item?.usage?.length) || Boolean(item?.avatarOwner)
|
||||
|
||||
const libraryMediaItems = computed(() => (mediaItems.value || []).filter((item) => !isThumbnailDiskItem(item)))
|
||||
|
||||
const thumbnailMediaItems = computed(() => (mediaItems.value || []).filter((item) => isThumbnailDiskItem(item)))
|
||||
|
||||
const scopeItems = computed(() => (activeTab.value === 'thumbnails' ? thumbnailMediaItems.value : libraryMediaItems.value))
|
||||
|
||||
/**
|
||||
* 상단 탭 전환 시 목록 상태를 초기화한다.
|
||||
* @param {'library' | 'thumbnails'} tab - 선택 탭
|
||||
* @returns {void}
|
||||
*/
|
||||
const setActiveTab = (tab) => {
|
||||
if (activeTab.value === tab) {
|
||||
return
|
||||
}
|
||||
|
||||
activeTab.value = tab
|
||||
activeFolder.value = ''
|
||||
searchText.value = ''
|
||||
clearMediaSelection()
|
||||
closeMediaDetail()
|
||||
}
|
||||
|
||||
/**
|
||||
* ISO 시각을 짧은 로캘 문자열로 표시한다.
|
||||
* @param {string | null} iso - ISO 시각
|
||||
* @returns {string} 표시 문자열
|
||||
*/
|
||||
const formatDateTime = (iso) => {
|
||||
if (!iso) {
|
||||
return '—'
|
||||
}
|
||||
|
||||
try {
|
||||
return new Date(iso).toLocaleString('ko-KR', {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short'
|
||||
})
|
||||
} catch {
|
||||
return '—'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 상세 모달에서 선택된 미디어를 브라우저로 내려받는다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const downloadSelectedMedia = () => {
|
||||
const item = selectedMedia.value
|
||||
|
||||
if (!item?.url || !import.meta.client) {
|
||||
return
|
||||
}
|
||||
|
||||
const anchor = document.createElement('a')
|
||||
anchor.href = item.url
|
||||
anchor.download = item.name || 'image'
|
||||
anchor.rel = 'noopener noreferrer'
|
||||
anchor.target = '_blank'
|
||||
document.body.appendChild(anchor)
|
||||
anchor.click()
|
||||
document.body.removeChild(anchor)
|
||||
}
|
||||
|
||||
const { data: mediaFolders, refresh: refreshMediaFolders } = await useFetch('/admin/api/media-folders', {
|
||||
default: () => ['미분류']
|
||||
})
|
||||
@@ -37,7 +129,7 @@ const normalizedFolders = computed(() => {
|
||||
}, '')
|
||||
})
|
||||
|
||||
mediaItems.value.forEach((item) => {
|
||||
libraryMediaItems.value.forEach((item) => {
|
||||
String(item.category || '미분류').split('/').filter(Boolean).reduce((parentPath, segment) => {
|
||||
const nextPath = parentPath ? `${parentPath}/${segment}` : segment
|
||||
folderSet.add(nextPath)
|
||||
@@ -49,21 +141,23 @@ const normalizedFolders = computed(() => {
|
||||
})
|
||||
|
||||
const folderMediaCounts = computed(() => normalizedFolders.value.reduce((counts, folder) => {
|
||||
counts[folder] = mediaItems.value.filter((item) => item.category === folder || item.category?.startsWith(`${folder}/`)).length
|
||||
counts[folder] = libraryMediaItems.value.filter((item) => item.category === folder || item.category?.startsWith(`${folder}/`)).length
|
||||
return counts
|
||||
}, {}))
|
||||
|
||||
const filteredMediaItems = computed(() => {
|
||||
const query = searchText.value.trim().toLowerCase()
|
||||
const folder = activeFolder.value
|
||||
const base = scopeItems.value
|
||||
|
||||
return mediaItems.value.filter((item) => {
|
||||
const matchesFolder = !folder || item.category === folder || item.category?.startsWith(`${folder}/`)
|
||||
return base.filter((item) => {
|
||||
const matchesFolder = activeTab.value === 'thumbnails'
|
||||
? true
|
||||
: (!folder || item.category === folder || item.category?.startsWith(`${folder}/`))
|
||||
const usageTitles = item.usage?.map((usage) => usage.title) || []
|
||||
const matchesQuery = !query || [
|
||||
item.name,
|
||||
item.url,
|
||||
item.category,
|
||||
...item.usage.map((usage) => usage.title)
|
||||
...usageTitles
|
||||
].some((value) => String(value || '').toLowerCase().includes(query))
|
||||
|
||||
return matchesFolder && matchesQuery
|
||||
@@ -120,30 +214,26 @@ const selectFolder = (folder) => {
|
||||
}
|
||||
|
||||
/**
|
||||
* 미디어 항목 선택
|
||||
* @param {MouseEvent} event - 클릭 이벤트
|
||||
* 체크박스로 미디어 선택 토글(Shift 시 범위 추가 선택)
|
||||
* @param {Object} item - 미디어 항목
|
||||
* @param {number} index - 필터 목록 내 순서
|
||||
* @param {MouseEvent} event - 클릭 이벤트
|
||||
* @returns {void}
|
||||
*/
|
||||
const selectMediaItem = (event, item, index) => {
|
||||
const toggleMediaSelection = (item, index, event) => {
|
||||
if (event.shiftKey && lastSelectedIndex.value >= 0) {
|
||||
const startIndex = Math.min(lastSelectedIndex.value, index)
|
||||
const endIndex = Math.max(lastSelectedIndex.value, index)
|
||||
const rangeUrls = filteredMediaItems.value.slice(startIndex, endIndex + 1).map((mediaItem) => mediaItem.url)
|
||||
selectedMediaUrls.value = [...new Set([...selectedMediaUrls.value, ...rangeUrls])]
|
||||
return
|
||||
}
|
||||
|
||||
if (event.metaKey || event.ctrlKey) {
|
||||
selectedMediaUrls.value = isMediaSelected(item)
|
||||
? selectedMediaUrls.value.filter((url) => url !== item.url)
|
||||
: [...selectedMediaUrls.value, item.url]
|
||||
const merged = new Set([...selectedMediaUrls.value, ...rangeUrls])
|
||||
selectedMediaUrls.value = [...merged]
|
||||
lastSelectedIndex.value = index
|
||||
return
|
||||
}
|
||||
|
||||
selectedMediaUrls.value = [item.url]
|
||||
selectedMediaUrls.value = isMediaSelected(item)
|
||||
? selectedMediaUrls.value.filter((url) => url !== item.url)
|
||||
: [...selectedMediaUrls.value, item.url]
|
||||
lastSelectedIndex.value = index
|
||||
}
|
||||
|
||||
@@ -166,7 +256,6 @@ const openMediaDetail = (item) => {
|
||||
editingUrl.value = item.url
|
||||
editingName.value = item.title
|
||||
editingCategory.value = item.category
|
||||
errorMessage.value = ''
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -188,18 +277,38 @@ const cancelRename = () => {
|
||||
}
|
||||
|
||||
/**
|
||||
* 미디어 폴더 생성
|
||||
* 폴더 추가 모달을 연다
|
||||
* @returns {void}
|
||||
*/
|
||||
const openCreateFolderModal = () => {
|
||||
createFolderModalName.value = ''
|
||||
isCreateFolderModalOpen.value = true
|
||||
}
|
||||
|
||||
/**
|
||||
* 폴더 추가 모달을 닫는다
|
||||
* @returns {void}
|
||||
*/
|
||||
const closeCreateFolderModal = () => {
|
||||
isCreateFolderModalOpen.value = false
|
||||
createFolderModalName.value = ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 미디어 폴더 생성(모달에서 확인 시)
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const createFolder = async () => {
|
||||
const folderName = newFolderName.value.trim()
|
||||
const submitCreateFolderModal = async () => {
|
||||
if (activeTab.value !== 'library') {
|
||||
return
|
||||
}
|
||||
|
||||
const folderName = createFolderModalName.value.trim()
|
||||
|
||||
if (!folderName) {
|
||||
return
|
||||
}
|
||||
|
||||
errorMessage.value = ''
|
||||
|
||||
try {
|
||||
const folderPath = activeFolder.value ? `${activeFolder.value}/${folderName}` : folderName
|
||||
const createdFolder = await $fetch('/admin/api/media-folders', {
|
||||
@@ -209,11 +318,50 @@ const createFolder = async () => {
|
||||
}
|
||||
})
|
||||
|
||||
newFolderName.value = ''
|
||||
closeCreateFolderModal()
|
||||
activeFolder.value = createdFolder.path
|
||||
await refreshMediaFolders()
|
||||
} catch (error) {
|
||||
errorMessage.value = error?.data?.message || '폴더를 만들지 못했습니다.'
|
||||
showToast('error', error?.data?.message || '폴더를 만들지 못했습니다.')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 미디어 폴더 삭제
|
||||
* @param {string} folder - 삭제할 폴더 경로
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const removeMediaFolder = async (folder) => {
|
||||
if (!folder || folder === '미분류' || folder === MEDIA_THUMBNAIL_ROOT || folder.startsWith(`${MEDIA_THUMBNAIL_ROOT}/`)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!confirm(`"${folder}" 폴더를 삭제할까요? 이 폴더(및 하위 경로)에 속한 미디어는 모두 "미분류"로 옮겨집니다.`)) {
|
||||
return
|
||||
}
|
||||
|
||||
deletingFolder.value = folder
|
||||
|
||||
try {
|
||||
await $fetch('/admin/api/media-folders', {
|
||||
method: 'DELETE',
|
||||
body: {
|
||||
path: folder
|
||||
}
|
||||
})
|
||||
if (activeFolder.value === folder || activeFolder.value.startsWith(`${folder}/`)) {
|
||||
activeFolder.value = ''
|
||||
}
|
||||
selectedMediaUrls.value = []
|
||||
lastSelectedIndex.value = -1
|
||||
await Promise.all([
|
||||
refresh(),
|
||||
refreshMediaFolders()
|
||||
])
|
||||
} catch (error) {
|
||||
showToast('error', error?.data?.message || '폴더를 삭제하지 못했습니다.')
|
||||
} finally {
|
||||
deletingFolder.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
@@ -224,14 +372,16 @@ const createFolder = async () => {
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const moveMediaToFolder = async (folder, urls = selectedMediaUrls.value) => {
|
||||
if (activeTab.value !== 'library') {
|
||||
return
|
||||
}
|
||||
|
||||
const targetUrls = [...new Set(urls.filter(Boolean))]
|
||||
|
||||
if (!targetUrls.length) {
|
||||
return
|
||||
}
|
||||
|
||||
errorMessage.value = ''
|
||||
|
||||
try {
|
||||
await $fetch('/admin/api/media', {
|
||||
method: 'PUT',
|
||||
@@ -247,7 +397,7 @@ const moveMediaToFolder = async (folder, urls = selectedMediaUrls.value) => {
|
||||
activeFolder.value = folder || '미분류'
|
||||
clearMediaSelection()
|
||||
} catch (error) {
|
||||
errorMessage.value = error?.data?.message || '미디어 폴더를 변경하지 못했습니다.'
|
||||
showToast('error', error?.data?.message || '미디어 폴더를 변경하지 못했습니다.')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -258,6 +408,10 @@ const moveMediaToFolder = async (folder, urls = selectedMediaUrls.value) => {
|
||||
* @returns {void}
|
||||
*/
|
||||
const startMediaDrag = (event, item) => {
|
||||
if (activeTab.value !== 'library') {
|
||||
return
|
||||
}
|
||||
|
||||
if (!isMediaSelected(item)) {
|
||||
selectedMediaUrls.value = [item.url]
|
||||
}
|
||||
@@ -276,6 +430,11 @@ const startMediaDrag = (event, item) => {
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const dropMediaOnFolder = async (folder) => {
|
||||
if (activeTab.value !== 'library') {
|
||||
draggingUrls.value = []
|
||||
return
|
||||
}
|
||||
|
||||
const urls = draggingUrls.value.length ? draggingUrls.value : selectedMediaUrls.value
|
||||
draggingUrls.value = []
|
||||
await moveMediaToFolder(folder, urls)
|
||||
@@ -286,7 +445,9 @@ const dropMediaOnFolder = async (folder) => {
|
||||
* @returns {Promise<void>} 저장 결과
|
||||
*/
|
||||
const saveMediaCategory = async () => {
|
||||
errorMessage.value = ''
|
||||
if (selectedMedia.value?.avatarOwner) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await $fetch('/admin/api/media', {
|
||||
@@ -301,7 +462,7 @@ const saveMediaCategory = async () => {
|
||||
refreshMediaFolders()
|
||||
])
|
||||
} catch (error) {
|
||||
errorMessage.value = error?.data?.message || '카테고리를 저장하지 못했습니다.'
|
||||
showToast('error', error?.data?.message || '카테고리를 저장하지 못했습니다.')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -312,13 +473,11 @@ const saveMediaCategory = async () => {
|
||||
const renameMedia = async () => {
|
||||
const editingItem = mediaItems.value.find((item) => item.url === editingUrl.value)
|
||||
|
||||
if (editingItem?.usage.length) {
|
||||
errorMessage.value = '사용 중인 미디어는 파일명을 변경할 수 없습니다.'
|
||||
if (editingItem && isMediaItemLocked(editingItem)) {
|
||||
showToast('error', '게시물·페이지에서 쓰이거나, 회원 프로필에 연결된 썸네일은 파일명을 바꿀 수 없습니다.')
|
||||
return
|
||||
}
|
||||
|
||||
errorMessage.value = ''
|
||||
|
||||
try {
|
||||
const renamedItem = await $fetch('/admin/api/media', {
|
||||
method: 'PUT',
|
||||
@@ -331,7 +490,7 @@ const renameMedia = async () => {
|
||||
await refresh()
|
||||
selectedMediaUrl.value = renamedItem.url
|
||||
} catch (error) {
|
||||
errorMessage.value = error?.data?.message || '파일명을 변경하지 못했습니다.'
|
||||
showToast('error', error?.data?.message || '파일명을 변경하지 못했습니다.')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -341,8 +500,8 @@ const renameMedia = async () => {
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const deleteMedia = async (item) => {
|
||||
if (item.usage.length) {
|
||||
errorMessage.value = '사용 중인 미디어는 삭제할 수 없습니다.'
|
||||
if (isMediaItemLocked(item)) {
|
||||
showToast('error', '게시물·페이지에서 쓰이거나, 회원 프로필에 연결된 썸네일은 삭제할 수 없습니다.')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -351,7 +510,6 @@ const deleteMedia = async (item) => {
|
||||
}
|
||||
|
||||
deletingUrl.value = item.url
|
||||
errorMessage.value = ''
|
||||
|
||||
try {
|
||||
await $fetch('/admin/api/media', {
|
||||
@@ -363,7 +521,7 @@ const deleteMedia = async (item) => {
|
||||
closeMediaDetail()
|
||||
await refresh()
|
||||
} catch (error) {
|
||||
errorMessage.value = error?.data?.message || '파일을 삭제하지 못했습니다.'
|
||||
showToast('error', error?.data?.message || '파일을 삭제하지 못했습니다.')
|
||||
} finally {
|
||||
deletingUrl.value = ''
|
||||
}
|
||||
@@ -372,29 +530,48 @@ const deleteMedia = async (item) => {
|
||||
|
||||
<template>
|
||||
<section class="admin-media bg-paper p-6">
|
||||
<div class="admin-media__header flex flex-wrap items-center justify-between gap-4">
|
||||
<div>
|
||||
<p class="admin-media__eyebrow text-xs font-semibold uppercase text-muted">
|
||||
Media
|
||||
</p>
|
||||
<h1 class="admin-media__title mt-2 text-3xl font-semibold">
|
||||
미디어
|
||||
</h1>
|
||||
<div class="admin-media__header flex flex-col gap-4">
|
||||
<div class="flex flex-wrap items-center justify-between gap-4">
|
||||
<div>
|
||||
<p class="admin-media__eyebrow text-xs font-semibold uppercase text-muted">
|
||||
Media
|
||||
</p>
|
||||
<h1 class="admin-media__title mt-2 text-3xl font-semibold">
|
||||
미디어
|
||||
</h1>
|
||||
<div class="admin-media__tabs mt-3 inline-flex rounded-lg border border-line bg-surface p-0.5">
|
||||
<button
|
||||
class="admin-media__tab rounded-md px-3 py-1.5 text-xs font-semibold transition"
|
||||
:class="activeTab === 'library' ? 'bg-[#15171a] text-white shadow-sm' : 'text-muted hover:text-ink'"
|
||||
type="button"
|
||||
@click="setActiveTab('library')"
|
||||
>
|
||||
미디어 라이브러리
|
||||
</button>
|
||||
<button
|
||||
class="admin-media__tab rounded-md px-3 py-1.5 text-xs font-semibold transition"
|
||||
:class="activeTab === 'thumbnails' ? 'bg-[#15171a] text-white shadow-sm' : 'text-muted hover:text-ink'"
|
||||
type="button"
|
||||
@click="setActiveTab('thumbnails')"
|
||||
>
|
||||
썸네일
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
v-model="searchText"
|
||||
class="admin-media__search w-full rounded border border-line bg-white px-3 py-2 text-sm md:w-72"
|
||||
type="search"
|
||||
:placeholder="activeTab === 'thumbnails' ? '파일명, 게시물 제목(사용처) 검색' : '파일명, 게시물 제목(사용처) 검색'"
|
||||
>
|
||||
</div>
|
||||
<input
|
||||
v-model="searchText"
|
||||
class="admin-media__search w-full rounded border border-line bg-white px-3 py-2 text-sm md:w-72"
|
||||
type="search"
|
||||
placeholder="파일명, 경로, 폴더, 사용처 검색"
|
||||
>
|
||||
</div>
|
||||
|
||||
<p v-if="errorMessage" class="admin-media__error mt-6 rounded border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||
{{ errorMessage }}
|
||||
</p>
|
||||
|
||||
<div class="admin-media__layout mt-8 grid gap-5 lg:grid-cols-[16rem_minmax(0,1fr)]">
|
||||
<aside class="admin-media__folders rounded border border-line bg-white p-3">
|
||||
<aside
|
||||
v-if="activeTab === 'library'"
|
||||
class="admin-media__folders rounded border border-line bg-white p-3"
|
||||
>
|
||||
<button
|
||||
class="admin-media__folder-button flex w-full items-center justify-between rounded px-3 py-2 text-left text-sm font-semibold hover:bg-surface"
|
||||
:class="!activeFolder ? 'bg-[#15171a] text-white hover:bg-[#15171a]' : 'text-ink'"
|
||||
@@ -404,48 +581,75 @@ const deleteMedia = async (item) => {
|
||||
@drop.prevent="dropMediaOnFolder('미분류')"
|
||||
>
|
||||
<span>전체 미디어</span>
|
||||
<span>{{ mediaItems.length }}</span>
|
||||
<span>{{ libraryMediaItems.length }}</span>
|
||||
</button>
|
||||
|
||||
<div class="admin-media__folder-list mt-3 grid gap-1">
|
||||
<button
|
||||
<div
|
||||
v-for="folder in normalizedFolders"
|
||||
:key="folder"
|
||||
class="admin-media__folder-button flex w-full items-center justify-between rounded py-2 pr-3 text-left text-sm hover:bg-surface"
|
||||
:class="activeFolder === folder ? 'bg-[#15171a] text-white hover:bg-[#15171a]' : 'text-ink'"
|
||||
:style="{ paddingLeft: `${12 + getFolderDepth(folder) * 14}px` }"
|
||||
type="button"
|
||||
@click="selectFolder(folder)"
|
||||
@dragover.prevent
|
||||
@drop.prevent="dropMediaOnFolder(folder)"
|
||||
class="admin-media__folder-row flex items-stretch rounded"
|
||||
:class="activeFolder === folder ? 'bg-[#15171a] text-white' : 'text-ink hover:bg-surface'"
|
||||
>
|
||||
<span class="admin-media__folder-name min-w-0 truncate">{{ getFolderName(folder) }}</span>
|
||||
<span class="admin-media__folder-count shrink-0 text-xs opacity-70">{{ folderMediaCounts[folder] || 0 }}</span>
|
||||
</button>
|
||||
<button
|
||||
class="admin-media__folder-button flex min-w-0 flex-1 items-center justify-between rounded py-2 pr-3 text-left text-sm"
|
||||
:style="{ paddingLeft: `${12 + getFolderDepth(folder) * 14}px` }"
|
||||
type="button"
|
||||
@click="selectFolder(folder)"
|
||||
@dragover.prevent
|
||||
@drop.prevent="dropMediaOnFolder(folder)"
|
||||
>
|
||||
<span class="admin-media__folder-name min-w-0 truncate">{{ getFolderName(folder) }}</span>
|
||||
<span class="admin-media__folder-count shrink-0 text-xs opacity-70">{{ folderMediaCounts[folder] || 0 }}</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="folder !== '미분류' && !isThumbnailFolderPath(folder)"
|
||||
class="admin-media__folder-delete mr-1 inline-flex size-8 shrink-0 items-center justify-center rounded text-current opacity-40 transition hover:opacity-100 hover:text-red-300 disabled:opacity-25"
|
||||
type="button"
|
||||
:disabled="deletingFolder === folder"
|
||||
:aria-label="`${folder} 폴더 삭제`"
|
||||
@click.stop="removeMediaFolder(folder)"
|
||||
>
|
||||
<svg class="size-4 shrink-0" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||
<path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form class="admin-media__folder-create mt-4 grid gap-2 border-t border-line pt-4" @submit.prevent="createFolder">
|
||||
<label class="admin-media__folder-label text-xs font-semibold text-muted" for="media-folder-name">
|
||||
새 폴더
|
||||
</label>
|
||||
<input
|
||||
id="media-folder-name"
|
||||
v-model="newFolderName"
|
||||
class="admin-media__folder-input rounded border border-line px-3 py-2 text-sm"
|
||||
type="text"
|
||||
placeholder="폴더 이름"
|
||||
<div v-if="activeTab === 'library'" class="admin-media__folder-actions mt-4 border-t border-line pt-4">
|
||||
<button
|
||||
class="admin-media__folder-add w-full rounded bg-[#15171a] px-3 py-2 text-xs font-semibold text-white"
|
||||
type="button"
|
||||
@click="openCreateFolderModal"
|
||||
>
|
||||
<button class="admin-media__folder-submit rounded bg-[#15171a] px-3 py-2 text-xs font-semibold text-white" type="submit">
|
||||
폴더 추가
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<aside
|
||||
v-else
|
||||
class="admin-media__folders admin-media__folders--thumbnails rounded border border-line bg-white p-3"
|
||||
>
|
||||
<button
|
||||
class="admin-media__folder-button flex w-full items-center justify-between rounded px-3 py-2 text-left text-sm font-semibold bg-[#15171a] text-white hover:bg-[#15171a]"
|
||||
type="button"
|
||||
@click="selectFolder('')"
|
||||
>
|
||||
<span>전체 썸네일</span>
|
||||
<span>{{ thumbnailMediaItems.length }}</span>
|
||||
</button>
|
||||
<p class="admin-media__thumb-hint mt-4 text-xs leading-relaxed text-muted">
|
||||
회원 프로필에서 쓰는 이미지는 연결 회원이 있을 때만 삭제·이름 변경이 막힙니다. 프로필에서 바꾸거나 해제해도 <strong class="font-semibold text-ink">디스크 파일은 삭제되지 않으며</strong> 이 목록에 남습니다. 목록이 바로 안 바뀌면 페이지를 새로고침하세요. 관리자는 필요 시 삭제·다운로드로 정리할 수 있습니다.
|
||||
</p>
|
||||
</aside>
|
||||
|
||||
<div class="admin-media__content min-w-0">
|
||||
<div class="admin-media__toolbar flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 class="admin-media__folder-title text-lg font-semibold text-ink">
|
||||
{{ activeFolder || '전체 미디어' }}
|
||||
{{ activeTab === 'thumbnails' ? '썸네일' : (activeFolder || '전체 미디어') }}
|
||||
</h2>
|
||||
<p class="admin-media__folder-summary mt-1 text-xs text-muted">
|
||||
{{ filteredMediaItems.length }}개 표시
|
||||
@@ -456,46 +660,65 @@ const deleteMedia = async (item) => {
|
||||
<button class="admin-media__selection-clear font-semibold text-muted hover:text-ink" type="button" @click="clearMediaSelection">
|
||||
선택 해제
|
||||
</button>
|
||||
<button
|
||||
v-if="activeFolder"
|
||||
class="admin-media__selection-move rounded bg-[#15171a] px-2.5 py-1 font-semibold text-white"
|
||||
type="button"
|
||||
@click="moveMediaToFolder(activeFolder)"
|
||||
>
|
||||
현재 폴더로 이동
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="filteredMediaItems.length" class="admin-media__grid mt-5 grid grid-cols-3 gap-2 sm:grid-cols-4 md:grid-cols-6 xl:grid-cols-8 2xl:grid-cols-10">
|
||||
<button
|
||||
<div
|
||||
v-for="(item, index) in filteredMediaItems"
|
||||
:key="item.url"
|
||||
class="admin-media__item group relative overflow-hidden border border-line bg-white text-left outline-none transition"
|
||||
:class="isMediaSelected(item) ? 'ring-2 ring-[#15171a]' : 'hover:border-[#15171a]'"
|
||||
type="button"
|
||||
draggable="true"
|
||||
@click="selectMediaItem($event, item, index)"
|
||||
@dblclick="openMediaDetail(item)"
|
||||
class="admin-media__item group relative overflow-hidden border border-line bg-white text-left outline-none transition hover:border-[#15171a]"
|
||||
:draggable="activeTab === 'library'"
|
||||
@dragstart="startMediaDrag($event, item)"
|
||||
>
|
||||
<img class="admin-media__image aspect-square w-full bg-surface object-cover" :src="item.url" :alt="item.title">
|
||||
<span
|
||||
v-if="item.usage.length"
|
||||
class="admin-media__usage-badge absolute right-1.5 top-1.5 rounded bg-[#15171a] px-1.5 py-0.5 text-[10px] font-semibold text-white"
|
||||
<button
|
||||
type="button"
|
||||
class="admin-media__select-toggle absolute left-1.5 top-1.5 z-10 grid size-7 place-items-center rounded-md border-2 shadow-md outline-none transition focus-visible:ring-2 focus-visible:ring-[#15171a] focus-visible:ring-offset-1"
|
||||
:class="isMediaSelected(item)
|
||||
? 'border-white bg-[#15171a] text-white'
|
||||
: 'border-[#394047] bg-white/95 text-[#15171a] hover:border-[#15171a] hover:bg-white'"
|
||||
:aria-label="`${item.name} 선택`"
|
||||
:aria-pressed="isMediaSelected(item)"
|
||||
@click.stop="toggleMediaSelection(item, index, $event)"
|
||||
>
|
||||
{{ item.usage.length }}
|
||||
</span>
|
||||
<span
|
||||
v-if="isMediaSelected(item)"
|
||||
class="admin-media__selected-badge absolute left-1.5 top-1.5 grid h-5 w-5 place-items-center rounded-full bg-white text-[11px] font-bold text-ink shadow"
|
||||
<svg
|
||||
v-if="isMediaSelected(item)"
|
||||
class="size-4 shrink-0"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M20 6 9 17l-5-5" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
class="admin-media__thumb relative flex w-full flex-col text-left outline-none"
|
||||
type="button"
|
||||
@click="openMediaDetail(item)"
|
||||
>
|
||||
✓
|
||||
</span>
|
||||
<span class="admin-media__name block truncate px-2 py-1.5 text-xs font-semibold text-ink">
|
||||
{{ item.name }}
|
||||
</span>
|
||||
</button>
|
||||
<img class="admin-media__image aspect-square w-full bg-surface object-cover" :src="item.url" :alt="item.title">
|
||||
<span
|
||||
v-if="item.avatarOwner"
|
||||
class="admin-media__usage-badge pointer-events-none absolute right-1.5 top-1.5 rounded bg-emerald-800 px-1.5 py-0.5 text-[10px] font-semibold text-white"
|
||||
>
|
||||
회원
|
||||
</span>
|
||||
<span
|
||||
v-else-if="item.usage.length"
|
||||
class="admin-media__usage-badge pointer-events-none absolute right-1.5 top-1.5 rounded bg-[#15171a] px-1.5 py-0.5 text-[10px] font-semibold text-white"
|
||||
>
|
||||
{{ item.usage.length }}
|
||||
</span>
|
||||
<span class="admin-media__name block truncate px-2 py-1.5 text-xs font-semibold text-ink">
|
||||
{{ item.name }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-else class="admin-media__empty mt-5 border border-dashed border-line bg-white p-8 text-center text-sm text-muted">
|
||||
@@ -504,6 +727,47 @@ const deleteMedia = async (item) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="isCreateFolderModalOpen"
|
||||
class="admin-media__folder-create-modal fixed inset-0 z-[60] grid place-items-center bg-black/40 px-5 py-8"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="media-folder-modal-title"
|
||||
@click.self="closeCreateFolderModal"
|
||||
>
|
||||
<section class="admin-media__folder-create-panel w-full max-w-sm rounded border border-line bg-white p-5 shadow-xl">
|
||||
<h3 id="media-folder-modal-title" class="text-lg font-semibold text-ink">
|
||||
새 폴더
|
||||
</h3>
|
||||
<p class="mt-1 text-xs text-muted">
|
||||
{{ activeFolder ? `${activeFolder} 아래에 하위 폴더를 만듭니다.` : '최상위 폴더를 만듭니다.' }}
|
||||
</p>
|
||||
<label class="mt-4 grid gap-1.5 text-sm">
|
||||
<span class="text-xs font-semibold text-muted">폴더 이름</span>
|
||||
<input
|
||||
v-model="createFolderModalName"
|
||||
class="rounded border border-line px-3 py-2 text-sm outline-none focus:border-[#8e9cac]"
|
||||
type="text"
|
||||
placeholder="폴더 이름"
|
||||
@keydown.enter.prevent="submitCreateFolderModal"
|
||||
>
|
||||
</label>
|
||||
<div class="mt-5 flex justify-end gap-2">
|
||||
<button class="rounded border border-line px-3 py-2 text-xs font-semibold text-ink" type="button" @click="closeCreateFolderModal">
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
class="rounded bg-[#15171a] px-3 py-2 text-xs font-semibold text-white disabled:opacity-50"
|
||||
type="button"
|
||||
:disabled="!createFolderModalName.trim()"
|
||||
@click="submitCreateFolderModal"
|
||||
>
|
||||
만들기
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="selectedMedia"
|
||||
class="admin-media__modal fixed inset-0 z-50 grid place-items-center bg-black/40 px-5 py-8"
|
||||
@@ -517,8 +781,8 @@ const deleteMedia = async (item) => {
|
||||
</div>
|
||||
|
||||
<aside class="admin-media__detail grid max-h-[86vh] content-start gap-5 overflow-y-auto border-l border-line p-5">
|
||||
<div class="admin-media__detail-header flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div class="admin-media__detail-header flex flex-wrap items-start justify-between gap-3">
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="admin-media__detail-eyebrow text-xs font-semibold uppercase text-muted">
|
||||
Attachment
|
||||
</p>
|
||||
@@ -526,9 +790,18 @@ const deleteMedia = async (item) => {
|
||||
{{ selectedMedia.name }}
|
||||
</h2>
|
||||
</div>
|
||||
<button class="admin-media__detail-close rounded border border-line px-3 py-1.5 text-sm font-semibold" type="button" @click="closeMediaDetail">
|
||||
닫기
|
||||
</button>
|
||||
<div class="admin-media__detail-header-actions flex shrink-0 flex-wrap items-center gap-2">
|
||||
<button
|
||||
class="admin-media__download rounded border border-line px-3 py-1.5 text-sm font-semibold text-ink hover:bg-surface"
|
||||
type="button"
|
||||
@click="downloadSelectedMedia"
|
||||
>
|
||||
다운로드
|
||||
</button>
|
||||
<button class="admin-media__detail-close rounded border border-line px-3 py-1.5 text-sm font-semibold" type="button" @click="closeMediaDetail">
|
||||
닫기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<dl class="admin-media__info grid gap-3 text-sm">
|
||||
@@ -540,10 +813,6 @@ const deleteMedia = async (item) => {
|
||||
<dt class="admin-media__info-label text-xs font-semibold text-muted">용량</dt>
|
||||
<dd class="admin-media__info-value mt-1">{{ formatFileSize(selectedMedia.size) }}</dd>
|
||||
</div>
|
||||
<div class="admin-media__info-row">
|
||||
<dt class="admin-media__info-label text-xs font-semibold text-muted">폴더</dt>
|
||||
<dd class="admin-media__info-value mt-1 break-all">{{ selectedMedia.category }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<div class="admin-media__category grid gap-2">
|
||||
@@ -554,22 +823,56 @@ const deleteMedia = async (item) => {
|
||||
<input
|
||||
id="media-category"
|
||||
v-model="editingCategory"
|
||||
class="admin-media__category-input min-w-0 flex-1 rounded border border-line px-3 py-2 text-sm"
|
||||
class="admin-media__category-input min-w-0 flex-1 rounded border border-line px-3 py-2 text-sm disabled:cursor-not-allowed disabled:bg-surface disabled:opacity-70"
|
||||
type="text"
|
||||
list="media-folder-options"
|
||||
placeholder="미분류"
|
||||
:disabled="Boolean(selectedMedia.avatarOwner)"
|
||||
@keydown.enter.prevent="saveMediaCategory"
|
||||
>
|
||||
<button class="admin-media__category-save rounded border border-line px-3 py-2 text-xs font-semibold" type="button" @click="saveMediaCategory">
|
||||
<button
|
||||
class="admin-media__category-save rounded border border-line px-3 py-2 text-xs font-semibold disabled:cursor-not-allowed disabled:opacity-50"
|
||||
type="button"
|
||||
:disabled="Boolean(selectedMedia.avatarOwner)"
|
||||
@click="saveMediaCategory"
|
||||
>
|
||||
저장
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="selectedMedia.avatarOwner" class="admin-media__category-hint text-xs text-muted">
|
||||
프로필 썸네일의 논리 폴더는 「{{ MEDIA_THUMBNAIL_ROOT }}」로 고정됩니다.
|
||||
</p>
|
||||
<datalist id="media-folder-options">
|
||||
<option v-for="folder in normalizedFolders" :key="folder" :value="folder" />
|
||||
</datalist>
|
||||
</div>
|
||||
|
||||
<div class="admin-media__usage rounded bg-surface p-3 text-xs">
|
||||
<div
|
||||
v-if="selectedMedia.avatarOwner"
|
||||
class="admin-media__avatar-owner rounded border border-line bg-surface p-3 text-xs"
|
||||
>
|
||||
<strong class="admin-media__avatar-owner-title text-ink">연결된 회원</strong>
|
||||
<dl class="admin-media__avatar-owner-fields mt-2 grid gap-2 text-muted">
|
||||
<div class="admin-media__avatar-owner-row">
|
||||
<dt class="text-[11px] font-semibold uppercase tracking-wide text-muted">닉네임</dt>
|
||||
<dd class="mt-0.5 font-semibold text-ink">{{ selectedMedia.avatarOwner.username }}</dd>
|
||||
</div>
|
||||
<div class="admin-media__avatar-owner-row">
|
||||
<dt class="text-[11px] font-semibold uppercase tracking-wide text-muted">이메일</dt>
|
||||
<dd class="mt-0.5 break-all text-ink">{{ selectedMedia.avatarOwner.email }}</dd>
|
||||
</div>
|
||||
<div class="admin-media__avatar-owner-row">
|
||||
<dt class="text-[11px] font-semibold uppercase tracking-wide text-muted">마지막 접속</dt>
|
||||
<dd class="mt-0.5 text-ink">{{ formatDateTime(selectedMedia.avatarOwner.lastSeenAt) }}</dd>
|
||||
</div>
|
||||
<div v-if="selectedMedia.avatarOwner.lastSeenIp" class="admin-media__avatar-owner-row">
|
||||
<dt class="text-[11px] font-semibold uppercase tracking-wide text-muted">마지막 IP</dt>
|
||||
<dd class="mt-0.5 break-all font-mono text-[11px] text-ink">{{ selectedMedia.avatarOwner.lastSeenIp }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div v-if="!selectedMedia.avatarOwner" class="admin-media__usage rounded bg-surface p-3 text-xs">
|
||||
<strong class="admin-media__usage-title text-ink">
|
||||
사용 현황 {{ selectedMedia.usage.length }}곳
|
||||
</strong>
|
||||
@@ -600,12 +903,12 @@ const deleteMedia = async (item) => {
|
||||
v-model="editingName"
|
||||
class="admin-media__rename-input rounded border border-line px-3 py-2 text-sm disabled:opacity-50"
|
||||
type="text"
|
||||
:disabled="selectedMedia.usage.length > 0"
|
||||
:disabled="isMediaItemLocked(selectedMedia)"
|
||||
:placeholder="selectedMedia.title"
|
||||
@keydown.enter.prevent="renameMedia"
|
||||
>
|
||||
<p v-if="selectedMedia.usage.length" class="admin-media__locked text-xs text-muted">
|
||||
사용 중인 미디어는 파일명 변경과 삭제가 잠겨 있습니다.
|
||||
<p v-if="isMediaItemLocked(selectedMedia)" class="admin-media__locked text-xs text-muted">
|
||||
게시물·페이지에서 사용 중이거나, 회원 프로필에 연결된 썸네일은 파일명 변경과 삭제가 잠깁니다.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -613,7 +916,7 @@ const deleteMedia = async (item) => {
|
||||
<button
|
||||
class="admin-media__rename-save rounded bg-[#15171a] px-3 py-1.5 text-xs font-semibold text-white disabled:opacity-50"
|
||||
type="button"
|
||||
:disabled="selectedMedia.usage.length > 0 || !editingName"
|
||||
:disabled="isMediaItemLocked(selectedMedia) || !editingName"
|
||||
@click="renameMedia"
|
||||
>
|
||||
파일명 저장
|
||||
@@ -621,7 +924,7 @@ const deleteMedia = async (item) => {
|
||||
<button
|
||||
class="admin-media__delete rounded border border-red-200 px-3 py-1.5 text-xs font-semibold text-red-700 disabled:opacity-50"
|
||||
type="button"
|
||||
:disabled="deletingUrl === selectedMedia.url || selectedMedia.usage.length > 0"
|
||||
:disabled="deletingUrl === selectedMedia.url || isMediaItemLocked(selectedMedia)"
|
||||
@click="deleteMedia(selectedMedia)"
|
||||
>
|
||||
{{ deletingUrl === selectedMedia.url ? '삭제 중' : '삭제' }}
|
||||
@@ -630,5 +933,18 @@ const deleteMedia = async (item) => {
|
||||
</aside>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="toast"
|
||||
class="admin-media__toast fixed right-5 top-5 z-[100] max-w-[min(24rem,calc(100vw-2.5rem))] rounded border px-4 py-3 text-sm font-semibold shadow-lg"
|
||||
:class="{
|
||||
'border-green-200 bg-green-50 text-green-800': toast.type === 'success',
|
||||
'border-red-200 bg-red-50 text-red-800': toast.type === 'error',
|
||||
'border-line bg-white text-ink': toast.type === 'info'
|
||||
}"
|
||||
role="status"
|
||||
>
|
||||
{{ toast.message }}
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
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>
|
||||
577
pages/admin/members/index.vue
Normal file
577
pages/admin/members/index.vue
Normal file
@@ -0,0 +1,577 @@
|
||||
<script setup>
|
||||
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 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} field - 필터 필드
|
||||
* @returns {'text'|'status'|'date'} 필드 타입
|
||||
*/
|
||||
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 '-'
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
<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>
|
||||
@@ -1,56 +1,324 @@
|
||||
<script setup>
|
||||
import { buildNavigationEditorTree } from '~/lib/navigation-editor-tree.js'
|
||||
|
||||
definePageMeta({
|
||||
layout: 'admin'
|
||||
})
|
||||
|
||||
const saving = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const toast = ref(null)
|
||||
let toastTimer = null
|
||||
const activeTab = ref('primary')
|
||||
const { toast, showToast, clearToast } = useAdminToast()
|
||||
|
||||
const { data: navigationItems } = await useFetch('/admin/api/navigation', {
|
||||
default: () => []
|
||||
})
|
||||
|
||||
const items = ref(navigationItems.value.map((item) => ({ ...item })))
|
||||
const items = ref(navigationItems.value.map((item) => ({
|
||||
...item,
|
||||
parentId: item.parentId ?? null,
|
||||
isFolder: Boolean(item.isFolder),
|
||||
isVisible: true
|
||||
})))
|
||||
|
||||
const navDraggingId = ref('')
|
||||
const navDragParentKey = ref('')
|
||||
const navDragOverId = ref('')
|
||||
|
||||
const footerDraggingId = ref('')
|
||||
const footerDragOverId = ref('')
|
||||
|
||||
/**
|
||||
* 저장 상태 토스트 표시
|
||||
* @param {'success'|'error'|'info'} type - 토스트 타입
|
||||
* @param {string} message - 표시 메시지
|
||||
* 네비게이션 항목을 저장 API와 동일한 형태로 직렬화한다.
|
||||
* @param {Array<Object>} list - 항목 목록
|
||||
* @returns {string} 비교용 JSON 문자열
|
||||
*/
|
||||
const serializeNavigationItems = (list) => JSON.stringify(
|
||||
[...list]
|
||||
.sort((a, b) => {
|
||||
if (a.location !== b.location) {
|
||||
return String(a.location).localeCompare(String(b.location))
|
||||
}
|
||||
return (a.sortOrder || 0) - (b.sortOrder || 0)
|
||||
})
|
||||
.map((item) => ({
|
||||
id: String(item.id || '').trim(),
|
||||
label: String(item.label || '').trim(),
|
||||
url: String(item.url || '').trim(),
|
||||
location: item.location,
|
||||
sortOrder: Number(item.sortOrder || 0),
|
||||
parentId: item.parentId ? String(item.parentId).trim() : null
|
||||
}))
|
||||
)
|
||||
|
||||
/** 서버에서 불러온 네비게이션 직렬화 스냅샷 */
|
||||
const navigationBaseline = ref(serializeNavigationItems(items.value))
|
||||
|
||||
/**
|
||||
* 현재 편집본이 서버 스냅샷과 다른지 여부
|
||||
* @returns {boolean} 변경 여부
|
||||
*/
|
||||
const isNavigationDirty = computed(() => serializeNavigationItems(items.value) !== navigationBaseline.value)
|
||||
|
||||
const primaryTree = computed(() => buildNavigationEditorTree(items.value, 'primary'))
|
||||
|
||||
const footerItemsSorted = computed(() =>
|
||||
items.value
|
||||
.filter((item) => item.location === 'footer' && !item.parentId)
|
||||
.sort((a, b) => (a.sortOrder || 0) - (b.sortOrder || 0))
|
||||
)
|
||||
|
||||
/**
|
||||
* 하위 항목 id를 모두 모은 뒤 삭제한다.
|
||||
* @param {string} rootId - 루트 id
|
||||
* @returns {void}
|
||||
*/
|
||||
const showToast = (type, message) => {
|
||||
window.clearTimeout(toastTimer)
|
||||
toast.value = { type, message }
|
||||
toastTimer = window.setTimeout(() => {
|
||||
toast.value = null
|
||||
}, 3200)
|
||||
const removeItemCascade = (rootId) => {
|
||||
const toRemove = new Set([String(rootId)])
|
||||
let growing = true
|
||||
while (growing) {
|
||||
growing = false
|
||||
for (const it of items.value) {
|
||||
const pid = it.parentId ? String(it.parentId) : ''
|
||||
if (pid && toRemove.has(pid) && !toRemove.has(String(it.id))) {
|
||||
toRemove.add(String(it.id))
|
||||
growing = true
|
||||
}
|
||||
}
|
||||
}
|
||||
items.value = items.value.filter((i) => !toRemove.has(String(i.id)))
|
||||
}
|
||||
|
||||
/**
|
||||
* 새 네비게이션 항목 추가
|
||||
* @param {'primary'|'footer'} location - 표시 위치
|
||||
* 상단 메뉴 동일 부모 형제 사이에서 순서만 바꾼다.
|
||||
* @param {string|null} parentId - 부모 id
|
||||
* @param {string} sourceId - 이동할 id
|
||||
* @param {string} targetId - 놓인 위치 기준 id
|
||||
* @returns {void}
|
||||
*/
|
||||
const addNavigationItem = (location = 'primary') => {
|
||||
items.value.push({
|
||||
id: `new-${Date.now()}`,
|
||||
label: '',
|
||||
url: '/',
|
||||
location,
|
||||
sortOrder: items.value.length * 10 + 10,
|
||||
isVisible: true
|
||||
const reorderPrimarySiblings = (parentId, sourceId, targetId) => {
|
||||
const pid = parentId || null
|
||||
const siblings = items.value
|
||||
.filter((i) => i.location === 'primary' && (pid ? i.parentId === pid : !i.parentId))
|
||||
.sort((a, b) => (a.sortOrder || 0) - (b.sortOrder || 0))
|
||||
const ids = siblings.map((s) => String(s.id))
|
||||
const si = ids.indexOf(String(sourceId))
|
||||
const ti = ids.indexOf(String(targetId))
|
||||
if (si < 0 || ti < 0 || si === ti) {
|
||||
return
|
||||
}
|
||||
const nextOrder = [...siblings]
|
||||
const [mv] = nextOrder.splice(si, 1)
|
||||
nextOrder.splice(ti, 0, mv)
|
||||
nextOrder.forEach((row, idx) => {
|
||||
row.sortOrder = (idx + 1) * 10
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 네비게이션 항목 삭제
|
||||
* @param {number} index - 항목 인덱스
|
||||
* 하단 메뉴 순서 변경
|
||||
* @param {string} sourceId - 이동할 id
|
||||
* @param {string} targetId - 기준 id
|
||||
* @returns {void}
|
||||
*/
|
||||
const removeNavigationItem = (index) => {
|
||||
items.value.splice(index, 1)
|
||||
const reorderFooter = (sourceId, targetId) => {
|
||||
const list = footerItemsSorted.value.map((i) => i)
|
||||
const ids = list.map((s) => String(s.id))
|
||||
const si = ids.indexOf(String(sourceId))
|
||||
const ti = ids.indexOf(String(targetId))
|
||||
if (si < 0 || ti < 0 || si === ti) {
|
||||
return
|
||||
}
|
||||
const [mv] = list.splice(si, 1)
|
||||
list.splice(ti, 0, mv)
|
||||
list.forEach((row, idx) => {
|
||||
row.sortOrder = (idx + 1) * 10
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 상단 트리 드래그 시작
|
||||
* @param {{ parentKey: string, itemId: string }} payload - 부모 키와 항목 id
|
||||
* @returns {void}
|
||||
*/
|
||||
const onPrimaryDragStart = ({ parentKey, itemId }) => {
|
||||
navDragParentKey.value = parentKey
|
||||
navDraggingId.value = itemId
|
||||
}
|
||||
|
||||
/**
|
||||
* 상단 트리 드래그 오버
|
||||
* @param {string} itemId - 항목 id
|
||||
* @returns {void}
|
||||
*/
|
||||
const onPrimaryDragOver = (itemId) => {
|
||||
navDragOverId.value = itemId
|
||||
}
|
||||
|
||||
/**
|
||||
* 상단 트리 드래그 종료
|
||||
* @returns {void}
|
||||
*/
|
||||
const onPrimaryDragEnd = () => {
|
||||
navDraggingId.value = ''
|
||||
navDragParentKey.value = ''
|
||||
navDragOverId.value = ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 상단 트리 드롭
|
||||
* @param {{ parentKey: string, targetId: string }} payload - 부모 키와 대상 id
|
||||
* @returns {void}
|
||||
*/
|
||||
const onPrimaryDrop = ({ parentKey, targetId }) => {
|
||||
if (navDragParentKey.value !== parentKey) {
|
||||
showToast('error', '같은 단계의 메뉴 안에서만 순서를 바꿀 수 있습니다.')
|
||||
onPrimaryDragEnd()
|
||||
return
|
||||
}
|
||||
const parentId = parentKey === 'root' ? null : parentKey
|
||||
reorderPrimarySiblings(parentId, navDraggingId.value, targetId)
|
||||
onPrimaryDragEnd()
|
||||
}
|
||||
|
||||
/**
|
||||
* 입력·버튼 위에서는 행 드래그를 시작하지 않는다.
|
||||
* @param {DragEvent} event - 이벤트
|
||||
* @returns {boolean}
|
||||
*/
|
||||
const shouldBlockFooterRowDrag = (event) => {
|
||||
const el = event.target
|
||||
if (!el || typeof el.closest !== 'function') {
|
||||
return false
|
||||
}
|
||||
return Boolean(el.closest('input, button, textarea, select, a'))
|
||||
}
|
||||
|
||||
/**
|
||||
* 하단 행 드래그 시작
|
||||
* @param {DragEvent} event - 이벤트
|
||||
* @param {string} id - 항목 id
|
||||
* @returns {void}
|
||||
*/
|
||||
const onFooterDragStart = (event, id) => {
|
||||
if (shouldBlockFooterRowDrag(event)) {
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
if (!event.dataTransfer) {
|
||||
return
|
||||
}
|
||||
footerDraggingId.value = id
|
||||
event.dataTransfer.effectAllowed = 'move'
|
||||
}
|
||||
|
||||
/**
|
||||
* 하단 행 드래그 오버
|
||||
* @param {DragEvent} event - 이벤트
|
||||
* @param {string} id - 항목 id
|
||||
* @returns {void}
|
||||
*/
|
||||
const onFooterDragOver = (event, id) => {
|
||||
event.preventDefault()
|
||||
footerDragOverId.value = id
|
||||
}
|
||||
|
||||
/**
|
||||
* 하단 행 드롭
|
||||
* @param {DragEvent} event - 이벤트
|
||||
* @param {string} targetId - 대상 id
|
||||
* @returns {void}
|
||||
*/
|
||||
const onFooterDrop = (event, targetId) => {
|
||||
event.preventDefault()
|
||||
if (!footerDraggingId.value) {
|
||||
return
|
||||
}
|
||||
reorderFooter(footerDraggingId.value, targetId)
|
||||
footerDraggingId.value = ''
|
||||
footerDragOverId.value = ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 하단 드래그 종료
|
||||
* @returns {void}
|
||||
*/
|
||||
const onFooterDragEnd = () => {
|
||||
footerDraggingId.value = ''
|
||||
footerDragOverId.value = ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 하단 행 하이라이트(태그 관리와 동일)
|
||||
* @param {string} id - 항목 id
|
||||
* @returns {string}
|
||||
*/
|
||||
const footerRowClass = (id) => {
|
||||
if (footerDragOverId.value === id) {
|
||||
return 'bg-[#f9f9f7]'
|
||||
}
|
||||
if (footerDraggingId.value === id) {
|
||||
return 'opacity-50'
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 상단 루트 항목 추가
|
||||
* @returns {void}
|
||||
*/
|
||||
const addPrimaryRoot = () => {
|
||||
const roots = items.value.filter((i) => i.location === 'primary' && !i.parentId)
|
||||
const maxOrder = Math.max(0, ...roots.map((r) => r.sortOrder || 0))
|
||||
items.value.push({
|
||||
id: crypto.randomUUID(),
|
||||
label: '새 메뉴',
|
||||
url: '/',
|
||||
location: 'primary',
|
||||
parentId: null,
|
||||
sortOrder: maxOrder + 10,
|
||||
isVisible: true,
|
||||
isFolder: false
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 상단 특정 항목의 하위 추가
|
||||
* @param {string} parentId - 부모 id
|
||||
* @returns {void}
|
||||
*/
|
||||
const addPrimaryChild = (parentId) => {
|
||||
const pid = String(parentId)
|
||||
const siblings = items.value.filter((i) => i.location === 'primary' && String(i.parentId || '') === pid)
|
||||
const maxOrder = Math.max(0, ...siblings.map((r) => r.sortOrder || 0))
|
||||
items.value.push({
|
||||
id: crypto.randomUUID(),
|
||||
label: '새 하위 메뉴',
|
||||
url: '#',
|
||||
location: 'primary',
|
||||
parentId: pid,
|
||||
sortOrder: maxOrder + 10,
|
||||
isVisible: true,
|
||||
isFolder: false
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 하단 항목 추가
|
||||
* @returns {void}
|
||||
*/
|
||||
const addFooterItem = () => {
|
||||
const list = items.value.filter((i) => i.location === 'footer' && !i.parentId)
|
||||
const maxOrder = Math.max(0, ...list.map((r) => r.sortOrder || 0))
|
||||
items.value.push({
|
||||
id: crypto.randomUUID(),
|
||||
label: '',
|
||||
url: '/',
|
||||
location: 'footer',
|
||||
parentId: null,
|
||||
sortOrder: maxOrder + 10,
|
||||
isVisible: true,
|
||||
isFolder: false
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -58,8 +326,12 @@ const removeNavigationItem = (index) => {
|
||||
* @returns {Promise<void>} 저장 결과
|
||||
*/
|
||||
const saveNavigation = async () => {
|
||||
if (saving.value || !isNavigationDirty.value) {
|
||||
return
|
||||
}
|
||||
|
||||
saving.value = true
|
||||
errorMessage.value = ''
|
||||
clearToast()
|
||||
showToast('info', '네비게이션을 저장하는 중입니다.')
|
||||
|
||||
try {
|
||||
@@ -67,33 +339,38 @@ const saveNavigation = async () => {
|
||||
method: 'PUT',
|
||||
body: {
|
||||
items: items.value.map((item) => ({
|
||||
id: item.id,
|
||||
label: item.label,
|
||||
url: item.url,
|
||||
location: item.location,
|
||||
sortOrder: Number(item.sortOrder || 0),
|
||||
isVisible: Boolean(item.isVisible)
|
||||
isVisible: true,
|
||||
parentId: item.parentId ?? null,
|
||||
isFolder: Boolean(item.isFolder)
|
||||
}))
|
||||
}
|
||||
})
|
||||
|
||||
items.value = savedItems.map((item) => ({ ...item }))
|
||||
items.value = savedItems.map((item) => ({
|
||||
...item,
|
||||
parentId: item.parentId ?? null,
|
||||
isFolder: Boolean(item.isFolder),
|
||||
isVisible: true
|
||||
}))
|
||||
navigationBaseline.value = serializeNavigationItems(items.value)
|
||||
showToast('success', '네비게이션이 저장되었습니다.')
|
||||
} catch (error) {
|
||||
errorMessage.value = error?.data?.message || '네비게이션을 저장하지 못했습니다.'
|
||||
showToast('error', errorMessage.value)
|
||||
const msg = error?.data?.message || error?.message || '네비게이션을 저장하지 못했습니다.'
|
||||
showToast('error', msg)
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.clearTimeout(toastTimer)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="admin-navigation bg-paper p-6">
|
||||
<div class="admin-navigation__header mb-8 flex items-start justify-between gap-4">
|
||||
<div class="admin-navigation__header mb-6 flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<p class="admin-navigation__eyebrow text-xs font-semibold uppercase text-muted">
|
||||
Navigation
|
||||
@@ -101,73 +378,143 @@ onBeforeUnmount(() => {
|
||||
<h1 class="admin-navigation__title mt-2 text-3xl font-semibold">
|
||||
메뉴 관리
|
||||
</h1>
|
||||
<p class="admin-navigation__lead mt-2 max-w-xl text-sm text-muted">
|
||||
상단·하단을 탭으로 나눕니다. 순서는 <span class="font-semibold text-ink">태그 관리의 메인 태그</span>와 같이 행을 드래그합니다(번호·여백에서 잡고 이동). 입력란 위에서는 드래그가 시작되지 않습니다. 하위 메뉴가 있으면 공개 사이드바에서 접는 그룹으로 보입니다.
|
||||
</p>
|
||||
</div>
|
||||
<div class="admin-navigation__header-actions flex flex-wrap gap-2">
|
||||
<button class="admin-navigation__add rounded border border-line bg-white px-4 py-2 text-sm font-semibold" type="button" @click="addNavigationItem('primary')">
|
||||
상단 메뉴 추가
|
||||
</button>
|
||||
<button class="admin-navigation__add rounded border border-line bg-white px-4 py-2 text-sm font-semibold" type="button" @click="addNavigationItem('footer')">
|
||||
하단 메뉴 추가
|
||||
<button
|
||||
class="admin-navigation__submit rounded bg-[#15171a] px-4 py-2 text-sm font-semibold text-white disabled:opacity-50"
|
||||
type="button"
|
||||
:disabled="saving || !isNavigationDirty"
|
||||
@click="saveNavigation"
|
||||
>
|
||||
{{ saving ? '저장 중' : '메뉴 저장' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="errorMessage" class="admin-navigation__error mb-5 rounded border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||
{{ errorMessage }}
|
||||
</p>
|
||||
<div class="admin-navigation__tabs mb-6 flex gap-2 border-b border-line">
|
||||
<button
|
||||
class="admin-navigation__tab -mb-px border-b-2 px-4 py-2 text-sm font-semibold transition-colors"
|
||||
:class="activeTab === 'primary' ? 'border-ink text-ink' : 'border-transparent text-muted hover:text-ink'"
|
||||
type="button"
|
||||
@click="activeTab = 'primary'"
|
||||
>
|
||||
상단 네비게이션
|
||||
</button>
|
||||
<button
|
||||
class="admin-navigation__tab -mb-px border-b-2 px-4 py-2 text-sm font-semibold transition-colors"
|
||||
:class="activeTab === 'footer' ? 'border-ink text-ink' : 'border-transparent text-muted hover:text-ink'"
|
||||
type="button"
|
||||
@click="activeTab = 'footer'"
|
||||
>
|
||||
하단 네비게이션
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form class="admin-navigation__form grid gap-5" @submit.prevent="saveNavigation">
|
||||
<div class="admin-navigation__table overflow-hidden border border-line bg-white">
|
||||
<table class="admin-navigation__table-inner w-full border-collapse text-left text-sm">
|
||||
<thead class="admin-navigation__table-head bg-[#f5f5f2] text-xs uppercase text-muted">
|
||||
<div v-show="activeTab === 'primary'" class="admin-navigation__panel-primary space-y-4">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
class="admin-navigation__add rounded border border-line bg-white px-4 py-2 text-sm font-semibold"
|
||||
type="button"
|
||||
@click="addPrimaryRoot"
|
||||
>
|
||||
상단 메뉴 추가
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="primaryTree.length === 0" class="admin-navigation__empty rounded border border-dashed border-line bg-white p-8 text-center text-sm text-muted">
|
||||
상단 메뉴가 없습니다. 위 버튼으로 항목을 추가하세요.
|
||||
</div>
|
||||
|
||||
<AdminNavPrimaryBranch
|
||||
v-else
|
||||
:wraps="primaryTree"
|
||||
parent-key="root"
|
||||
:dragging-id="navDraggingId"
|
||||
:drag-over-id="navDragOverId"
|
||||
@drag-start="onPrimaryDragStart"
|
||||
@drag-over="onPrimaryDragOver"
|
||||
@drag-end="onPrimaryDragEnd"
|
||||
@drop="onPrimaryDrop"
|
||||
@add-child="addPrimaryChild"
|
||||
@remove="removeItemCascade"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-show="activeTab === 'footer'" class="admin-navigation__panel-footer space-y-4">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
class="admin-navigation__add rounded border border-line bg-white px-4 py-2 text-sm font-semibold"
|
||||
type="button"
|
||||
@click="addFooterItem"
|
||||
>
|
||||
하단 메뉴 추가
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="footerItemsSorted.length === 0" class="admin-navigation__empty rounded border border-dashed border-line bg-white p-8 text-center text-sm text-muted">
|
||||
하단 메뉴가 없습니다.
|
||||
</div>
|
||||
|
||||
<div v-else class="admin-navigation__footer-table overflow-hidden rounded border border-line">
|
||||
<table class="admin-navigation__footer-table-inner w-full border-collapse text-left text-sm">
|
||||
<thead class="admin-navigation__footer-head bg-[#f5f5f2] text-xs uppercase text-muted">
|
||||
<tr>
|
||||
<th class="admin-navigation__cell px-4 py-3">표시</th>
|
||||
<th class="admin-navigation__cell px-4 py-3">라벨</th>
|
||||
<th class="admin-navigation__cell px-4 py-3">URL</th>
|
||||
<th class="admin-navigation__cell px-4 py-3">위치</th>
|
||||
<th class="admin-navigation__cell px-4 py-3">순서</th>
|
||||
<th class="admin-navigation__cell px-4 py-3">관리</th>
|
||||
<th class="admin-navigation__footer-cell px-4 py-3">
|
||||
#
|
||||
</th>
|
||||
<th class="admin-navigation__footer-cell px-4 py-3">
|
||||
라벨
|
||||
</th>
|
||||
<th class="admin-navigation__footer-cell px-4 py-3">
|
||||
URL
|
||||
</th>
|
||||
<th class="admin-navigation__footer-cell px-4 py-3">
|
||||
관리
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="admin-navigation__table-body divide-y divide-line">
|
||||
<tr v-for="(item, index) in items" :key="item.id || index" class="admin-navigation__row">
|
||||
<td class="admin-navigation__cell px-4 py-3">
|
||||
<input v-model="item.isVisible" class="admin-navigation__checkbox h-4 w-4" type="checkbox">
|
||||
<tbody class="admin-navigation__footer-body divide-y divide-line bg-white">
|
||||
<tr
|
||||
v-for="(item, index) in footerItemsSorted"
|
||||
:key="item.id"
|
||||
class="admin-navigation__footer-row cursor-move"
|
||||
:class="footerRowClass(item.id)"
|
||||
draggable="true"
|
||||
@dragstart="onFooterDragStart($event, item.id)"
|
||||
@dragover="onFooterDragOver($event, item.id)"
|
||||
@drop="onFooterDrop($event, item.id)"
|
||||
@dragend="onFooterDragEnd"
|
||||
>
|
||||
<td class="admin-navigation__footer-cell px-4 py-4 text-muted">
|
||||
{{ index + 1 }}
|
||||
</td>
|
||||
<td class="admin-navigation__cell px-4 py-3">
|
||||
<td class="admin-navigation__footer-cell px-4 py-4">
|
||||
<input
|
||||
v-model="item.label"
|
||||
class="admin-navigation__input w-full rounded border border-line px-3 py-2"
|
||||
class="w-full min-w-[8rem] rounded border border-line px-3 py-2 text-sm outline-none focus:border-[#8e9cac]"
|
||||
type="text"
|
||||
placeholder="라벨"
|
||||
required
|
||||
>
|
||||
</td>
|
||||
<td class="admin-navigation__cell px-4 py-3">
|
||||
<td class="admin-navigation__footer-cell px-4 py-4">
|
||||
<input
|
||||
v-model="item.url"
|
||||
class="admin-navigation__input w-full rounded border border-line px-3 py-2"
|
||||
class="w-full min-w-[10rem] rounded border border-line px-3 py-2 font-mono text-sm outline-none focus:border-[#8e9cac]"
|
||||
type="text"
|
||||
placeholder="URL"
|
||||
required
|
||||
pattern="^(\/|https?:\/\/).*"
|
||||
>
|
||||
</td>
|
||||
<td class="admin-navigation__cell px-4 py-3">
|
||||
<select v-model="item.location" class="admin-navigation__select rounded border border-line px-3 py-2">
|
||||
<option value="primary">상단</option>
|
||||
<option value="footer">하단</option>
|
||||
</select>
|
||||
</td>
|
||||
<td class="admin-navigation__cell px-4 py-3">
|
||||
<input
|
||||
v-model.number="item.sortOrder"
|
||||
class="admin-navigation__sort w-24 rounded border border-line px-3 py-2"
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
<td class="admin-navigation__footer-cell px-4 py-4">
|
||||
<button
|
||||
class="rounded border border-red-200 px-3 py-1.5 text-xs font-semibold text-red-700"
|
||||
type="button"
|
||||
@click="removeItemCascade(item.id)"
|
||||
>
|
||||
</td>
|
||||
<td class="admin-navigation__cell px-4 py-3">
|
||||
<button class="admin-navigation__remove rounded border border-red-200 px-3 py-1.5 text-xs font-semibold text-red-700" type="button" @click="removeNavigationItem(index)">
|
||||
삭제
|
||||
</button>
|
||||
</td>
|
||||
@@ -175,25 +522,11 @@ onBeforeUnmount(() => {
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<p v-if="items.length === 0" class="admin-navigation__empty rounded border border-dashed border-line bg-white p-8 text-center text-sm text-muted">
|
||||
메뉴 항목이 없습니다.
|
||||
</p>
|
||||
|
||||
<div class="admin-navigation__actions flex justify-end border-t border-line pt-5">
|
||||
<button
|
||||
class="admin-navigation__submit rounded bg-[#15171a] px-4 py-2 text-sm font-semibold text-white disabled:opacity-50"
|
||||
type="submit"
|
||||
:disabled="saving"
|
||||
>
|
||||
{{ saving ? '저장 중' : '메뉴 저장' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="toast"
|
||||
class="admin-navigation__toast fixed right-5 top-5 z-50 rounded border px-4 py-3 text-sm font-semibold shadow-lg"
|
||||
class="admin-navigation__toast fixed right-5 top-5 z-[100] max-w-sm rounded border px-4 py-3 text-sm font-semibold shadow-lg"
|
||||
:class="{
|
||||
'border-green-200 bg-green-50 text-green-800': toast.type === 'success',
|
||||
'border-red-200 bg-red-50 text-red-800': toast.type === 'error',
|
||||
|
||||
@@ -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 || '글을 삭제하지 못했습니다.'
|
||||
|
||||
@@ -5,11 +5,18 @@ definePageMeta({
|
||||
|
||||
const deletingId = ref('')
|
||||
const errorMessage = ref('')
|
||||
const statusFilter = ref('all')
|
||||
const tagFilter = ref('all')
|
||||
const sortOrder = ref('newest')
|
||||
|
||||
const { data: posts, refresh } = await useFetch('/admin/api/posts', {
|
||||
default: () => []
|
||||
})
|
||||
|
||||
const { data: tags } = await useFetch('/admin/api/tags', {
|
||||
default: () => []
|
||||
})
|
||||
|
||||
/**
|
||||
* 날짜 표시 형식 변환
|
||||
* @param {string | null} value - ISO 날짜 문자열
|
||||
@@ -36,27 +43,106 @@ const formatDate = (value) => {
|
||||
const isPublicPost = (post) => post.status === 'published'
|
||||
&& (!post.publishedAt || new Date(post.publishedAt) <= new Date())
|
||||
|
||||
/**
|
||||
* 게시물 상태 필터 키 생성
|
||||
* @param {Object} post - 게시물
|
||||
* @returns {'published' | 'scheduled' | 'draft' | 'private'} 상태 키
|
||||
*/
|
||||
const getPostStatusKey = (post) => {
|
||||
if (post.status === 'published' && !isPublicPost(post)) {
|
||||
return 'scheduled'
|
||||
}
|
||||
|
||||
if (post.status === 'published') {
|
||||
return 'published'
|
||||
}
|
||||
|
||||
if (post.status === 'private') {
|
||||
return 'private'
|
||||
}
|
||||
|
||||
return 'draft'
|
||||
}
|
||||
|
||||
/**
|
||||
* 게시물 상태 표시 문자열 생성
|
||||
* @param {Object} post - 게시물
|
||||
* @returns {string} 상태 표시 문자열
|
||||
*/
|
||||
const getPostStatusLabel = (post) => {
|
||||
if (post.status === 'published' && !isPublicPost(post)) {
|
||||
const statusKey = getPostStatusKey(post)
|
||||
|
||||
if (statusKey === 'scheduled') {
|
||||
return '예약'
|
||||
}
|
||||
|
||||
if (post.status === 'published') {
|
||||
if (statusKey === 'published') {
|
||||
return '발행'
|
||||
}
|
||||
|
||||
if (post.status === 'private') {
|
||||
if (statusKey === 'private') {
|
||||
return '비공개'
|
||||
}
|
||||
|
||||
return '초안'
|
||||
}
|
||||
|
||||
/**
|
||||
* 게시물 상태 텍스트 클래스 생성
|
||||
* @param {Object} post - 게시물
|
||||
* @returns {string} 상태 텍스트 클래스
|
||||
*/
|
||||
const getPostStatusClass = (post) => {
|
||||
const statusKey = getPostStatusKey(post)
|
||||
|
||||
if (statusKey === 'scheduled') {
|
||||
return 'font-bold text-[#30cf43]'
|
||||
}
|
||||
|
||||
if (statusKey === 'draft') {
|
||||
return 'font-bold text-[#fb2d8d]'
|
||||
}
|
||||
|
||||
if (statusKey === 'published') {
|
||||
return 'text-[#99A3AD]'
|
||||
}
|
||||
|
||||
return 'font-bold text-[#8e9cac]'
|
||||
}
|
||||
|
||||
/**
|
||||
* 태그 슬러그의 표시 이름을 조회한다.
|
||||
* @param {string} slug - 태그 슬러그
|
||||
* @returns {string} 태그 표시 이름
|
||||
*/
|
||||
const getTagName = (slug) => tags.value.find((tag) => tag.slug === slug)?.name || slug
|
||||
|
||||
const usedTagSlugs = computed(() => {
|
||||
const slugs = new Set()
|
||||
for (const post of posts.value) {
|
||||
for (const tag of post.tags || []) {
|
||||
slugs.add(tag)
|
||||
}
|
||||
}
|
||||
return [...slugs].sort((a, b) => getTagName(a).localeCompare(getTagName(b), 'ko'))
|
||||
})
|
||||
|
||||
const filteredPosts = computed(() => {
|
||||
const filtered = posts.value.filter((post) => {
|
||||
const matchesStatus = statusFilter.value === 'all' || getPostStatusKey(post) === statusFilter.value
|
||||
const matchesTag = tagFilter.value === 'all' || (post.tags || []).includes(tagFilter.value)
|
||||
|
||||
return matchesStatus && matchesTag
|
||||
})
|
||||
|
||||
return [...filtered].sort((a, b) => {
|
||||
const left = new Date(a.updatedAt || a.createdAt || 0).getTime()
|
||||
const right = new Date(b.updatedAt || b.createdAt || 0).getTime()
|
||||
|
||||
return sortOrder.value === 'oldest' ? left - right : right - left
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* 게시물 삭제
|
||||
* @param {Object} post - 삭제할 게시물
|
||||
@@ -99,6 +185,34 @@ const deletePost = async (post) => {
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
<div class="admin-posts__filters mt-6 flex flex-wrap items-center gap-2">
|
||||
<label class="admin-posts__filter">
|
||||
<span class="sr-only">상태 필터</span>
|
||||
<select v-model="statusFilter" class="admin-posts__filter-select h-10 rounded border border-line bg-white px-3 text-sm text-[#394047] outline-none transition-colors hover:border-[#c8ced3] focus:border-[#8e9cac]">
|
||||
<option value="all">전체 상태</option>
|
||||
<option value="published">발행</option>
|
||||
<option value="draft">초안</option>
|
||||
<option value="scheduled">예약</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="admin-posts__filter">
|
||||
<span class="sr-only">태그 필터</span>
|
||||
<select v-model="tagFilter" class="admin-posts__filter-select h-10 rounded border border-line bg-white px-3 text-sm text-[#394047] outline-none transition-colors hover:border-[#c8ced3] focus:border-[#8e9cac]">
|
||||
<option value="all">전체 태그</option>
|
||||
<option v-for="tag in usedTagSlugs" :key="tag" :value="tag">
|
||||
{{ getTagName(tag) }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="admin-posts__filter">
|
||||
<span class="sr-only">정렬</span>
|
||||
<select v-model="sortOrder" class="admin-posts__filter-select h-10 rounded border border-line bg-white px-3 text-sm text-[#394047] outline-none transition-colors hover:border-[#c8ced3] focus:border-[#8e9cac]">
|
||||
<option value="newest">최신순</option>
|
||||
<option value="oldest">오래된순</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<p v-if="errorMessage" class="admin-posts__error mt-6 rounded border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||
{{ errorMessage }}
|
||||
</p>
|
||||
@@ -115,24 +229,24 @@ const deletePost = async (post) => {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="admin-posts__table-body divide-y divide-line bg-white">
|
||||
<tr v-for="post in posts" :key="post.id" class="admin-posts__row">
|
||||
<tr v-for="post in filteredPosts" :key="post.id" class="admin-posts__row">
|
||||
<td class="admin-posts__cell px-4 py-4">
|
||||
<NuxtLink class="admin-posts__title-link font-semibold hover:opacity-70" :to="`/admin/posts/${post.id}`">
|
||||
{{ post.title }}
|
||||
</NuxtLink>
|
||||
<div class="admin-posts__title-row flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<NuxtLink class="admin-posts__title-link font-semibold hover:opacity-70" :to="`/admin/posts/${post.id}`">
|
||||
{{ post.title || '(제목 없음)' }}
|
||||
</NuxtLink>
|
||||
<span class="admin-posts__comment-count text-xs font-medium text-muted">
|
||||
댓글 {{ post.commentCount || 0 }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="admin-posts__slug mt-1 text-xs text-muted">
|
||||
/post/{{ post.slug }}
|
||||
</p>
|
||||
</td>
|
||||
<td class="admin-posts__cell px-4 py-4">
|
||||
<span
|
||||
class="admin-posts__status rounded px-2 py-1 text-xs font-semibold"
|
||||
:class="{
|
||||
'bg-green-50 text-green-700': getPostStatusLabel(post) === '발행',
|
||||
'bg-blue-50 text-blue-700': getPostStatusLabel(post) === '예약',
|
||||
'bg-[#f5f5f2] text-muted': getPostStatusLabel(post) === '초안',
|
||||
'bg-red-50 text-red-700': getPostStatusLabel(post) === '비공개'
|
||||
}"
|
||||
class="admin-posts__status-text text-xs"
|
||||
:class="getPostStatusClass(post)"
|
||||
>
|
||||
{{ getPostStatusLabel(post) }}
|
||||
</span>
|
||||
@@ -141,27 +255,47 @@ 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) }}
|
||||
</td>
|
||||
<td class="admin-posts__cell px-4 py-4">
|
||||
<button
|
||||
class="admin-posts__delete rounded border border-red-200 px-3 py-1.5 text-xs font-semibold text-red-700 disabled:opacity-50"
|
||||
class="admin-posts__delete-icon inline-flex size-9 items-center justify-center rounded text-muted opacity-35 transition-all hover:opacity-100 hover:text-red-600 focus-visible:outline focus-visible:ring-2 focus-visible:ring-[#8e9cac] focus-visible:ring-offset-1 disabled:pointer-events-none disabled:opacity-20"
|
||||
type="button"
|
||||
:disabled="deletingId === post.id"
|
||||
:aria-label="deletingId === post.id ? '삭제 중' : '삭제'"
|
||||
@click="deletePost(post)"
|
||||
>
|
||||
{{ deletingId === post.id ? '삭제 중' : '삭제' }}
|
||||
<span v-if="deletingId === post.id" class="admin-posts__delete-progress text-[10px] font-semibold text-muted" aria-hidden="true">…</span>
|
||||
<svg
|
||||
v-else
|
||||
class="size-5 shrink-0"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z" />
|
||||
</svg>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p v-if="posts.length === 0" class="admin-posts__empty mt-6 text-sm text-muted">
|
||||
아직 작성된 글이 없습니다.
|
||||
<p v-if="filteredPosts.length === 0" class="admin-posts__empty mt-6 text-sm text-muted">
|
||||
{{ posts.length === 0 ? '아직 작성된 글이 없습니다.' : '조건에 맞는 글이 없습니다.' }}
|
||||
</p>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -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: '글이 저장되었습니다.'
|
||||
|
||||
@@ -34,21 +34,25 @@ onMounted(loadPreviewPost)
|
||||
관리자 미리보기
|
||||
</div>
|
||||
|
||||
<p v-if="previewError" class="admin-post-preview__error rounded border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||
<p v-if="previewError" class="admin-post-preview__error rounded border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700 sm:mx-auto sm:max-w-[720px] sm:px-5">
|
||||
{{ previewError }}
|
||||
</p>
|
||||
|
||||
<ContentRenderer v-else-if="previewPost">
|
||||
<ProseHeaderCard>
|
||||
<p class="post-detail__eyebrow text-sm uppercase text-white/70">
|
||||
PREVIEW
|
||||
</p>
|
||||
<h1 class="post-detail__title mt-3 text-4xl font-semibold leading-tight">
|
||||
{{ previewPost.title || '제목 없음' }}
|
||||
</h1>
|
||||
</ProseHeaderCard>
|
||||
<section v-else-if="previewPost" class="admin-post-preview__body">
|
||||
<div class="mx-auto max-w-[720px] px-4 sm:px-5">
|
||||
<ContentRenderer>
|
||||
<ProseHeaderCard>
|
||||
<p class="post-detail__eyebrow text-sm uppercase text-white/70">
|
||||
PREVIEW
|
||||
</p>
|
||||
<h1 class="post-detail__title mt-3 text-4xl font-semibold leading-tight">
|
||||
{{ previewPost.title || '제목 없음' }}
|
||||
</h1>
|
||||
</ProseHeaderCard>
|
||||
|
||||
<ContentMarkdownRenderer class="post-detail__content" :content="previewPost.content || ''" />
|
||||
</ContentRenderer>
|
||||
<ContentMarkdownRenderer class="post-detail__content" :content="previewPost.content || ''" />
|
||||
</ContentRenderer>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -4,8 +4,10 @@ definePageMeta({
|
||||
})
|
||||
|
||||
const saving = ref(false)
|
||||
const uploadingLogo = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const toast = ref(null)
|
||||
const logoInputRef = ref(null)
|
||||
let toastTimer = null
|
||||
|
||||
const { data: settings } = await useFetch('/admin/api/settings')
|
||||
@@ -15,6 +17,8 @@ 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'
|
||||
})
|
||||
|
||||
@@ -32,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>} 저장 결과
|
||||
@@ -48,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
|
||||
}
|
||||
})
|
||||
@@ -83,7 +134,47 @@ onBeforeUnmount(() => {
|
||||
{{ errorMessage }}
|
||||
</p>
|
||||
|
||||
<form class="admin-settings__form grid max-w-3xl gap-6" @submit.prevent="saveSettings">
|
||||
<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="form.logoUrl"
|
||||
class="h-full w-full object-cover"
|
||||
:src="form.logoUrl"
|
||||
alt="사이트 로고"
|
||||
>
|
||||
<span v-else class="text-2xl font-semibold text-muted">
|
||||
{{ form.logoText || '井' }}
|
||||
</span>
|
||||
</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>
|
||||
<button
|
||||
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="uploadingLogo"
|
||||
@click="openLogoFilePicker"
|
||||
>
|
||||
{{ uploadingLogo ? '업로드 중' : form.logoUrl ? '로고 변경' : '로고 등록' }}
|
||||
</button>
|
||||
<input
|
||||
ref="logoInputRef"
|
||||
class="hidden"
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp"
|
||||
:disabled="uploadingLogo"
|
||||
@change="uploadLogo"
|
||||
>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<label class="admin-settings__field grid gap-2 text-sm">
|
||||
<span class="admin-settings__label font-medium">사이트 이름</span>
|
||||
<input
|
||||
@@ -99,6 +190,7 @@ onBeforeUnmount(() => {
|
||||
<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>
|
||||
|
||||
@@ -112,36 +204,29 @@ onBeforeUnmount(() => {
|
||||
>
|
||||
</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">
|
||||
|
||||
@@ -3,42 +3,344 @@ definePageMeta({
|
||||
layout: 'admin'
|
||||
})
|
||||
|
||||
const deletingId = ref('')
|
||||
const errorMessage = ref('')
|
||||
const draggingTagId = ref('')
|
||||
const dragOverTagId = ref('')
|
||||
const savingOrder = ref(false)
|
||||
const promotingTagId = ref('')
|
||||
const demotingTagId = ref('')
|
||||
const deletingGeneralTagId = ref('')
|
||||
const toast = ref(null)
|
||||
let toastTimer = null
|
||||
const generalTagQuery = ref('')
|
||||
const generalTagSortMode = ref('recent')
|
||||
|
||||
const { data: tags, refresh } = await useFetch('/admin/api/tags', {
|
||||
default: () => []
|
||||
})
|
||||
|
||||
const managedTags = computed(() => tags.value.filter((tag) => tag.tagType === 'managed'))
|
||||
const generalTags = computed(() => tags.value.filter((tag) => tag.tagType === 'general'))
|
||||
const filteredGeneralTags = computed(() => {
|
||||
const keyword = generalTagQuery.value.trim().toLowerCase()
|
||||
const sortedTags = [...generalTags.value].sort((a, b) => {
|
||||
if (generalTagSortMode.value === 'count') {
|
||||
const countDiff = Number(b.postCount || 0) - Number(a.postCount || 0)
|
||||
if (countDiff !== 0) {
|
||||
return countDiff
|
||||
}
|
||||
}
|
||||
|
||||
if (generalTagSortMode.value === 'name') {
|
||||
return a.name.localeCompare(b.name, 'ko')
|
||||
}
|
||||
|
||||
const aTime = new Date(a.lastUsedAt || a.updatedAt || 0).getTime()
|
||||
const bTime = new Date(b.lastUsedAt || b.updatedAt || 0).getTime()
|
||||
|
||||
if (aTime !== bTime) {
|
||||
return bTime - aTime
|
||||
}
|
||||
|
||||
return a.name.localeCompare(b.name, 'ko')
|
||||
})
|
||||
|
||||
if (!keyword) {
|
||||
return sortedTags
|
||||
}
|
||||
|
||||
return sortedTags.filter((tag) =>
|
||||
tag.name.toLowerCase().includes(keyword) ||
|
||||
tag.slug.toLowerCase().includes(keyword)
|
||||
)
|
||||
})
|
||||
|
||||
/** 서버 기준 메인 태그 id 순서(자동 저장 필요 여부 비교용) */
|
||||
const baselineManagedTagIds = ref([])
|
||||
|
||||
/**
|
||||
* 태그 삭제
|
||||
* @param {Object} tag - 삭제할 태그
|
||||
* @returns {Promise<void>} 삭제 처리 결과
|
||||
* 서버와 동기화된 메인 태그 순서를 기준선으로 저장한다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const deleteTag = async (tag) => {
|
||||
if (!confirm(`"${tag.name}" 태그를 삭제할까요? 연결된 글에서도 이 태그가 제거됩니다.`)) {
|
||||
const resetManagedOrderBaseline = () => {
|
||||
baselineManagedTagIds.value = managedTags.value.map((tag) => tag.id)
|
||||
}
|
||||
|
||||
/**
|
||||
* 태그 목록을 다시 불러온 뒤 메인 태그 순서 기준선을 맞춘다.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const refreshTagsFromServer = async () => {
|
||||
await refresh()
|
||||
resetManagedOrderBaseline()
|
||||
}
|
||||
|
||||
resetManagedOrderBaseline()
|
||||
|
||||
/**
|
||||
* 메인 태그 드래그 순서가 서버 기준선과 다른지 여부
|
||||
* @returns {boolean} 변경 여부
|
||||
*/
|
||||
const isManagedOrderDirty = computed(() => {
|
||||
const currentIds = managedTags.value.map((tag) => tag.id)
|
||||
const base = baselineManagedTagIds.value
|
||||
|
||||
if (!currentIds.length) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (currentIds.length !== base.length) {
|
||||
return true
|
||||
}
|
||||
|
||||
return currentIds.some((id, index) => id !== base[index])
|
||||
})
|
||||
|
||||
/**
|
||||
* 피드백 토스트 표시
|
||||
* @param {'success'|'error'|'info'} type - 토스트 타입
|
||||
* @param {string} message - 표시 메시지
|
||||
* @returns {void}
|
||||
*/
|
||||
const showToast = (type, message) => {
|
||||
window.clearTimeout(toastTimer)
|
||||
toast.value = { type, message }
|
||||
toastTimer = window.setTimeout(() => {
|
||||
toast.value = null
|
||||
}, 3200)
|
||||
}
|
||||
|
||||
/**
|
||||
* 관리용 태그 드래그 시작
|
||||
* @param {DragEvent} event - 드래그 이벤트
|
||||
* @param {string} tagId - 태그 ID
|
||||
* @returns {void}
|
||||
*/
|
||||
const handleDragStart = (event, tagId) => {
|
||||
if (savingOrder.value) {
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
deletingId.value = tag.id
|
||||
errorMessage.value = ''
|
||||
if (!event.dataTransfer) {
|
||||
return
|
||||
}
|
||||
draggingTagId.value = tagId
|
||||
event.dataTransfer.effectAllowed = 'move'
|
||||
}
|
||||
|
||||
/**
|
||||
* 관리용 태그 드래그 오버
|
||||
* @param {DragEvent} event - 드래그 이벤트
|
||||
* @param {string} tagId - 태그 ID
|
||||
* @returns {void}
|
||||
*/
|
||||
const handleDragOver = (event, tagId) => {
|
||||
if (savingOrder.value) {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
dragOverTagId.value = tagId
|
||||
}
|
||||
|
||||
/**
|
||||
* 관리용 태그 드래그 종료
|
||||
* @returns {void}
|
||||
*/
|
||||
const handleDragEnd = () => {
|
||||
draggingTagId.value = ''
|
||||
dragOverTagId.value = ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 관리용 태그 순서를 교환한다.
|
||||
* @param {string} sourceId - 원본 태그 ID
|
||||
* @param {string} targetId - 대상 태그 ID
|
||||
* @returns {void}
|
||||
*/
|
||||
const moveManagedTag = (sourceId, targetId) => {
|
||||
const sourceIndex = tags.value.findIndex((tag) => tag.id === sourceId)
|
||||
const targetIndex = tags.value.findIndex((tag) => tag.id === targetId)
|
||||
if (sourceIndex < 0 || targetIndex < 0 || sourceIndex === targetIndex) {
|
||||
return
|
||||
}
|
||||
|
||||
const sourceTag = tags.value[sourceIndex]
|
||||
const targetTag = tags.value[targetIndex]
|
||||
if (sourceTag.tagType !== 'managed' || targetTag.tagType !== 'managed') {
|
||||
return
|
||||
}
|
||||
|
||||
const nextTags = [...tags.value]
|
||||
const [moved] = nextTags.splice(sourceIndex, 1)
|
||||
nextTags.splice(targetIndex, 0, moved)
|
||||
tags.value = nextTags
|
||||
}
|
||||
|
||||
/**
|
||||
* 관리용 태그 드롭 처리
|
||||
* @param {DragEvent} event - 드래그 이벤트
|
||||
* @param {string} targetId - 대상 태그 ID
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const handleDrop = async (event, targetId) => {
|
||||
event.preventDefault()
|
||||
if (!draggingTagId.value || savingOrder.value) {
|
||||
return
|
||||
}
|
||||
moveManagedTag(draggingTagId.value, targetId)
|
||||
handleDragEnd()
|
||||
await nextTick()
|
||||
await saveManagedOrder()
|
||||
}
|
||||
|
||||
/**
|
||||
* 관리용 태그 순서를 저장한다.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const saveManagedOrder = async () => {
|
||||
if (savingOrder.value || managedTags.value.length === 0 || !isManagedOrderDirty.value) {
|
||||
return
|
||||
}
|
||||
|
||||
savingOrder.value = true
|
||||
|
||||
try {
|
||||
const reordered = await $fetch('/admin/api/tags/reorder', {
|
||||
method: 'PUT',
|
||||
body: {
|
||||
tagIds: managedTags.value.map((tag) => tag.id)
|
||||
}
|
||||
})
|
||||
|
||||
tags.value = [...reordered]
|
||||
await refreshTagsFromServer()
|
||||
showToast('success', '메인 태그 순서가 자동 저장되었습니다.')
|
||||
} catch (error) {
|
||||
showToast('error', error?.data?.message || '정렬 순서를 저장하지 못했습니다.')
|
||||
await refreshTagsFromServer()
|
||||
} finally {
|
||||
savingOrder.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 일반 태그 검색
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const searchGeneralTags = async () => {
|
||||
generalTagQuery.value = generalTagQuery.value.trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* 일반 태그 정렬 기준을 변경한다.
|
||||
* @param {'recent'|'count'|'name'} mode - 정렬 기준
|
||||
* @returns {void}
|
||||
*/
|
||||
const setGeneralTagSortMode = (mode) => {
|
||||
generalTagSortMode.value = mode
|
||||
}
|
||||
|
||||
/**
|
||||
* 일반 태그를 메인 태그로 전환한다.
|
||||
* @param {Object} tag - 대상 태그
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const promoteToMainTag = async (tag) => {
|
||||
if (promotingTagId.value) {
|
||||
return
|
||||
}
|
||||
|
||||
promotingTagId.value = tag.id
|
||||
|
||||
try {
|
||||
await $fetch(`/admin/api/tags/${tag.id}`, {
|
||||
method: 'PUT',
|
||||
body: {
|
||||
name: tag.name,
|
||||
slug: tag.slug,
|
||||
description: tag.description || '',
|
||||
sortOrder: (managedTags.value.length + 1) * 10,
|
||||
color: tag.color || '#15171a',
|
||||
tagType: 'managed'
|
||||
}
|
||||
})
|
||||
await refreshTagsFromServer()
|
||||
showToast('success', `"${tag.name}" 태그를 메인 태그로 전환했습니다.`)
|
||||
} catch (error) {
|
||||
showToast('error', error?.data?.message || '메인 태그 전환에 실패했습니다.')
|
||||
} finally {
|
||||
promotingTagId.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 메인 태그를 일반 태그로 강등한다.
|
||||
* @param {Object} tag - 대상 태그
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const demoteToGeneralTag = async (tag) => {
|
||||
if (demotingTagId.value) {
|
||||
return
|
||||
}
|
||||
|
||||
demotingTagId.value = tag.id
|
||||
|
||||
try {
|
||||
await $fetch(`/admin/api/tags/${tag.id}`, {
|
||||
method: 'PUT',
|
||||
body: {
|
||||
name: tag.name,
|
||||
slug: tag.slug,
|
||||
description: tag.description || '',
|
||||
sortOrder: 0,
|
||||
color: tag.color || '#15171a',
|
||||
tagType: 'general'
|
||||
}
|
||||
})
|
||||
await refreshTagsFromServer()
|
||||
showToast('success', `"${tag.name}" 태그를 일반 태그로 변경했습니다.`)
|
||||
} catch (error) {
|
||||
showToast('error', error?.data?.message || '일반 태그 변경에 실패했습니다.')
|
||||
} finally {
|
||||
demotingTagId.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 검색된 일반 태그를 삭제한다.
|
||||
* @param {Object} tag - 대상 태그
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const deleteGeneralTag = async (tag) => {
|
||||
if (!confirm(`"${tag.name}" 일반 태그를 삭제할까요? 연결된 글에서도 이 태그가 제거됩니다.`)) {
|
||||
return
|
||||
}
|
||||
|
||||
deletingGeneralTagId.value = tag.id
|
||||
|
||||
try {
|
||||
await $fetch(`/admin/api/tags/${tag.id}`, {
|
||||
method: 'DELETE'
|
||||
})
|
||||
await refresh()
|
||||
await refreshTagsFromServer()
|
||||
showToast('success', `"${tag.name}" 일반 태그를 삭제했습니다.`)
|
||||
} catch (error) {
|
||||
errorMessage.value = error?.data?.message || '태그를 삭제하지 못했습니다.'
|
||||
showToast('error', error?.data?.message || '일반 태그를 삭제하지 못했습니다.')
|
||||
} finally {
|
||||
deletingId.value = ''
|
||||
deletingGeneralTagId.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.clearTimeout(toastTimer)
|
||||
})
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="admin-tags bg-paper p-6">
|
||||
<div class="admin-tags__header flex items-center justify-between gap-4">
|
||||
<div class="admin-tags__header">
|
||||
<div>
|
||||
<p class="admin-tags__eyebrow text-xs font-semibold uppercase text-muted">
|
||||
Tags
|
||||
@@ -47,20 +349,23 @@ const deleteTag = async (tag) => {
|
||||
태그 관리
|
||||
</h1>
|
||||
</div>
|
||||
<NuxtLink class="admin-tags__new rounded bg-[#15171a] px-4 py-2 text-sm font-semibold text-white" to="/admin/tags/new">
|
||||
태그 추가
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
<p v-if="errorMessage" class="admin-tags__error mt-6 rounded border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||
{{ errorMessage }}
|
||||
<p class="mt-3 text-xs text-muted">
|
||||
메인 태그는 홈페이지 카테고리 영역에서 사용되며 드래그 정렬이 가능합니다. 일반 태그는 아래 목록에서 확인하고 필요할 때 메인 태그로 전환할 수 있습니다.
|
||||
</p>
|
||||
|
||||
<div class="admin-tags__table mt-8 overflow-hidden border border-line">
|
||||
<div class="admin-tags__table mt-6 overflow-hidden border border-line">
|
||||
<div class="flex items-center justify-between border-b border-line bg-[#f7f7f5] px-4 py-2.5">
|
||||
<p class="text-xs font-semibold uppercase text-muted">메인 태그</p>
|
||||
<span v-if="savingOrder" class="inline-flex items-center gap-2 text-xs font-semibold text-muted">
|
||||
<span class="size-3 animate-spin rounded-full border-2 border-line border-t-[#15171a]" />
|
||||
저장 중
|
||||
</span>
|
||||
</div>
|
||||
<table class="admin-tags__table-inner w-full border-collapse text-left text-sm">
|
||||
<thead class="admin-tags__table-head bg-[#f5f5f2] text-xs uppercase text-muted">
|
||||
<tr>
|
||||
<th class="admin-tags__cell px-4 py-3">순서</th>
|
||||
<th class="admin-tags__cell px-4 py-3">번호</th>
|
||||
<th class="admin-tags__cell px-4 py-3">색상</th>
|
||||
<th class="admin-tags__cell px-4 py-3">이름</th>
|
||||
<th class="admin-tags__cell px-4 py-3">슬러그</th>
|
||||
@@ -69,9 +374,23 @@ const deleteTag = async (tag) => {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="admin-tags__table-body divide-y divide-line bg-white">
|
||||
<tr v-for="tag in tags" :key="tag.id" class="admin-tags__row">
|
||||
<tr
|
||||
v-for="(tag, index) in managedTags"
|
||||
:key="tag.id"
|
||||
class="admin-tags__row"
|
||||
:class="[
|
||||
dragOverTagId === tag.id ? 'bg-[#f9f9f7]' : '',
|
||||
draggingTagId === tag.id ? 'opacity-50' : '',
|
||||
savingOrder ? 'cursor-not-allowed opacity-60' : 'cursor-move'
|
||||
]"
|
||||
:draggable="!savingOrder"
|
||||
@dragstart="handleDragStart($event, tag.id)"
|
||||
@dragover="handleDragOver($event, tag.id)"
|
||||
@drop="handleDrop($event, tag.id)"
|
||||
@dragend="handleDragEnd"
|
||||
>
|
||||
<td class="admin-tags__cell px-4 py-4 text-muted">
|
||||
{{ tag.sortOrder }}
|
||||
{{ index + 1 }}
|
||||
</td>
|
||||
<td class="admin-tags__cell px-4 py-4">
|
||||
<span class="admin-tags__color flex items-center gap-2">
|
||||
@@ -94,12 +413,12 @@ const deleteTag = async (tag) => {
|
||||
수정
|
||||
</NuxtLink>
|
||||
<button
|
||||
class="admin-tags__delete rounded border border-red-200 px-3 py-1.5 text-xs font-semibold text-red-700 disabled:opacity-50"
|
||||
class="rounded border border-line px-3 py-1.5 text-xs font-semibold disabled:opacity-50"
|
||||
type="button"
|
||||
:disabled="deletingId === tag.id"
|
||||
@click="deleteTag(tag)"
|
||||
:disabled="demotingTagId === tag.id"
|
||||
@click="demoteToGeneralTag(tag)"
|
||||
>
|
||||
{{ deletingId === tag.id ? '삭제 중' : '삭제' }}
|
||||
{{ demotingTagId === tag.id ? '변경 중' : '제외' }}
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
@@ -108,8 +427,98 @@ const deleteTag = async (tag) => {
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="admin-tags__table mt-8 overflow-hidden border border-line">
|
||||
<div class="flex items-center justify-between gap-3 border-b border-line bg-[#f7f7f5] px-4 py-2.5">
|
||||
<p class="text-xs font-semibold uppercase text-muted">일반 태그</p>
|
||||
<NuxtLink class="admin-tags__new rounded bg-[#15171a] px-3 py-1.5 text-xs font-semibold text-white" to="/admin/tags/new">
|
||||
태그 추가
|
||||
</NuxtLink>
|
||||
</div>
|
||||
<div class="space-y-3 bg-white p-4">
|
||||
<div class="flex flex-col gap-3 xl:flex-row xl:items-center xl:justify-between">
|
||||
<input
|
||||
v-model="generalTagQuery"
|
||||
type="text"
|
||||
class="h-10 min-w-0 flex-1 rounded border border-line px-3 text-sm outline-none focus:border-[#8e9cac]"
|
||||
placeholder="일반 태그 이름 또는 슬러그 필터"
|
||||
@keydown.enter.prevent="searchGeneralTags"
|
||||
>
|
||||
<div class="inline-flex shrink-0 rounded border border-line bg-[#f7f7f5] p-1">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded px-3 py-1.5 text-xs font-semibold"
|
||||
:class="generalTagSortMode === 'recent' ? 'bg-[#15171a] text-white' : 'text-muted'"
|
||||
@click="setGeneralTagSortMode('recent')"
|
||||
>
|
||||
최근 사용순
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded px-3 py-1.5 text-xs font-semibold"
|
||||
:class="generalTagSortMode === 'count' ? 'bg-[#15171a] text-white' : 'text-muted'"
|
||||
@click="setGeneralTagSortMode('count')"
|
||||
>
|
||||
많이 사용순
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded px-3 py-1.5 text-xs font-semibold"
|
||||
:class="generalTagSortMode === 'name' ? 'bg-[#15171a] text-white' : 'text-muted'"
|
||||
@click="setGeneralTagSortMode('name')"
|
||||
>
|
||||
이름순
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="filteredGeneralTags.length" class="flex flex-wrap gap-2">
|
||||
<div
|
||||
v-for="tag in filteredGeneralTags"
|
||||
:key="tag.id"
|
||||
class="admin-tags__general-badge group inline-flex max-w-full items-center gap-2 rounded-full border border-line bg-[#f7f7f5] px-3 py-2 text-sm"
|
||||
:title="tag.slug"
|
||||
>
|
||||
<span class="h-3 w-1 rounded-full" :style="{ backgroundColor: tag.color }" />
|
||||
<span class="truncate font-semibold text-ink">{{ tag.name }}</span>
|
||||
<span class="text-xs text-muted">{{ tag.postCount || 0 }}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-full border border-line bg-white px-2 py-1 text-[11px] font-semibold disabled:opacity-50"
|
||||
:disabled="promotingTagId === tag.id"
|
||||
@click="promoteToMainTag(tag)"
|
||||
>
|
||||
{{ promotingTagId === tag.id ? '전환 중' : '메인' }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-full border border-red-200 bg-white px-2 py-1 text-[11px] font-semibold text-red-700 disabled:opacity-50"
|
||||
:disabled="deletingGeneralTagId === tag.id"
|
||||
@click="deleteGeneralTag(tag)"
|
||||
>
|
||||
{{ deletingGeneralTagId === tag.id ? '삭제 중' : '삭제' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else class="text-sm text-muted">
|
||||
{{ generalTagQuery.trim() ? '일치하는 일반 태그가 없습니다.' : '아직 일반 태그가 없습니다.' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="tags.length === 0" class="admin-tags__empty mt-6 text-sm text-muted">
|
||||
아직 등록된 태그가 없습니다.
|
||||
</p>
|
||||
|
||||
<div
|
||||
v-if="toast"
|
||||
class="admin-tags__toast fixed right-5 top-5 z-50 rounded border px-4 py-3 text-sm font-semibold shadow-lg"
|
||||
:class="{
|
||||
'border-green-200 bg-green-50 text-green-800': toast.type === 'success',
|
||||
'border-red-200 bg-red-50 text-red-800': toast.type === 'error',
|
||||
'border-line bg-white text-ink': toast.type === 'info'
|
||||
}"
|
||||
role="status"
|
||||
>
|
||||
{{ toast.message }}
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
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>
|
||||
135
pages/index.vue
135
pages/index.vue
@@ -53,10 +53,17 @@ const onDocumentPointerDown = (event) => {
|
||||
* @returns {{name: string, color: string}} 태그 정보
|
||||
*/
|
||||
const getTagMeta = (slug) => {
|
||||
if (!slug) {
|
||||
return {
|
||||
name: '',
|
||||
color: '#4d4d4d'
|
||||
}
|
||||
}
|
||||
|
||||
const matchedTag = tags.value.find((item) => item.slug === slug)
|
||||
|
||||
return {
|
||||
name: matchedTag?.name || (slug ? slug.toUpperCase() : 'POST'),
|
||||
name: matchedTag?.name || String(slug).toUpperCase(),
|
||||
color: matchedTag?.color || '#4d4d4d'
|
||||
}
|
||||
}
|
||||
@@ -64,10 +71,9 @@ const getTagMeta = (slug) => {
|
||||
/**
|
||||
* Latest 목록 데이터 변환
|
||||
* @param {Object} post - API 게시물
|
||||
* @param {number} index - 목록 인덱스
|
||||
* @returns {Object} 화면 표시 데이터
|
||||
*/
|
||||
const mapLatestPost = (post, index) => {
|
||||
const mapLatestPost = (post) => {
|
||||
const primaryTagSlug = post.tags?.[0]
|
||||
const tagMeta = getTagMeta(primaryTagSlug)
|
||||
|
||||
@@ -80,14 +86,76 @@ const mapLatestPost = (post, index) => {
|
||||
publishedAt: formatPostDate(post.publishedAt),
|
||||
publishedAtIso: post.publishedAt || '',
|
||||
to: `/post/${post.slug}`,
|
||||
isFeatured: index === 0
|
||||
isFeatured: Boolean(post.isFeatured),
|
||||
commentCount: Number(post.commentCount || 0)
|
||||
}
|
||||
}
|
||||
|
||||
const featuredPosts = computed(() => posts.value.slice(0, 6))
|
||||
const featuredPosts = computed(() => posts.value.filter((post) => post.isFeatured).slice(0, 6))
|
||||
const latestPosts = computed(() => posts.value.map(mapLatestPost))
|
||||
|
||||
const featuredTrackRef = ref(null)
|
||||
/** Featured 트랙이 스크롤 시작에 붙었는지 — 이전 화살표 비활성 */
|
||||
const featuredAtStart = ref(true)
|
||||
/** Featured 트랙이 스크롤 끝에 붙었는지 — 다음 화살표 비활성 */
|
||||
const featuredAtEnd = ref(true)
|
||||
|
||||
let unbindFeaturedScroll = () => {}
|
||||
|
||||
/**
|
||||
* Featured 가로 스크롤 위치에 따라 이전/다음 버튼 상태를 갱신한다.
|
||||
* @param {HTMLElement | null} el - 스크롤 컨테이너
|
||||
* @returns {void}
|
||||
*/
|
||||
const updateFeaturedScrollEdges = (el) => {
|
||||
const target = el || featuredTrackRef.value
|
||||
|
||||
if (!target) {
|
||||
featuredAtStart.value = true
|
||||
featuredAtEnd.value = true
|
||||
return
|
||||
}
|
||||
|
||||
const { scrollLeft, scrollWidth, clientWidth } = target
|
||||
const maxScroll = Math.max(0, scrollWidth - clientWidth)
|
||||
const epsilon = 2
|
||||
|
||||
featuredAtStart.value = scrollLeft <= epsilon
|
||||
featuredAtEnd.value = scrollLeft >= maxScroll - epsilon
|
||||
}
|
||||
|
||||
watch(featuredTrackRef, (el) => {
|
||||
unbindFeaturedScroll()
|
||||
unbindFeaturedScroll = () => {}
|
||||
|
||||
if (!import.meta.client || !el) {
|
||||
updateFeaturedScrollEdges(null)
|
||||
return
|
||||
}
|
||||
|
||||
const onScroll = () => {
|
||||
updateFeaturedScrollEdges(el)
|
||||
}
|
||||
|
||||
onScroll()
|
||||
el.addEventListener('scroll', onScroll, { passive: true })
|
||||
const resizeObserver = new ResizeObserver(onScroll)
|
||||
resizeObserver.observe(el)
|
||||
unbindFeaturedScroll = () => {
|
||||
el.removeEventListener('scroll', onScroll)
|
||||
resizeObserver.disconnect()
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
watch(featuredPosts, () => {
|
||||
if (!import.meta.client) {
|
||||
return
|
||||
}
|
||||
|
||||
nextTick(() => {
|
||||
updateFeaturedScrollEdges(featuredTrackRef.value)
|
||||
})
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
if (!import.meta.client) {
|
||||
@@ -107,6 +175,7 @@ onBeforeUnmount(() => {
|
||||
return
|
||||
}
|
||||
|
||||
unbindFeaturedScroll()
|
||||
document.removeEventListener('pointerdown', onDocumentPointerDown)
|
||||
})
|
||||
|
||||
@@ -120,6 +189,14 @@ const scrollFeatured = (direction) => {
|
||||
return
|
||||
}
|
||||
|
||||
if (direction === 'left' && featuredAtStart.value) {
|
||||
return
|
||||
}
|
||||
|
||||
if (direction === 'right' && featuredAtEnd.value) {
|
||||
return
|
||||
}
|
||||
|
||||
const firstCard = featuredTrackRef.value.querySelector('[data-featured-slide]')
|
||||
const cardWidth = firstCard ? firstCard.getBoundingClientRect().width : 244
|
||||
const gap = 24
|
||||
@@ -134,7 +211,7 @@ const scrollFeatured = (direction) => {
|
||||
|
||||
<template>
|
||||
<MainColumn>
|
||||
<section class="px-5 py-6 sm:px-6 md:py-8">
|
||||
<section class="py-6 px-6 md:py-8">
|
||||
<div class="mx-auto flex max-w-[720px] flex-col-reverse gap-6">
|
||||
<div class="z-[2] flex flex-col items-center justify-center gap-2 text-center">
|
||||
<h1 class="text-xl font-semibold leading-[1.125] md:text-2xl">
|
||||
@@ -156,18 +233,34 @@ const scrollFeatured = (direction) => {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="px-5 py-4 sm:px-6">
|
||||
<section v-if="featuredPosts.length" class="py-4 px-6">
|
||||
<div class="mx-auto max-w-[720px]">
|
||||
<div class="flex items-end justify-between gap-2 border-b border-[var(--site-line)] pb-2">
|
||||
<h2 class="text-sm font-medium uppercase site-muted">Featured</h2>
|
||||
<div class="flex justify-between gap-2">
|
||||
<button class="cursor-pointer p-1 hover:opacity-75" type="button" aria-label="Previous" @click="scrollFeatured('left')">‹</button>
|
||||
<button class="cursor-pointer p-1 hover:opacity-75" type="button" aria-label="Next" @click="scrollFeatured('right')">›</button>
|
||||
<button
|
||||
class="featured-nav-prev cursor-pointer p-1 text-[var(--site-text)] hover:opacity-75 disabled:cursor-not-allowed disabled:opacity-35 disabled:hover:opacity-35"
|
||||
type="button"
|
||||
aria-label="Featured 이전"
|
||||
:disabled="featuredAtStart"
|
||||
@click="scrollFeatured('left')"
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
<button
|
||||
class="featured-nav-next cursor-pointer p-1 text-[var(--site-text)] hover:opacity-75 disabled:cursor-not-allowed disabled:opacity-35 disabled:hover:opacity-35"
|
||||
type="button"
|
||||
aria-label="Featured 다음"
|
||||
:disabled="featuredAtEnd"
|
||||
@click="scrollFeatured('right')"
|
||||
>
|
||||
›
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
ref="featuredTrackRef"
|
||||
class="mt-4 flex snap-x snap-mandatory gap-6 overflow-x-auto scroll-smooth pb-1 [--slides:1.4] sm:[--slides:1.6] lg:[--slides:2.6] [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
|
||||
class="featured-posts-track mt-4 flex snap-x snap-mandatory gap-6 overflow-x-auto overscroll-x-contain scroll-smooth pb-1 touch-pan-x [-webkit-overflow-scrolling:touch] [--slides:1.4] sm:[--slides:1.6] lg:[--slides:2.6] [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
|
||||
>
|
||||
<NuxtLink
|
||||
v-for="post in featuredPosts"
|
||||
@@ -195,7 +288,7 @@ const scrollFeatured = (direction) => {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="px-5 py-4 sm:px-6">
|
||||
<section class="py-4 px-6">
|
||||
<div class="mx-auto max-w-[720px]">
|
||||
<div class="flex items-end justify-between gap-2 border-b border-[var(--site-line)] pb-2">
|
||||
<h2 class="text-sm font-medium uppercase site-muted">Latest</h2>
|
||||
@@ -368,19 +461,21 @@ const scrollFeatured = (direction) => {
|
||||
|
||||
<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.tagName }}
|
||||
</span>
|
||||
<span class="text-[var(--site-line)]">/</span>
|
||||
<template v-if="post.tagName">
|
||||
<span v-if="post.publishedAt" 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.tagName }}
|
||||
</span>
|
||||
</template>
|
||||
<span v-if="post.publishedAt || post.tagName" class="text-[var(--site-line)]">/</span>
|
||||
<span class="flex items-center gap-1">
|
||||
<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>{{ post.commentCount }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -23,12 +23,22 @@ if (!post.value) {
|
||||
|
||||
const primaryTagSlug = computed(() => post.value.tags?.[0] || '')
|
||||
const primaryTagMeta = computed(() => {
|
||||
const matchedTag = tags.value.find((item) => item.slug === primaryTagSlug.value)
|
||||
const slug = primaryTagSlug.value
|
||||
|
||||
if (!slug) {
|
||||
return {
|
||||
name: '',
|
||||
color: '',
|
||||
to: '/tags'
|
||||
}
|
||||
}
|
||||
|
||||
const matchedTag = tags.value.find((item) => item.slug === slug)
|
||||
|
||||
return {
|
||||
name: matchedTag?.name || (primaryTagSlug.value ? primaryTagSlug.value.toUpperCase() : 'POST'),
|
||||
name: matchedTag?.name || slug.toUpperCase(),
|
||||
color: matchedTag?.color || '#4d4d4d',
|
||||
to: primaryTagSlug.value ? `/tag/${primaryTagSlug.value}` : '/tags'
|
||||
to: `/tag/${slug}`
|
||||
}
|
||||
})
|
||||
|
||||
@@ -212,8 +222,8 @@ useHead(() => ({
|
||||
|
||||
<template>
|
||||
<div class="post-detail">
|
||||
<section class="px-4 mt-6 mb-8 sm:px-5">
|
||||
<div class="mx-auto flex max-w-[720px] flex-col gap-2.5">
|
||||
<section class="mt-6 mb-8">
|
||||
<div class="mx-auto flex max-w-[720px] flex-col gap-2.5 px-4 sm:px-5">
|
||||
<h1 class="text-xl font-semibold leading-[1.125] sm:text-2xl">
|
||||
{{ post.title }}
|
||||
</h1>
|
||||
@@ -228,8 +238,8 @@ useHead(() => ({
|
||||
{{ authorLabel }}
|
||||
</a>
|
||||
|
||||
<ul class="flex flex-wrap items-center font-medium">
|
||||
<li v-if="primaryTagMeta.name" :style="{ '--color-accent': primaryTagMeta.color }">
|
||||
<ul v-if="primaryTagSlug" class="flex flex-wrap items-center font-medium">
|
||||
<li :style="{ '--color-accent': primaryTagMeta.color }">
|
||||
<NuxtLink
|
||||
class="rounded-sm px-1.5 py-px text-[var(--site-text)] hover:opacity-75"
|
||||
:style="{ backgroundColor: `${primaryTagMeta.color}1a` }"
|
||||
@@ -244,7 +254,7 @@ useHead(() => ({
|
||||
<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 class="pointer-events-none">0</span>
|
||||
<span class="pointer-events-none">{{ post.commentCount || 0 }}</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -266,25 +276,22 @@ useHead(() => ({
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="px-4 sm:px-5">
|
||||
<div class="mx-auto max-w-[720px]">
|
||||
<section>
|
||||
<div class="mx-auto max-w-[720px] px-4 sm:px-5">
|
||||
<ContentRenderer>
|
||||
<ContentMarkdownRenderer class="post-detail__content" :content="post.content" />
|
||||
</ContentRenderer>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="comments" class="mb-6 border-y border-[var(--site-line)] bg-[var(--site-panel-strong)] px-5 py-5 scroll-mt-14 sm:px-6">
|
||||
<div class="mx-auto max-w-[720px] text-sm">
|
||||
<p class="font-medium">Comments</p>
|
||||
<p class="mt-2 site-muted">
|
||||
댓글 UI는 추후 연결 예정입니다.
|
||||
</p>
|
||||
<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>
|
||||
</section>
|
||||
|
||||
<section class="mb-6 px-5 sm:px-6" aria-label="Previous and next post">
|
||||
<div class="mx-auto max-w-[720px]">
|
||||
<section class="mb-6" aria-label="Previous and next post">
|
||||
<div class="mx-auto max-w-[720px] px-4 sm:px-5">
|
||||
<div class="grid gap-4 text-sm font-medium leading-tight md:grid-cols-2">
|
||||
<NuxtLink v-if="previousPost" :to="previousPost.to" class="flex flex-col items-start gap-1 hover:opacity-75">
|
||||
<span class="flex items-center gap-1 text-[0.7rem] font-medium uppercase opacity-75 site-muted">
|
||||
|
||||
@@ -7,7 +7,7 @@ const postCards = computed(() => posts.value.map((post) => ({
|
||||
title: post.title,
|
||||
excerpt: post.excerpt,
|
||||
featuredImage: post.featuredImage,
|
||||
tag: post.tags?.[0]?.toUpperCase() || 'POST',
|
||||
tag: post.tags?.[0] ? String(post.tags[0]).toUpperCase() : '',
|
||||
publishedAt: formatPostDate(post.publishedAt),
|
||||
to: `/post/${post.slug}`
|
||||
})))
|
||||
|
||||
579
pages/settings/index.vue
Normal file
579
pages/settings/index.vue
Normal file
@@ -0,0 +1,579 @@
|
||||
<script setup>
|
||||
const loading = ref(true)
|
||||
const savingProfile = ref(false)
|
||||
const savingPassword = ref(false)
|
||||
const deletingAccount = ref(false)
|
||||
const uploadingAvatar = ref(false)
|
||||
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: '',
|
||||
createdAt: '',
|
||||
updatedAt: '',
|
||||
lastSeenAt: '',
|
||||
previousLastSeenAt: '',
|
||||
previousLastSeenIp: '',
|
||||
commentCount: 0
|
||||
})
|
||||
|
||||
const avatarInputRef = ref(null)
|
||||
|
||||
const passwordForm = reactive({
|
||||
currentPassword: '',
|
||||
nextPassword: '',
|
||||
nextPasswordConfirm: ''
|
||||
})
|
||||
|
||||
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>}
|
||||
*/
|
||||
const loadProfile = async () => {
|
||||
try {
|
||||
const profile = await $fetch('/api/auth/profile')
|
||||
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 {
|
||||
loading.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 saveProfile = async () => {
|
||||
profileMessage.value = ''
|
||||
const available = await checkUsernameAvailable()
|
||||
if (!available) {
|
||||
return
|
||||
}
|
||||
|
||||
savingProfile.value = true
|
||||
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()
|
||||
}
|
||||
|
||||
/**
|
||||
* 썸네일을 제거한다.
|
||||
* @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
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 썸네일 파일을 업로드한다.
|
||||
* @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 {void}
|
||||
*/
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
* 비밀번호를 변경한다.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const savePassword = 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 = ''
|
||||
passwordModalOpen.value = false
|
||||
profileMessage.value = '비밀번호가 변경되었습니다.'
|
||||
} catch (error) {
|
||||
passwordMessage.value = error?.data?.message || '비밀번호 변경에 실패했습니다.'
|
||||
} finally {
|
||||
savingPassword.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 회원 탈퇴를 처리한다.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const removeAccount = async () => {
|
||||
deleteMessage.value = ''
|
||||
if (!deleteForm.password) {
|
||||
deleteMessage.value = '탈퇴 확인용 비밀번호를 입력해 주세요.'
|
||||
return
|
||||
}
|
||||
|
||||
deletingAccount.value = true
|
||||
try {
|
||||
await $fetch('/api/auth/account', {
|
||||
method: 'DELETE',
|
||||
body: {
|
||||
password: deleteForm.password
|
||||
}
|
||||
})
|
||||
await navigateTo('/')
|
||||
} catch (error) {
|
||||
deleteMessage.value = error?.data?.message || '회원 탈퇴에 실패했습니다.'
|
||||
} finally {
|
||||
deletingAccount.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadProfile)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<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="settings-page__loading mt-8 text-sm site-muted">
|
||||
설정 정보를 불러오는 중입니다.
|
||||
</div>
|
||||
|
||||
<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="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>
|
||||
</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>
|
||||
@@ -13,6 +13,12 @@ const form = reactive({
|
||||
password: ''
|
||||
})
|
||||
|
||||
/**
|
||||
* 회원 로그인 제출 가능 여부
|
||||
* @returns {boolean} 제출 가능 여부
|
||||
*/
|
||||
const canSubmitSignIn = computed(() => Boolean(form.email.trim()) && Boolean(form.password))
|
||||
|
||||
/**
|
||||
* 로그인 입력값을 검증한다.
|
||||
* @returns {boolean} 검증 통과 여부
|
||||
@@ -35,7 +41,7 @@ const validateSignIn = () => {
|
||||
}
|
||||
|
||||
/**
|
||||
* 로그인 요청을 시뮬레이션한다.
|
||||
* 로그인 요청을 처리한다.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const submitSignIn = async () => {
|
||||
@@ -44,16 +50,29 @@ const submitSignIn = async () => {
|
||||
}
|
||||
|
||||
isSubmitting.value = true
|
||||
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||
isSubmitting.value = false
|
||||
statusMessage.value = '현재 로그인 API 연결 전입니다. 관리자 로그인은 /admin 을 사용해 주세요.'
|
||||
|
||||
try {
|
||||
await $fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
email: form.email.trim(),
|
||||
password: form.password
|
||||
}
|
||||
})
|
||||
statusMessage.value = '로그인되었습니다. 잠시 후 이동합니다.'
|
||||
await navigateTo('/')
|
||||
} catch (error) {
|
||||
errorMessage.value = error?.data?.message || '로그인에 실패했습니다.'
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="auth-signin min-h-screen bg-[#0a0b0d] text-[#f5f7fa]">
|
||||
<section class="auth-signin 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="w-full max-w-[430px] rounded-2xl border border-[#1a212a] bg-[#0d1116] p-5 sm:p-8">
|
||||
<div class="w-full max-w-[430px] p-5 sm:p-8">
|
||||
<p class="text-2xl font-semibold leading-tight">
|
||||
로그인
|
||||
</p>
|
||||
@@ -66,7 +85,7 @@ const submitSignIn = async () => {
|
||||
<label class="text-xs text-[#d8dee6]">이메일</label>
|
||||
<input
|
||||
v-model="form.email"
|
||||
class="h-10 w-full rounded-[8px] border border-[#1a212a] bg-transparent px-3 text-sm outline-none transition-colors focus:border-[#2f6feb]"
|
||||
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"
|
||||
>
|
||||
@@ -77,24 +96,18 @@ const submitSignIn = async () => {
|
||||
<div class="flex items-center rounded-[8px] border border-[#1a212a] transition-colors focus-within:border-[#2f6feb]">
|
||||
<input
|
||||
v-model="form.password"
|
||||
class="h-10 w-full bg-transparent px-3 text-sm outline-none"
|
||||
class="auth-form-input h-10 min-w-0 flex-1 bg-transparent px-3 text-sm outline-none"
|
||||
:type="showPassword ? 'text' : 'password'"
|
||||
autocomplete="current-password"
|
||||
>
|
||||
<button
|
||||
class="px-3 text-xs text-[#9ba3af] transition-opacity hover:opacity-80"
|
||||
type="button"
|
||||
@click="showPassword = !showPassword"
|
||||
>
|
||||
{{ showPassword ? '숨기기' : '보기' }}
|
||||
</button>
|
||||
<AuthPasswordVisibilityToggle v-model="showPassword" />
|
||||
</div>
|
||||
</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-60"
|
||||
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"
|
||||
:disabled="isSubmitting || !canSubmitSignIn"
|
||||
>
|
||||
로그인
|
||||
</button>
|
||||
@@ -113,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>
|
||||
|
||||
271
pages/signup.vue
271
pages/signup.vue
@@ -4,20 +4,29 @@ definePageMeta({
|
||||
})
|
||||
|
||||
const currentStep = ref(1)
|
||||
const resendCooldown = ref(0)
|
||||
const isSubmitting = ref(false)
|
||||
const signupCompleted = ref(false)
|
||||
const createdAdmin = ref(false)
|
||||
const statusMessage = ref('')
|
||||
const submitErrorMessage = ref('')
|
||||
const { data: siteSettings } = await useFetch('/api/site-settings', {
|
||||
default: () => ({
|
||||
title: 'AFFiNE',
|
||||
description: 'Configure your Self Host AFFiNE with a few simple settings.'
|
||||
})
|
||||
})
|
||||
const { data: bootstrapStatus } = await useFetch('/api/auth/bootstrap-status', {
|
||||
default: () => ({
|
||||
hasUsers: true,
|
||||
needsAdminSetup: false,
|
||||
emailOtpConfigured: false
|
||||
})
|
||||
})
|
||||
|
||||
const form = reactive({
|
||||
username: '',
|
||||
email: '',
|
||||
emailOtp: '',
|
||||
password: '',
|
||||
passwordConfirm: ''
|
||||
})
|
||||
@@ -25,13 +34,26 @@ const form = reactive({
|
||||
const errors = reactive({
|
||||
username: '',
|
||||
email: '',
|
||||
emailOtp: '',
|
||||
password: '',
|
||||
passwordConfirm: ''
|
||||
})
|
||||
|
||||
const canResend = computed(() => resendCooldown.value <= 0)
|
||||
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 ? '관리자 등록' : '회원 가입'))
|
||||
const stepTwoDescription = computed(() => (isAdminBootstrapMode.value
|
||||
? '첫 번째 사용자이므로 소유자 권한이 부여됩니다. 관리 작업 및 사용자 생성이 가능합니다.'
|
||||
: '서비스 이용을 위한 회원 정보를 입력해 주세요.'))
|
||||
|
||||
/**
|
||||
* 필드 에러 메시지를 초기화한다.
|
||||
@@ -40,6 +62,7 @@ const welcomeDescription = computed(() => siteSettings.value?.description || 'Co
|
||||
const resetErrors = () => {
|
||||
errors.username = ''
|
||||
errors.email = ''
|
||||
errors.emailOtp = ''
|
||||
errors.password = ''
|
||||
errors.passwordConfirm = ''
|
||||
}
|
||||
@@ -81,15 +104,72 @@ 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 {void}
|
||||
* 회원가입용 이메일 인증번호를 요청한다.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const goNextStep = () => {
|
||||
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>}
|
||||
*/
|
||||
const goNextStep = async () => {
|
||||
statusMessage.value = ''
|
||||
submitErrorMessage.value = ''
|
||||
|
||||
if (currentStep.value === 1) {
|
||||
currentStep.value = 2
|
||||
@@ -101,8 +181,34 @@ const goNextStep = () => {
|
||||
return
|
||||
}
|
||||
|
||||
currentStep.value = 3
|
||||
resendCooldown.value = 30
|
||||
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: signupBody
|
||||
})
|
||||
createdAdmin.value = Boolean(signupResult?.isAdmin)
|
||||
|
||||
signupCompleted.value = true
|
||||
currentStep.value = 3
|
||||
statusMessage.value = createdAdmin.value
|
||||
? '관리자 등록이 완료되었습니다. 관리자 화면으로 이동합니다.'
|
||||
: '회원가입이 완료되었습니다. 잠시 후 홈으로 이동합니다.'
|
||||
await navigateTo(createdAdmin.value ? '/admin' : '/')
|
||||
} catch (error) {
|
||||
submitErrorMessage.value = error?.data?.message || '회원가입에 실패했습니다.'
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,55 +223,12 @@ const goPreviousStep = () => {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 인증 메일 재전송을 시뮬레이션한다.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const resendVerificationEmail = async () => {
|
||||
if (!canResend.value || isSubmitting.value) {
|
||||
return
|
||||
}
|
||||
|
||||
isSubmitting.value = true
|
||||
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||
isSubmitting.value = false
|
||||
resendCooldown.value = 30
|
||||
statusMessage.value = '인증 메일을 다시 보냈습니다. 메일함을 확인해 주세요.'
|
||||
}
|
||||
|
||||
/**
|
||||
* 이메일 인증 완료를 시뮬레이션한다.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const completeSignup = async () => {
|
||||
isSubmitting.value = true
|
||||
await new Promise((resolve) => setTimeout(resolve, 600))
|
||||
isSubmitting.value = false
|
||||
signupCompleted.value = true
|
||||
statusMessage.value = '이메일 인증이 완료되었습니다. 로그인 페이지로 이동할 수 있습니다.'
|
||||
}
|
||||
|
||||
const countdownTimer = ref(/** @type {ReturnType<typeof setInterval> | null} */ (null))
|
||||
|
||||
onMounted(() => {
|
||||
countdownTimer.value = setInterval(() => {
|
||||
if (resendCooldown.value > 0) {
|
||||
resendCooldown.value -= 1
|
||||
}
|
||||
}, 1000)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (countdownTimer.value) {
|
||||
clearInterval(countdownTimer.value)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="auth-signup min-h-screen bg-[#0a0b0d] text-[#f5f7fa]">
|
||||
<section class="auth-signup min-h-screen bg-[#0a0b0d] text-[#f5f7fa] [color-scheme:dark]">
|
||||
<div class="mx-auto flex min-h-screen w-full max-w-[1280px] items-start px-5 py-12 sm:px-10 sm:py-16 lg:px-16 lg:py-24">
|
||||
<div class="flex min-h-[calc(100vh-6rem)] w-full max-w-[430px] flex-col rounded-2xl border border-[#1a212a] bg-[#0d1116] p-5 sm:min-h-[calc(100vh-8rem)] sm:p-8 lg:min-h-[calc(100vh-12rem)]">
|
||||
<div class="auth-signup__panel flex min-h-[calc(100vh-6rem)] w-full max-w-[430px] flex-col rounded-2xl border border-[#1a212a] bg-[#0d1116] p-5 sm:min-h-[calc(100vh-8rem)] sm:p-8 lg:min-h-[calc(100vh-12rem)]">
|
||||
<div>
|
||||
<template v-if="currentStep === 1">
|
||||
<p class="text-[32px] font-semibold leading-tight sm:text-[40px]">
|
||||
@@ -178,18 +241,18 @@ onBeforeUnmount(() => {
|
||||
|
||||
<template v-else-if="currentStep === 2">
|
||||
<p class="text-2xl font-semibold leading-tight">
|
||||
회원 가입
|
||||
{{ stepTwoTitle }}
|
||||
</p>
|
||||
<p class="mt-2 text-sm text-[#9ba3af]">
|
||||
처음 생성하는 계정은 관리자 계정으로 자동 생성됩니다.
|
||||
{{ stepTwoDescription }}
|
||||
</p>
|
||||
|
||||
<form class="mt-8 space-y-5" @submit.prevent="goNextStep">
|
||||
<div class="space-y-1.5">
|
||||
<label class="text-xs text-[#d8dee6]">사용자명</label>
|
||||
<label class="text-xs text-[#d8dee6]">{{ isAdminBootstrapMode ? '관리자 이름' : '사용자명' }}</label>
|
||||
<input
|
||||
v-model="form.username"
|
||||
class="h-10 w-full rounded-[8px] border bg-transparent px-3 text-sm outline-none transition-colors"
|
||||
class="auth-form-input h-10 w-full rounded-[8px] border bg-transparent px-3 text-sm outline-none transition-colors"
|
||||
:class="errors.username ? 'border-[#b03b43]' : 'border-[#1a212a] focus:border-[#2f6feb]'"
|
||||
type="text"
|
||||
autocomplete="username"
|
||||
@@ -200,10 +263,10 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<label class="text-xs text-[#d8dee6]">이메일</label>
|
||||
<label class="text-xs text-[#d8dee6]">{{ isAdminBootstrapMode ? '관리자 이메일' : '이메일' }}</label>
|
||||
<input
|
||||
v-model="form.email"
|
||||
class="h-10 w-full rounded-[8px] border bg-transparent px-3 text-sm outline-none transition-colors"
|
||||
class="auth-form-input h-10 w-full rounded-[8px] border bg-transparent px-3 text-sm outline-none transition-colors"
|
||||
:class="errors.email ? 'border-[#b03b43]' : 'border-[#1a212a] focus:border-[#2f6feb]'"
|
||||
type="email"
|
||||
autocomplete="email"
|
||||
@@ -213,29 +276,69 @@ onBeforeUnmount(() => {
|
||||
</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]">비밀번호</label>
|
||||
<input
|
||||
v-model="form.password"
|
||||
class="h-10 w-full rounded-[8px] border bg-transparent px-3 text-sm outline-none transition-colors"
|
||||
:class="errors.password ? 'border-[#b03b43]' : 'border-[#1a212a] focus:border-[#2f6feb]'"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
<label class="text-xs text-[#d8dee6]">{{ isAdminBootstrapMode ? '관리자 비밀번호' : '비밀번호' }}</label>
|
||||
<div
|
||||
class="flex items-center rounded-[8px] border transition-colors focus-within:border-[#2f6feb]"
|
||||
:class="errors.password ? 'border-[#b03b43]' : 'border-[#1a212a]'"
|
||||
>
|
||||
<input
|
||||
v-model="form.password"
|
||||
class="auth-form-input h-10 min-w-0 flex-1 bg-transparent px-3 text-sm outline-none"
|
||||
:type="showSignupPassword ? 'text' : 'password'"
|
||||
autocomplete="new-password"
|
||||
>
|
||||
<AuthPasswordVisibilityToggle v-model="showSignupPassword" />
|
||||
</div>
|
||||
<p v-if="errors.password" class="text-xs text-[#e05d67]">
|
||||
{{ errors.password }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<label class="text-xs text-[#d8dee6]">비밀번호 확인</label>
|
||||
<input
|
||||
v-model="form.passwordConfirm"
|
||||
class="h-10 w-full rounded-[8px] border bg-transparent px-3 text-sm outline-none transition-colors"
|
||||
:class="errors.passwordConfirm ? 'border-[#b03b43]' : 'border-[#1a212a] focus:border-[#2f6feb]'"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
<label class="text-xs text-[#d8dee6]">{{ isAdminBootstrapMode ? '관리자 비밀번호 확인' : '비밀번호 확인' }}</label>
|
||||
<div
|
||||
class="flex items-center rounded-[8px] border transition-colors focus-within:border-[#2f6feb]"
|
||||
:class="errors.passwordConfirm ? 'border-[#b03b43]' : 'border-[#1a212a]'"
|
||||
>
|
||||
<input
|
||||
v-model="form.passwordConfirm"
|
||||
class="auth-form-input h-10 min-w-0 flex-1 bg-transparent px-3 text-sm outline-none"
|
||||
:type="showSignupPasswordConfirm ? 'text' : 'password'"
|
||||
autocomplete="new-password"
|
||||
>
|
||||
<AuthPasswordVisibilityToggle v-model="showSignupPasswordConfirm" field-name="비밀번호 확인" />
|
||||
</div>
|
||||
<p v-if="errors.passwordConfirm" class="text-xs text-[#e05d67]">
|
||||
{{ errors.passwordConfirm }}
|
||||
</p>
|
||||
@@ -258,25 +361,17 @@ onBeforeUnmount(() => {
|
||||
|
||||
<template v-else>
|
||||
<p class="text-2xl font-semibold leading-tight">
|
||||
이메일 확인
|
||||
{{ createdAdmin ? '관리자 등록 완료' : '회원가입 완료' }}
|
||||
</p>
|
||||
<p class="mt-2 text-sm text-[#9ba3af]">
|
||||
{{ form.email }} 주소로 인증 메일을 보냈습니다.<br>
|
||||
이메일 링크를 확인해야 회원가입이 확정됩니다.
|
||||
{{ form.email }} 계정으로 {{ createdAdmin ? '관리자 등록' : '가입' }}되었습니다.<br>
|
||||
{{ createdAdmin ? '이제 관리자 화면에서 사이트 운영을 시작할 수 있습니다.' : '이제 로그인 후 댓글을 작성할 수 있습니다.' }}
|
||||
</p>
|
||||
|
||||
<div class="mt-8 rounded-[10px] border border-[#1a212a] bg-[#0d1116] p-4">
|
||||
<p class="text-sm text-[#d8dee6]">
|
||||
메일이 오지 않았다면 인증 메일을 재전송해 주세요.
|
||||
가입이 완료되면 자동으로 홈으로 이동합니다.
|
||||
</p>
|
||||
<button
|
||||
class="mt-3 h-9 rounded-[8px] border border-[#2f6feb] px-4 text-xs font-medium text-[#7eb8ff] transition-opacity disabled:cursor-not-allowed disabled:opacity-40"
|
||||
type="button"
|
||||
:disabled="!canResend || isSubmitting"
|
||||
@click="resendVerificationEmail"
|
||||
>
|
||||
{{ canResend ? '인증 메일 재전송' : `${resendCooldown}초 후 재전송` }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p v-if="statusMessage" class="mt-4 text-sm text-[#7ccf90]" aria-live="polite">
|
||||
@@ -311,9 +406,9 @@ onBeforeUnmount(() => {
|
||||
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-60"
|
||||
type="button"
|
||||
:disabled="isSubmitting"
|
||||
@click="completeSignup"
|
||||
@click="goNextStep"
|
||||
>
|
||||
인증 완료
|
||||
가입 처리 중
|
||||
</button>
|
||||
|
||||
<NuxtLink
|
||||
@@ -325,6 +420,10 @@ onBeforeUnmount(() => {
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
<p v-if="submitErrorMessage" class="mt-3 text-xs text-[#e5acb1]" aria-live="polite">
|
||||
{{ submitErrorMessage }}
|
||||
</p>
|
||||
|
||||
<div class="mt-8 flex items-center gap-1.5">
|
||||
<span class="h-[2px] w-9 rounded-full" :class="currentStep >= 1 ? 'bg-[#2f6feb]' : 'bg-[#222a34]'" />
|
||||
<span class="h-[2px] w-9 rounded-full" :class="currentStep >= 2 ? 'bg-[#2f6feb]' : 'bg-[#222a34]'" />
|
||||
|
||||
@@ -14,13 +14,14 @@ const tag = computed(() => tags.value.find((item) => item.slug === slug.value))
|
||||
|
||||
const tagPosts = computed(() => posts.value
|
||||
.filter((post) => post.tags.includes(slug.value))
|
||||
.map((post, index) => ({
|
||||
.map((post) => ({
|
||||
title: post.title,
|
||||
excerpt: post.excerpt,
|
||||
featuredImage: post.featuredImage,
|
||||
tag: tags.value.find((item) => item.slug === (post.tags?.[0] || slug.value))?.name || (post.tags?.[0] || slug.value).toUpperCase(),
|
||||
tagColor: tags.value.find((item) => item.slug === (post.tags?.[0] || slug.value))?.color || '#4d4d4d',
|
||||
isFeatured: index === 0,
|
||||
isFeatured: Boolean(post.isFeatured),
|
||||
commentCount: Number(post.commentCount || 0),
|
||||
publishedAt: formatPostDate(post.publishedAt),
|
||||
publishedAtIso: post.publishedAt || '',
|
||||
to: `/post/${post.slug}`
|
||||
@@ -29,8 +30,8 @@ const tagPosts = computed(() => posts.value
|
||||
|
||||
<template>
|
||||
<MainColumn>
|
||||
<section class="px-5 pt-4 sm:px-6 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 +43,95 @@ const tagPosts = computed(() => posts.value
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="px-5 sm:px-6">
|
||||
<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>{{ post.commentCount }}</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="px-5 py-4 sm:px-6">
|
||||
<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>
|
||||
|
||||
@@ -17,7 +17,7 @@ const getPostCount = (slug) => posts.value.filter((post) => post.tags.includes(s
|
||||
|
||||
<template>
|
||||
<MainColumn>
|
||||
<section class="tags-page-hero px-5 sm:px-6">
|
||||
<section class="tags-page-hero">
|
||||
<div class="mx-auto flex max-w-[720px] flex-col gap-6 py-6 text-center md:py-8">
|
||||
<div class="mx-auto flex max-w-xl flex-1 flex-col items-center justify-center gap-2">
|
||||
<h1 class="text-xl font-semibold leading-[1.125] md:text-2xl lg:text-3xl">
|
||||
@@ -30,8 +30,8 @@ const getPostCount = (slug) => posts.value.filter((post) => post.tags.includes(s
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="tags-page-list mb-8 px-5 sm:px-6">
|
||||
<ul class="mx-auto grid max-w-[720px] gap-4 sm:gap-5 lg:grid-cols-3">
|
||||
<section class="tags-page-list mb-8">
|
||||
<ul class="px-6 mx-auto grid max-w-[720px] gap-4 sm:gap-5 lg:grid-cols-3">
|
||||
<li
|
||||
v-for="tag in tags"
|
||||
:key="tag.id"
|
||||
|
||||
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`)
|
||||
15
scripts/node-paths-nitro-shim.mjs
Normal file
15
scripts/node-paths-nitro-shim.mjs
Normal file
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* 루트 `package.json`의 `imports`로 Node가 직접 해석할 때만 쓰인다.
|
||||
* 비개발용 `.nuxt/paths.mjs`가 `useRuntimeConfig().app`만 참조하므로 해당 형태만 맞춘다.
|
||||
* @returns {{ app: { baseURL: string, buildAssetsDir: string, cdnURL: string } }} 최소 런타임 설정
|
||||
*/
|
||||
export function useRuntimeConfig () {
|
||||
const env = process.env
|
||||
return {
|
||||
app: {
|
||||
baseURL: env.NUXT_APP_BASE_URL || env.NUXT_PUBLIC_APP_BASE_URL || '/',
|
||||
buildAssetsDir: env.NUXT_APP_BUILD_ASSETS_DIR || '/_nuxt/',
|
||||
cdnURL: env.NUXT_APP_CDN_URL || env.NUXT_PUBLIC_CDN_URL || ''
|
||||
}
|
||||
}
|
||||
}
|
||||
61
server/api/auth/account.delete.js
Normal file
61
server/api/auth/account.delete.js
Normal file
@@ -0,0 +1,61 @@
|
||||
import bcrypt from 'bcrypt'
|
||||
import { createError, readBody } from 'h3'
|
||||
import { z } from 'zod'
|
||||
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'
|
||||
|
||||
const deleteAccountSchema = z.object({
|
||||
password: z.string().min(1)
|
||||
})
|
||||
|
||||
/**
|
||||
* 회원 탈퇴 API
|
||||
* @param {import('h3').H3Event} event - 요청 이벤트
|
||||
* @returns {Promise<{ ok: true }>} 처리 결과
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const session = requireMemberSession(event)
|
||||
const parsedBody = deleteAccountSchema.safeParse(await readBody(event))
|
||||
|
||||
if (!parsedBody.success) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '탈퇴 요청 형식이 올바르지 않습니다.'
|
||||
})
|
||||
}
|
||||
|
||||
const user = await getUserByIdWithPassword(session.userId)
|
||||
if (!user) {
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
message: '회원 정보를 찾을 수 없습니다.'
|
||||
})
|
||||
}
|
||||
|
||||
const isPasswordValid = await bcrypt.compare(parsedBody.data.password, user.passwordHash)
|
||||
if (!isPasswordValid) {
|
||||
throw createError({
|
||||
statusCode: 401,
|
||||
message: '비밀번호가 올바르지 않습니다.'
|
||||
})
|
||||
}
|
||||
|
||||
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 }
|
||||
})
|
||||
34
server/api/auth/avatar.delete.js
Normal file
34
server/api/auth/avatar.delete.js
Normal file
@@ -0,0 +1,34 @@
|
||||
import { createError } from 'h3'
|
||||
import { getUserById, updateMemberProfile } from '../../repositories/member-repository'
|
||||
import { requireMemberSession } from '../../utils/member-auth'
|
||||
import { removeManagedAvatarAsset } from '../../utils/member-avatar'
|
||||
|
||||
/**
|
||||
* 회원 썸네일 삭제 API
|
||||
* @param {import('h3').H3Event} event - 요청 이벤트
|
||||
* @returns {Promise<{ ok: true }>} 처리 결과
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const session = requireMemberSession(event)
|
||||
const user = await getUserById(session.userId)
|
||||
|
||||
if (!user) {
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
message: '회원 정보를 찾을 수 없습니다.'
|
||||
})
|
||||
}
|
||||
|
||||
if (user.avatarUrl) {
|
||||
await removeManagedAvatarAsset(user.avatarUrl)
|
||||
}
|
||||
|
||||
await updateMemberProfile({
|
||||
userId: user.id,
|
||||
username: user.username,
|
||||
avatarUrl: ''
|
||||
})
|
||||
|
||||
return { ok: true }
|
||||
})
|
||||
|
||||
40
server/api/auth/avatar.post.js
Normal file
40
server/api/auth/avatar.post.js
Normal file
@@ -0,0 +1,40 @@
|
||||
import { createError } from 'h3'
|
||||
import { updateMemberProfile, getUserById } from '../../repositories/member-repository'
|
||||
import { requireMemberSession } from '../../utils/member-auth'
|
||||
import { removeManagedAvatarAsset } from '../../utils/member-avatar'
|
||||
import { uploadMemberAvatarImage } from '../../utils/member-avatar-upload'
|
||||
import { MEDIA_THUMBNAIL_ROOT, upsertMediaMetadataCategory } from '../../utils/media-library'
|
||||
|
||||
/**
|
||||
* 회원 썸네일 업로드 API
|
||||
* @param {import('h3').H3Event} event - 요청 이벤트
|
||||
* @returns {Promise<{ avatarUrl: string }>} 업로드 결과
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const session = requireMemberSession(event)
|
||||
const currentUser = await getUserById(session.userId)
|
||||
if (!currentUser) {
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
message: '회원 정보를 찾을 수 없습니다.'
|
||||
})
|
||||
}
|
||||
|
||||
const { avatarUrl } = await uploadMemberAvatarImage(event)
|
||||
|
||||
await updateMemberProfile({
|
||||
userId: session.userId,
|
||||
username: currentUser.username,
|
||||
avatarUrl
|
||||
})
|
||||
|
||||
await upsertMediaMetadataCategory(avatarUrl, MEDIA_THUMBNAIL_ROOT)
|
||||
|
||||
if (currentUser.avatarUrl && currentUser.avatarUrl !== avatarUrl) {
|
||||
await removeManagedAvatarAsset(currentUser.avatarUrl)
|
||||
}
|
||||
|
||||
return {
|
||||
avatarUrl
|
||||
}
|
||||
})
|
||||
18
server/api/auth/bootstrap-status.get.js
vendored
Normal file
18
server/api/auth/bootstrap-status.get.js
vendored
Normal file
@@ -0,0 +1,18 @@
|
||||
import { getMemberBootstrapState } from '../../repositories/member-repository'
|
||||
import { isResendConfigured } from '../../utils/resend-mail'
|
||||
import { getRuntimeEnvValue } from '../../utils/runtime-env'
|
||||
|
||||
/**
|
||||
* 최초 관리자 등록 필요 여부·이메일 OTP(Resend) 사용 가능 여부를 조회한다.
|
||||
* @returns {Promise<{ hasUsers: boolean, needsAdminSetup: boolean, emailOtpConfigured: boolean }>} 부트스트랩 상태
|
||||
*/
|
||||
export default defineEventHandler(async () => {
|
||||
const base = await getMemberBootstrapState()
|
||||
const config = useRuntimeConfig()
|
||||
const hasPepper = Boolean(getRuntimeEnvValue('EMAIL_OTP_PEPPER', 'emailOtpPepper', getRuntimeEnvValue('MEMBER_SESSION_SECRET', 'memberSessionSecret')).trim())
|
||||
const emailOtpConfigured = isResendConfigured(config) && hasPepper
|
||||
return {
|
||||
...base,
|
||||
emailOtpConfigured
|
||||
}
|
||||
})
|
||||
31
server/api/auth/check-username.get.js
Normal file
31
server/api/auth/check-username.get.js
Normal file
@@ -0,0 +1,31 @@
|
||||
import { createError } from 'h3'
|
||||
import { isUsernameTaken } from '../../repositories/member-repository'
|
||||
import { requireMemberSession } from '../../utils/member-auth'
|
||||
|
||||
/**
|
||||
* 사용자명 중복 확인 API
|
||||
* @param {import('h3').H3Event} event - 요청 이벤트
|
||||
* @returns {Promise<{ available: boolean }>} 사용 가능 여부
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const session = requireMemberSession(event)
|
||||
const rawUsername = getQuery(event).username
|
||||
const username = typeof rawUsername === 'string' ? rawUsername.trim() : ''
|
||||
|
||||
if (!username) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '닉네임을 입력해 주세요.'
|
||||
})
|
||||
}
|
||||
|
||||
const taken = await isUsernameTaken({
|
||||
username,
|
||||
excludeUserId: session.userId
|
||||
})
|
||||
|
||||
return {
|
||||
available: !taken
|
||||
}
|
||||
})
|
||||
|
||||
173
server/api/auth/email-otp/request.post.js
Normal file
173
server/api/auth/email-otp/request.post.js
Normal file
@@ -0,0 +1,173 @@
|
||||
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'
|
||||
import { getRuntimeEnvValue } from '../../../utils/runtime-env'
|
||||
|
||||
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 = getRuntimeEnvValue('EMAIL_OTP_PEPPER', 'emailOtpPepper', getRuntimeEnvValue('MEMBER_SESSION_SECRET', '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: getRuntimeEnvValue('RESEND_API_KEY', 'resendApiKey').trim(),
|
||||
from: getRuntimeEnvValue('RESEND_FROM_EMAIL', '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'
|
||||
? '인증 메일을 발송했습니다. 메일함을 확인해 주세요.'
|
||||
: '등록된 주소라면 인증 메일을 발송했습니다. 메일함을 확인해 주세요.'
|
||||
}
|
||||
})
|
||||
51
server/api/auth/login.post.js
Normal file
51
server/api/auth/login.post.js
Normal file
@@ -0,0 +1,51 @@
|
||||
import bcrypt from 'bcrypt'
|
||||
import { z } from 'zod'
|
||||
import { createError, getRequestIP, readBody } from 'h3'
|
||||
import { getUserByEmail, touchUserActivity } from '../../repositories/member-repository'
|
||||
import { setMemberSession } from '../../utils/member-auth'
|
||||
|
||||
const loginSchema = z.object({
|
||||
email: z.string().trim().email(),
|
||||
password: z.string().min(1)
|
||||
})
|
||||
|
||||
/**
|
||||
* 회원 로그인 API
|
||||
* @param {import('h3').H3Event} event - 요청 이벤트
|
||||
* @returns {Promise<{ id: string, username: string, email: string }>} 회원 정보
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const parsedBody = loginSchema.safeParse(await readBody(event))
|
||||
|
||||
if (!parsedBody.success) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '로그인 요청 형식이 올바르지 않습니다.'
|
||||
})
|
||||
}
|
||||
|
||||
const body = parsedBody.data
|
||||
const emailNorm = body.email.trim().toLowerCase()
|
||||
const user = await getUserByEmail(emailNorm)
|
||||
|
||||
if (!user || !(await bcrypt.compare(body.password, user.passwordHash))) {
|
||||
throw createError({
|
||||
statusCode: 401,
|
||||
message: '이메일 또는 비밀번호가 올바르지 않습니다.'
|
||||
})
|
||||
}
|
||||
|
||||
setMemberSession(event, { userId: user.id, email: user.email })
|
||||
await touchUserActivity({
|
||||
userId: user.id,
|
||||
ip: String(getRequestIP(event) || '')
|
||||
})
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
avatarUrl: user.avatarUrl || ''
|
||||
}
|
||||
})
|
||||
|
||||
12
server/api/auth/logout.post.js
Normal file
12
server/api/auth/logout.post.js
Normal file
@@ -0,0 +1,12 @@
|
||||
import { clearMemberSession } from '../../utils/member-auth'
|
||||
|
||||
/**
|
||||
* 회원 로그아웃 API
|
||||
* @param {import('h3').H3Event} event - 요청 이벤트
|
||||
* @returns {{ ok: true }} 처리 결과
|
||||
*/
|
||||
export default defineEventHandler((event) => {
|
||||
clearMemberSession(event)
|
||||
return { ok: true }
|
||||
})
|
||||
|
||||
28
server/api/auth/me.get.js
Normal file
28
server/api/auth/me.get.js
Normal file
@@ -0,0 +1,28 @@
|
||||
import { getUserById } from '../../repositories/member-repository'
|
||||
import { requireMemberSession } from '../../utils/member-auth'
|
||||
|
||||
/**
|
||||
* 회원 세션 조회 API
|
||||
* @param {import('h3').H3Event} event - 요청 이벤트
|
||||
* @returns {Promise<{ id: string, username: string, email: string, avatarUrl: string }>} 회원 정보
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const session = requireMemberSession(event)
|
||||
const user = await getUserById(session.userId)
|
||||
|
||||
if (!user) {
|
||||
return {
|
||||
id: session.userId,
|
||||
username: '',
|
||||
email: session.email,
|
||||
avatarUrl: ''
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
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'
|
||||
import { getRuntimeEnvValue } from '../../../utils/runtime-env'
|
||||
|
||||
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 pepper = getRuntimeEnvValue('EMAIL_OTP_PEPPER', 'emailOtpPepper', getRuntimeEnvValue('MEMBER_SESSION_SECRET', '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 }
|
||||
})
|
||||
52
server/api/auth/password.put.js
Normal file
52
server/api/auth/password.put.js
Normal file
@@ -0,0 +1,52 @@
|
||||
import bcrypt from 'bcrypt'
|
||||
import { createError, readBody } from 'h3'
|
||||
import { z } from 'zod'
|
||||
import { getUserByIdWithPassword, updateMemberPassword } from '../../repositories/member-repository'
|
||||
import { requireMemberSession } from '../../utils/member-auth'
|
||||
|
||||
const updatePasswordSchema = z.object({
|
||||
currentPassword: z.string().min(1),
|
||||
nextPassword: z.string().min(8).max(32)
|
||||
})
|
||||
|
||||
/**
|
||||
* 회원 비밀번호 변경 API
|
||||
* @param {import('h3').H3Event} event - 요청 이벤트
|
||||
* @returns {Promise<{ ok: true }>} 변경 결과
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const session = requireMemberSession(event)
|
||||
const parsedBody = updatePasswordSchema.safeParse(await readBody(event))
|
||||
|
||||
if (!parsedBody.success) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '비밀번호 변경 요청 형식이 올바르지 않습니다.'
|
||||
})
|
||||
}
|
||||
|
||||
const user = await getUserByIdWithPassword(session.userId)
|
||||
if (!user) {
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
message: '회원 정보를 찾을 수 없습니다.'
|
||||
})
|
||||
}
|
||||
|
||||
const isCurrentValid = await bcrypt.compare(parsedBody.data.currentPassword, user.passwordHash)
|
||||
if (!isCurrentValid) {
|
||||
throw createError({
|
||||
statusCode: 401,
|
||||
message: '현재 비밀번호가 올바르지 않습니다.'
|
||||
})
|
||||
}
|
||||
|
||||
const nextHash = await bcrypt.hash(parsedBody.data.nextPassword, 12)
|
||||
await updateMemberPassword({
|
||||
userId: session.userId,
|
||||
passwordHash: nextHash
|
||||
})
|
||||
|
||||
return { ok: true }
|
||||
})
|
||||
|
||||
33
server/api/auth/profile.get.js
Normal file
33
server/api/auth/profile.get.js
Normal file
@@ -0,0 +1,33 @@
|
||||
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, 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 getUserProfileById(session.userId)
|
||||
|
||||
if (!user) {
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
message: '회원 정보를 찾을 수 없습니다.'
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
username: user.username,
|
||||
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)
|
||||
}
|
||||
})
|
||||
84
server/api/auth/profile.put.js
Normal file
84
server/api/auth/profile.put.js
Normal file
@@ -0,0 +1,84 @@
|
||||
import { createError, readBody } from 'h3'
|
||||
import { z } from 'zod'
|
||||
import { getUserById, isUsernameTaken, updateMemberProfile } from '../../repositories/member-repository'
|
||||
import { requireMemberSession } from '../../utils/member-auth'
|
||||
import { isManagedAvatarUrl, removeManagedAvatarAsset } from '../../utils/member-avatar'
|
||||
|
||||
const updateProfileSchema = z.object({
|
||||
username: z.string().trim().min(1).max(30),
|
||||
avatarUrl: z.string().trim().max(500).optional().default('')
|
||||
})
|
||||
|
||||
/**
|
||||
* 회원 프로필 수정 API
|
||||
* @param {import('h3').H3Event} event - 요청 이벤트
|
||||
* @returns {Promise<{ id: string, email: string, username: string, avatarUrl: string }>} 수정된 회원 프로필
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const session = requireMemberSession(event)
|
||||
const parsedBody = updateProfileSchema.safeParse(await readBody(event))
|
||||
|
||||
if (!parsedBody.success) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '프로필 요청 형식이 올바르지 않습니다.'
|
||||
})
|
||||
}
|
||||
|
||||
const taken = await isUsernameTaken({
|
||||
username: parsedBody.data.username,
|
||||
excludeUserId: session.userId
|
||||
})
|
||||
|
||||
if (taken) {
|
||||
throw createError({
|
||||
statusCode: 409,
|
||||
message: '이미 사용 중인 닉네임입니다.'
|
||||
})
|
||||
}
|
||||
|
||||
const existing = await getUserById(session.userId)
|
||||
|
||||
if (!existing) {
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
message: '회원 정보를 찾을 수 없습니다.'
|
||||
})
|
||||
}
|
||||
|
||||
const previousAvatarUrl = existing.avatarUrl || ''
|
||||
const nextAvatarUrl = parsedBody.data.avatarUrl || ''
|
||||
|
||||
if (previousAvatarUrl && previousAvatarUrl !== nextAvatarUrl && isManagedAvatarUrl(previousAvatarUrl)) {
|
||||
await removeManagedAvatarAsset(previousAvatarUrl)
|
||||
}
|
||||
|
||||
const updated = await updateMemberProfile({
|
||||
userId: session.userId,
|
||||
username: parsedBody.data.username,
|
||||
avatarUrl: nextAvatarUrl
|
||||
})
|
||||
|
||||
if (!updated) {
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
message: '회원 정보를 찾을 수 없습니다.'
|
||||
})
|
||||
}
|
||||
|
||||
const user = await getUserById(session.userId)
|
||||
if (!user) {
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
message: '회원 정보를 찾을 수 없습니다.'
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
username: user.username,
|
||||
avatarUrl: user.avatarUrl || ''
|
||||
}
|
||||
})
|
||||
|
||||
122
server/api/auth/signup.post.js
Normal file
122
server/api/auth/signup.post.js
Normal file
@@ -0,0 +1,122 @@
|
||||
import bcrypt from 'bcrypt'
|
||||
import { z } from 'zod'
|
||||
import { createError, getRequestIP, readBody } from 'h3'
|
||||
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'
|
||||
import { getRuntimeEnvValue } from '../../utils/runtime-env'
|
||||
|
||||
const signupSchema = z.object({
|
||||
username: z.string().trim().min(1),
|
||||
email: z.string().trim().email(),
|
||||
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(getRuntimeEnvValue('EMAIL_OTP_PEPPER', 'emailOtpPepper', getRuntimeEnvValue('MEMBER_SESSION_SECRET', 'memberSessionSecret')).trim())
|
||||
return isResendConfigured(config) && hasPepper
|
||||
}
|
||||
|
||||
/**
|
||||
* 회원 가입 API
|
||||
* @param {import('h3').H3Event} event - 요청 이벤트
|
||||
* @returns {Promise<{ id: string, username: string, email: string, avatarUrl: string, isAdmin: boolean }>} 회원 정보
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const parsedBody = signupSchema.safeParse(await readBody(event))
|
||||
|
||||
if (!parsedBody.success) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '회원가입 요청 형식이 올바르지 않습니다.'
|
||||
})
|
||||
}
|
||||
|
||||
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
|
||||
})
|
||||
|
||||
if (usernameTaken) {
|
||||
throw createError({
|
||||
statusCode: 409,
|
||||
message: '이미 사용 중인 닉네임입니다.'
|
||||
})
|
||||
}
|
||||
|
||||
const existingUser = await getUserByEmail(emailNorm)
|
||||
|
||||
if (existingUser) {
|
||||
throw createError({
|
||||
statusCode: 409,
|
||||
message: '이미 사용 중인 이메일입니다.'
|
||||
})
|
||||
}
|
||||
|
||||
if (otpRequired) {
|
||||
const otp = body.emailOtp
|
||||
if (!otp) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '이메일 인증번호를 입력해 주세요.'
|
||||
})
|
||||
}
|
||||
const pepper = getRuntimeEnvValue('EMAIL_OTP_PEPPER', 'emailOtpPepper', getRuntimeEnvValue('MEMBER_SESSION_SECRET', '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.trim(),
|
||||
email: emailNorm,
|
||||
passwordHash
|
||||
})
|
||||
|
||||
setMemberSession(event, { userId: created.id, email: created.email })
|
||||
if (created.isAdmin) {
|
||||
setAdminSession(event, {
|
||||
userId: created.id,
|
||||
email: created.email
|
||||
})
|
||||
}
|
||||
await touchUserActivity({
|
||||
userId: created.id,
|
||||
ip: String(getRequestIP(event) || '')
|
||||
})
|
||||
|
||||
return {
|
||||
id: created.id,
|
||||
username: created.username,
|
||||
email: created.email,
|
||||
avatarUrl: created.avatarUrl || '',
|
||||
isAdmin: Boolean(created.isAdmin)
|
||||
}
|
||||
})
|
||||
@@ -2,6 +2,6 @@ import { getPublicNavigation } from '../repositories/content-repository'
|
||||
|
||||
/**
|
||||
* 공개 네비게이션 API
|
||||
* @returns {Promise<{primary: Array<Object>, footer: Array<Object>}>} 위치별 네비게이션 항목
|
||||
* @returns {Promise<{primary: Array<Object>, footer: Array<Object>}>} `primary`는 트리(`children` 선택), `footer`는 평면
|
||||
*/
|
||||
export default defineEventHandler(() => getPublicNavigation())
|
||||
|
||||
18
server/api/posts/[slug]/comments.get.js
Normal file
18
server/api/posts/[slug]/comments.get.js
Normal file
@@ -0,0 +1,18 @@
|
||||
import { listPostCommentsBySlug } from '../../../repositories/comment-repository'
|
||||
import { getMemberSession } from '../../../utils/member-auth'
|
||||
|
||||
/**
|
||||
* 게시물 댓글 목록 조회 API
|
||||
* @param {import('h3').H3Event} event - 요청 이벤트
|
||||
* @returns {Promise<{ comments: Array<Object> }>} 댓글 목록
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const slug = String(getRouterParam(event, 'slug') || '')
|
||||
const session = getMemberSession(event)
|
||||
const comments = await listPostCommentsBySlug(slug, session?.userId || null)
|
||||
|
||||
return {
|
||||
comments
|
||||
}
|
||||
})
|
||||
|
||||
44
server/api/posts/[slug]/comments.post.js
Normal file
44
server/api/posts/[slug]/comments.post.js
Normal file
@@ -0,0 +1,44 @@
|
||||
import { createError, getRequestIP, readBody } from 'h3'
|
||||
import { z } from 'zod'
|
||||
import { createComment } from '../../../repositories/comment-repository'
|
||||
import { touchUserActivity } from '../../../repositories/member-repository'
|
||||
import { requireMemberSession } from '../../../utils/member-auth'
|
||||
|
||||
const createCommentSchema = z.object({
|
||||
body: z.string().trim().min(1).max(5000),
|
||||
parentId: z.string().uuid().optional().nullable()
|
||||
})
|
||||
|
||||
/**
|
||||
* 게시물 댓글 생성 API
|
||||
* @param {import('h3').H3Event} event - 요청 이벤트
|
||||
* @returns {Promise<{ comment: Object }>} 생성 댓글
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const session = requireMemberSession(event)
|
||||
const slug = String(getRouterParam(event, 'slug') || '')
|
||||
const parsedBody = createCommentSchema.safeParse(await readBody(event))
|
||||
|
||||
if (!parsedBody.success) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '댓글 요청 형식이 올바르지 않습니다.'
|
||||
})
|
||||
}
|
||||
|
||||
const comment = await createComment({
|
||||
slug,
|
||||
userId: session.userId,
|
||||
body: parsedBody.data.body,
|
||||
parentId: parsedBody.data.parentId || null
|
||||
})
|
||||
await touchUserActivity({
|
||||
userId: session.userId,
|
||||
ip: String(getRequestIP(event) || '')
|
||||
})
|
||||
|
||||
return {
|
||||
comment
|
||||
}
|
||||
})
|
||||
|
||||
28
server/api/posts/[slug]/comments/[commentId]/like.post.js
Normal file
28
server/api/posts/[slug]/comments/[commentId]/like.post.js
Normal file
@@ -0,0 +1,28 @@
|
||||
import { getRequestIP } from 'h3'
|
||||
import { toggleCommentLike } from '../../../../../repositories/comment-repository'
|
||||
import { touchUserActivity } from '../../../../../repositories/member-repository'
|
||||
import { requireMemberSession } from '../../../../../utils/member-auth'
|
||||
|
||||
/**
|
||||
* 댓글 좋아요 토글 API
|
||||
* @param {import('h3').H3Event} event - 요청 이벤트
|
||||
* @returns {Promise<{ liked: boolean, likeCount: number }>} 좋아요 상태
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const session = requireMemberSession(event)
|
||||
const slug = String(getRouterParam(event, 'slug') || '')
|
||||
const commentId = String(getRouterParam(event, 'commentId') || '')
|
||||
|
||||
const result = await toggleCommentLike({
|
||||
slug,
|
||||
commentId,
|
||||
userId: session.userId
|
||||
})
|
||||
|
||||
await touchUserActivity({
|
||||
userId: session.userId,
|
||||
ip: String(getRequestIP(event) || '')
|
||||
})
|
||||
|
||||
return result
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user