Compare commits
13 Commits
6e25cdfd60
...
v1.0.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 3b331b8fe6 | |||
| 069d1bfbd4 | |||
| 965a8fd1f6 | |||
| 020471a1b8 | |||
| 52f22b4ff1 | |||
| bebf7ee1c9 | |||
| 6481f958f5 | |||
| 79d0a30475 | |||
| fb0dadb7b9 | |||
| b4f3fdb77d | |||
| 6cb6268b43 | |||
| b490d5b90f | |||
| ec9f9ea57f |
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>
|
<template>
|
||||||
<NuxtLayout>
|
<NuxtLayout>
|
||||||
<NuxtPage />
|
<NuxtPage />
|
||||||
|
|||||||
@@ -22,11 +22,19 @@ const isApplyingExternalValue = ref(false)
|
|||||||
const uploadingBlockIds = ref([])
|
const uploadingBlockIds = ref([])
|
||||||
const mediaItems = ref([])
|
const mediaItems = ref([])
|
||||||
const mediaPickerTarget = ref(null)
|
const mediaPickerTarget = ref(null)
|
||||||
|
const selectedGalleryMediaUrls = ref([])
|
||||||
const isMediaPickerOpen = ref(false)
|
const isMediaPickerOpen = ref(false)
|
||||||
const isLoadingMedia = ref(false)
|
const isLoadingMedia = ref(false)
|
||||||
const isComposingText = ref(false)
|
const isComposingText = ref(false)
|
||||||
const isNormalizingTrailingBlock = ref(false)
|
const isNormalizingTrailingBlock = ref(false)
|
||||||
const pendingSoftLineBreakIndex = ref(-1)
|
const pendingSoftLineBreakIndex = ref(-1)
|
||||||
|
const pendingSlashCommandIndex = ref(-1)
|
||||||
|
const draggingGalleryImage = ref(null)
|
||||||
|
const galleryDragTarget = ref(null)
|
||||||
|
const isKeyboardPriorityMode = ref(false)
|
||||||
|
const calloutEmojiPickerBlockId = ref('')
|
||||||
|
const calloutColorPopoverBlockId = ref('')
|
||||||
|
const calloutEmojiComposingBlockId = ref('')
|
||||||
let blockIdSeed = 0
|
let blockIdSeed = 0
|
||||||
|
|
||||||
const imageWidthOptions = [
|
const imageWidthOptions = [
|
||||||
@@ -35,6 +43,16 @@ const imageWidthOptions = [
|
|||||||
{ value: 'full', label: '풀사이즈' }
|
{ value: 'full', label: '풀사이즈' }
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const calloutBackgroundOptions = [
|
||||||
|
{ value: 'gray', label: '회색' },
|
||||||
|
{ value: 'blue', label: '파랑' },
|
||||||
|
{ value: 'green', label: '초록' },
|
||||||
|
{ value: 'yellow', label: '노랑' },
|
||||||
|
{ value: 'red', label: '빨강' },
|
||||||
|
{ value: 'purple', label: '보라' },
|
||||||
|
{ value: 'pink', label: '핑크' }
|
||||||
|
]
|
||||||
|
|
||||||
const blockCommands = [
|
const blockCommands = [
|
||||||
{
|
{
|
||||||
type: 'paragraph',
|
type: 'paragraph',
|
||||||
@@ -132,9 +150,52 @@ const createEditorBlock = (type = 'paragraph', text = '', level = null, id = '',
|
|||||||
alt: options.alt || '',
|
alt: options.alt || '',
|
||||||
title: options.title || '',
|
title: options.title || '',
|
||||||
width: options.width || 'regular',
|
width: options.width || 'regular',
|
||||||
images: options.images || []
|
images: options.images || [],
|
||||||
|
calloutEmojiEnabled: options.calloutEmojiEnabled ?? true,
|
||||||
|
calloutEmoji: options.calloutEmoji || '💡',
|
||||||
|
calloutBackground: options.calloutBackground || 'blue'
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 콜아웃 선언부 옵션을 파싱
|
||||||
|
* @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.some((item) => item.value === value)) {
|
||||||
|
options.calloutBackground = value
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return options
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 이미지 마크다운 행을 블록 옵션으로 변환
|
* 이미지 마크다운 행을 블록 옵션으로 변환
|
||||||
* @param {string} line - 마크다운 행
|
* @param {string} line - 마크다운 행
|
||||||
@@ -216,9 +277,9 @@ const parseMarkdownToBlocks = (markdown) => {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if (trimmedLine === ':::callout') {
|
if (trimmedLine.startsWith(':::callout')) {
|
||||||
const { contentLines, nextIndex } = collectFencedLines(lines, index + 1)
|
const { contentLines, nextIndex } = collectFencedLines(lines, index + 1)
|
||||||
blocks.push(createEditorBlock('callout', contentLines.join('\n'), null, `editor-block-${blocks.length}`))
|
blocks.push(createEditorBlock('callout', contentLines.join('\n'), null, `editor-block-${blocks.length}`, parseCalloutOptions(trimmedLine)))
|
||||||
index = nextIndex
|
index = nextIndex
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -340,8 +401,14 @@ const serializeBlocks = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (block.type === 'callout') {
|
if (block.type === 'callout') {
|
||||||
|
const emoji = block.calloutEmojiEnabled
|
||||||
|
? (block.calloutEmoji || '💡')
|
||||||
|
: 'none'
|
||||||
|
const bg = calloutBackgroundOptions.some((item) => item.value === block.calloutBackground)
|
||||||
|
? block.calloutBackground
|
||||||
|
: 'blue'
|
||||||
return text
|
return text
|
||||||
? { type: block.type, value: `:::callout\n${text}\n:::` }
|
? { type: block.type, value: `:::callout emoji=${emoji} bg=${bg}\n${text}\n:::` }
|
||||||
: null
|
: null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -412,6 +479,27 @@ const emitContent = () => {
|
|||||||
emit('update:modelValue', serializeBlocks())
|
emit('update:modelValue', serializeBlocks())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 키보드 입력 우선 모드 활성화
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const enableKeyboardPriorityMode = () => {
|
||||||
|
isKeyboardPriorityMode.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 마우스 상호작용 시 hover 복귀
|
||||||
|
* @param {MouseEvent} event - 마우스 이동 이벤트
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const handleEditorMouseMove = (event) => {
|
||||||
|
if (event.buttons !== 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isKeyboardPriorityMode.value = false
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 텍스트 입력 블록 여부 반환
|
* 텍스트 입력 블록 여부 반환
|
||||||
* @param {Object} block - 에디터 블록
|
* @param {Object} block - 에디터 블록
|
||||||
@@ -419,6 +507,20 @@ const emitContent = () => {
|
|||||||
*/
|
*/
|
||||||
const isTextBlock = (block) => !['divider', 'image', 'gallery', 'toggle', 'embed'].includes(block.type)
|
const isTextBlock = (block) => !['divider', 'image', 'gallery', 'toggle', 'embed'].includes(block.type)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 갤러리 열 개수 반환
|
||||||
|
* @param {Object} block - 갤러리 블록
|
||||||
|
* @returns {number} 열 개수
|
||||||
|
*/
|
||||||
|
const getGalleryColumnCount = (block) => Math.min(Math.max(block.images.length, 1), 3)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 갤러리 미디어 선택 여부 확인
|
||||||
|
* @param {Object} mediaItem - 미디어 항목
|
||||||
|
* @returns {boolean} 선택 여부
|
||||||
|
*/
|
||||||
|
const isGalleryMediaSelected = (mediaItem) => selectedGalleryMediaUrls.value.includes(mediaItem.url)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 비어 있는 문단 블록 여부 반환
|
* 비어 있는 문단 블록 여부 반환
|
||||||
* @param {Object|undefined} block - 에디터 블록
|
* @param {Object|undefined} block - 에디터 블록
|
||||||
@@ -665,7 +767,6 @@ const getBlockClass = (block) => [
|
|||||||
'admin-block-editor__heading--h2 text-4xl': block.type === 'heading' && block.level === 2,
|
'admin-block-editor__heading--h2 text-4xl': block.type === 'heading' && block.level === 2,
|
||||||
'admin-block-editor__heading--h3 text-3xl': block.type === 'heading' && block.level === 3,
|
'admin-block-editor__heading--h3 text-3xl': block.type === 'heading' && block.level === 3,
|
||||||
'admin-block-editor__quote border-l-4 border-ink bg-surface px-5 py-3 text-xl font-medium leading-8': block.type === 'quote',
|
'admin-block-editor__quote border-l-4 border-ink bg-surface px-5 py-3 text-xl font-medium leading-8': block.type === 'quote',
|
||||||
'admin-block-editor__callout min-h-14 rounded border border-line bg-surface px-5 py-4 text-[16px] leading-7': block.type === 'callout',
|
|
||||||
'admin-block-editor__list relative min-h-8 pl-7 text-[17px] leading-8 before:absolute before:left-2 before:top-3 before:h-2 before:w-2 before:rounded-full before:bg-current': block.type === 'list',
|
'admin-block-editor__list relative min-h-8 pl-7 text-[17px] leading-8 before:absolute before:left-2 before:top-3 before:h-2 before:w-2 before:rounded-full before:bg-current': block.type === 'list',
|
||||||
'admin-block-editor__code min-h-14 whitespace-pre-wrap rounded bg-[#15171a] px-4 py-3 font-mono text-sm leading-6 text-white': block.type === 'code'
|
'admin-block-editor__code min-h-14 whitespace-pre-wrap rounded bg-[#15171a] px-4 py-3 font-mono text-sm leading-6 text-white': block.type === 'code'
|
||||||
}
|
}
|
||||||
@@ -695,6 +796,8 @@ const getImageWidthClass = (width) => {
|
|||||||
* @returns {void}
|
* @returns {void}
|
||||||
*/
|
*/
|
||||||
const updateBlockText = (event, index) => {
|
const updateBlockText = (event, index) => {
|
||||||
|
enableKeyboardPriorityMode()
|
||||||
|
|
||||||
const block = editorBlocks.value[index]
|
const block = editorBlocks.value[index]
|
||||||
const text = getTextBlockDomText(index)
|
const text = getTextBlockDomText(index)
|
||||||
|
|
||||||
@@ -728,26 +831,49 @@ const startTextComposition = () => {
|
|||||||
const finishTextComposition = (event, index) => {
|
const finishTextComposition = (event, index) => {
|
||||||
isComposingText.value = false
|
isComposingText.value = false
|
||||||
|
|
||||||
nextTick(() => {
|
const syncAfterComposition = () => {
|
||||||
window.setTimeout(() => {
|
const block = syncTextBlockFromDom(index)
|
||||||
const block = syncTextBlockFromDom(index)
|
|
||||||
|
|
||||||
if (!block) {
|
|
||||||
pendingSoftLineBreakIndex.value = -1
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
applyMarkdownShortcut(block, index)
|
|
||||||
|
|
||||||
if (pendingSoftLineBreakIndex.value === index && isTextBlock(block) && block.type !== 'code') {
|
|
||||||
pendingSoftLineBreakIndex.value = -1
|
|
||||||
insertSoftLineBreak(index)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
|
if (!block) {
|
||||||
pendingSoftLineBreakIndex.value = -1
|
pendingSoftLineBreakIndex.value = -1
|
||||||
emitContent()
|
pendingSlashCommandIndex.value = -1
|
||||||
}, 0)
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
applyMarkdownShortcut(block, index)
|
||||||
|
|
||||||
|
const trimmedText = (block.text || '').trim()
|
||||||
|
const canApplyPendingSlashCommand = pendingSlashCommandIndex.value === index
|
||||||
|
&& block.text.startsWith('/')
|
||||||
|
&& visibleCommands.value.length > 0
|
||||||
|
&& trimmedText !== '/'
|
||||||
|
&& trimmedText !== '//'
|
||||||
|
|
||||||
|
if (canApplyPendingSlashCommand) {
|
||||||
|
pendingSlashCommandIndex.value = -1
|
||||||
|
const command = highlightedCommand.value || visibleCommands.value[0]
|
||||||
|
|
||||||
|
if (command) {
|
||||||
|
applyCommand(command)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pendingSoftLineBreakIndex.value === index && isTextBlock(block) && block.type !== 'code') {
|
||||||
|
pendingSoftLineBreakIndex.value = -1
|
||||||
|
pendingSlashCommandIndex.value = -1
|
||||||
|
insertSoftLineBreak(index)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
pendingSoftLineBreakIndex.value = -1
|
||||||
|
pendingSlashCommandIndex.value = -1
|
||||||
|
emitContent()
|
||||||
|
}
|
||||||
|
|
||||||
|
nextTick(() => {
|
||||||
|
syncAfterComposition()
|
||||||
|
window.setTimeout(syncAfterComposition, 0)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -839,6 +965,21 @@ const visibleCommands = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const highlightedCommand = computed(() => visibleCommands.value[highlightedCommandIndex.value])
|
const highlightedCommand = computed(() => visibleCommands.value[highlightedCommandIndex.value])
|
||||||
|
const activeCalloutBlock = computed(() => {
|
||||||
|
const activeBlock = editorBlocks.value.find((block) => block.id === activeBlockId.value)
|
||||||
|
|
||||||
|
if (activeBlock?.type === 'callout') {
|
||||||
|
return activeBlock
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedBlock = editorBlocks.value.find((block) => block.id === selectedBlockId.value)
|
||||||
|
|
||||||
|
if (selectedBlock?.type === 'callout') {
|
||||||
|
return selectedBlock
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 슬래시 메뉴 명령 적용
|
* 슬래시 메뉴 명령 적용
|
||||||
@@ -953,6 +1094,9 @@ const openMediaPicker = async (block) => {
|
|||||||
blockId: block.id,
|
blockId: block.id,
|
||||||
type: block.type
|
type: block.type
|
||||||
}
|
}
|
||||||
|
selectedGalleryMediaUrls.value = block.type === 'gallery'
|
||||||
|
? block.images.map((image) => image.url).filter(Boolean)
|
||||||
|
: []
|
||||||
isMediaPickerOpen.value = true
|
isMediaPickerOpen.value = true
|
||||||
await fetchMediaItems()
|
await fetchMediaItems()
|
||||||
}
|
}
|
||||||
@@ -964,6 +1108,44 @@ const openMediaPicker = async (block) => {
|
|||||||
const closeMediaPicker = () => {
|
const closeMediaPicker = () => {
|
||||||
isMediaPickerOpen.value = false
|
isMediaPickerOpen.value = false
|
||||||
mediaPickerTarget.value = null
|
mediaPickerTarget.value = null
|
||||||
|
selectedGalleryMediaUrls.value = []
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 갤러리 미디어 선택 상태 전환
|
||||||
|
* @param {Object} mediaItem - 미디어 항목
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const toggleGalleryMediaSelection = (mediaItem) => {
|
||||||
|
if (selectedGalleryMediaUrls.value.includes(mediaItem.url)) {
|
||||||
|
selectedGalleryMediaUrls.value = selectedGalleryMediaUrls.value.filter((url) => url !== mediaItem.url)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
selectedGalleryMediaUrls.value = [...selectedGalleryMediaUrls.value, mediaItem.url]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 갤러리 미디어 선택을 블록에 적용
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const applyGalleryMediaSelection = () => {
|
||||||
|
const block = editorBlocks.value.find((item) => item.id === mediaPickerTarget.value?.blockId)
|
||||||
|
|
||||||
|
if (!block || mediaPickerTarget.value?.type !== 'gallery') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingImages = new Map(block.images.map((image) => [image.url, image]))
|
||||||
|
block.images = selectedGalleryMediaUrls.value.map((url) => existingImages.get(url) || {
|
||||||
|
url,
|
||||||
|
alt: '',
|
||||||
|
width: 'regular'
|
||||||
|
})
|
||||||
|
|
||||||
|
normalizeTrailingTextBlock()
|
||||||
|
emitContent()
|
||||||
|
closeMediaPicker()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -972,26 +1154,19 @@ const closeMediaPicker = () => {
|
|||||||
* @returns {void}
|
* @returns {void}
|
||||||
*/
|
*/
|
||||||
const selectMediaItem = (mediaItem) => {
|
const selectMediaItem = (mediaItem) => {
|
||||||
|
if (mediaPickerTarget.value?.type === 'gallery') {
|
||||||
|
toggleGalleryMediaSelection(mediaItem)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const block = editorBlocks.value.find((item) => item.id === mediaPickerTarget.value?.blockId)
|
const block = editorBlocks.value.find((item) => item.id === mediaPickerTarget.value?.blockId)
|
||||||
|
|
||||||
if (!block) {
|
if (!block) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mediaPickerTarget.value.type === 'gallery') {
|
block.url = mediaItem.url
|
||||||
block.images = [
|
block.alt = ''
|
||||||
...block.images,
|
|
||||||
{
|
|
||||||
url: mediaItem.url,
|
|
||||||
alt: '',
|
|
||||||
width: 'regular'
|
|
||||||
}
|
|
||||||
]
|
|
||||||
} else {
|
|
||||||
block.url = mediaItem.url
|
|
||||||
block.alt = ''
|
|
||||||
}
|
|
||||||
|
|
||||||
normalizeTrailingTextBlock()
|
normalizeTrailingTextBlock()
|
||||||
emitContent()
|
emitContent()
|
||||||
closeMediaPicker()
|
closeMediaPicker()
|
||||||
@@ -1084,6 +1259,88 @@ const removeGalleryImage = (block, imageIndex) => {
|
|||||||
emitContent()
|
emitContent()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 갤러리 이미지 드래그 시작
|
||||||
|
* @param {DragEvent} event - 드래그 이벤트
|
||||||
|
* @param {Object} block - 갤러리 블록
|
||||||
|
* @param {number} imageIndex - 이미지 인덱스
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const startGalleryImageDrag = (event, block, imageIndex) => {
|
||||||
|
draggingGalleryImage.value = {
|
||||||
|
blockId: block.id,
|
||||||
|
imageIndex
|
||||||
|
}
|
||||||
|
event.dataTransfer.effectAllowed = 'move'
|
||||||
|
event.dataTransfer.setData('text/plain', `${block.id}:${imageIndex}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 갤러리 이미지 드래그 종료
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const finishGalleryImageDrag = () => {
|
||||||
|
draggingGalleryImage.value = null
|
||||||
|
galleryDragTarget.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 갤러리 이미지 삽입 위치 표시
|
||||||
|
* @param {DragEvent} event - 드래그 이벤트
|
||||||
|
* @param {Object} block - 갤러리 블록
|
||||||
|
* @param {number} imageIndex - 이미지 인덱스
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const updateGalleryImageDropTarget = (event, block, imageIndex) => {
|
||||||
|
const source = draggingGalleryImage.value
|
||||||
|
|
||||||
|
if (!source || source.blockId !== block.id) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const rect = event.currentTarget.getBoundingClientRect()
|
||||||
|
galleryDragTarget.value = {
|
||||||
|
blockId: block.id,
|
||||||
|
imageIndex,
|
||||||
|
position: event.clientX < rect.left + rect.width / 2 ? 'before' : 'after'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 갤러리 이미지 순서 변경
|
||||||
|
* @param {DragEvent} event - 드롭 이벤트
|
||||||
|
* @param {Object} block - 갤러리 블록
|
||||||
|
* @param {number} targetIndex - 대상 인덱스
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const dropGalleryImage = (event, block, targetIndex) => {
|
||||||
|
const source = draggingGalleryImage.value
|
||||||
|
const target = galleryDragTarget.value
|
||||||
|
|
||||||
|
if (!source || source.blockId !== block.id || source.imageIndex === targetIndex) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let nextTargetIndex = target?.blockId === block.id && target.position === 'after'
|
||||||
|
? targetIndex + 1
|
||||||
|
: targetIndex
|
||||||
|
|
||||||
|
if (source.imageIndex < nextTargetIndex) {
|
||||||
|
nextTargetIndex -= 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if (source.imageIndex === nextTargetIndex) {
|
||||||
|
finishGalleryImageDrag()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const [image] = block.images.splice(source.imageIndex, 1)
|
||||||
|
block.images.splice(nextTargetIndex, 0, image)
|
||||||
|
finishGalleryImageDrag()
|
||||||
|
normalizeTrailingTextBlock()
|
||||||
|
emitContent()
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 슬래시 메뉴 선택을 아래로 이동
|
* 슬래시 메뉴 선택을 아래로 이동
|
||||||
* @param {KeyboardEvent} event - 키보드 이벤트
|
* @param {KeyboardEvent} event - 키보드 이벤트
|
||||||
@@ -1203,6 +1460,8 @@ const navigateAcrossBlocks = (event, index, direction) => {
|
|||||||
* @returns {void}
|
* @returns {void}
|
||||||
*/
|
*/
|
||||||
const handleEnter = (event, index) => {
|
const handleEnter = (event, index) => {
|
||||||
|
enableKeyboardPriorityMode()
|
||||||
|
|
||||||
const currentBlock = syncTextBlockFromDom(index)
|
const currentBlock = syncTextBlockFromDom(index)
|
||||||
|
|
||||||
if (isComposingText.value || event.isComposing || event.keyCode === 229) {
|
if (isComposingText.value || event.isComposing || event.keyCode === 229) {
|
||||||
@@ -1212,6 +1471,12 @@ const handleEnter = (event, index) => {
|
|||||||
pendingSoftLineBreakIndex.value = index
|
pendingSoftLineBreakIndex.value = index
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!event.shiftKey && (currentBlock.text || '').startsWith('/')) {
|
||||||
|
pendingSlashCommandIndex.value = index
|
||||||
|
} else {
|
||||||
|
pendingSlashCommandIndex.value = -1
|
||||||
|
}
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1225,7 +1490,13 @@ const handleEnter = (event, index) => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (currentBlock.text.startsWith('/')) {
|
const trimmedText = (currentBlock.text || '').trim()
|
||||||
|
const canApplySlashCommand = currentBlock.text.startsWith('/')
|
||||||
|
&& visibleCommands.value.length > 0
|
||||||
|
&& trimmedText !== '/'
|
||||||
|
&& trimmedText !== '//'
|
||||||
|
|
||||||
|
if (canApplySlashCommand) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
const command = highlightedCommand.value || visibleCommands.value[0]
|
const command = highlightedCommand.value || visibleCommands.value[0]
|
||||||
|
|
||||||
@@ -1246,7 +1517,7 @@ const handleEnter = (event, index) => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!currentBlock.text.trim() && currentBlock.type !== 'paragraph') {
|
if (!currentBlock.text.trim() && !['paragraph', 'callout'].includes(currentBlock.type)) {
|
||||||
currentBlock.type = 'paragraph'
|
currentBlock.type = 'paragraph'
|
||||||
currentBlock.level = null
|
currentBlock.level = null
|
||||||
normalizeTrailingTextBlock()
|
normalizeTrailingTextBlock()
|
||||||
@@ -1293,6 +1564,51 @@ const handleBackspace = (event, index) => {
|
|||||||
*/
|
*/
|
||||||
const getBlockIndex = (blockId) => editorBlocks.value.findIndex((block) => block.id === blockId)
|
const getBlockIndex = (blockId) => editorBlocks.value.findIndex((block) => block.id === blockId)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 포커스 이탈 시 자동 정리할 구조형 블록인지 확인
|
||||||
|
* @param {Object|undefined} block - 에디터 블록
|
||||||
|
* @returns {boolean} 자동 정리 대상 여부
|
||||||
|
*/
|
||||||
|
const isDiscardableStructuredBlock = (block) => {
|
||||||
|
if (!block) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (block.type === 'image') {
|
||||||
|
return !block.url
|
||||||
|
}
|
||||||
|
|
||||||
|
if (block.type === 'gallery') {
|
||||||
|
return !block.images.length
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 다른 블록으로 이동할 때 미사용 구조형 블록을 정리
|
||||||
|
* @param {string} nextBlockId - 다음 활성 블록 ID
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const cleanupUnusedStructuredBlockOnActivate = (nextBlockId) => {
|
||||||
|
const previousActiveBlockId = activeBlockId.value
|
||||||
|
|
||||||
|
if (!previousActiveBlockId || previousActiveBlockId === nextBlockId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const previousBlockIndex = getBlockIndex(previousActiveBlockId)
|
||||||
|
const previousBlock = editorBlocks.value[previousBlockIndex]
|
||||||
|
|
||||||
|
if (!isDiscardableStructuredBlock(previousBlock) || editorBlocks.value.length <= 1) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
editorBlocks.value.splice(previousBlockIndex, 1)
|
||||||
|
normalizeTrailingTextBlock()
|
||||||
|
emitContent()
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 블록 선택 상태 적용
|
* 블록 선택 상태 적용
|
||||||
* @param {Object} block - 선택할 블록
|
* @param {Object} block - 선택할 블록
|
||||||
@@ -1447,6 +1763,8 @@ const finishBlockDrag = () => {
|
|||||||
* @returns {void}
|
* @returns {void}
|
||||||
*/
|
*/
|
||||||
const activateBlock = (block) => {
|
const activateBlock = (block) => {
|
||||||
|
cleanupUnusedStructuredBlockOnActivate(block.id)
|
||||||
|
|
||||||
const index = editorBlocks.value.findIndex((item) => item.id === block.id)
|
const index = editorBlocks.value.findIndex((item) => item.id === block.id)
|
||||||
activeBlockId.value = block.id
|
activeBlockId.value = block.id
|
||||||
selectedBlockId.value = ''
|
selectedBlockId.value = ''
|
||||||
@@ -1474,6 +1792,123 @@ const updateStructuredBlock = () => {
|
|||||||
emitContent()
|
emitContent()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 콜아웃 이모지 표시 상태를 전환
|
||||||
|
* @param {Object} block - 콜아웃 블록
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const toggleCalloutEmoji = (block) => {
|
||||||
|
block.calloutEmojiEnabled = !block.calloutEmojiEnabled
|
||||||
|
if (!block.calloutEmojiEnabled) {
|
||||||
|
calloutEmojiPickerBlockId.value = ''
|
||||||
|
}
|
||||||
|
updateStructuredBlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 콜아웃 이모지를 변경
|
||||||
|
* @param {Object} block - 콜아웃 블록
|
||||||
|
* @param {string} emoji - 이모지 문자열
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const updateCalloutEmoji = (block, emoji) => {
|
||||||
|
block.calloutEmoji = emoji || ''
|
||||||
|
block.calloutEmojiEnabled = true
|
||||||
|
updateStructuredBlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 콜아웃 배경 프리셋을 변경
|
||||||
|
* @param {Object} block - 콜아웃 블록
|
||||||
|
* @param {string} background - 배경 프리셋 값
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const updateCalloutBackground = (block, background) => {
|
||||||
|
block.calloutBackground = background
|
||||||
|
calloutColorPopoverBlockId.value = ''
|
||||||
|
updateStructuredBlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 콜아웃 이모지 팝업 토글
|
||||||
|
* @param {Object} block - 콜아웃 블록
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const toggleCalloutEmojiPicker = (block) => {
|
||||||
|
calloutEmojiPickerBlockId.value = calloutEmojiPickerBlockId.value === block.id
|
||||||
|
? ''
|
||||||
|
: block.id
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 콜아웃 배경색 팝오버 토글
|
||||||
|
* @param {Object} block - 콜아웃 블록
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const toggleCalloutColorPopover = (block) => {
|
||||||
|
calloutColorPopoverBlockId.value = calloutColorPopoverBlockId.value === block.id
|
||||||
|
? ''
|
||||||
|
: block.id
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 콜아웃 이모지 입력값 적용
|
||||||
|
* @param {Object} block - 콜아웃 블록
|
||||||
|
* @param {Event} event - 입력 이벤트
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const updateCalloutEmojiFromInput = (block, event) => {
|
||||||
|
if (calloutEmojiComposingBlockId.value === block.id || event?.isComposing) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawValue = String(event.target?.value || event.target?.textContent || '')
|
||||||
|
block.calloutEmoji = rawValue
|
||||||
|
block.calloutEmojiEnabled = true
|
||||||
|
updateStructuredBlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 콜아웃 이모지 입력 조합 시작 처리
|
||||||
|
* @param {Object} block - 콜아웃 블록
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const startCalloutEmojiComposition = (block) => {
|
||||||
|
calloutEmojiComposingBlockId.value = block.id
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 콜아웃 이모지 입력 조합 종료 처리
|
||||||
|
* @param {Object} block - 콜아웃 블록
|
||||||
|
* @param {CompositionEvent} event - 조합 종료 이벤트
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const finishCalloutEmojiComposition = (block, event) => {
|
||||||
|
calloutEmojiComposingBlockId.value = ''
|
||||||
|
updateCalloutEmojiFromInput(block, event)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 콜아웃 이모지 입력을 1문자 슬롯으로 정규화
|
||||||
|
* @param {Object} block - 콜아웃 블록
|
||||||
|
* @param {FocusEvent} event - blur 이벤트
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const normalizeCalloutEmojiInput = (block, event) => {
|
||||||
|
const rawValue = String(block.calloutEmoji || '').trim()
|
||||||
|
const graphemes = typeof Intl !== 'undefined' && Intl.Segmenter
|
||||||
|
? Array.from(new Intl.Segmenter('ko', { granularity: 'grapheme' }).segment(rawValue), (segment) => segment.segment)
|
||||||
|
: Array.from(rawValue)
|
||||||
|
const nextValue = graphemes.length ? graphemes[graphemes.length - 1] : ''
|
||||||
|
|
||||||
|
block.calloutEmoji = nextValue
|
||||||
|
updateStructuredBlock()
|
||||||
|
|
||||||
|
if (event?.target && 'value' in event.target) {
|
||||||
|
event.target.value = nextValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
watch(() => props.modelValue, (value) => {
|
watch(() => props.modelValue, (value) => {
|
||||||
if (isApplyingExternalValue.value) {
|
if (isApplyingExternalValue.value) {
|
||||||
return
|
return
|
||||||
@@ -1504,13 +1939,24 @@ watch(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
watch(activeCalloutBlock, (nextBlock) => {
|
||||||
|
if (!nextBlock) {
|
||||||
|
calloutEmojiPickerBlockId.value = ''
|
||||||
|
calloutColorPopoverBlockId.value = ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
defineExpose({
|
defineExpose({
|
||||||
focusFirstBlock: () => focusBlock(0)
|
focusFirstBlock: () => focusBlock(0)
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="admin-block-editor bg-transparent py-4 text-ink">
|
<div
|
||||||
|
class="admin-block-editor bg-transparent py-4 text-ink"
|
||||||
|
:class="{ 'admin-block-editor--keyboard-priority': isKeyboardPriorityMode }"
|
||||||
|
@mousemove="handleEditorMouseMove"
|
||||||
|
>
|
||||||
<div class="admin-block-editor__surface post-prose">
|
<div class="admin-block-editor__surface post-prose">
|
||||||
<div
|
<div
|
||||||
v-for="(block, index) in editorBlocks"
|
v-for="(block, index) in editorBlocks"
|
||||||
@@ -1521,6 +1967,7 @@ defineExpose({
|
|||||||
'admin-block-editor__row--dragging opacity-50': draggingBlockId === block.id,
|
'admin-block-editor__row--dragging opacity-50': draggingBlockId === block.id,
|
||||||
'admin-block-editor__row--drop-before': dragTargetIndex === index && dragTargetPosition === 'before',
|
'admin-block-editor__row--drop-before': dragTargetIndex === index && dragTargetPosition === 'before',
|
||||||
'admin-block-editor__row--drop-after': dragTargetIndex === index && dragTargetPosition === 'after',
|
'admin-block-editor__row--drop-after': dragTargetIndex === index && dragTargetPosition === 'after',
|
||||||
|
'admin-block-editor__row--callout': block.type === 'callout',
|
||||||
'admin-block-editor__row--menu-open z-30': visibleCommands.length && activeBlockId === block.id,
|
'admin-block-editor__row--menu-open z-30': visibleCommands.length && activeBlockId === block.id,
|
||||||
'admin-block-editor__row--text': isTextBlock(block),
|
'admin-block-editor__row--text': isTextBlock(block),
|
||||||
'admin-block-editor__row--structure': !isTextBlock(block)
|
'admin-block-editor__row--structure': !isTextBlock(block)
|
||||||
@@ -1530,7 +1977,7 @@ defineExpose({
|
|||||||
@drop="dropBlock($event, index)"
|
@drop="dropBlock($event, index)"
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
class="admin-block-editor__handle absolute -left-9 bottom-0 top-0 z-10 flex w-5 cursor-grab items-stretch justify-center rounded opacity-0 outline-none transition-opacity duration-150 group-hover/block:opacity-100 focus:opacity-100 active:cursor-grabbing"
|
class="admin-block-editor__handle absolute -left-9 bottom-0 top-0 z-10 flex w-5 cursor-grab items-stretch justify-center rounded opacity-0 outline-none transition-opacity duration-150 focus:opacity-100 active:cursor-grabbing"
|
||||||
type="button"
|
type="button"
|
||||||
draggable="true"
|
draggable="true"
|
||||||
aria-label="블록 이동 및 선택"
|
aria-label="블록 이동 및 선택"
|
||||||
@@ -1607,13 +2054,30 @@ defineExpose({
|
|||||||
@keydown.enter="handleEnter($event, index)"
|
@keydown.enter="handleEnter($event, index)"
|
||||||
@keydown.backspace="handleBackspace($event, index)"
|
@keydown.backspace="handleBackspace($event, index)"
|
||||||
>
|
>
|
||||||
<div v-if="block.images.length" class="admin-block-editor__gallery-grid grid grid-cols-2 gap-2 md:grid-cols-3">
|
<div
|
||||||
|
v-if="block.images.length"
|
||||||
|
class="admin-block-editor__gallery-grid grid gap-2"
|
||||||
|
:style="{ gridTemplateColumns: `repeat(${getGalleryColumnCount(block)}, minmax(0, 1fr))` }"
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
v-for="(image, imageIndex) in block.images"
|
v-for="(image, imageIndex) in block.images"
|
||||||
:key="`${block.id}-${image.url}`"
|
:key="`${block.id}-${image.url}`"
|
||||||
class="admin-block-editor__gallery-item group/item relative overflow-hidden rounded bg-surface"
|
class="admin-block-editor__gallery-item group/item relative overflow-hidden rounded bg-surface"
|
||||||
|
:class="{
|
||||||
|
'opacity-50': draggingGalleryImage?.blockId === block.id && draggingGalleryImage?.imageIndex === imageIndex,
|
||||||
|
'admin-block-editor__gallery-item--drop-before': galleryDragTarget?.blockId === block.id && galleryDragTarget?.imageIndex === imageIndex && galleryDragTarget?.position === 'before',
|
||||||
|
'admin-block-editor__gallery-item--drop-after': galleryDragTarget?.blockId === block.id && galleryDragTarget?.imageIndex === imageIndex && galleryDragTarget?.position === 'after'
|
||||||
|
}"
|
||||||
|
draggable="true"
|
||||||
|
@dragstart.stop="startGalleryImageDrag($event, block, imageIndex)"
|
||||||
|
@dragover.prevent.stop="updateGalleryImageDropTarget($event, block, imageIndex)"
|
||||||
|
@drop.prevent.stop="dropGalleryImage($event, block, imageIndex)"
|
||||||
|
@dragend="finishGalleryImageDrag"
|
||||||
>
|
>
|
||||||
<img class="admin-block-editor__gallery-image aspect-[4/3] w-full object-cover" :src="image.url" :alt="image.alt">
|
<img class="admin-block-editor__gallery-image aspect-[4/3] w-full object-cover" :src="image.url" :alt="image.alt">
|
||||||
|
<span class="admin-block-editor__gallery-drag-hint pointer-events-none absolute left-2 top-2 rounded bg-black/70 px-2 py-1 text-xs font-semibold text-white opacity-0 transition-opacity group-hover/item:opacity-100">
|
||||||
|
드래그
|
||||||
|
</span>
|
||||||
<button
|
<button
|
||||||
class="admin-block-editor__gallery-remove absolute right-2 top-2 rounded bg-white/95 px-2 py-1 text-xs font-semibold text-ink opacity-0 shadow transition-opacity group-hover/item:opacity-100"
|
class="admin-block-editor__gallery-remove absolute right-2 top-2 rounded bg-white/95 px-2 py-1 text-xs font-semibold text-ink opacity-0 shadow transition-opacity group-hover/item:opacity-100"
|
||||||
type="button"
|
type="button"
|
||||||
@@ -1634,6 +2098,138 @@ defineExpose({
|
|||||||
</div>
|
</div>
|
||||||
</figure>
|
</figure>
|
||||||
|
|
||||||
|
<section
|
||||||
|
v-else-if="block.type === 'callout'"
|
||||||
|
class="admin-block-editor__callout-editor relative"
|
||||||
|
@focusin="activateBlock(block)"
|
||||||
|
@click="activateBlock(block)"
|
||||||
|
@keydown.enter="handleEnter($event, index)"
|
||||||
|
@keydown.backspace="handleBackspace($event, index)"
|
||||||
|
>
|
||||||
|
<ProseCallout
|
||||||
|
:emoji-enabled="false"
|
||||||
|
:background="block.calloutBackground"
|
||||||
|
>
|
||||||
|
<div class="flex items-start gap-2">
|
||||||
|
<button
|
||||||
|
v-if="block.calloutEmojiEnabled"
|
||||||
|
class="inline-flex size-9 shrink-0 items-center justify-center cursor-pointer rounded-md text-xl text-[#1f2328] hover:bg-[#7f8da1]/20"
|
||||||
|
type="button"
|
||||||
|
@click.stop="toggleCalloutEmojiPicker(block)"
|
||||||
|
>
|
||||||
|
{{ block.calloutEmoji || '💡' }}
|
||||||
|
</button>
|
||||||
|
<div
|
||||||
|
:ref="(element) => setBlockRef(element, index)"
|
||||||
|
class="admin-block-editor__callout-input w-full bg-transparent text-[15px] leading-8 text-[#1f2328] outline-none"
|
||||||
|
contenteditable="true"
|
||||||
|
spellcheck="true"
|
||||||
|
data-placeholder="콜아웃 텍스트 입력은 이렇게"
|
||||||
|
:data-show-placeholder="!block.text"
|
||||||
|
@focus="activateBlock(block)"
|
||||||
|
@input="updateBlockText($event, index)"
|
||||||
|
@compositionstart="startTextComposition"
|
||||||
|
@compositionend="finishTextComposition($event, index)"
|
||||||
|
@keydown.enter="handleEnter($event, index)"
|
||||||
|
@keydown.down="block.text.startsWith('/') ? highlightNextCommand($event) : navigateAcrossBlocks($event, index, 'down')"
|
||||||
|
@keydown.up="block.text.startsWith('/') ? highlightPreviousCommand($event) : navigateAcrossBlocks($event, index, 'up')"
|
||||||
|
@keydown.backspace="handleBackspace($event, index)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</ProseCallout>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="activeCalloutBlock?.id === block.id"
|
||||||
|
class="admin-block-editor__callout-settings absolute left-full top-0 z-[70] ml-4 flex w-[320px] flex-col gap-3 rounded-lg bg-white p-6 shadow-lg"
|
||||||
|
>
|
||||||
|
<label class="flex w-full items-center justify-between">
|
||||||
|
<div class="text-sm font-medium text-ink">Emoji</div>
|
||||||
|
<button
|
||||||
|
class="relative inline-flex h-4 w-7 items-center rounded-full transition-colors"
|
||||||
|
:class="block.calloutEmojiEnabled ? 'bg-black' : 'bg-[#d0d7de]'"
|
||||||
|
type="button"
|
||||||
|
@click="toggleCalloutEmoji(block)"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="absolute size-3 rounded-full bg-white transition-transform"
|
||||||
|
:class="block.calloutEmojiEnabled ? 'translate-x-[12px]' : 'translate-x-[2px]'"
|
||||||
|
></span>
|
||||||
|
</button>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="relative flex w-full items-center justify-between">
|
||||||
|
<div class="text-sm font-medium text-ink">Background</div>
|
||||||
|
<button
|
||||||
|
class="relative size-6 shrink-0 cursor-pointer rounded-full p-[2px]"
|
||||||
|
type="button"
|
||||||
|
@click="toggleCalloutColorPopover(block)"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="absolute inset-0 rounded-full"
|
||||||
|
style="background: conic-gradient(rgb(255,0,0), rgb(255,0,191), rgb(128,0,255), rgb(0,64,255), rgb(0,255,255), rgb(0,255,64), rgb(128,255,0), rgb(255,191,0), rgb(255,0,0)); mask: linear-gradient(#fff 0 0) content-box exclude, linear-gradient(#fff 0 0); padding:3px;"
|
||||||
|
></span>
|
||||||
|
<span
|
||||||
|
class="relative block size-full rounded-full border-2 border-white ring-1 ring-black/10"
|
||||||
|
:style="{
|
||||||
|
background: block.calloutBackground === 'gray' ? 'rgba(100,116,139,0.28)'
|
||||||
|
: block.calloutBackground === 'blue' ? 'rgba(59,130,246,0.3)'
|
||||||
|
: block.calloutBackground === 'green' ? 'rgba(34,197,94,0.3)'
|
||||||
|
: block.calloutBackground === 'yellow' ? 'rgba(245,158,11,0.34)'
|
||||||
|
: block.calloutBackground === 'red' ? 'rgba(239,68,68,0.3)'
|
||||||
|
: block.calloutBackground === 'purple' ? 'rgba(168,85,247,0.3)'
|
||||||
|
: 'rgba(236,72,153,0.3)'
|
||||||
|
}"
|
||||||
|
></span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="calloutColorPopoverBlockId === block.id"
|
||||||
|
class="absolute -right-2 bottom-full mb-2 z-[90] rounded-lg bg-white px-3 py-2 shadow"
|
||||||
|
>
|
||||||
|
<ul class="flex items-center gap-1">
|
||||||
|
<li v-for="backgroundOption in calloutBackgroundOptions" :key="`color-pop-${block.id}-${backgroundOption.value}`">
|
||||||
|
<button
|
||||||
|
class="group relative flex size-6 cursor-pointer items-center justify-center rounded-full border-2"
|
||||||
|
:class="block.calloutBackground === backgroundOption.value ? 'border-[#22c55e]' : 'border-transparent'"
|
||||||
|
type="button"
|
||||||
|
:aria-label="backgroundOption.label"
|
||||||
|
@click="updateCalloutBackground(block, backgroundOption.value)"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="size-[1.4rem] rounded-full border border-black/10"
|
||||||
|
:style="{
|
||||||
|
background: backgroundOption.value === 'gray' ? 'rgba(100,116,139,0.28)'
|
||||||
|
: backgroundOption.value === 'blue' ? 'rgba(59,130,246,0.3)'
|
||||||
|
: backgroundOption.value === 'green' ? 'rgba(34,197,94,0.3)'
|
||||||
|
: backgroundOption.value === 'yellow' ? 'rgba(245,158,11,0.34)'
|
||||||
|
: backgroundOption.value === 'red' ? 'rgba(239,68,68,0.3)'
|
||||||
|
: backgroundOption.value === 'purple' ? 'rgba(168,85,247,0.3)'
|
||||||
|
: 'rgba(236,72,153,0.3)'
|
||||||
|
}"
|
||||||
|
></span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="block.calloutEmojiEnabled && calloutEmojiPickerBlockId === block.id" class="rounded-lg border border-line bg-white p-3">
|
||||||
|
<p class="mb-2 text-xs text-muted">이모지를 붙여넣거나 시스템 이모지 입력을 사용하세요</p>
|
||||||
|
<input
|
||||||
|
class="w-16 rounded-lg border border-line px-2 py-2 text-center text-2xl text-[#1f2328] outline-none"
|
||||||
|
type="text"
|
||||||
|
:value="block.calloutEmoji || ''"
|
||||||
|
maxlength="8"
|
||||||
|
spellcheck="false"
|
||||||
|
@input="updateCalloutEmojiFromInput(block, $event)"
|
||||||
|
@compositionstart="startCalloutEmojiComposition(block)"
|
||||||
|
@compositionend="finishCalloutEmojiComposition(block, $event)"
|
||||||
|
@blur="normalizeCalloutEmojiInput(block, $event)"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section
|
<section
|
||||||
v-else-if="block.type === 'toggle'"
|
v-else-if="block.type === 'toggle'"
|
||||||
class="admin-block-editor__toggle rounded border border-line bg-paper p-5"
|
class="admin-block-editor__toggle rounded border border-line bg-paper p-5"
|
||||||
@@ -1752,11 +2348,19 @@ defineExpose({
|
|||||||
<button
|
<button
|
||||||
v-for="item in mediaItems"
|
v-for="item in mediaItems"
|
||||||
:key="item.url"
|
:key="item.url"
|
||||||
class="admin-block-editor__media-picker-item overflow-hidden border border-line bg-white text-left"
|
class="admin-block-editor__media-picker-item relative overflow-hidden border bg-white text-left transition-colors"
|
||||||
|
:class="mediaPickerTarget?.type === 'gallery' && isGalleryMediaSelected(item) ? 'border-[#15171a] ring-2 ring-[#15171a]/20' : 'border-line hover:border-[#8e9cac]'"
|
||||||
type="button"
|
type="button"
|
||||||
@click="selectMediaItem(item)"
|
@click="selectMediaItem(item)"
|
||||||
>
|
>
|
||||||
<img class="admin-block-editor__media-picker-image aspect-[4/3] w-full bg-surface object-cover" :src="item.url" :alt="item.title">
|
<img class="admin-block-editor__media-picker-image aspect-[4/3] w-full bg-surface object-cover" :src="item.url" :alt="item.title">
|
||||||
|
<span
|
||||||
|
v-if="mediaPickerTarget?.type === 'gallery' && isGalleryMediaSelected(item)"
|
||||||
|
class="admin-block-editor__media-picker-selected absolute right-2 top-2 grid size-6 place-items-center rounded-full bg-[#15171a] text-xs font-bold text-white"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
✓
|
||||||
|
</span>
|
||||||
<span class="admin-block-editor__media-picker-name block truncate px-3 py-2 text-xs font-semibold text-ink">{{ item.name }}</span>
|
<span class="admin-block-editor__media-picker-name block truncate px-3 py-2 text-xs font-semibold text-ink">{{ item.name }}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -1764,6 +2368,22 @@ defineExpose({
|
|||||||
선택할 미디어가 없습니다.
|
선택할 미디어가 없습니다.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="mediaPickerTarget?.type === 'gallery'"
|
||||||
|
class="admin-block-editor__media-picker-footer flex items-center justify-between gap-3 border-t border-line px-5 py-4"
|
||||||
|
>
|
||||||
|
<p class="admin-block-editor__media-picker-count text-sm text-muted">
|
||||||
|
{{ selectedGalleryMediaUrls.length }}개 선택됨
|
||||||
|
</p>
|
||||||
|
<div class="admin-block-editor__media-picker-actions flex gap-2">
|
||||||
|
<button class="admin-block-editor__media-picker-cancel rounded border border-line px-3 py-2 text-sm font-semibold text-ink" type="button" @click="closeMediaPicker">
|
||||||
|
취소
|
||||||
|
</button>
|
||||||
|
<button class="admin-block-editor__media-picker-apply rounded bg-[#15171a] px-4 py-2 text-sm font-semibold text-white" type="button" @click="applyGalleryMediaSelection">
|
||||||
|
갤러리에 적용
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1812,13 +2432,18 @@ defineExpose({
|
|||||||
transform 120ms ease;
|
transform 120ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-block-editor__row:hover::before,
|
.admin-block-editor:not(.admin-block-editor--keyboard-priority) .admin-block-editor__row:hover::before,
|
||||||
.admin-block-editor__row--selected::before {
|
.admin-block-editor__row--selected::before {
|
||||||
background: #eff1f2;
|
background: #eff1f2;
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
transform: scaleX(1);
|
transform: scaleX(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.admin-block-editor__row--callout:hover::before,
|
||||||
|
.admin-block-editor__row--callout.admin-block-editor__row--selected::before {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.admin-block-editor__row--drop-before::after {
|
.admin-block-editor__row--drop-before::after {
|
||||||
top: -18px;
|
top: -18px;
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
@@ -1835,6 +2460,10 @@ defineExpose({
|
|||||||
min-height: 32px;
|
min-height: 32px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.admin-block-editor:not(.admin-block-editor--keyboard-priority) .admin-block-editor__row:hover .admin-block-editor__handle {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
.admin-block-editor__handle-container {
|
.admin-block-editor__handle-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
width: 16px;
|
width: 16px;
|
||||||
@@ -1857,7 +2486,7 @@ defineExpose({
|
|||||||
opacity 160ms ease;
|
opacity 160ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-block-editor__row:hover .admin-block-editor__handle-grabber,
|
.admin-block-editor:not(.admin-block-editor--keyboard-priority) .admin-block-editor__row:hover .admin-block-editor__handle-grabber,
|
||||||
.admin-block-editor__row--selected .admin-block-editor__handle-grabber,
|
.admin-block-editor__row--selected .admin-block-editor__handle-grabber,
|
||||||
.admin-block-editor__handle:focus .admin-block-editor__handle-grabber {
|
.admin-block-editor__handle:focus .admin-block-editor__handle-grabber {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
@@ -1882,4 +2511,35 @@ defineExpose({
|
|||||||
color: #f8fafc;
|
color: #f8fafc;
|
||||||
caret-color: #f8fafc;
|
caret-color: #f8fafc;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.admin-block-editor__callout-input:empty[data-show-placeholder="true"]::before {
|
||||||
|
color: var(--site-soft);
|
||||||
|
content: attr(data-placeholder);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-block-editor__gallery-item--drop-before::before,
|
||||||
|
.admin-block-editor__gallery-item--drop-after::before {
|
||||||
|
position: absolute;
|
||||||
|
top: 8px;
|
||||||
|
bottom: 8px;
|
||||||
|
z-index: 20;
|
||||||
|
width: 4px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #2eb6ea;
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 3px rgba(46, 182, 234, 0.16),
|
||||||
|
0 6px 18px rgba(46, 182, 234, 0.35);
|
||||||
|
content: "";
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-block-editor__gallery-item--drop-before::before {
|
||||||
|
left: 0;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-block-editor__gallery-item--drop-after::before {
|
||||||
|
right: 0;
|
||||||
|
transform: translateX(50%);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
646
components/admin/AdminMemberForm.vue
Normal file
646
components/admin/AdminMemberForm.vue
Normal file
@@ -0,0 +1,646 @@
|
|||||||
|
<script setup>
|
||||||
|
const props = defineProps({
|
||||||
|
member: {
|
||||||
|
type: Object,
|
||||||
|
default: () => null
|
||||||
|
},
|
||||||
|
mode: {
|
||||||
|
type: String,
|
||||||
|
default: 'edit'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['saved', 'deleted'])
|
||||||
|
|
||||||
|
const isNewMember = computed(() => props.mode === 'new')
|
||||||
|
const saveMessage = ref('')
|
||||||
|
const saveError = ref('')
|
||||||
|
const isSaving = ref(false)
|
||||||
|
const savedMemberSnapshot = ref('')
|
||||||
|
const avatarInputRef = ref(null)
|
||||||
|
const isUploadingAvatar = ref(false)
|
||||||
|
const actionMenuOpen = ref(false)
|
||||||
|
const passwordModalOpen = ref(false)
|
||||||
|
const deleteModalOpen = ref(false)
|
||||||
|
const isUpdatingPassword = ref(false)
|
||||||
|
const isDeletingMember = ref(false)
|
||||||
|
const actionMessage = ref('')
|
||||||
|
const actionError = ref('')
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
username: '',
|
||||||
|
email: '',
|
||||||
|
avatarUrl: '',
|
||||||
|
labelsText: '',
|
||||||
|
note: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const passwordForm = reactive({
|
||||||
|
password: '',
|
||||||
|
passwordConfirm: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const deleteForm = reactive({
|
||||||
|
confirmText: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 회원 폼 값을 현재 회원 정보로 동기화한다.
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const syncMemberForm = () => {
|
||||||
|
const member = props.member || {}
|
||||||
|
form.username = member.username || ''
|
||||||
|
form.email = member.email || ''
|
||||||
|
form.avatarUrl = member.avatarUrl || ''
|
||||||
|
form.labelsText = Array.isArray(member.labels) ? member.labels.join(', ') : ''
|
||||||
|
form.note = member.note || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => props.member, syncMemberForm, { immediate: true })
|
||||||
|
|
||||||
|
const pageTitle = computed(() => {
|
||||||
|
if (isNewMember.value) {
|
||||||
|
return '새 멤버'
|
||||||
|
}
|
||||||
|
|
||||||
|
return form.username || props.member?.email || '멤버'
|
||||||
|
})
|
||||||
|
|
||||||
|
const memberInitial = computed(() => String(form.username || form.email || '?').slice(0, 1).toUpperCase())
|
||||||
|
const noteLength = computed(() => form.note.length)
|
||||||
|
|
||||||
|
const normalizedLabels = computed(() => [...new Set(
|
||||||
|
form.labelsText
|
||||||
|
.split(',')
|
||||||
|
.map((label) => label.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
)])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 회원 저장 요청 본문을 문자열로 직렬화한다.
|
||||||
|
* @returns {string} 직렬화된 회원 입력값
|
||||||
|
*/
|
||||||
|
const serializeMemberPayload = () => JSON.stringify(getMemberPayload())
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 날짜 표시 형식 변환
|
||||||
|
* @param {string | null} value - ISO 날짜 문자열
|
||||||
|
* @returns {string} 화면 표시 날짜
|
||||||
|
*/
|
||||||
|
const formatDate = (value) => {
|
||||||
|
if (!value) {
|
||||||
|
return '-'
|
||||||
|
}
|
||||||
|
|
||||||
|
const date = new Date(value)
|
||||||
|
if (Number.isNaN(date.getTime())) {
|
||||||
|
return '-'
|
||||||
|
}
|
||||||
|
|
||||||
|
const year = date.getFullYear()
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||||
|
const day = String(date.getDate()).padStart(2, '0')
|
||||||
|
|
||||||
|
return `${year}.${month}.${day}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 최근 활동 시각을 상대 시간으로 표시한다.
|
||||||
|
* @param {string | null} value - ISO 시각
|
||||||
|
* @returns {string} 상대 시간
|
||||||
|
*/
|
||||||
|
const formatRelativeTime = (value) => {
|
||||||
|
if (!value) {
|
||||||
|
return '최근 활동 없음'
|
||||||
|
}
|
||||||
|
|
||||||
|
const date = new Date(value)
|
||||||
|
if (Number.isNaN(date.getTime())) {
|
||||||
|
return '최근 활동 없음'
|
||||||
|
}
|
||||||
|
|
||||||
|
const diffMs = Date.now() - date.getTime()
|
||||||
|
const minute = 1000 * 60
|
||||||
|
const hour = minute * 60
|
||||||
|
const day = hour * 24
|
||||||
|
|
||||||
|
if (diffMs < minute) {
|
||||||
|
return '방금 전'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (diffMs < hour) {
|
||||||
|
return `${Math.floor(diffMs / minute)}분 전`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (diffMs < day) {
|
||||||
|
return `${Math.floor(diffMs / hour)}시간 전`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (diffMs < day * 30) {
|
||||||
|
return `${Math.floor(diffMs / day)}일 전`
|
||||||
|
}
|
||||||
|
|
||||||
|
return formatDate(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 회원 저장 요청 본문을 만든다.
|
||||||
|
* @returns {{ username: string, email: string, avatarUrl: string, labels: string[], note: string }} 저장 본문
|
||||||
|
*/
|
||||||
|
const getMemberPayload = () => ({
|
||||||
|
username: form.username.trim(),
|
||||||
|
email: form.email.trim(),
|
||||||
|
avatarUrl: form.avatarUrl.trim(),
|
||||||
|
labels: normalizedLabels.value,
|
||||||
|
note: form.note
|
||||||
|
})
|
||||||
|
|
||||||
|
const hasUnsavedMemberChanges = computed(() => serializeMemberPayload() !== savedMemberSnapshot.value)
|
||||||
|
|
||||||
|
const {
|
||||||
|
isUnsavedModalOpen,
|
||||||
|
stayOnUnsavedPage,
|
||||||
|
leaveUnsavedPage
|
||||||
|
} = useAdminUnsavedChangesGuard(hasUnsavedMemberChanges)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 썸네일 파일 선택창을 연다.
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const openAvatarFilePicker = () => {
|
||||||
|
avatarInputRef.value?.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 회원 썸네일 파일을 업로드하고 폼에 반영한다.
|
||||||
|
* @param {Event} event - 파일 선택 이벤트
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
const uploadAvatar = async (event) => {
|
||||||
|
const target = event.target instanceof HTMLInputElement ? event.target : null
|
||||||
|
const file = target?.files?.[0]
|
||||||
|
|
||||||
|
if (!file || isUploadingAvatar.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isUploadingAvatar.value = true
|
||||||
|
saveError.value = ''
|
||||||
|
saveMessage.value = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('files', file)
|
||||||
|
const result = await $fetch('/admin/api/uploads', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
})
|
||||||
|
form.avatarUrl = result.files?.[0]?.url || ''
|
||||||
|
} catch (error) {
|
||||||
|
saveError.value = error?.data?.message || '썸네일 업로드에 실패했습니다.'
|
||||||
|
} finally {
|
||||||
|
isUploadingAvatar.value = false
|
||||||
|
if (target) {
|
||||||
|
target.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 회원 썸네일 연결을 제거한다.
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const removeAvatar = () => {
|
||||||
|
form.avatarUrl = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 회원 작업 메뉴를 토글한다.
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const toggleActionMenu = () => {
|
||||||
|
actionMenuOpen.value = !actionMenuOpen.value
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 회원 작업 메뉴를 닫는다.
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const closeActionMenu = () => {
|
||||||
|
actionMenuOpen.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 비밀번호 변경 모달을 연다.
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const openPasswordModal = () => {
|
||||||
|
passwordForm.password = ''
|
||||||
|
passwordForm.passwordConfirm = ''
|
||||||
|
actionMessage.value = ''
|
||||||
|
actionError.value = ''
|
||||||
|
passwordModalOpen.value = true
|
||||||
|
closeActionMenu()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 회원 삭제 모달을 연다.
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const openDeleteModal = () => {
|
||||||
|
deleteForm.confirmText = ''
|
||||||
|
actionMessage.value = ''
|
||||||
|
actionError.value = ''
|
||||||
|
deleteModalOpen.value = true
|
||||||
|
closeActionMenu()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 비밀번호 변경 모달을 닫는다.
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const closePasswordModal = () => {
|
||||||
|
if (isUpdatingPassword.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
passwordModalOpen.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 회원 삭제 모달을 닫는다.
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const closeDeleteModal = () => {
|
||||||
|
if (isDeletingMember.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteModalOpen.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 관리자 권한으로 회원 비밀번호를 변경한다.
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
const updateMemberPassword = async () => {
|
||||||
|
actionMessage.value = ''
|
||||||
|
actionError.value = ''
|
||||||
|
|
||||||
|
if (!passwordForm.password || passwordForm.password.length < 8 || passwordForm.password.length > 32) {
|
||||||
|
actionError.value = '새 비밀번호는 8~32자로 입력해 주세요.'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (passwordForm.password !== passwordForm.passwordConfirm) {
|
||||||
|
actionError.value = '새 비밀번호와 확인 값이 일치하지 않습니다.'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isUpdatingPassword.value = true
|
||||||
|
try {
|
||||||
|
await $fetch(`/admin/api/members/${props.member.id}/password`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: {
|
||||||
|
password: passwordForm.password
|
||||||
|
}
|
||||||
|
})
|
||||||
|
passwordModalOpen.value = false
|
||||||
|
passwordForm.password = ''
|
||||||
|
passwordForm.passwordConfirm = ''
|
||||||
|
actionMessage.value = '비밀번호가 변경되었습니다.'
|
||||||
|
} catch (error) {
|
||||||
|
actionError.value = error?.data?.message || '비밀번호 변경에 실패했습니다.'
|
||||||
|
} finally {
|
||||||
|
isUpdatingPassword.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 관리자 권한으로 회원을 삭제한다.
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
const deleteMember = async () => {
|
||||||
|
actionMessage.value = ''
|
||||||
|
actionError.value = ''
|
||||||
|
|
||||||
|
if (deleteForm.confirmText !== form.email) {
|
||||||
|
actionError.value = '삭제하려면 회원 이메일을 정확히 입력해 주세요.'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isDeletingMember.value = true
|
||||||
|
try {
|
||||||
|
await $fetch(`/admin/api/members/${props.member.id}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
})
|
||||||
|
emit('deleted')
|
||||||
|
} catch (error) {
|
||||||
|
actionError.value = error?.data?.message || '회원 삭제에 실패했습니다.'
|
||||||
|
} finally {
|
||||||
|
isDeletingMember.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 회원 기본 정보를 저장한다.
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
const saveMember = async () => {
|
||||||
|
if (isSaving.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
saveMessage.value = ''
|
||||||
|
saveError.value = ''
|
||||||
|
isSaving.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = getMemberPayload()
|
||||||
|
const saved = isNewMember.value
|
||||||
|
? await $fetch('/admin/api/members', {
|
||||||
|
method: 'POST',
|
||||||
|
body: payload
|
||||||
|
})
|
||||||
|
: await $fetch(`/admin/api/members/${props.member.id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: payload
|
||||||
|
})
|
||||||
|
|
||||||
|
savedMemberSnapshot.value = serializeMemberPayload()
|
||||||
|
emit('saved', saved)
|
||||||
|
saveMessage.value = '저장되었습니다.'
|
||||||
|
} catch (error) {
|
||||||
|
saveError.value = error?.data?.message || '저장에 실패했습니다.'
|
||||||
|
} finally {
|
||||||
|
isSaving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => props.member, () => {
|
||||||
|
savedMemberSnapshot.value = serializeMemberPayload()
|
||||||
|
}, { immediate: true, flush: 'post' })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="admin-member-form bg-paper p-6">
|
||||||
|
<div class="admin-member-form__header sticky top-0 z-10 -mx-6 -mt-6 border-b border-line bg-paper/95 px-6 py-5 backdrop-blur">
|
||||||
|
<div class="admin-member-form__header-inner flex flex-col gap-4 xl:flex-row xl:items-center xl:justify-between">
|
||||||
|
<div class="admin-member-form__title-block">
|
||||||
|
<div class="admin-member-form__breadcrumb flex items-center gap-2 text-sm text-[#8a95a5]">
|
||||||
|
<NuxtLink class="admin-member-form__breadcrumb-link text-[#3f4650] hover:text-[#15171a]" to="/admin/members">
|
||||||
|
멤버
|
||||||
|
</NuxtLink>
|
||||||
|
<svg class="h-3 w-3" viewBox="0 0 18 27" aria-hidden="true">
|
||||||
|
<path d="M2.397 25.426l13.143-11.5-13.143-11.5" stroke-width="3" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
|
</svg>
|
||||||
|
<span>{{ isNewMember ? '새 멤버' : '멤버 편집' }}</span>
|
||||||
|
</div>
|
||||||
|
<h1 class="admin-member-form__title mt-4 text-3xl font-semibold tracking-[-0.01em] text-[#15171a]">
|
||||||
|
{{ pageTitle }}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<div class="admin-member-form__actions flex items-center gap-3">
|
||||||
|
<div v-if="!isNewMember" class="admin-member-form__action-menu relative">
|
||||||
|
<button class="admin-member-form__action-icon flex h-11 w-11 items-center justify-center rounded-md border border-line bg-white text-[#3f4650] transition hover:border-[#c5ccd5] hover:bg-[#f4f6f8]" type="button" aria-label="멤버 작업" :aria-expanded="actionMenuOpen" @click="toggleActionMenu">
|
||||||
|
<svg class="h-5 w-5" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="M10.546 2.438a1.957 1.957 0 002.908 0L14.4 1.4a1.959 1.959 0 013.41 1.413l-.071 1.4a1.958 1.958 0 002.051 2.054l1.4-.071a1.959 1.959 0 011.41 3.41l-1.042.94a1.96 1.96 0 000 2.909l1.042.94a1.959 1.959 0 01-1.413 3.41l-1.4-.071a1.958 1.958 0 00-2.056 2.056l.071 1.4A1.959 1.959 0 0114.4 22.6l-.941-1.041a1.959 1.959 0 00-2.908 0L9.606 22.6A1.959 1.959 0 016.2 21.192l.072-1.4a1.958 1.958 0 00-2.056-2.056l-1.4.071A1.958 1.958 0 011.4 14.4l1.041-.94a1.96 1.96 0 000-2.909L1.4 9.606A1.958 1.958 0 012.809 6.2l1.4.071a1.958 1.958 0 002.058-2.06L6.2 2.81A1.959 1.959 0 019.606 1.4z" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
|
||||||
|
<circle cx="12" cy="12.001" r="4.5" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<div v-if="actionMenuOpen" class="admin-member-form__action-popover absolute right-0 top-12 z-20 grid w-52 overflow-hidden rounded-xl border border-line bg-white py-2 text-sm text-[#3f4650] shadow-[0_16px_44px_rgba(15,23,42,0.16)]">
|
||||||
|
<button class="admin-member-form__action-item px-4 py-2.5 text-left hover:bg-[#f3f5f7]" type="button" @click="openPasswordModal">
|
||||||
|
비밀번호 변경
|
||||||
|
</button>
|
||||||
|
<button class="admin-member-form__action-item px-4 py-2.5 text-left text-[#d21a26] hover:bg-[#fff1f2]" type="button" @click="openDeleteModal">
|
||||||
|
멤버 삭제
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button v-else class="admin-member-form__action-icon flex h-11 w-11 items-center justify-center rounded-md border border-line bg-white text-[#3f4650]" type="button" aria-label="멤버 작업" disabled>
|
||||||
|
<svg class="h-5 w-5" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="M10.546 2.438a1.957 1.957 0 002.908 0L14.4 1.4a1.959 1.959 0 013.41 1.413l-.071 1.4a1.958 1.958 0 002.051 2.054l1.4-.071a1.959 1.959 0 011.41 3.41l-1.042.94a1.96 1.96 0 000 2.909l1.042.94a1.959 1.959 0 01-1.413 3.41l-1.4-.071a1.958 1.958 0 00-2.056 2.056l.071 1.4A1.959 1.959 0 0114.4 22.6l-.941-1.041a1.959 1.959 0 00-2.908 0L9.606 22.6A1.959 1.959 0 016.2 21.192l.072-1.4a1.958 1.958 0 00-2.056-2.056l-1.4.071A1.958 1.958 0 011.4 14.4l1.041-.94a1.96 1.96 0 000-2.909L1.4 9.606A1.958 1.958 0 012.809 6.2l1.4.071a1.958 1.958 0 002.058-2.06L6.2 2.81A1.959 1.959 0 019.606 1.4z" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
|
||||||
|
<circle cx="12" cy="12.001" r="4.5" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button class="admin-member-form__save h-11 rounded-md bg-[#15171a] px-5 text-sm font-semibold text-white transition hover:bg-[#2b2f35] disabled:opacity-50" type="button" :disabled="isSaving" @click="saveMember">
|
||||||
|
{{ isSaving ? '저장 중' : '저장' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="admin-member-form__body grid gap-8 py-8 xl:grid-cols-3">
|
||||||
|
<aside class="admin-member-form__summary">
|
||||||
|
<div class="admin-member-form__identity flex items-center gap-4">
|
||||||
|
<div class="admin-member-form__avatar-control group relative h-20 w-20 shrink-0">
|
||||||
|
<button
|
||||||
|
class="admin-member-form__avatar-button relative h-20 w-20 overflow-hidden rounded-full bg-[#15171a] text-white"
|
||||||
|
type="button"
|
||||||
|
:aria-label="form.avatarUrl ? '썸네일 변경' : '썸네일 등록'"
|
||||||
|
@click="openAvatarFilePicker"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
v-if="form.avatarUrl"
|
||||||
|
class="admin-member-form__avatar h-full w-full object-cover"
|
||||||
|
:src="form.avatarUrl"
|
||||||
|
:alt="pageTitle"
|
||||||
|
>
|
||||||
|
<span v-else class="admin-member-form__avatar flex h-full w-full items-center justify-center text-2xl font-semibold">
|
||||||
|
{{ memberInitial }}
|
||||||
|
</span>
|
||||||
|
<span class="admin-member-form__avatar-caption absolute inset-x-0 bottom-0 flex min-h-8 items-end justify-center bg-gradient-to-t from-black/80 via-black/35 to-transparent px-2 pb-2 text-center text-[11px] font-semibold leading-tight text-white opacity-0 transition-opacity group-hover:opacity-100 group-focus-within:opacity-100">
|
||||||
|
{{ isUploadingAvatar ? '업로드 중' : form.avatarUrl ? '썸네일 변경' : '썸네일 등록' }}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="form.avatarUrl"
|
||||||
|
class="admin-member-form__avatar-remove absolute right-0 top-0 grid size-6 -translate-y-1 translate-x-1 place-items-center rounded-full bg-black/85 text-white opacity-0 shadow-sm transition hover:bg-[#d21a26] group-hover:opacity-100 group-focus-within:opacity-100"
|
||||||
|
type="button"
|
||||||
|
aria-label="썸네일 제거"
|
||||||
|
@click.stop="removeAvatar"
|
||||||
|
>
|
||||||
|
<svg class="h-3 w-3" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="M12.707 12L23.854.854a.5.5 0 00-.707-.707L12 11.293.854.146a.5.5 0 00-.707.707L11.293 12 .146 23.146a.5.5 0 00.708.708L12 12.707l11.146 11.146a.5.5 0 10.708-.706L12.707 12z" fill="currentColor" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<input ref="avatarInputRef" class="sr-only" type="file" accept="image/*" @change="uploadAvatar">
|
||||||
|
</div>
|
||||||
|
<div class="min-w-0">
|
||||||
|
<h2 class="truncate text-lg font-semibold text-[#15171a]">{{ pageTitle }}</h2>
|
||||||
|
<p class="mt-1 truncate text-sm text-[#657080]">{{ form.email || '이메일 없음' }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="!isNewMember" class="admin-member-form__meta mt-10 space-y-3 text-sm text-[#4d5663]">
|
||||||
|
<p class="flex items-center gap-2">
|
||||||
|
<svg class="h-4 w-4 text-[#8a95a5]" fill="none" viewBox="0 0 24 26" aria-hidden="true">
|
||||||
|
<path d="M12 14.75a4 4 0 100-8 4 4 0 000 8z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
|
<path d="M21 10.75c0 7.9-6.932 12.331-8.629 13.3a.751.751 0 01-.743 0C9.931 23.08 3 18.648 3 10.75a9 9 0 1118 0z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
|
</svg>
|
||||||
|
{{ member?.lastSeenIp || '접속 IP 없음' }}
|
||||||
|
</p>
|
||||||
|
<p class="flex items-center gap-2">
|
||||||
|
<svg class="h-4 w-4 text-[#8a95a5]" fill="none" viewBox="0 0 26 24" aria-hidden="true">
|
||||||
|
<path d="M13 5.001c-4.03-.078-8.2 3.157-10.82 6.47-.276.35-.428.805-.428 1.277 0 .472.152.928.427 1.278C4.743 17.27 8.9 20.578 13 20.5c4.1.079 8.258-3.23 10.824-6.473.275-.35.428-.806.428-1.278s-.153-.927-.428-1.278C21.2 8.158 17.031 4.923 13 5.001z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
|
<path d="M16.75 12.751a3.75 3.75 0 11-7.5-.002 3.75 3.75 0 017.5.002z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
|
</svg>
|
||||||
|
{{ formatRelativeTime(member?.lastSeenAt) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="!isNewMember" class="admin-member-form__side-section mt-12 border-t border-line pt-6">
|
||||||
|
<h3 class="text-xs font-semibold uppercase tracking-[0.04em] text-[#15171a]">가입 정보</h3>
|
||||||
|
<p class="mt-5 flex items-center gap-2 text-sm text-[#4d5663]">
|
||||||
|
<svg class="h-4 w-4 text-[#8a95a5]" fill="none" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="M11.5 12c-2.824 0-2.83.024-4.5.53-3.5 1.058-5 3.176-5 6.386V21h10m7-5v6m-3-3h6m-10.5-7a5.5 5.5 0 100-11 5.5 5.5 0 000 11z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
|
</svg>
|
||||||
|
생성됨 — <strong>{{ formatDate(member?.createdAt) }}</strong>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="!isNewMember" class="admin-member-form__side-section mt-12 border-t border-line pt-6">
|
||||||
|
<h3 class="text-xs font-semibold uppercase tracking-[0.04em] text-[#15171a]">참여도</h3>
|
||||||
|
<p class="mt-5 text-sm leading-6 text-[#8a95a5]">
|
||||||
|
댓글 작성 {{ member?.commentCount || 0 }}개
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<div class="admin-member-form__content space-y-8 xl:col-span-2">
|
||||||
|
<form class="admin-member-form__card rounded-xl border border-line bg-white p-5 md:p-6" @submit.prevent="saveMember">
|
||||||
|
<div class="grid gap-5 md:grid-cols-2">
|
||||||
|
<label class="admin-member-form__field block">
|
||||||
|
<span class="admin-member-form__label mb-2 block text-sm font-semibold text-[#15171a]">이름</span>
|
||||||
|
<input v-model="form.username" class="admin-member-form__input h-12 w-full rounded-md border border-transparent bg-[#eef1f4] px-4 text-sm text-[#15171a] outline-none focus:border-[#c5ccd5] focus:bg-white focus:ring-2 focus:ring-[#dce2e8]" type="text" maxlength="60" required>
|
||||||
|
</label>
|
||||||
|
<label class="admin-member-form__field block">
|
||||||
|
<span class="admin-member-form__label mb-2 block text-sm font-semibold text-[#15171a]">이메일</span>
|
||||||
|
<input v-model="form.email" class="admin-member-form__input h-12 w-full rounded-md border border-transparent bg-[#eef1f4] px-4 text-sm text-[#15171a] outline-none focus:border-[#c5ccd5] focus:bg-white focus:ring-2 focus:ring-[#dce2e8]" type="email" maxlength="254" required>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="admin-member-form__field mt-5 block">
|
||||||
|
<span class="admin-member-form__label mb-2 block text-sm font-semibold text-[#15171a]">레이블</span>
|
||||||
|
<input v-model="form.labelsText" class="admin-member-form__input h-12 w-full rounded-md border border-transparent bg-[#eef1f4] px-4 text-sm text-[#15171a] outline-none focus:border-[#c5ccd5] focus:bg-white focus:ring-2 focus:ring-[#dce2e8]" type="text" placeholder="쉼표로 구분">
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="admin-member-form__field mt-5 block">
|
||||||
|
<span class="admin-member-form__label mb-2 block text-sm font-semibold text-[#15171a]">
|
||||||
|
노트 <span class="font-normal text-[#657080]">(멤버에게 보이지 않음)</span>
|
||||||
|
</span>
|
||||||
|
<textarea v-model="form.note" class="admin-member-form__textarea min-h-32 w-full resize-y rounded-md border border-transparent bg-[#eef1f4] px-4 py-3 text-sm text-[#15171a] outline-none focus:border-[#c5ccd5] focus:bg-white focus:ring-2 focus:ring-[#dce2e8]" maxlength="500" />
|
||||||
|
<span class="admin-member-form__count mt-2 block text-sm text-[#8a95a5]">
|
||||||
|
최대 500자. 현재 {{ noteLength }}자
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<p v-if="saveMessage" class="admin-member-form__message mt-5 rounded-md border border-green-200 bg-green-50 px-4 py-3 text-sm text-green-700">{{ saveMessage }}</p>
|
||||||
|
<p v-if="saveError" class="admin-member-form__error mt-5 rounded-md border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{{ saveError }}</p>
|
||||||
|
<p v-if="actionMessage" class="admin-member-form__message mt-5 rounded-md border border-green-200 bg-green-50 px-4 py-3 text-sm text-green-700">{{ actionMessage }}</p>
|
||||||
|
<p v-if="actionError && !passwordModalOpen && !deleteModalOpen" class="admin-member-form__error mt-5 rounded-md border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{{ actionError }}</p>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<section v-if="!isNewMember" class="admin-member-form__activity">
|
||||||
|
<h2 class="admin-member-form__section-title mb-4 text-xs font-semibold uppercase tracking-[0.04em] text-[#15171a]">활동</h2>
|
||||||
|
<div class="admin-member-form__activity-card rounded-xl border border-line bg-white px-5 md:px-6">
|
||||||
|
<div class="admin-member-form__activity-row flex items-center justify-between gap-4 border-b border-line py-5 text-sm last:border-b-0">
|
||||||
|
<span class="flex items-center gap-3 text-[#3f4650]">
|
||||||
|
<svg class="h-5 w-5 text-[#6c747d]" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||||
|
<path d="M4 12h10.31m-3.076-3.076L14.31 12l-3.076 3.077" stroke="currentColor" stroke-width="1.714" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
|
<path d="M4.998 16.308a7.69 7.69 0 003.733 3.182 7.238 7.238 0 004.8.189 7.608 7.608 0 003.949-2.88A8.283 8.283 0 0018.998 12c0-1.73-.533-3.414-1.518-4.798a7.607 7.607 0 00-3.949-2.88 7.237 7.237 0 00-4.8.188 7.69 7.69 0 00-3.733 3.182" stroke="currentColor" stroke-width="1.714" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
|
</svg>
|
||||||
|
로그인
|
||||||
|
</span>
|
||||||
|
<span class="text-[#9aa4b2]">{{ formatRelativeTime(member?.lastSeenAt) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="admin-member-form__activity-row flex items-center justify-between gap-4 py-5 text-sm">
|
||||||
|
<span class="flex items-center gap-3 text-[#3f4650]">
|
||||||
|
<svg class="h-5 w-5 text-[#6c747d]" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||||
|
<path d="M11.246 12.144a4.242 4.242 0 100-8.484 4.242 4.242 0 000 8.484zM4 18.761a8.484 8.484 0 0110.5-3.42" stroke="currentColor" stroke-width="1.714" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
|
<path d="M17.54 16.077V23m-3.463-3.46H21" stroke="#30CF43" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
|
</svg>
|
||||||
|
가입
|
||||||
|
</span>
|
||||||
|
<span class="text-[#9aa4b2]">{{ formatRelativeTime(member?.createdAt) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AdminUnsavedChangesModal
|
||||||
|
:open="isUnsavedModalOpen"
|
||||||
|
@stay="stayOnUnsavedPage"
|
||||||
|
@leave="leaveUnsavedPage"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Teleport to="body">
|
||||||
|
<div v-if="passwordModalOpen" class="admin-member-form__modal fixed inset-0 z-50 flex items-start justify-center bg-black/35 px-4 pt-10">
|
||||||
|
<section class="admin-member-form__modal-panel w-full max-w-[520px] rounded-xl bg-white text-[#15171a] shadow-[0_22px_70px_rgba(15,23,42,0.25)]">
|
||||||
|
<header class="flex items-center justify-between border-b border-line px-6 py-5">
|
||||||
|
<h2 class="text-xl font-semibold">비밀번호 변경</h2>
|
||||||
|
<button class="grid h-8 w-8 place-items-center rounded-md text-[#657080] hover:bg-[#f3f5f7]" type="button" aria-label="닫기" @click="closePasswordModal">
|
||||||
|
<svg class="h-4 w-4" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="M12.707 12L23.854.854a.5.5 0 00-.707-.707L12 11.293.854.146a.5.5 0 00-.707.707L11.293 12 .146 23.146a.5.5 0 00.708.708L12 12.707l11.146 11.146a.5.5 0 10.708-.706L12.707 12z" fill="currentColor" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
<div class="grid gap-4 px-6 py-5">
|
||||||
|
<p class="text-sm leading-6 text-[#657080]">
|
||||||
|
이메일 전송이 불가능한 상황을 대비해 관리자가 직접 새 비밀번호를 설정합니다.
|
||||||
|
</p>
|
||||||
|
<label class="grid gap-2 text-sm font-semibold">
|
||||||
|
새 비밀번호
|
||||||
|
<input v-model="passwordForm.password" class="h-11 rounded-md border border-transparent bg-[#eef1f4] px-4 text-sm outline-none focus:border-[#c5ccd5] focus:bg-white focus:ring-2 focus:ring-[#dce2e8]" type="password" autocomplete="new-password">
|
||||||
|
</label>
|
||||||
|
<label class="grid gap-2 text-sm font-semibold">
|
||||||
|
새 비밀번호 확인
|
||||||
|
<input v-model="passwordForm.passwordConfirm" class="h-11 rounded-md border border-transparent bg-[#eef1f4] px-4 text-sm outline-none focus:border-[#c5ccd5] focus:bg-white focus:ring-2 focus:ring-[#dce2e8]" type="password" autocomplete="new-password">
|
||||||
|
</label>
|
||||||
|
<p v-if="actionError" class="rounded-md border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{{ actionError }}</p>
|
||||||
|
</div>
|
||||||
|
<footer class="flex justify-end gap-2 border-t border-line px-6 py-4">
|
||||||
|
<button class="h-10 rounded-md border border-line bg-white px-4 text-sm font-semibold text-[#3f4650]" type="button" @click="closePasswordModal">
|
||||||
|
취소
|
||||||
|
</button>
|
||||||
|
<button class="h-10 rounded-md bg-[#15171a] px-4 text-sm font-semibold text-white disabled:opacity-50" type="button" :disabled="isUpdatingPassword" @click="updateMemberPassword">
|
||||||
|
{{ isUpdatingPassword ? '변경 중' : '변경' }}
|
||||||
|
</button>
|
||||||
|
</footer>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
|
||||||
|
<Teleport to="body">
|
||||||
|
<div v-if="deleteModalOpen" class="admin-member-form__modal fixed inset-0 z-50 flex items-start justify-center bg-black/35 px-4 pt-10">
|
||||||
|
<section class="admin-member-form__modal-panel w-full max-w-[520px] rounded-xl bg-white text-[#15171a] shadow-[0_22px_70px_rgba(15,23,42,0.25)]">
|
||||||
|
<header class="flex items-center justify-between border-b border-line px-6 py-5">
|
||||||
|
<h2 class="text-xl font-semibold">멤버 삭제</h2>
|
||||||
|
<button class="grid h-8 w-8 place-items-center rounded-md text-[#657080] hover:bg-[#f3f5f7]" type="button" aria-label="닫기" @click="closeDeleteModal">
|
||||||
|
<svg class="h-4 w-4" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="M12.707 12L23.854.854a.5.5 0 00-.707-.707L12 11.293.854.146a.5.5 0 00-.707.707L11.293 12 .146 23.146a.5.5 0 00.708.708L12 12.707l11.146 11.146a.5.5 0 10.708-.706L12.707 12z" fill="currentColor" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
<div class="grid gap-4 px-6 py-5">
|
||||||
|
<p class="text-sm leading-6 text-[#657080]">
|
||||||
|
삭제하면 멤버 계정과 작성 댓글이 함께 삭제됩니다. 계속하려면 아래에 <strong class="text-[#15171a]">{{ form.email }}</strong> 을 입력해 주세요.
|
||||||
|
</p>
|
||||||
|
<input v-model="deleteForm.confirmText" class="h-11 rounded-md border border-transparent bg-[#eef1f4] px-4 text-sm outline-none focus:border-[#c5ccd5] focus:bg-white focus:ring-2 focus:ring-[#dce2e8]" type="text" autocomplete="off">
|
||||||
|
<p v-if="actionError" class="rounded-md border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{{ actionError }}</p>
|
||||||
|
</div>
|
||||||
|
<footer class="flex justify-end gap-2 border-t border-line px-6 py-4">
|
||||||
|
<button class="h-10 rounded-md border border-line bg-white px-4 text-sm font-semibold text-[#3f4650]" type="button" @click="closeDeleteModal">
|
||||||
|
취소
|
||||||
|
</button>
|
||||||
|
<button class="h-10 rounded-md bg-[#d21a26] px-4 text-sm font-semibold text-white disabled:opacity-50" type="button" :disabled="isDeletingMember" @click="deleteMember">
|
||||||
|
{{ isDeletingMember ? '삭제 중' : '삭제' }}
|
||||||
|
</button>
|
||||||
|
</footer>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -47,8 +47,15 @@ const isRestoringAutosave = ref(false)
|
|||||||
const isSettingsOpen = ref(true)
|
const isSettingsOpen = ref(true)
|
||||||
const tagInput = ref('')
|
const tagInput = ref('')
|
||||||
const isTagInputComposing = ref(false)
|
const isTagInputComposing = ref(false)
|
||||||
|
const isTitleInputComposing = ref(false)
|
||||||
const activeMediaPickerTab = ref('upload')
|
const activeMediaPickerTab = ref('upload')
|
||||||
const selectedMediaPickerUrl = ref('')
|
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 입력값으로 변환
|
* ISO 날짜를 datetime-local 입력값으로 변환
|
||||||
@@ -234,6 +241,49 @@ const editorStatusLabel = computed(() => {
|
|||||||
return '초안'
|
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} 게시물 입력값
|
* @returns {Object} 게시물 입력값
|
||||||
@@ -260,6 +310,14 @@ const createPostPayload = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 현재 게시물 입력값을 문자열로 직렬화한다.
|
||||||
|
* @returns {string} 직렬화된 게시물 입력값
|
||||||
|
*/
|
||||||
|
const serializePostPayload = () => JSON.stringify(createPostPayload())
|
||||||
|
|
||||||
|
const hasUnsavedPostChanges = computed(() => serializePostPayload() !== savedPostSnapshot.value)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 자동 저장 데이터 생성
|
* 자동 저장 데이터 생성
|
||||||
* @returns {Object} 자동 저장 데이터
|
* @returns {Object} 자동 저장 데이터
|
||||||
@@ -374,6 +432,15 @@ const discardAutosave = () => {
|
|||||||
autosaveStatus.value = ''
|
autosaveStatus.value = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
isUnsavedModalOpen,
|
||||||
|
stayOnUnsavedPage,
|
||||||
|
leaveUnsavedPage,
|
||||||
|
allowNextRouteLeave
|
||||||
|
} = useAdminUnsavedChangesGuard(hasUnsavedPostChanges, {
|
||||||
|
onLeaveConfirmed: discardAutosave
|
||||||
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 미디어 라이브러리 목록 조회
|
* 미디어 라이브러리 목록 조회
|
||||||
* @returns {Promise<void>}
|
* @returns {Promise<void>}
|
||||||
@@ -544,9 +611,15 @@ const dropFeaturedImage = async (event) => {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 제목 입력 후 본문 에디터로 이동
|
* 제목 입력 후 본문 에디터로 이동
|
||||||
|
* @param {KeyboardEvent} event - 키보드 이벤트
|
||||||
* @returns {void}
|
* @returns {void}
|
||||||
*/
|
*/
|
||||||
const focusContentEditor = () => {
|
const focusContentEditor = (event) => {
|
||||||
|
if (event?.isComposing || isTitleInputComposing.value || event?.keyCode === 229) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
event?.preventDefault()
|
||||||
blockEditor.value?.focusFirstBlock()
|
blockEditor.value?.focusFirstBlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -555,6 +628,7 @@ const focusContentEditor = () => {
|
|||||||
* @returns {void}
|
* @returns {void}
|
||||||
*/
|
*/
|
||||||
const submitPost = () => {
|
const submitPost = () => {
|
||||||
|
isPublishModalOpen.value = false
|
||||||
emit('submit', createPostPayload())
|
emit('submit', createPostPayload())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -582,9 +656,123 @@ const toggleSettingsPanel = () => {
|
|||||||
isSettingsOpen.value = !isSettingsOpen.value
|
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 })
|
watch(form, scheduleAutosave, { deep: true })
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
markSaved()
|
||||||
|
|
||||||
const savedRaw = localStorage.getItem(autosaveKey.value)
|
const savedRaw = localStorage.getItem(autosaveKey.value)
|
||||||
|
|
||||||
if (!savedRaw) {
|
if (!savedRaw) {
|
||||||
@@ -610,23 +798,47 @@ onBeforeUnmount(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
defineExpose({
|
defineExpose({
|
||||||
clearAutosave: discardAutosave
|
clearAutosave: discardAutosave,
|
||||||
|
markSaved,
|
||||||
|
allowNextRouteLeave
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<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">
|
<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">
|
<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-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">
|
<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 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">
|
<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 class="admin-post-form__toolbar-back text-lg leading-none" aria-hidden="true"><</span>
|
||||||
<span>Posts</span>
|
<span>Posts</span>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<span class="admin-post-form__toolbar-status truncate rounded px-2 py-1.5 text-sm text-[#8e9cac]">
|
<div class="admin-post-form__toolbar-status-row flex min-w-0 flex-1 items-center gap-2">
|
||||||
{{ editorStatusLabel }}
|
<span class="admin-post-form__toolbar-status truncate rounded px-2 py-1.5 text-sm text-[#8e9cac]">
|
||||||
</span>
|
{{ 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>
|
||||||
<div class="admin-post-form__toolbar-actions flex h-full shrink-0 items-center gap-2">
|
<div class="admin-post-form__toolbar-actions flex h-full shrink-0 items-center gap-2">
|
||||||
<button
|
<button
|
||||||
@@ -637,9 +849,10 @@ defineExpose({
|
|||||||
미리보기
|
미리보기
|
||||||
</button>
|
</button>
|
||||||
<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"
|
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="submit"
|
type="button"
|
||||||
:disabled="saving"
|
:disabled="saving || !hasUnsavedPostChanges"
|
||||||
|
@click="openPublishModal"
|
||||||
>
|
>
|
||||||
{{ saving ? '저장 중' : submitLabel }}
|
{{ saving ? '저장 중' : submitLabel }}
|
||||||
</button>
|
</button>
|
||||||
@@ -686,26 +899,11 @@ defineExpose({
|
|||||||
type="text"
|
type="text"
|
||||||
placeholder="제목"
|
placeholder="제목"
|
||||||
required
|
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">
|
<div class="admin-post-form__field admin-post-form__content-editor text-sm">
|
||||||
<AdminBlockEditor ref="blockEditor" v-model="form.content" />
|
<AdminBlockEditor ref="blockEditor" v-model="form.content" />
|
||||||
</div>
|
</div>
|
||||||
@@ -799,12 +997,14 @@ defineExpose({
|
|||||||
>
|
>
|
||||||
<span>{{ tag }}</span>
|
<span>{{ tag }}</span>
|
||||||
<button
|
<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"
|
type="button"
|
||||||
:aria-label="`${tag} 태그 삭제`"
|
:aria-label="`${tag} 태그 삭제`"
|
||||||
@click="removeTag(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>
|
</button>
|
||||||
</span>
|
</span>
|
||||||
<input
|
<input
|
||||||
@@ -846,7 +1046,7 @@ defineExpose({
|
|||||||
</div>
|
</div>
|
||||||
<div v-if="showDelete" class="admin-post-form__settings-bottom shrink-0 border-t border-[#e3e6e8] px-8 py-6">
|
<div v-if="showDelete" class="admin-post-form__settings-bottom shrink-0 border-t border-[#e3e6e8] px-8 py-6">
|
||||||
<button
|
<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"
|
type="button"
|
||||||
:disabled="deleting"
|
:disabled="deleting"
|
||||||
@click="deletePost"
|
@click="deletePost"
|
||||||
@@ -952,5 +1152,182 @@ defineExpose({
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</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>
|
</form>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
61
components/admin/AdminUnsavedChangesModal.vue
Normal file
61
components/admin/AdminUnsavedChangesModal.vue
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
<script setup>
|
||||||
|
defineProps({
|
||||||
|
open: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
defineEmits(['stay', 'leave'])
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<div
|
||||||
|
v-if="open"
|
||||||
|
class="admin-unsaved-modal fixed inset-0 z-[100] flex items-start justify-center bg-black/40 px-5 pb-8 pt-10"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="admin-unsaved-modal-title"
|
||||||
|
>
|
||||||
|
<div class="admin-unsaved-modal__content relative w-full max-w-[520px] rounded-xl bg-white text-[#15171a] shadow-2xl">
|
||||||
|
<header class="admin-unsaved-modal__header border-b border-[#e3e6e8] px-8 py-6">
|
||||||
|
<h1 id="admin-unsaved-modal-title" class="admin-unsaved-modal__title text-xl font-semibold tracking-[-0.01em]">
|
||||||
|
이 페이지를 떠날까요?
|
||||||
|
</h1>
|
||||||
|
</header>
|
||||||
|
<button
|
||||||
|
class="admin-unsaved-modal__close absolute right-5 top-5 grid size-8 place-items-center rounded-md text-[#4d5663] transition hover:bg-[#eff1f2] hover:text-black"
|
||||||
|
type="button"
|
||||||
|
title="닫기"
|
||||||
|
aria-label="닫기"
|
||||||
|
@click="$emit('stay')"
|
||||||
|
>
|
||||||
|
<svg class="h-4 w-4" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="M12.707 12L23.854.854a.5.5 0 00-.707-.707L12 11.293.854.146a.5.5 0 00-.707.707L11.293 12 .146 23.146a.5.5 0 00.708.708L12 12.707l11.146 11.146a.5.5 0 10.708-.706L12.707 12z" fill="currentColor" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<div class="admin-unsaved-modal__body space-y-3 px-8 py-7 text-sm leading-6 text-[#4d5663]">
|
||||||
|
<p>저장하지 않은 변경사항이 있습니다.</p>
|
||||||
|
<p>떠나기 전에 저장해 주세요.</p>
|
||||||
|
</div>
|
||||||
|
<footer class="admin-unsaved-modal__footer flex justify-end gap-3 border-t border-[#e3e6e8] px-8 py-5">
|
||||||
|
<button
|
||||||
|
class="admin-unsaved-modal__stay h-10 rounded-md border border-[#d7dce0] bg-white px-4 text-sm font-semibold text-[#394047] transition hover:bg-[#eff1f2]"
|
||||||
|
type="button"
|
||||||
|
@click="$emit('stay')"
|
||||||
|
>
|
||||||
|
머무르기
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="admin-unsaved-modal__leave h-10 rounded-md bg-[#e5484d] px-4 text-sm font-semibold text-white transition hover:bg-[#d21a26]"
|
||||||
|
type="button"
|
||||||
|
@click="$emit('leave')"
|
||||||
|
>
|
||||||
|
나가기
|
||||||
|
</button>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
@@ -32,9 +32,54 @@ const createBlock = (type = 'paragraph', text = '', level = null, id = '', optio
|
|||||||
ordered: options.ordered || false,
|
ordered: options.ordered || false,
|
||||||
width: options.width || 'regular',
|
width: options.width || 'regular',
|
||||||
images: options.images || [],
|
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 - 마크다운 행
|
* @param {string} line - 마크다운 행
|
||||||
@@ -240,9 +285,9 @@ const parseMarkdownBlocks = (markdown) => {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if (trimmedLine === ':::callout') {
|
if (trimmedLine.startsWith(':::callout')) {
|
||||||
const { contentLines, nextIndex } = collectFencedLines(lines, index + 1)
|
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
|
index = nextIndex
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -400,7 +445,12 @@ const showNextImage = () => {
|
|||||||
<ProseImage v-else-if="block.type === 'image'" :src="block.url" :alt="block.alt" :variant="block.width">
|
<ProseImage v-else-if="block.type === 'image'" :src="block.url" :alt="block.alt" :variant="block.width">
|
||||||
{{ block.alt }}
|
{{ block.alt }}
|
||||||
</ProseImage>
|
</ProseImage>
|
||||||
<ProseCallout v-else-if="block.type === 'callout'">
|
<ProseCallout
|
||||||
|
v-else-if="block.type === 'callout'"
|
||||||
|
:emoji-enabled="block.calloutEmojiEnabled"
|
||||||
|
:emoji="block.calloutEmoji"
|
||||||
|
:background="block.calloutBackground"
|
||||||
|
>
|
||||||
{{ block.text }}
|
{{ block.text }}
|
||||||
</ProseCallout>
|
</ProseCallout>
|
||||||
<ProseToggle v-else-if="block.type === 'toggle'" :title="block.title || '더 보기'">
|
<ProseToggle v-else-if="block.type === 'toggle'" :title="block.title || '더 보기'">
|
||||||
|
|||||||
@@ -53,10 +53,24 @@ const imageSrc = computed(() => props.thumbnail || faviconUrl.value)
|
|||||||
* @returns {string}
|
* @returns {string}
|
||||||
*/
|
*/
|
||||||
const displayTitle = computed(() => props.title || displayHost.value || props.url)
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<a
|
<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"
|
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"
|
:href="url"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
@@ -92,4 +106,7 @@ const displayTitle = computed(() => props.title || displayHost.value || props.ur
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</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>
|
</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>
|
<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)]">
|
<aside
|
||||||
<div class="whitespace-pre-line">
|
class="prose-callout prose-callout-card mt-8 rounded-[10px] p-5 text-[15px] leading-8 text-[var(--site-text)]"
|
||||||
<slot />
|
: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>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
</template>
|
</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 youtubeEmbedUrl = computed(() => youtubeId.value ? `https://www.youtube.com/embed/${youtubeId.value}` : '')
|
||||||
const tweetId = computed(() => getTweetId(props.url))
|
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 주소
|
* Twitter 공식 embed iframe 주소
|
||||||
* @returns {string}
|
* @returns {string}
|
||||||
@@ -98,13 +114,16 @@ const tweetEmbedUrl = computed(() => {
|
|||||||
loading="lazy"
|
loading="lazy"
|
||||||
/>
|
/>
|
||||||
<a
|
<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"
|
class="prose-embed__link block p-5 text-sm font-semibold text-[var(--site-text)] hover:opacity-70"
|
||||||
:href="url"
|
:href="safeExternalUrl"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
>
|
>
|
||||||
{{ url }}
|
{{ url }}
|
||||||
</a>
|
</a>
|
||||||
|
<p v-else class="prose-embed__invalid p-5 text-sm font-semibold text-[var(--site-muted)]">
|
||||||
|
지원하지 않는 임베드 URL입니다.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ const { data: siteSettings } = await useFetch('/api/site-settings', {
|
|||||||
title: 'sori.studio',
|
title: 'sori.studio',
|
||||||
description: 'sori.studio 개인 블로그',
|
description: 'sori.studio 개인 블로그',
|
||||||
logoText: '井',
|
logoText: '井',
|
||||||
|
logoUrl: '',
|
||||||
copyrightText: '©2026 sori.studio'
|
copyrightText: '©2026 sori.studio'
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -23,8 +24,14 @@ const { data: siteSettings } = await useFetch('/api/site-settings', {
|
|||||||
<div class="right-sidebar__scroll site-sidebar-scroll min-h-0 flex-1">
|
<div class="right-sidebar__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__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__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)]">
|
<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)]">
|
||||||
{{ siteSettings.logoText }}
|
<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>
|
||||||
<div>
|
<div>
|
||||||
<p class="right-sidebar__title font-semibold">
|
<p class="right-sidebar__title font-semibold">
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ const member = ref(null)
|
|||||||
|
|
||||||
const { data: siteSettings } = await useFetch('/api/site-settings', {
|
const { data: siteSettings } = await useFetch('/api/site-settings', {
|
||||||
default: () => ({
|
default: () => ({
|
||||||
title: 'sori.studio'
|
title: 'sori.studio',
|
||||||
|
logoUrl: ''
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -185,6 +186,12 @@ onBeforeUnmount(() => {
|
|||||||
</svg>
|
</svg>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
<img
|
||||||
|
v-if="siteSettings.logoUrl"
|
||||||
|
class="site-header__brand-logo h-7 w-7 shrink-0 rounded-md object-cover"
|
||||||
|
:src="siteSettings.logoUrl"
|
||||||
|
:alt="siteSettings.title"
|
||||||
|
>
|
||||||
<span class="min-w-0 truncate">{{ siteSettings.title }}</span>
|
<span class="min-w-0 truncate">{{ siteSettings.title }}</span>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
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);
|
ON navigation_items (location, sort_order ASC, label ASC);
|
||||||
|
|
||||||
INSERT INTO navigation_items (label, url, location, sort_order, is_visible)
|
INSERT INTO navigation_items (label, url, location, sort_order, is_visible)
|
||||||
VALUES
|
SELECT seed.label, seed.url, seed.location, seed.sort_order, seed.is_visible
|
||||||
('Home pages', '/', 'primary', 10, true),
|
FROM (
|
||||||
('Tags', '/tags', 'primary', 20, true),
|
VALUES
|
||||||
('Authors', '/pages/about', 'primary', 30, true),
|
('Home pages', '/', 'primary', 10, true),
|
||||||
('Style', '/post/hello-sori-studio', 'primary', 40, true),
|
('Tags', '/tags', 'primary', 20, true),
|
||||||
('Post types', '/post/custom-writing-tool', 'primary', 50, true),
|
('Authors', '/pages/about', 'primary', 30, true),
|
||||||
('Members', '/pages/contact', 'primary', 60, true),
|
('Style', '/post/hello-sori-studio', 'primary', 40, true),
|
||||||
('Landing pages', '/pages/projects', 'primary', 70, true),
|
('Post types', '/post/custom-writing-tool', 'primary', 50, true),
|
||||||
('Portal', '/pages/links', 'footer', 10, true),
|
('Members', '/pages/contact', 'primary', 60, true),
|
||||||
('Docs', '/pages/about', 'footer', 20, true),
|
('Landing pages', '/pages/projects', 'primary', 70, true),
|
||||||
('Projects', '/pages/projects', 'footer', 30, true)
|
('Portal', '/pages/links', 'footer', 10, true),
|
||||||
ON CONFLICT DO NOTHING;
|
('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
|
||||||
|
);
|
||||||
|
|||||||
23
db/migrations/019_dedupe_navigation_items.sql
Normal file
23
db/migrations/019_dedupe_navigation_items.sql
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
-- 반복 마이그레이션 실행으로 생긴 동일 위치·상위·라벨·URL 메뉴 중복 정리
|
||||||
|
WITH ranked_navigation AS (
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
row_number() OVER (
|
||||||
|
PARTITION BY location, COALESCE(parent_id::text, ''), label, url
|
||||||
|
ORDER BY
|
||||||
|
CASE WHEN is_folder THEN 0 ELSE 1 END,
|
||||||
|
sort_order ASC,
|
||||||
|
created_at ASC,
|
||||||
|
id ASC
|
||||||
|
) AS row_rank
|
||||||
|
FROM navigation_items
|
||||||
|
)
|
||||||
|
DELETE FROM navigation_items
|
||||||
|
WHERE id IN (
|
||||||
|
SELECT id
|
||||||
|
FROM ranked_navigation
|
||||||
|
WHERE row_rank > 1
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS navigation_items_location_parent_label_url_unique_idx
|
||||||
|
ON navigation_items (location, COALESCE(parent_id::text, ''), label, url);
|
||||||
5
db/migrations/020_add_member_admin_fields.sql
Normal file
5
db/migrations/020_add_member_admin_fields.sql
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
ALTER TABLE users
|
||||||
|
ADD COLUMN IF NOT EXISTS member_labels TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[];
|
||||||
|
|
||||||
|
ALTER TABLE users
|
||||||
|
ADD COLUMN IF NOT EXISTS member_note TEXT NOT NULL DEFAULT '';
|
||||||
12
db/migrations/021_add_member_previous_login.sql
Normal file
12
db/migrations/021_add_member_previous_login.sql
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
ALTER TABLE users
|
||||||
|
ADD COLUMN IF NOT EXISTS previous_last_seen_at TIMESTAMPTZ;
|
||||||
|
|
||||||
|
ALTER TABLE users
|
||||||
|
ADD COLUMN IF NOT EXISTS previous_last_seen_ip TEXT NOT NULL DEFAULT '';
|
||||||
|
|
||||||
|
UPDATE users
|
||||||
|
SET
|
||||||
|
previous_last_seen_at = last_seen_at,
|
||||||
|
previous_last_seen_ip = last_seen_ip
|
||||||
|
WHERE previous_last_seen_at IS NULL
|
||||||
|
AND last_seen_at IS NOT NULL;
|
||||||
5
db/migrations/022_add_site_logo_urls.sql
Normal file
5
db/migrations/022_add_site_logo_urls.sql
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
ALTER TABLE site_settings
|
||||||
|
ADD COLUMN IF NOT EXISTS logo_url TEXT NOT NULL DEFAULT '';
|
||||||
|
|
||||||
|
ALTER TABLE site_settings
|
||||||
|
ADD COLUMN IF NOT EXISTS favicon_url TEXT NOT NULL DEFAULT '';
|
||||||
@@ -1,5 +1,14 @@
|
|||||||
# 업데이트 요약
|
# 업데이트 요약
|
||||||
|
|
||||||
|
## v1.0.0
|
||||||
|
|
||||||
|
- 운영 시작 기준 버전.
|
||||||
|
- 운영 환경 DB 설정 누락 시 샘플 콘텐츠 대신 즉시 실패하도록 보강.
|
||||||
|
- 회원 세션 비밀값을 관리자 비밀번호와 분리.
|
||||||
|
- JavaScript 문법 점검과 프로덕션 빌드를 묶은 검증 스크립트 추가.
|
||||||
|
- Nitro 보안 권고 반영 및 취약점 0건 확인.
|
||||||
|
- Docker compose 설정과 앱 이미지 빌드 검증 완료.
|
||||||
|
|
||||||
## v0.0.6
|
## v0.0.6
|
||||||
|
|
||||||
- `.env.example`을 실제 비밀값이 없는 공유 템플릿으로 정리.
|
- `.env.example`을 실제 비밀값이 없는 공유 템플릿으로 정리.
|
||||||
|
|||||||
@@ -55,3 +55,10 @@
|
|||||||
- 하드코딩 금지
|
- 하드코딩 금지
|
||||||
- 로컬 개발 설정과 NAS 운영 설정은 별도 환경 파일로 분리
|
- 로컬 개발 설정과 NAS 운영 설정은 별도 환경 파일로 분리
|
||||||
- 운영 DB 접속 정보는 개발용 `.env`에 기록하지 않음
|
- 운영 DB 접속 정보는 개발용 `.env`에 기록하지 않음
|
||||||
|
- 운영 환경에서는 `DATABASE_URL`과 `MEMBER_SESSION_SECRET` 누락을 허용하지 않음
|
||||||
|
|
||||||
|
## 검증
|
||||||
|
|
||||||
|
- `npm run lint`: JavaScript 파일 문법 점검
|
||||||
|
- `npm run test`: Nuxt 프로덕션 빌드 기반 회귀 검증
|
||||||
|
- `npm run verify`: 문법 점검과 빌드를 함께 실행
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# 배포 가이드
|
# 배포 가이드
|
||||||
|
|
||||||
> 현재 프로젝트는 Nuxt 3 초기 스캐폴딩 상태다. Docker 설정은 파일 기준 초안이 있으며 운영 DB 확정 후 NAS에서 검증한다.
|
> 로컬 기준 `npm run build`, `docker compose --env-file .env.production config --quiet`, `docker compose --env-file .env.production build sori-studio` 검증을 통과했다. NAS 실제 컨테이너 기동과 도메인/프록시 접속 검증은 운영 배포 단계에서 진행한다.
|
||||||
|
|
||||||
## 빌드 유형
|
## 빌드 유형
|
||||||
|
|
||||||
@@ -8,6 +8,7 @@
|
|||||||
|------|--------|------|
|
|------|--------|------|
|
||||||
| 개발 | `npm run dev` | 로컬 테스트, 개발 서버 |
|
| 개발 | `npm run dev` | 로컬 테스트, 개발 서버 |
|
||||||
| 프로덕션 | `npm run build` | NAS 배포, 운영 서버 |
|
| 프로덕션 | `npm run build` | NAS 배포, 운영 서버 |
|
||||||
|
| 검증 | `npm run verify` | JavaScript 문법 점검 + 프로덕션 빌드 |
|
||||||
|
|
||||||
> `npm run dev`는 프로젝트 전용 실행 스크립트를 통해 개발 서버, Admin, Tailwind Viewer 링크만 요약 출력한다.
|
> `npm run dev`는 프로젝트 전용 실행 스크립트를 통해 개발 서버, Admin, Tailwind Viewer 링크만 요약 출력한다.
|
||||||
|
|
||||||
@@ -128,6 +129,7 @@ cd sori.studio
|
|||||||
# .env.production은 Git에 올리지 않는 운영 전용 파일
|
# .env.production은 Git에 올리지 않는 운영 전용 파일
|
||||||
cp .env.example .env.production
|
cp .env.example .env.production
|
||||||
# .env.production 파일에 운영 DB 연결 정보, 운영 전용 랜덤 비밀번호, APP_PORT=43118 입력
|
# .env.production 파일에 운영 DB 연결 정보, 운영 전용 랜덤 비밀번호, APP_PORT=43118 입력
|
||||||
|
# MEMBER_SESSION_SECRET은 ADMIN_PASSWORD와 다른 긴 난수 문자열로 반드시 입력
|
||||||
# Docker 내부 앱에서 PostgreSQL에 접근할 때는 sori-studio-db:5432 사용
|
# Docker 내부 앱에서 PostgreSQL에 접근할 때는 sori-studio-db:5432 사용
|
||||||
|
|
||||||
# Docker 빌드 및 실행
|
# Docker 빌드 및 실행
|
||||||
@@ -163,8 +165,10 @@ docker run -d -p 3000:3000 sori.studio:latest
|
|||||||
- NAS Docker 예시: `postgres://sori_studio:비밀번호@sori-studio-db:5432/sori_studio`
|
- NAS Docker 예시: `postgres://sori_studio:비밀번호@sori-studio-db:5432/sori_studio`
|
||||||
- `.env.example`에는 실제 비밀번호나 개인 이메일을 기록하지 않음
|
- `.env.example`에는 실제 비밀번호나 개인 이메일을 기록하지 않음
|
||||||
- 개발/운영 DB 비밀번호와 관리자 비밀번호는 서로 다른 랜덤 값을 사용
|
- 개발/운영 DB 비밀번호와 관리자 비밀번호는 서로 다른 랜덤 값을 사용
|
||||||
|
- 회원 세션 서명용 `MEMBER_SESSION_SECRET`은 관리자 비밀번호와 분리된 긴 난수 문자열을 사용
|
||||||
- 개발 DB와 운영 DB는 반드시 별도 인스턴스 또는 별도 데이터베이스로 분리
|
- 개발 DB와 운영 DB는 반드시 별도 인스턴스 또는 별도 데이터베이스로 분리
|
||||||
- 운영 DB는 로컬 개발 서버에서 직접 연결하지 않음
|
- 운영 DB는 로컬 개발 서버에서 직접 연결하지 않음
|
||||||
|
- 운영 환경에서 `DATABASE_URL`이 없으면 샘플 콘텐츠로 대체하지 않고 서버 오류로 실패
|
||||||
|
|
||||||
### 이메일 인증(Resend, 선택)
|
### 이메일 인증(Resend, 선택)
|
||||||
|
|
||||||
@@ -174,7 +178,7 @@ docker run -d -p 3000:3000 sori.studio:latest
|
|||||||
|------|------|
|
|------|------|
|
||||||
| `RESEND_API_KEY` | [Resend](https://resend.com) API 키 |
|
| `RESEND_API_KEY` | [Resend](https://resend.com) API 키 |
|
||||||
| `RESEND_FROM_EMAIL` | 발신 주소(Resend에서 허용된 도메인 또는 테스트 발신자) |
|
| `RESEND_FROM_EMAIL` | 발신 주소(Resend에서 허용된 도메인 또는 테스트 발신자) |
|
||||||
| `MEMBER_SESSION_SECRET` | 세션 쿠키 서명용 비밀값. **OTP 해시에 쓰는 pepper로도 사용**되므로, `EMAIL_OTP_PEPPER`를 비워 두면 이 값이 OTP용 비밀 재료가 된다. |
|
| `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로 쓴다. |
|
| `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 생략)이 동작한다.
|
`RESEND_API_KEY`와 `RESEND_FROM_EMAIL`이 비어 있으면 기존처럼 이메일 OTP 없이 최초 관리자 가입·일반 가입(OTP 생략)이 동작한다.
|
||||||
@@ -183,6 +187,8 @@ docker run -d -p 3000:3000 sori.studio:latest
|
|||||||
- 로컬 개발 Docker Compose 실행 시 `ENV_FILE=.env.development`와 `--env-file .env.development`를 함께 사용
|
- 로컬 개발 Docker Compose 실행 시 `ENV_FILE=.env.development`와 `--env-file .env.development`를 함께 사용
|
||||||
- 로컬 개발 DB 마이그레이션은 `npm run db:migrate:dev`로 실행
|
- 로컬 개발 DB 마이그레이션은 `npm run db:migrate:dev`로 실행
|
||||||
- 네비게이션 계층(`parent_id`, `is_folder`)은 `017_navigation_hierarchy.sql` 적용 후 저장 API가 정상 동작한다(미적용 시 `INSERT` 컬럼 불일치).
|
- 네비게이션 계층(`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 분리 검증 절차
|
### 개발/운영 DB 분리 검증 절차
|
||||||
|
|
||||||
@@ -216,6 +222,7 @@ test -f .env.production && rg -n "^(DATABASE_URL|POSTGRES_DB|POSTGRES_USER|APP_P
|
|||||||
- NAS Docker 내부 실행 기준이면 `DATABASE_URL` 호스트가 `sori-studio-db`
|
- NAS Docker 내부 실행 기준이면 `DATABASE_URL` 호스트가 `sori-studio-db`
|
||||||
- NAS 외부 DB를 별도 인스턴스로 쓰는 경우에도 로컬 개발 DB(`127.0.0.1:43119`)를 가리키지 않음
|
- NAS 외부 DB를 별도 인스턴스로 쓰는 경우에도 로컬 개발 DB(`127.0.0.1:43119`)를 가리키지 않음
|
||||||
- `APP_PORT=43118`
|
- `APP_PORT=43118`
|
||||||
|
- `MEMBER_SESSION_SECRET`이 비어 있지 않고 `ADMIN_PASSWORD`와 다름
|
||||||
- `.env.development`와 DB 비밀번호, 관리자 비밀번호가 서로 다름
|
- `.env.development`와 DB 비밀번호, 관리자 비밀번호가 서로 다름
|
||||||
|
|
||||||
3. 로컬 개발 DB 연결 확인.
|
3. 로컬 개발 DB 연결 확인.
|
||||||
|
|||||||
134
docs/history.md
134
docs/history.md
@@ -1,5 +1,139 @@
|
|||||||
# 의사결정 이력
|
# 의사결정 이력
|
||||||
|
|
||||||
|
## 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
|
## 2026-05-12 v0.0.102
|
||||||
|
|
||||||
### 관리자 블록 에디터 빈 단락·키보드·슬래시 메뉴
|
### 관리자 블록 에디터 빈 단락·키보드·슬래시 메뉴
|
||||||
|
|||||||
59
docs/map.md
59
docs/map.md
@@ -8,8 +8,9 @@
|
|||||||
|------|------|
|
|------|------|
|
||||||
| layouts/default.vue | 메인·목록·태그 — 3열 `gap`+중앙 `1fr`, `site-main` `max-w-[720px]`, 모바일 슬라이드 메뉴 |
|
| layouts/default.vue | 메인·목록·태그 — 3열 `gap`+중앙 `1fr`, `site-main` `max-w-[720px]`, 모바일 슬라이드 메뉴 |
|
||||||
| layouts/post.vue | 개별 게시물 — `default`와 동일 |
|
| layouts/post.vue | 개별 게시물 — `default`와 동일 |
|
||||||
| layouts/admin.vue | 관리자 전체, 글 작성/수정 화면의 전체 화면 편집 모드와 문서 스크롤 잠금 |
|
| layouts/admin.vue | 관리자 전체, 320px 밝은 Ghost형 사이드바, 우측 전체 높이 캔버스, 멤버 수 표시, 하단 사용자 드롭다운·설정, `내 프로필` 멤버 편집 이동, 게시글 `+` 새 글 진입, 글 작성/수정 화면의 전체 화면 편집 모드와 문서 스크롤 잠금 |
|
||||||
| layouts/page.vue | 고정 페이지 전체 화면 |
|
| layouts/page.vue | 고정 페이지 전체 화면 |
|
||||||
|
| app.vue | 공개 사이트 설정 기반 파비콘 head 링크와 기본 title template |
|
||||||
|
|
||||||
## Composables
|
## Composables
|
||||||
|
|
||||||
@@ -30,16 +31,28 @@
|
|||||||
|------|------|
|
|------|------|
|
||||||
| modules/nuxt-ssr-paths-write.mjs | `paths.mjs`를 `.nuxt`에 기록해 Node가 `#internal/nuxt/paths`를 해석할 수 있게 함 |
|
| modules/nuxt-ssr-paths-write.mjs | `paths.mjs`를 `.nuxt`에 기록해 Node가 `#internal/nuxt/paths`를 해석할 수 있게 함 |
|
||||||
|
|
||||||
|
## Scripts
|
||||||
|
|
||||||
|
| 파일 | 용도 |
|
||||||
|
|------|------|
|
||||||
|
| scripts/check-js-syntax.js | `npm run lint`에서 JS/MJS/CJS 파일을 `node --check`로 문법 점검 |
|
||||||
|
|
||||||
|
## 서버 미들웨어
|
||||||
|
|
||||||
|
| 파일 | 용도 |
|
||||||
|
|------|------|
|
||||||
|
| server/middleware/admin-api-session.js | `/admin/api/*` 요청마다 관리자 세션과 현재 DB 권한(`owner`/`admin`) 재확인 |
|
||||||
|
|
||||||
## 사이트 컴포넌트
|
## 사이트 컴포넌트
|
||||||
|
|
||||||
| 파일 | 화면 위치 |
|
| 파일 | 화면 위치 |
|
||||||
|------|-----------|
|
|------|-----------|
|
||||||
| components/auth/AuthPasswordVisibilityToggle.vue | 로그인·회원가입 비밀번호 표시/숨김 토글(SVG, scoped 스타일·`field-name`으로 접근성 레이블 구분) |
|
| components/auth/AuthPasswordVisibilityToggle.vue | 로그인·회원가입 비밀번호 표시/숨김 토글(SVG, scoped 스타일·`field-name`으로 접근성 레이블 구분) |
|
||||||
| components/site/SiteHeader.vue | 모든 공개 페이지 상단, `grid-cols-3`로 검색 패널 중앙 정렬(`md+`), 우측 사용자 아바타 드롭다운, `/`·`SiteSearchModal` |
|
| components/site/SiteHeader.vue | 모든 공개 페이지 상단, 이미지 로고 fallback, `grid-cols-3`로 검색 패널 중앙 정렬(`md+`), 우측 사용자 아바타 드롭다운, `/`·`SiteSearchModal` |
|
||||||
| components/site/SiteSearchModal.vue | `Teleport`·전체 화면 딤·Tags/Posts 결과·일치 구간 강조, 열림 시 `html.site-search-open` 스크롤 잠금 |
|
| components/site/SiteSearchModal.vue | `Teleport`·전체 화면 딤·Tags/Posts 결과·일치 구간 강조, 열림 시 `html.site-search-open` 스크롤 잠금 |
|
||||||
| components/site/LeftSidebar.vue | 왼쪽 사이드바, `lg+`는 `sticky`+고정 높이+내부 무스크롤바 스크롤, `lg` 미만은 고정 슬라이드 패널, 상단 메뉴는 `SidebarPrimaryNavList`+`provide`로 트리·펼침 상태(`sori-primary-nav-expanded`), 푸터 `footer` 링크는 `flex-wrap`·테마 버튼 `shrink-0` |
|
| components/site/LeftSidebar.vue | 왼쪽 사이드바, `lg+`는 `sticky`+고정 높이+내부 무스크롤바 스크롤, `lg` 미만은 고정 슬라이드 패널, 상단 메뉴는 `SidebarPrimaryNavList`+`provide`로 트리·펼침 상태(`sori-primary-nav-expanded`), 푸터 `footer` 링크는 `flex-wrap`·테마 버튼 `shrink-0` |
|
||||||
| components/site/SidebarPrimaryNavList.vue | 상단 네비: 부모·리프 동일 `before` 막대/호버 원형, 내부 현재 경로 `--site-accent`, 행 `w-full`로 패널 호버 배경 폭, `inject`·`localStorage` 펼침 |
|
| components/site/SidebarPrimaryNavList.vue | 상단 네비: 부모·리프 동일 `before` 막대/호버 원형, 내부 현재 경로 `--site-accent`, 행 `w-full`로 패널 호버 배경 폭, `inject`·`localStorage` 펼침 |
|
||||||
| components/site/RightSidebar.vue | 오른쪽 사이드바, `lg+`는 고정 열 높이·스티키, 모바일은 본문 아래 전체 너비, 하단 푸터 `pr-3` |
|
| components/site/RightSidebar.vue | 오른쪽 사이드바, 공개 사이트 이미지 로고 fallback, `lg+`는 고정 열 높이·스티키, 모바일은 본문 아래 전체 너비, 하단 푸터 `pr-3` |
|
||||||
| components/site/MainColumn.vue | 메인 화면 중앙, `lg:max-w-[720px]`로 본문 상한 |
|
| components/site/MainColumn.vue | 메인 화면 중앙, `lg:max-w-[720px]`로 본문 상한 |
|
||||||
| components/site/PostCard.vue | 목록의 게시물 카드, 대표 이미지 썸네일, 카드 hover 인터랙션 |
|
| components/site/PostCard.vue | 목록의 게시물 카드, 대표 이미지 썸네일, 카드 hover 인터랙션 |
|
||||||
| components/site/TagHeader.vue | 태그 페이지 헤더 |
|
| components/site/TagHeader.vue | 태그 페이지 헤더 |
|
||||||
@@ -49,11 +62,19 @@
|
|||||||
|
|
||||||
| 파일 | 화면 위치 |
|
| 파일 | 화면 위치 |
|
||||||
|------|-----------|
|
|------|-----------|
|
||||||
| components/admin/AdminPostForm.vue | 관리자 글 작성/수정 폼, Ghost 스타일 전체 화면 에디터, 좌우 분할 설정 패널, 설정 패널 전환 애니메이션, Post URL 보기 액션, 하단 삭제 액션, 대표 이미지 hover 액션과 업로드/미디어 선택 확정, 배지형 태그 입력(한글 유지), 로컬 자동 저장, 예약 발행 시각 입력, 검색 노출 제외(noindex), 저장 시 제목·요약을 SEO 메타로 반영, 미리보기 요청 |
|
| components/admin/AdminPostForm.vue | 관리자 글 작성/수정 폼, Ghost 스타일 전체 화면 에디터, 변경사항 기반 저장 버튼 활성화, 저장 클릭 시 전체 화면 발행 모달(행 접기/펼침, Ghost 동일 SVG 아이콘, 상태 요약 후 발행/초안/비공개 선택, 발행 시에만 시점 행·즉시/예약·datetime-local), 좌우 분할 설정 패널, 설정 패널 전환 애니메이션, Post URL 보기 액션, 중립 톤 삭제 액션, 대표 이미지 hover 액션과 업로드/미디어 선택 확정, 제목 IME Enter 가드, SVG 닫기 아이콘 배지형 태그 입력(한글 유지), 로컬 자동 저장(툴바 상태 옆 복원·무시), 미저장 변경사항 이탈 확인, 검색 노출 제외(noindex), 저장 시 제목·요약을 SEO 메타로 반영, 미리보기 요청 |
|
||||||
| components/admin/AdminPageForm.vue | 관리자 페이지 작성/수정 폼, 대표 이미지 선택 |
|
| components/admin/AdminPageForm.vue | 관리자 페이지 작성/수정 폼, 대표 이미지 선택 |
|
||||||
| components/admin/AdminBlockEditor.vue | 관리자 글 블록형 에디터, 이미지/갤러리/콜아웃/토글/임베드 블록, 한글 조합 입력 처리, Shift+Enter 줄바꿈, 코드 블록 단축 변환, AFFiNE 참고 세로 막대형 블록 핸들 선택/삭제/드래그 이동과 삽입선 표시, 하단 빈 입력 블록 유지, 본문 placeholder 표시 |
|
| components/admin/AdminBlockEditor.vue | 관리자 글 블록형 에디터, 이미지/갤러리/콜아웃/토글/임베드 블록, 콜아웃 Emoji on/off·이모지 프리셋·배경 프리셋 선택(우측 고정 설정 패널), 갤러리 복수 미디어 선택·이미지 수별 열 배치·삽입 위치 표시 드래그 순서 변경, 한글 조합 입력 처리, Shift+Enter 줄바꿈, 코드 블록 단축 변환, AFFiNE 참고 세로 막대형 블록 핸들 선택/삭제/드래그 이동과 삽입선 표시, 하단 빈 입력 블록 유지, 본문 placeholder 표시 |
|
||||||
| components/admin/AdminNavPrimaryBranch.vue | 관리자 상단 네비 트리(테이블·태그와 동일한 행 드래그 하이라이트, 하위·삭제) |
|
| components/admin/AdminNavPrimaryBranch.vue | 관리자 상단 네비 트리(테이블·태그와 동일한 행 드래그 하이라이트, 하위·삭제) |
|
||||||
| components/admin/AdminTagForm.vue | 관리자 태그 생성/수정 폼(이름/슬러그/설명/색상만 편집) |
|
| components/admin/AdminTagForm.vue | 관리자 태그 생성/수정 폼(이름/슬러그/설명/색상만 편집) |
|
||||||
|
| components/admin/AdminMemberForm.vue | 관리자 멤버 추가/수정 폼(Ghost형 3분할, 요약 1fr·입력 2fr, 원형 썸네일 hover 등록·변경·삭제, 기본 정보·레이블·관리자 노트·활동 요약, 설정 메뉴의 비밀번호 변경·멤버 삭제 모달, 미저장 변경사항 이탈 확인) |
|
||||||
|
| components/admin/AdminUnsavedChangesModal.vue | 관리자 편집 화면 공통 미저장 변경사항 이탈 확인 모달(상단 40px 위치) |
|
||||||
|
|
||||||
|
## 관리자 컴포저블
|
||||||
|
|
||||||
|
| 파일 | 화면 위치 |
|
||||||
|
|------|-----------|
|
||||||
|
| composables/useAdminUnsavedChangesGuard.js | 관리자 게시글/멤버 편집 화면 라우트 이탈 확인과 브라우저 `beforeunload` 연결 |
|
||||||
|
|
||||||
## 콘텐츠 컴포넌트
|
## 콘텐츠 컴포넌트
|
||||||
|
|
||||||
@@ -66,16 +87,16 @@
|
|||||||
| components/content/ProseList.vue | 목록 |
|
| components/content/ProseList.vue | 목록 |
|
||||||
| components/content/ProseBlockquote.vue | 인용구 |
|
| components/content/ProseBlockquote.vue | 인용구 |
|
||||||
| components/content/ProseButton.vue | 버튼 |
|
| components/content/ProseButton.vue | 버튼 |
|
||||||
| components/content/ProseCallout.vue | Callout 카드 |
|
| components/content/ProseCallout.vue | Callout 카드(Emoji 표시/숨김, 배경 프리셋, 상단 여백 중심) |
|
||||||
| components/content/ProseToggle.vue | Toggle 카드 |
|
| components/content/ProseToggle.vue | Toggle 카드 |
|
||||||
| components/content/ProseVideo.vue | 비디오 |
|
| components/content/ProseVideo.vue | 비디오 |
|
||||||
| components/content/ProseAudio.vue | 오디오 |
|
| components/content/ProseAudio.vue | 오디오 |
|
||||||
| components/content/ProseFile.vue | 파일 |
|
| components/content/ProseFile.vue | 파일 |
|
||||||
| components/content/ProseProduct.vue | 상품 카드 |
|
| components/content/ProseProduct.vue | 상품 카드 |
|
||||||
| components/content/ProseBookmark.vue | 본문 북마크 카드(썸네일·도메인·외부 링크) |
|
| components/content/ProseBookmark.vue | 본문 북마크 카드(썸네일·도메인·`http(s)` 외부 링크) |
|
||||||
| components/content/ProseSignup.vue | 본문 회원가입·뉴스레터 CTA 카드 |
|
| components/content/ProseSignup.vue | 본문 회원가입·뉴스레터 CTA 카드 |
|
||||||
| components/content/ProseHeaderCard.vue | 헤더 카드 |
|
| components/content/ProseHeaderCard.vue | 헤더 카드 |
|
||||||
| components/content/ProseEmbed.vue | YouTube·Twitter/X iframe 임베드, 기타 외부 링크 |
|
| components/content/ProseEmbed.vue | YouTube·Twitter/X iframe 임베드, 기타 `http(s)` 외부 링크 |
|
||||||
|
|
||||||
## 관리자 페이지
|
## 관리자 페이지
|
||||||
|
|
||||||
@@ -83,7 +104,7 @@
|
|||||||
|------|------|
|
|------|------|
|
||||||
| pages/admin/index.vue | 대시보드 |
|
| pages/admin/index.vue | 대시보드 |
|
||||||
| pages/admin/login.vue | 관리자 로그인(이메일·비밀번호 모두 입력 시에만 제출 버튼 활성) |
|
| pages/admin/login.vue | 관리자 로그인(이메일·비밀번호 모두 입력 시에만 제출 버튼 활성) |
|
||||||
| pages/admin/posts/index.vue | 글 목록, 예약 발행 상태 표시, 삭제는 휴지통 아이콘·기본 낮은 강조 |
|
| pages/admin/posts/index.vue | 글 목록, 예약 발행 상태 표시, 읽기 전용 태그 배지, 삭제는 휴지통 아이콘·기본 낮은 강조 |
|
||||||
| pages/admin/posts/new.vue | 글 작성, Ghost 스타일 작성 폼, 저장 토스트 |
|
| pages/admin/posts/new.vue | 글 작성, Ghost 스타일 작성 폼, 저장 토스트 |
|
||||||
| pages/admin/posts/[id].vue | 글 수정, Ghost 스타일 작성 폼, 저장/삭제 토스트 |
|
| pages/admin/posts/[id].vue | 글 수정, Ghost 스타일 작성 폼, 저장/삭제 토스트 |
|
||||||
| pages/admin/posts/preview.vue | 글 미리보기(공개 상세와 동일한 중앙 컬럼·수평 패딩) |
|
| pages/admin/posts/preview.vue | 글 미리보기(공개 상세와 동일한 중앙 컬럼·수평 패딩) |
|
||||||
@@ -91,12 +112,14 @@
|
|||||||
| pages/admin/pages/new.vue | 페이지 작성, 저장 토스트 |
|
| pages/admin/pages/new.vue | 페이지 작성, 저장 토스트 |
|
||||||
| pages/admin/pages/[id].vue | 페이지 수정, 저장/삭제 토스트 |
|
| pages/admin/pages/[id].vue | 페이지 수정, 저장/삭제 토스트 |
|
||||||
| pages/admin/media/index.vue | 미디어 관리, **미디어 라이브러리/썸네일** 탭, 검색은 파일명·게시물 사용처 제목만, 라이브러리: 폴더 트리·드래그 이동 등, 썸네일: 미참조 파일 삭제·이름 변경 가능, 상세 모달(연결 회원·폴더 편집·**다운로드**) |
|
| pages/admin/media/index.vue | 미디어 관리, **미디어 라이브러리/썸네일** 탭, 검색은 파일명·게시물 사용처 제목만, 라이브러리: 폴더 트리·드래그 이동 등, 썸네일: 미참조 파일 삭제·이름 변경 가능, 상세 모달(연결 회원·폴더 편집·**다운로드**) |
|
||||||
| pages/admin/navigation/index.vue | 메뉴 관리: 상단/하단 탭, 테이블+행 드래그(태그 메인과 동일 톤), `useAdminToast`, 마이그레이션 안내 |
|
| pages/admin/navigation/index.vue | 메뉴 관리: 상단/하단 탭, 테이블+행 드래그(태그 메인과 동일 톤), `useAdminToast` |
|
||||||
| pages/admin/tags/index.vue | 태그 관리(메인 태그 드래그 정렬·일반 강등, 일반 태그 검색/메인 전환/삭제), 액션 피드백 토스트, 순서 변경 시에만 정렬 저장 활성 |
|
| pages/admin/tags/index.vue | 태그 관리(메인 태그 드래그 정렬·일반 강등, 일반 태그 검색/메인 전환/삭제), 액션 피드백 토스트, 순서 변경 시에만 정렬 저장 활성 |
|
||||||
| pages/admin/tags/new.vue | 태그 생성 |
|
| pages/admin/tags/new.vue | 태그 생성 |
|
||||||
| pages/admin/tags/[id].vue | 태그 수정 |
|
| pages/admin/tags/[id].vue | 태그 수정 |
|
||||||
| pages/admin/settings/index.vue | 사이트 설정 + 관리자 프로필(썸네일/이름) + 관리자 비밀번호 변경 |
|
| pages/admin/settings/index.vue | 사이트 설정(이름, 설명, URL, 1:1 로고 업로드·파비콘 생성, 저작권 문구) |
|
||||||
| pages/admin/members/index.vue | 관리자 멤버 목록(닉네임, 이메일, 최근 접속, IP, 댓글 수, 활동 상태) |
|
| pages/admin/members/index.vue | 관리자 멤버 목록(Ghost형 테이블, 검색, 조건 필터, 멤버 추가 버튼, 닉네임+이메일, 가입일+최근 활동, IP, 댓글 수, 활동 상태 텍스트) |
|
||||||
|
| pages/admin/members/new.vue | 관리자 멤버 추가(썸네일 URL, 이름, 이메일, 레이블, 관리자 노트) |
|
||||||
|
| pages/admin/members/[id].vue | 관리자 멤버 상세/수정(회원 요약, 가입 정보, 활동 요약, 기본 정보 저장, 삭제 후 목록 이동) |
|
||||||
|
|
||||||
## 공개 페이지
|
## 공개 페이지
|
||||||
|
|
||||||
@@ -108,12 +131,12 @@
|
|||||||
| 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/index.vue | 태그 전체 목록, 중앙 히어로와 3열 태그 카드, 좌측 컬러 보더/hover 오버레이 |
|
||||||
| pages/tags/[slug].vue | `/tag/:slug` 리다이렉트 |
|
| pages/tags/[slug].vue | `/tag/:slug` 리다이렉트 |
|
||||||
| pages/tag/[slug].vue | 태그별 글 목록, 상단 태그 헤더 + 리스트형 게시물 카드 |
|
| pages/tag/[slug].vue | 태그별 글 목록, 상단 태그 헤더 + 공통 섹션 패딩을 쓰는 리스트형 게시물 카드 |
|
||||||
| pages/pages/[slug].vue | 고정 페이지 상세 |
|
| pages/pages/[slug].vue | 고정 페이지 상세 |
|
||||||
| pages/signup.vue | 회원가입 3단계, `emailOtpConfigured`일 때 이메일 OTP·인증번호 받기, `POST /api/auth/email-otp/request` |
|
| pages/signup.vue | 회원가입 3단계, `emailOtpConfigured`일 때 이메일 OTP·인증번호 받기, `POST /api/auth/email-otp/request` |
|
||||||
| pages/signin.vue | 로그인, `/forgot-password` 링크 |
|
| pages/signin.vue | 로그인, `/forgot-password` 링크 |
|
||||||
| pages/forgot-password.vue | 비밀번호 찾기(Resend 설정 시 OTP 발송·`POST /api/auth/password-reset/confirm`) |
|
| pages/forgot-password.vue | 비밀번호 찾기(Resend 설정 시 OTP 발송·`POST /api/auth/password-reset/confirm`) |
|
||||||
| pages/settings/index.vue | 회원 설정(썸네일 미리보기/이미지 변경·제거, 닉네임 변경/중복확인, 비밀번호 변경, 회원 탈퇴) |
|
| pages/settings/index.vue | 회원 설정(상단 프로필 요약, 가입 정보, 댓글 참여도, 하단 프로필 입력·이전 로그인 활동, 썸네일 변경·제거, 닉네임 저장, 설정 메뉴 모달 비밀번호 변경·회원 탈퇴) |
|
||||||
|
|
||||||
## 서버 API
|
## 서버 API
|
||||||
|
|
||||||
@@ -129,7 +152,7 @@
|
|||||||
| server/api/navigation.get.js | 공개 네비게이션 API |
|
| server/api/navigation.get.js | 공개 네비게이션 API |
|
||||||
| server/api/auth/signup.post.js | 회원 가입 API(선택 `emailOtp`, Resend 설정 시 일반 가입에 필수) |
|
| server/api/auth/signup.post.js | 회원 가입 API(선택 `emailOtp`, Resend 설정 시 일반 가입에 필수) |
|
||||||
| server/api/auth/bootstrap-status.get.js | 최초 관리자 여부 + `emailOtpConfigured` |
|
| server/api/auth/bootstrap-status.get.js | 최초 관리자 여부 + `emailOtpConfigured` |
|
||||||
| server/api/auth/email-otp/request.post.js | Resend로 OTP 발송(`signup` / `password_reset`) |
|
| server/api/auth/email-otp/request.post.js | Resend로 OTP 발송(`signup` / `password_reset`), 발송 실패 챌린지 삭제 |
|
||||||
| server/api/auth/password-reset/confirm.post.js | OTP 검증 후 비밀번호 재설정 |
|
| server/api/auth/password-reset/confirm.post.js | OTP 검증 후 비밀번호 재설정 |
|
||||||
| server/repositories/email-otp-repository.js | `email_otp_challenges` CRUD·검증 |
|
| server/repositories/email-otp-repository.js | `email_otp_challenges` CRUD·검증 |
|
||||||
| server/utils/email-otp.js | OTP 생성·해시 |
|
| server/utils/email-otp.js | OTP 생성·해시 |
|
||||||
@@ -143,7 +166,7 @@
|
|||||||
| server/api/auth/avatar.delete.js | 회원 썸네일 삭제 API |
|
| server/api/auth/avatar.delete.js | 회원 썸네일 삭제 API |
|
||||||
| server/api/auth/check-username.get.js | 닉네임 중복 확인 API |
|
| server/api/auth/check-username.get.js | 닉네임 중복 확인 API |
|
||||||
| server/api/auth/password.put.js | 회원 비밀번호 변경 API |
|
| server/api/auth/password.put.js | 회원 비밀번호 변경 API |
|
||||||
| server/api/auth/account.delete.js | 회원 탈퇴 API |
|
| server/api/auth/account.delete.js | 회원 탈퇴 API(마지막 `owner` 보호, 관리자 세션 함께 정리) |
|
||||||
| server/api/posts/[slug]/comments.get.js | 게시물 댓글 목록 조회 API |
|
| server/api/posts/[slug]/comments.get.js | 게시물 댓글 목록 조회 API |
|
||||||
| server/api/posts/[slug]/comments.post.js | 게시물 댓글 작성 API |
|
| server/api/posts/[slug]/comments.post.js | 게시물 댓글 작성 API |
|
||||||
| server/api/posts/[slug]/comments/[commentId]/like.post.js | 댓글 좋아요 토글 API |
|
| server/api/posts/[slug]/comments/[commentId]/like.post.js | 댓글 좋아요 토글 API |
|
||||||
@@ -177,6 +200,9 @@
|
|||||||
| server/routes/admin/api/navigation.get.js | 관리자 네비게이션 목록 API |
|
| server/routes/admin/api/navigation.get.js | 관리자 네비게이션 목록 API |
|
||||||
| server/routes/admin/api/navigation.put.js | 관리자 네비게이션 일괄 저장 API |
|
| server/routes/admin/api/navigation.put.js | 관리자 네비게이션 일괄 저장 API |
|
||||||
| server/routes/admin/api/members.get.js | 관리자 멤버 목록 API |
|
| server/routes/admin/api/members.get.js | 관리자 멤버 목록 API |
|
||||||
|
| server/routes/admin/api/members.post.js | 관리자 멤버 생성 API |
|
||||||
|
| server/routes/admin/api/members/[id].get.js | 관리자 멤버 상세 API |
|
||||||
|
| server/routes/admin/api/members/[id].put.js | 관리자 멤버 기본 정보 수정 API |
|
||||||
| server/routes/admin/api/members/[id]/role.put.js | 관리자 멤버 권한 변경 API |
|
| server/routes/admin/api/members/[id]/role.put.js | 관리자 멤버 권한 변경 API |
|
||||||
| server/utils/content-schema.js | Zod 콘텐츠 스키마 |
|
| server/utils/content-schema.js | Zod 콘텐츠 스키마 |
|
||||||
| server/utils/sample-content.js | 샘플 콘텐츠 저장소 |
|
| server/utils/sample-content.js | 샘플 콘텐츠 저장소 |
|
||||||
@@ -206,6 +232,7 @@
|
|||||||
| db/migrations/003_add_tag_display_fields.sql | 태그 표시 순서와 색상 필드 추가 |
|
| db/migrations/003_add_tag_display_fields.sql | 태그 표시 순서와 색상 필드 추가 |
|
||||||
| db/migrations/004_add_site_settings.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/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/005_add_navigation_items.sql | 네비게이션 항목 테이블 추가 |
|
||||||
| db/migrations/006_add_media_metadata.sql | 미디어 메타데이터 테이블 추가 |
|
| db/migrations/006_add_media_metadata.sql | 미디어 메타데이터 테이블 추가 |
|
||||||
| db/migrations/007_add_media_folders.sql | 미디어 폴더 테이블 추가 |
|
| db/migrations/007_add_media_folders.sql | 미디어 폴더 테이블 추가 |
|
||||||
|
|||||||
70
docs/spec.md
70
docs/spec.md
@@ -58,6 +58,7 @@
|
|||||||
|
|
||||||
- 게시물 화면은 레이아웃 그리드의 좌우 패딩을 기본으로 사용하고, 섹션 단위 `px-*`는 내부 래퍼로만 두어 패딩이 2중 적용되지 않게 한다.
|
- 게시물 화면은 레이아웃 그리드의 좌우 패딩을 기본으로 사용하고, 섹션 단위 `px-*`는 내부 래퍼로만 두어 패딩이 2중 적용되지 않게 한다.
|
||||||
- 댓글 시작 섹션의 구분선(`border-y`)은 패딩 없이 전체 폭으로 표시하고, 내용만 내부 래퍼에 패딩을 둔다.
|
- 댓글 시작 섹션의 구분선(`border-y`)은 패딩 없이 전체 폭으로 표시하고, 내용만 내부 래퍼에 패딩을 둔다.
|
||||||
|
- 본문 끝과 댓글 섹션 시작 사이 간격은 `48px`(`mt-12`)로 유지한다.
|
||||||
- 댓글은 로그인 회원만 작성 가능하며, 대댓글은 1단까지만 허용한다.
|
- 댓글은 로그인 회원만 작성 가능하며, 대댓글은 1단까지만 허용한다.
|
||||||
- 댓글은 작성자 썸네일(없으면 이니셜)과 좋아요 수를 표시한다.
|
- 댓글은 작성자 썸네일(없으면 이니셜)과 좋아요 수를 표시한다.
|
||||||
- 댓글 시간은 24시간 이내일 때 상대 시간(분/시간 전), 이후에는 날짜로 표시한다.
|
- 댓글 시간은 24시간 이내일 때 상대 시간(분/시간 전), 이후에는 날짜로 표시한다.
|
||||||
@@ -86,6 +87,7 @@
|
|||||||
- `/post/:slug` - 개별 게시물 상세
|
- `/post/:slug` - 개별 게시물 상세
|
||||||
- `/tags` - 태그 전체 목록
|
- `/tags` - 태그 전체 목록
|
||||||
- `/tag/:slug` - 태그별 게시물 목록
|
- `/tag/:slug` - 태그별 게시물 목록
|
||||||
|
- `/tag/:slug` 화면의 헤더와 게시물 목록은 공개 목록 공통 섹션 패딩(`site-section-header`, `site-section-body`)을 사용해 메인 컬럼 좌우 여백을 다른 목록 화면과 맞춘다.
|
||||||
- `/signup` - 회원가입(3단계: 환영/입력/이메일 확인)
|
- `/signup` - 회원가입(3단계: 환영/입력/이메일 확인)
|
||||||
- `/signin` - 로그인
|
- `/signin` - 로그인
|
||||||
- `/settings` - 회원 설정(썸네일 미리보기/변경/제거, 닉네임, 비밀번호, 회원 탈퇴)
|
- `/settings` - 회원 설정(썸네일 미리보기/변경/제거, 닉네임, 비밀번호, 회원 탈퇴)
|
||||||
@@ -100,6 +102,7 @@
|
|||||||
- 로그인·회원가입(2단계) 비밀번호 입력은 `AuthPasswordVisibilityToggle` SVG(눈 열림/가림) 토글로 표시 여부를 바꾼다. 스크린 리더용 `aria-label`은 필드별 `field-name`으로 구분한다. 텍스트 입력은 `.auth-form-input`으로 글자색·캐럿 등을 보정한다.
|
- 로그인·회원가입(2단계) 비밀번호 입력은 `AuthPasswordVisibilityToggle` SVG(눈 열림/가림) 토글로 표시 여부를 바꾼다. 스크린 리더용 `aria-label`은 필드별 `field-name`으로 구분한다. 텍스트 입력은 `.auth-form-input`으로 글자색·캐럿 등을 보정한다.
|
||||||
- 인증 화면 상태 메시지는 오류/안내를 분리해 `aria-live`로 노출한다.
|
- 인증 화면 상태 메시지는 오류/안내를 분리해 `aria-live`로 노출한다.
|
||||||
- 회원가입 1단계의 타이틀/설명은 `GET /api/site-settings`의 `title`, `description` 값을 우선 사용한다.
|
- 회원가입 1단계의 타이틀/설명은 `GET /api/site-settings`의 `title`, `description` 값을 우선 사용한다.
|
||||||
|
- 회원 세션 쿠키 서명에는 `MEMBER_SESSION_SECRET`만 사용하며, 관리자 비밀번호를 fallback으로 쓰지 않는다.
|
||||||
|
|
||||||
### 레이아웃 파일
|
### 레이아웃 파일
|
||||||
|
|
||||||
@@ -110,6 +113,17 @@ layouts/
|
|||||||
└── admin.vue # 관리자 화면
|
└── admin.vue # 관리자 화면
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### 관리자 레이아웃
|
||||||
|
|
||||||
|
- 관리자 사이드바는 밝은 Ghost형 톤을 기준으로 하며, 메뉴 행은 아이콘+라벨 구조를 사용한다.
|
||||||
|
- 관리자 사이드바는 데스크톱에서 320px 너비를 사용한다.
|
||||||
|
- 관리자 우측 캔버스는 기본 `min-h-screen`과 `bg-paper`를 유지해 콘텐츠 높이가 짧아도 배경이 화면 전체를 채운다. 일반 관리자 화면은 레이아웃 레벨에서 넉넉한 외부 여백을 둔다.
|
||||||
|
- 게시글 메뉴 라벨은 `게시글`로 표시하고, 우측 `+` 아이콘은 `/admin/posts/new`로 바로 이동한다.
|
||||||
|
- 메뉴 관리 항목은 `네비게이션`으로 표시한다.
|
||||||
|
- 게시글·페이지·태그·미디어·네비게이션·멤버·설정 메뉴는 전용 SVG 아이콘을 사용한다.
|
||||||
|
- 멤버 메뉴는 우측에 현재 총 멤버 수를 표시한다.
|
||||||
|
- 로그아웃은 사이드바 상단 메뉴가 아니라 하단 사용자 썸네일 드롭다운 안에서 제공한다. 하단에는 사용자 썸네일 트리거와 설정 아이콘을 둔다.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 컴포넌트 구조
|
## 컴포넌트 구조
|
||||||
@@ -184,6 +198,7 @@ components/content/
|
|||||||
- 로컬 개발 환경과 NAS 운영 환경은 서로 다른 DB를 사용
|
- 로컬 개발 환경과 NAS 운영 환경은 서로 다른 DB를 사용
|
||||||
- 로컬 개발 서버는 개발 DB만 연결
|
- 로컬 개발 서버는 개발 DB만 연결
|
||||||
- NAS 배포 환경은 운영 DB만 연결
|
- NAS 배포 환경은 운영 DB만 연결
|
||||||
|
- 운영 환경(`NODE_ENV=production`)에서는 `DATABASE_URL` 누락 시 샘플 콘텐츠로 대체하지 않고 서버 오류로 즉시 실패
|
||||||
- 운영 DB 접속 정보는 로컬 기본 `.env`에 기록하지 않음
|
- 운영 DB 접속 정보는 로컬 기본 `.env`에 기록하지 않음
|
||||||
- DB 관리 도구는 CloudBeaver 등을 사용할 수 있도록 접속 정보를 환경별로 분리
|
- DB 관리 도구는 CloudBeaver 등을 사용할 수 있도록 접속 정보를 환경별로 분리
|
||||||
|
|
||||||
@@ -278,7 +293,9 @@ components/content/
|
|||||||
| title | String | 사이트 이름 |
|
| title | String | 사이트 이름 |
|
||||||
| description | String | 사이트 설명 |
|
| description | String | 사이트 설명 |
|
||||||
| site_url | String | 사이트 기본 URL |
|
| site_url | String | 사이트 기본 URL |
|
||||||
| logo_text | String | 텍스트 로고 |
|
| logo_text | String | 레거시 텍스트 로고 fallback |
|
||||||
|
| logo_url | String | 공개 로고 이미지 URL |
|
||||||
|
| favicon_url | String | 파비콘 이미지 URL |
|
||||||
| copyright_text | String | 저작권 문구 |
|
| copyright_text | String | 저작권 문구 |
|
||||||
| updated_at | DateTime | 수정일 |
|
| updated_at | DateTime | 수정일 |
|
||||||
|
|
||||||
@@ -351,7 +368,7 @@ components/content/
|
|||||||
- `GET /api/navigation` - 공개 네비게이션(`primary`는 트리·`footer`는 평면, 상세는 위 메뉴/네비게이션 절)
|
- `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` 없이 가입 가능. 이메일은 저장 시 소문자로 정규화한다.
|
- `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에 해시해 둘 때만 쓰이는 서버 비밀(긴 난문자열 권장)이다.
|
- `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회 한도. DB 테이블 `email_otp_challenges`(마이그레이션 `018_email_otp_challenges.sql`) 필요.
|
- `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/password-reset/confirm` - 본문: `email`, `code`(6자리), `newPassword`(8~32자). `password_reset` OTP 검증·소진 후 해당 이메일 회원 비밀번호를 갱신한다.
|
||||||
- `POST /api/auth/login` - 회원 로그인
|
- `POST /api/auth/login` - 회원 로그인
|
||||||
- `GET /api/auth/me` - 현재 회원 세션 조회
|
- `GET /api/auth/me` - 현재 회원 세션 조회
|
||||||
@@ -362,7 +379,7 @@ components/content/
|
|||||||
- `DELETE /api/auth/avatar` - 회원 썸네일 제거
|
- `DELETE /api/auth/avatar` - 회원 썸네일 제거
|
||||||
- `GET /api/auth/check-username?username=` - 닉네임 중복 확인
|
- `GET /api/auth/check-username?username=` - 닉네임 중복 확인
|
||||||
- `PUT /api/auth/password` - 회원 비밀번호 변경
|
- `PUT /api/auth/password` - 회원 비밀번호 변경
|
||||||
- `DELETE /api/auth/account` - 회원 탈퇴
|
- `DELETE /api/auth/account` - 회원 탈퇴. 마지막 `owner` 계정은 삭제할 수 없으며, 탈퇴 성공 시 회원 세션과 관리자 세션 쿠키를 함께 정리한다.
|
||||||
|
|
||||||
> 회원 썸네일 이미지는 `/uploads/members/avatars/YYYY/MM` 경로로 저장하며, 업로드 시 WebP로 변환하고 `AVATAR_MIN_WIDTH`/`AVATAR_MIN_HEIGHT` 최소 해상도 검증 후 중앙 기준 1:1 정사각형으로 크롭한다.
|
> 회원 썸네일 이미지는 `/uploads/members/avatars/YYYY/MM` 경로로 저장하며, 업로드 시 WebP로 변환하고 `AVATAR_MIN_WIDTH`/`AVATAR_MIN_HEIGHT` 최소 해상도 검증 후 중앙 기준 1:1 정사각형으로 크롭한다.
|
||||||
> 최종 저장 크기는 `AVATAR_MAX_WIDTH`, `AVATAR_MAX_HEIGHT` 중 작은 값을 사용해 `N x N` 정사각형으로 맞춘다.
|
> 최종 저장 크기는 `AVATAR_MAX_WIDTH`, `AVATAR_MAX_HEIGHT` 중 작은 값을 사용해 `N x N` 정사각형으로 맞춘다.
|
||||||
@@ -402,7 +419,10 @@ components/content/
|
|||||||
- `PUT /admin/api/settings` - 사이트 설정 수정
|
- `PUT /admin/api/settings` - 사이트 설정 수정
|
||||||
- `GET /admin/api/navigation` - 네비게이션 항목 목록
|
- `GET /admin/api/navigation` - 네비게이션 항목 목록
|
||||||
- `PUT /admin/api/navigation` - 네비게이션 항목 일괄 저장
|
- `PUT /admin/api/navigation` - 네비게이션 항목 일괄 저장
|
||||||
- `GET /admin/api/members` - 회원 목록(권한, 최근 접속, 접속 IP, 댓글 수 포함)
|
- `GET /admin/api/members` - 회원 목록(권한 코드, 최근 접속, 접속 IP, 댓글 수 포함)
|
||||||
|
- `POST /admin/api/members` - 관리자 회원 생성. 본문: `username`, `email`, 선택 `avatarUrl`, `labels`, `note`. 생성된 회원은 `member` 권한이며 초기 비밀번호는 임의 해시로 저장한다.
|
||||||
|
- `GET /admin/api/members/:id` - 관리자 회원 상세(썸네일, 이름, 이메일, 레이블, 관리자 노트, 활동 요약 포함)
|
||||||
|
- `PUT /admin/api/members/:id` - 관리자 회원 기본 정보 수정. 본문: `username`, `email`, 선택 `avatarUrl`, `labels`, `note`
|
||||||
- `PUT /admin/api/members/:id/role` - 회원 권한 변경(`owner`/`admin`/`member`)
|
- `PUT /admin/api/members/:id/role` - 회원 권한 변경(`owner`/`admin`/`member`)
|
||||||
|
|
||||||
> 글 발행/초안/비공개 전환은 현재 `PUT /admin/api/posts/:id`의 `status` 값으로 처리한다.
|
> 글 발행/초안/비공개 전환은 현재 `PUT /admin/api/posts/:id`의 `status` 값으로 처리한다.
|
||||||
@@ -451,13 +471,15 @@ components/content/
|
|||||||
- 에디터 마지막에는 클릭 가능한 빈 문단 블록을 항상 유지하며, 해당 블록이 비어 있으면 저장 콘텐츠에는 포함하지 않는다.
|
- 에디터 마지막에는 클릭 가능한 빈 문단 블록을 항상 유지하며, 해당 블록이 비어 있으면 저장 콘텐츠에는 포함하지 않는다.
|
||||||
- 제목은 별도 라벨 영역이 아니라 에디터 상단의 큰 제목 입력으로 표시한다.
|
- 제목은 별도 라벨 영역이 아니라 에디터 상단의 큰 제목 입력으로 표시한다.
|
||||||
- 글 작성/수정 화면은 페이지 제목 헤더를 별도로 두지 않고, 폼 상단의 Ghost 스타일 도구막대에서 목록 이동, 상태, 미리보기, 저장, 설정 패널 토글을 제공한다.
|
- 글 작성/수정 화면은 페이지 제목 헤더를 별도로 두지 않고, 폼 상단의 Ghost 스타일 도구막대에서 목록 이동, 상태, 미리보기, 저장, 설정 패널 토글을 제공한다.
|
||||||
|
- 글 작성/수정 화면의 저장 버튼은 즉시 저장하지 않고, 전체 화면 발행 모달에서 상태(발행/초안/비공개)와 발행 시점(즉시/예약)을 최종 선택한 뒤 확정한다. 모달에서는 행마다 접힌 상태로 현재 선택 요약만 보이고, 행을 눌러 펼친 뒤 버튼으로 값을 고른다. 게시 상태 행에는 Ghost와 같은 종이비행기 아이콘·펼침 화살표 SVG를 쓰고, 발행 시점 행에는 시계 아이콘·동일 화살표를 쓴다. 발행이 아닌 상태에서는 발행 시점 행 자체를 숨긴다.
|
||||||
|
- 글 작성/수정 화면의 저장 버튼은 현재 입력값이 마지막 저장 기준점과 다를 때만 활성화한다.
|
||||||
- 글 작성/수정 화면은 관리자 사이드바 네비게이션을 숨기고, 관리자 공통 내부 패딩 없이 전체 화면 편집 모드로 표시한다.
|
- 글 작성/수정 화면은 관리자 사이드바 네비게이션을 숨기고, 관리자 공통 내부 패딩 없이 전체 화면 편집 모드로 표시한다.
|
||||||
- 글 작성/수정 화면은 브라우저 문서가 아니라 에디터 작업 영역 내부에서만 세로 스크롤한다.
|
- 글 작성/수정 화면은 브라우저 문서가 아니라 에디터 작업 영역 내부에서만 세로 스크롤한다.
|
||||||
- 글 작성/수정 폼은 에디터 작업 영역과 우측 설정 패널을 먼저 좌우로 나누고, 에디터 작업 영역 안에 상단 도구막대와 본문 스크롤 영역을 배치한다.
|
- 글 작성/수정 폼은 에디터 작업 영역과 우측 설정 패널을 먼저 좌우로 나누고, 에디터 작업 영역 안에 상단 도구막대와 본문 스크롤 영역을 배치한다.
|
||||||
- 본문 에디터는 데스크톱에서 좌우 32px 패딩을 포함한 804px 중앙 컬럼으로 배치해 실제 입력 영역 최대 폭을 740px로 유지한다.
|
- 본문 에디터는 데스크톱에서 좌우 32px 패딩을 포함한 804px 중앙 컬럼으로 배치해 실제 입력 영역 최대 폭을 740px로 유지한다.
|
||||||
- 게시물 설정은 데스크톱에서 420px 우측 패널로 표시하며, 도구막대 버튼으로 열고 닫을 수 있고 너비 전환 애니메이션을 적용한다.
|
- 게시물 설정은 데스크톱에서 420px 우측 패널로 표시하며, 도구막대 버튼으로 열고 닫을 수 있고 너비 전환 애니메이션을 적용한다.
|
||||||
- 공개 가능한 글의 보기 액션은 게시물 설정 패널의 Post URL 라벨 오른쪽에 표시한다.
|
- 공개 가능한 글의 보기 액션은 게시물 설정 패널의 Post URL 라벨 오른쪽에 표시한다.
|
||||||
- 글 삭제 액션은 게시물 설정 패널 하단의 빨간 outline 버튼으로 제공한다.
|
- 글 삭제 액션은 게시물 설정 패널 하단에 기본 중립 톤 버튼으로 제공하고 hover 시 위험 색상으로 강조한다.
|
||||||
- 대표 이미지는 본문 제목 위의 에디터 흐름 안에서 추가, 변경, 삭제한다.
|
- 대표 이미지는 본문 제목 위의 에디터 흐름 안에서 추가, 변경, 삭제한다.
|
||||||
- 대표 이미지가 설정된 상태의 변경/삭제 액션은 실제 이미지 레이아웃을 바꾸지 않도록 이미지 hover 또는 focus 오버레이로 표시한다.
|
- 대표 이미지가 설정된 상태의 변경/삭제 액션은 실제 이미지 레이아웃을 바꾸지 않도록 이미지 hover 또는 focus 오버레이로 표시한다.
|
||||||
- 대표 이미지 선택 모달은 이미지 업로드 탭과 미디어 라이브러리 탭을 제공한다.
|
- 대표 이미지 선택 모달은 이미지 업로드 탭과 미디어 라이브러리 탭을 제공한다.
|
||||||
@@ -465,7 +487,7 @@ components/content/
|
|||||||
- 새 글 작성처럼 본문이 비어 있는 경우에도 빈 문단 블록을 먼저 생성한다.
|
- 새 글 작성처럼 본문이 비어 있는 경우에도 빈 문단 블록을 먼저 생성한다.
|
||||||
- 글 작성/수정 중인 입력값은 브라우저 `localStorage`에 자동 저장한다.
|
- 글 작성/수정 중인 입력값은 브라우저 `localStorage`에 자동 저장한다.
|
||||||
- 자동 저장 키는 `SORI_ADMIN_POST_AUTOSAVE:new` 또는 `SORI_ADMIN_POST_AUTOSAVE:{postId}` 형식을 사용한다.
|
- 자동 저장 키는 `SORI_ADMIN_POST_AUTOSAVE:new` 또는 `SORI_ADMIN_POST_AUTOSAVE:{postId}` 형식을 사용한다.
|
||||||
- 자동 저장본이 있으면 작성 화면에서 복원 또는 삭제를 선택할 수 있다.
|
- 자동 저장본이 있으면 상단 툴바의 상태 문구 옆에서 복원 또는 무시(로컬 초안 삭제)를 선택할 수 있다.
|
||||||
- 글 저장 성공 시 해당 자동 저장본은 삭제한다.
|
- 글 저장 성공 시 해당 자동 저장본은 삭제한다.
|
||||||
- 미리보기 버튼은 현재 작성 폼 값을 `SORI_ADMIN_POST_PREVIEW` 브라우저 저장소에 기록한 뒤 `/admin/posts/preview` 새 탭에서 렌더링한다.
|
- 미리보기 버튼은 현재 작성 폼 값을 `SORI_ADMIN_POST_PREVIEW` 브라우저 저장소에 기록한 뒤 `/admin/posts/preview` 새 탭에서 렌더링한다.
|
||||||
- 미리보기 화면은 공개 게시물 상세와 같은 본문 렌더링 컴포넌트를 사용하되, 데이터베이스에는 임시 글을 저장하지 않는다.
|
- 미리보기 화면은 공개 게시물 상세와 같은 본문 렌더링 컴포넌트를 사용하되, 데이터베이스에는 임시 글을 저장하지 않는다.
|
||||||
@@ -476,7 +498,10 @@ components/content/
|
|||||||
- 관리자 글 에디터의 실제 입력 텍스트 색상은 placeholder보다 진하게 표시한다.
|
- 관리자 글 에디터의 실제 입력 텍스트 색상은 placeholder보다 진하게 표시한다.
|
||||||
- 관리자 작성 화면과 공개 본문은 같은 마크다운 렌더링 기준을 사용한다.
|
- 관리자 작성 화면과 공개 본문은 같은 마크다운 렌더링 기준을 사용한다.
|
||||||
- 태그 입력은 배지형 입력으로 제공하며 Enter 또는 쉼표 입력 시 태그를 추가하고, 배지의 x 버튼으로 삭제한다.
|
- 태그 입력은 배지형 입력으로 제공하며 Enter 또는 쉼표 입력 시 태그를 추가하고, 배지의 x 버튼으로 삭제한다.
|
||||||
|
- 태그 배지 삭제 버튼은 SVG 닫기 아이콘으로 표시한다.
|
||||||
- 태그 토큰은 게시물 URL용 `toSlug`(한글 로마자화)와 분리하여 한글을 유지하고, 공백은 하이픈으로만 정리하며 `a-z0-9가-힣` 및 하이픈만 허용한다.
|
- 태그 토큰은 게시물 URL용 `toSlug`(한글 로마자화)와 분리하여 한글을 유지하고, 공백은 하이픈으로만 정리하며 `a-z0-9가-힣` 및 하이픈만 허용한다.
|
||||||
|
- 제목 입력에서 한글 IME 조합 중 Enter는 조합 확정으로만 처리하고 본문 에디터 포커스 이동을 실행하지 않는다.
|
||||||
|
- 관리자 게시글 목록의 태그는 쉼표 구분 문자열이 아니라 읽기 전용 배지 목록으로 표시한다.
|
||||||
- 대표 이미지는 URL 직접 입력이 아니라 미디어 선택 또는 이미지 업로드 탭으로 설정한다.
|
- 대표 이미지는 URL 직접 입력이 아니라 미디어 선택 또는 이미지 업로드 탭으로 설정한다.
|
||||||
- 대표 이미지 선택 모달에서 미디어를 클릭하면 선택 상태만 표시하고, 하단 대표 이미지로 적용 버튼을 눌렀을 때 글 폼에 반영한다.
|
- 대표 이미지 선택 모달에서 미디어를 클릭하면 선택 상태만 표시하고, 하단 대표 이미지로 적용 버튼을 눌렀을 때 글 폼에 반영한다.
|
||||||
- 대표 이미지가 설정되면 관리자 글 폼에 썸네일과 hover 오버레이 삭제/변경 액션을 표시한다.
|
- 대표 이미지가 설정되면 관리자 글 폼에 썸네일과 hover 오버레이 삭제/변경 액션을 표시한다.
|
||||||
@@ -491,9 +516,15 @@ components/content/
|
|||||||
- 이미지 블록 alt 입력은 이미지 hover 또는 focus 상태에서만 표시한다.
|
- 이미지 블록 alt 입력은 이미지 hover 또는 focus 상태에서만 표시한다.
|
||||||
- 이미지 블록 표시 옵션은 `regular`, `wide`, `full` 값을 사용하며 `regular`는 width 옵션을 생략한다.
|
- 이미지 블록 표시 옵션은 `regular`, `wide`, `full` 값을 사용하며 `regular`는 width 옵션을 생략한다.
|
||||||
- 갤러리 블록은 `:::gallery` fenced block 안에 이미지 마크다운 행을 여러 개 저장한다.
|
- 갤러리 블록은 `:::gallery` fenced block 안에 이미지 마크다운 행을 여러 개 저장한다.
|
||||||
|
- 관리자 갤러리 미디어 선택은 여러 이미지를 선택한 뒤 확인 버튼으로 적용한다.
|
||||||
|
- 관리자 갤러리 블록은 이미지 1개일 때 1열, 2개일 때 2열, 3개 이상일 때 3열로 표시한다.
|
||||||
|
- 관리자 갤러리 블록의 이미지 순서는 드래그 앤 드롭으로 변경하며, 드래그 중 삽입 위치를 이미지 사이 라인으로 표시한다.
|
||||||
- 공개 갤러리는 한 줄에 2~3개 이미지 그리드로 표시하고 클릭 시 라이트박스로 크게 확인한다.
|
- 공개 갤러리는 한 줄에 2~3개 이미지 그리드로 표시하고 클릭 시 라이트박스로 크게 확인한다.
|
||||||
- 이미지와 갤러리 블록은 기존 업로드 미디어 선택 또는 새 이미지 업로드를 제공한다.
|
- 이미지와 갤러리 블록은 기존 업로드 미디어 선택 또는 새 이미지 업로드를 제공한다.
|
||||||
- 콜아웃 블록은 `:::callout` fenced block 안에 본문을 저장한다.
|
- 콜아웃 블록은 `:::callout` fenced block 안에 본문을 저장한다.
|
||||||
|
- 콜아웃 블록은 선언부 옵션으로 `emoji`, `bg`를 저장할 수 있다. 예: `:::callout emoji=💡 bg=blue`
|
||||||
|
- `emoji=none`이면 공개 렌더러에서 이모지를 숨긴다.
|
||||||
|
- 콜아웃 배경 프리셋은 `gray`, `blue`, `green`, `yellow`, `red`, `purple`, `pink`를 지원한다.
|
||||||
- 토글 블록은 `:::toggle 제목` fenced block 안에 펼침 본문을 저장한다.
|
- 토글 블록은 `:::toggle 제목` fenced block 안에 펼침 본문을 저장한다.
|
||||||
- 임베드 블록은 `:::embed` fenced block 안에 URL을 저장한다.
|
- 임베드 블록은 `:::embed` fenced block 안에 URL을 저장한다.
|
||||||
- YouTube 임베드 URL은 공개 화면에서 iframe으로 렌더링한다.
|
- YouTube 임베드 URL은 공개 화면에서 iframe으로 렌더링한다.
|
||||||
@@ -512,14 +543,17 @@ components/content/
|
|||||||
### 사이트 설정
|
### 사이트 설정
|
||||||
|
|
||||||
- 사이트 설정은 `site_settings` 테이블의 단일 레코드로 관리한다.
|
- 사이트 설정은 `site_settings` 테이블의 단일 레코드로 관리한다.
|
||||||
- 관리자는 사이트 이름, 설명, 사이트 URL, 텍스트 로고, 저작권 문구를 수정할 수 있다.
|
- 관리자는 사이트 이름, 설명, 사이트 URL, 로고 이미지, 저작권 문구를 수정할 수 있다.
|
||||||
|
- 로고 이미지는 1:1 비율로 저장하며 `/admin/api/settings/logo` 업로드 시 `/uploads/system/logo.webp`와 `/uploads/system/favicon.png`를 함께 생성한다.
|
||||||
- 공개 헤더와 오른쪽 사이드바는 공개 사이트 설정 API 값을 사용한다.
|
- 공개 헤더와 오른쪽 사이드바는 공개 사이트 설정 API 값을 사용한다.
|
||||||
|
- 공개 헤더와 오른쪽 사이드바는 `logo_url`이 있으면 이미지 로고를 표시하고, 없으면 `logo_text` fallback을 쓴다. `favicon_url`은 head의 icon 링크로 연결한다.
|
||||||
- DB 연결이 없는 환경에서는 환경 변수와 기본값 기반 설정을 사용한다.
|
- DB 연결이 없는 환경에서는 환경 변수와 기본값 기반 설정을 사용한다.
|
||||||
|
|
||||||
### 메뉴/네비게이션
|
### 메뉴/네비게이션
|
||||||
|
|
||||||
- 네비게이션은 `navigation_items` 테이블로 관리한다.
|
- 네비게이션은 `navigation_items` 테이블로 관리한다.
|
||||||
- 컬럼: `parent_id`(nullable, self FK, `ON DELETE CASCADE`), `is_folder`(boolean, **자식이 있는 상단 항목이면 저장 시 서버가 true로 설정**), `location`(`primary`|`footer`), `sort_order`, `label`, `url`, `is_visible`(저장 시 항상 true로 정리). `(location, label, url)` 유니크 제약은 제거되었다.
|
- 컬럼: `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`만 내려간다.
|
- `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`는 요청값과 무관하게 서버에서 **항상 표시·자식 유무 기준 폴더**로 덮어쓴다.
|
- `PUT /admin/api/navigation` 요청 본문의 각 항목은 반드시 `id`(UUID)를 포함한다. 저장 시 `sort_order`는 서버가 위치별 트리 DFS 순으로 다시 부여한다. `parent_id`는 `primary`에서만 허용되며 `footer` 항목은 항상 루트다. `is_visible`·`is_folder`는 요청값과 무관하게 서버에서 **항상 표시·자식 유무 기준 폴더**로 덮어쓴다.
|
||||||
- URL은 `/`로 시작하는 내부 경로, `http(s)://` 외부 URL, 폴더 전용 자리 표시 `#`를 허용한다.
|
- URL은 `/`로 시작하는 내부 경로, `http(s)://` 외부 URL, 폴더 전용 자리 표시 `#`를 허용한다.
|
||||||
@@ -532,23 +566,33 @@ components/content/
|
|||||||
|
|
||||||
- 관리자 인증은 `users.is_admin=true` 회원의 이메일/비밀번호(bcrypt 해시) 기반으로 처리한다.
|
- 관리자 인증은 `users.is_admin=true` 회원의 이메일/비밀번호(bcrypt 해시) 기반으로 처리한다.
|
||||||
- DB에 사용자가 없으면 `/signup`에서 관리자 등록 모드(관리자 이름/이메일/비밀번호/비밀번호 확인)로 첫 계정을 생성한다.
|
- DB에 사용자가 없으면 `/signup`에서 관리자 등록 모드(관리자 이름/이메일/비밀번호/비밀번호 확인)로 첫 계정을 생성한다.
|
||||||
- 첫 계정 생성 시 `is_admin=true`, `user_role=owner`를 자동 부여한다.
|
- 첫 계정 생성 시 `is_admin=true`, `user_role=owner`를 자동 부여한다. 최초 가입 판정은 동시 요청에서 중복 owner가 생기지 않도록 `users` 테이블 잠금 안에서 처리한다.
|
||||||
- 관리자 로그인 성공 시 httpOnly 세션 쿠키를 `/admin` 경로에 설정한다.
|
- 관리자 로그인 성공 시 httpOnly 세션 쿠키를 `/admin` 경로에 설정한다.
|
||||||
- `/admin/login` 로그인 제출 버튼은 이메일·비밀번호가 모두 입력된 경우에만 활성화한다.
|
- `/admin/login` 로그인 제출 버튼은 이메일·비밀번호가 모두 입력된 경우에만 활성화한다.
|
||||||
- 관리자 로그인 성공 시 관리자/회원 세션 쿠키를 함께 설정해 관리자 화면에서도 프로필(썸네일, 이름) 수정 API를 공통으로 사용한다.
|
- 관리자 로그인 성공 시 관리자/회원 세션 쿠키를 함께 설정하고 `users.previous_last_seen_at`/`previous_last_seen_ip`에 직전 로그인 값을 보존한 뒤 `last_seen_at`/`last_seen_ip`를 현재 로그인으로 갱신한다.
|
||||||
- 관리자 설정 화면에서 관리자 프로필(이름, 썸네일)과 관리자 비밀번호를 수정할 수 있다.
|
- 관리자 설정 화면은 사이트 자체 설정만 관리한다. 관리자 프로필과 비밀번호는 멤버 편집 화면의 계정 작업에서 처리한다.
|
||||||
- 관리자 멤버 화면에서 권한은 `소유자(owner)`, `관리자(admin)`, `멤버(member)` 3단계로 표시하고 변경할 수 있다.
|
- 관리자 사이드바 하단 사용자 메뉴의 `내 프로필`은 `/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/api/auth/me` 확인 후 허용한다.
|
||||||
- 관리자 세션 토큰은 `ADMIN_PASSWORD` 기반 HMAC 서명으로 검증한다.
|
- 관리자 세션 토큰은 `ADMIN_PASSWORD` 기반 HMAC 서명으로 검증한다.
|
||||||
|
- `/admin/api/auth/login`, `/admin/api/auth/logout`을 제외한 관리자 API 요청은 서버 미들웨어에서 세션 사용자 ID의 현재 DB 권한이 `owner` 또는 `admin`인지 다시 확인한다. 권한 변경이나 계정 삭제 이후 남아 있는 기존 관리자 쿠키는 이 단계에서 차단한다.
|
||||||
|
|
||||||
### 회원 인증
|
### 회원 인증
|
||||||
|
|
||||||
- 회원 인증은 `users` 테이블의 이메일/비밀번호(bcrypt 해시) 기반으로 처리한다.
|
- 회원 인증은 `users` 테이블의 이메일/비밀번호(bcrypt 해시) 기반으로 처리한다.
|
||||||
- `/signin` 로그인 제출 버튼은 이메일·비밀번호가 모두 입력된 경우에만 활성화한다.
|
- `/signin` 로그인 제출 버튼은 이메일·비밀번호가 모두 입력된 경우에만 활성화한다.
|
||||||
- 로그인 성공 시 httpOnly 세션 쿠키(`sori_member_session`)를 `/` 경로에 설정한다.
|
- 로그인 성공 시 httpOnly 세션 쿠키(`sori_member_session`)를 `/` 경로에 설정한다.
|
||||||
- 회원 API(`POST /api/auth/signup`, `POST /api/auth/login`, `GET /api/auth/me`, `POST /api/auth/logout`, `GET /api/auth/bootstrap-status`, `POST /api/auth/email-otp/request`, `POST /api/auth/password-reset/confirm`)로 세션·이메일 OTP를 관리한다.
|
- 회원 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`)으로 바로 진입할 수 있다.
|
- 첫 회원가입 시 관리자 세션도 함께 설정되어 관리자 화면(`/admin`)으로 바로 진입할 수 있다.
|
||||||
- 회원 세션 서명은 `MEMBER_SESSION_SECRET`을 우선 사용하고, 값이 없으면 개발 편의를 위해 `ADMIN_PASSWORD`를 fallback으로 사용한다.
|
- 회원 세션 서명은 `MEMBER_SESSION_SECRET`만 사용하며, 값이 없으면 서버 오류로 실패한다.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
## 2차 관리자 개발
|
## 2차 관리자 개발
|
||||||
|
|
||||||
- [ ] 미디어 라이브러리 프로필/사이트 설정 이미지 사용처 추적
|
- [ ] 미디어 라이브러리 프로필/사이트 설정 이미지 사용처 추적
|
||||||
|
- [ ] 관리자 멤버 추가 후 초대 메일 또는 비밀번호 설정 안내 플로우 연결
|
||||||
|
|
||||||
## 프론트엔드 개발
|
## 프론트엔드 개발
|
||||||
|
|
||||||
@@ -30,6 +31,7 @@
|
|||||||
- [ ] ProseFile 실제 파일 데이터 연결
|
- [ ] ProseFile 실제 파일 데이터 연결
|
||||||
- [ ] ProseProduct 실제 상품 카드 데이터 연결
|
- [ ] ProseProduct 실제 상품 카드 데이터 연결
|
||||||
- [ ] ProseHeaderCard Simple/Wide/Full-width/Split 변형 구현
|
- [ ] ProseHeaderCard Simple/Wide/Full-width/Split 변형 구현
|
||||||
|
|
||||||
## 데이터베이스
|
## 데이터베이스
|
||||||
|
|
||||||
- [ ] NAS 운영 DB 연결 설정 실제 값 작성
|
- [ ] NAS 운영 DB 연결 설정 실제 값 작성
|
||||||
@@ -37,7 +39,5 @@
|
|||||||
|
|
||||||
## 배포
|
## 배포
|
||||||
|
|
||||||
- [ ] UGREEN NAS Docker 배포 가이드 작성
|
- [ ] NAS 운영 환경 변수 최종 점검
|
||||||
- [ ] Docker 빌드 검증
|
- [ ] NAS 실제 컨테이너 기동 및 도메인/프록시 접속 QA
|
||||||
- [ ] `.env.production` 작성 후 `docker compose --env-file .env.production config` 검증
|
|
||||||
- [ ] NAS 운영 환경 변수 작성
|
|
||||||
|
|||||||
176
docs/update.md
176
docs/update.md
@@ -1,5 +1,181 @@
|
|||||||
# 업데이트 이력
|
# 업데이트 이력
|
||||||
|
|
||||||
|
## 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
|
## v0.0.102
|
||||||
|
|
||||||
- `AdminBlockEditor`: 빈 단락은 HTML 주석 마커로 직렬화·복원, 슬래시 메뉴 하이라이트·스크롤·긴 목록 `max-h`·블록 경계에서 위/아래 방향키로 인접 블록 이동.
|
- `AdminBlockEditor`: 빈 단락은 HTML 주석 마커로 직렬화·복원, 슬래시 메뉴 하이라이트·스크롤·긴 목록 `max-h`·블록 경계에서 위/아래 방향키로 인접 블록 이동.
|
||||||
|
|||||||
@@ -5,6 +5,66 @@ const isPostEditorRoute = computed(() => route.path === '/admin/posts/new'
|
|||||||
|| (route.path.startsWith('/admin/posts/') && route.path !== '/admin/posts/preview'))
|
|| (route.path.startsWith('/admin/posts/') && route.path !== '/admin/posts/preview'))
|
||||||
|
|
||||||
const editorDocumentClass = 'admin-post-editor-document'
|
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)
|
watchEffect(syncPostEditorDocumentClass)
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
document.addEventListener('pointerdown', onAdminDocumentPointerDown)
|
||||||
|
})
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
if (!import.meta.client) {
|
if (!import.meta.client) {
|
||||||
return
|
return
|
||||||
@@ -28,6 +92,7 @@ onBeforeUnmount(() => {
|
|||||||
|
|
||||||
document.documentElement.classList.remove(editorDocumentClass)
|
document.documentElement.classList.remove(editorDocumentClass)
|
||||||
document.body.classList.remove(editorDocumentClass)
|
document.body.classList.remove(editorDocumentClass)
|
||||||
|
document.removeEventListener('pointerdown', onAdminDocumentPointerDown)
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -38,54 +103,167 @@ const logoutAdmin = async () => {
|
|||||||
await $fetch('/admin/api/auth/logout', {
|
await $fetch('/admin/api/auth/logout', {
|
||||||
method: 'POST'
|
method: 'POST'
|
||||||
})
|
})
|
||||||
|
closeAdminUserMenu()
|
||||||
await navigateTo('/admin/login')
|
await navigateTo('/admin/login')
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<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'"
|
:class="isPostEditorRoute ? 'h-screen overflow-hidden bg-white' : 'min-h-screen'"
|
||||||
>
|
>
|
||||||
<aside
|
<aside
|
||||||
v-if="!isPostEditorRoute"
|
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">
|
<NuxtLink class="admin-layout__brand flex items-center gap-3 px-2 text-[0.95rem] font-semibold tracking-[-0.01em]" to="/admin">
|
||||||
sori.studio
|
<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>
|
</NuxtLink>
|
||||||
<nav class="admin-layout__nav mt-8 grid gap-2 text-sm text-white/75">
|
<nav class="admin-layout__nav mt-10 grid gap-1.5 text-sm font-medium text-[#5d6673]">
|
||||||
<NuxtLink class="admin-layout__nav-link rounded px-3 py-2 transition-colors hover:bg-white/10 hover:text-white" to="/admin/posts">
|
<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>
|
||||||
<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>
|
||||||
<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>
|
||||||
<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>
|
||||||
<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>
|
||||||
<NuxtLink class="admin-layout__nav-link rounded px-3 py-2 transition-colors hover:bg-white/10 hover:text-white" to="/admin/members">
|
|
||||||
멤버
|
|
||||||
</NuxtLink>
|
|
||||||
<NuxtLink class="admin-layout__nav-link rounded px-3 py-2 transition-colors hover:bg-white/10 hover:text-white" to="/admin/settings">
|
|
||||||
설정
|
|
||||||
</NuxtLink>
|
|
||||||
<button class="admin-layout__logout rounded px-3 py-2 text-left transition-colors hover:bg-white/10 hover:text-white" type="button" @click="logoutAdmin">
|
|
||||||
로그아웃
|
|
||||||
</button>
|
|
||||||
</nav>
|
</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>
|
</aside>
|
||||||
<main
|
<main
|
||||||
class="admin-layout__main"
|
class="admin-layout__main bg-paper"
|
||||||
:class="[
|
:class="[
|
||||||
isPostEditorRoute ? 'h-screen overflow-hidden' : 'min-h-screen',
|
isPostEditorRoute ? 'h-screen overflow-hidden' : 'min-h-screen px-8 py-8 xl:px-12 xl:py-10',
|
||||||
{ 'lg:ml-64': !isPostEditorRoute }
|
{ 'lg:ml-80': !isPostEditorRoute }
|
||||||
]"
|
]"
|
||||||
>
|
>
|
||||||
<slot />
|
<slot />
|
||||||
|
|||||||
491
package-lock.json
generated
491
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "sori.studio",
|
"name": "sori.studio",
|
||||||
"version": "0.0.73",
|
"version": "1.0.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "sori.studio",
|
"name": "sori.studio",
|
||||||
"version": "0.0.73",
|
"version": "1.0.0",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||||
@@ -7993,9 +7993,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/nitropack": {
|
"node_modules/nitropack": {
|
||||||
"version": "2.13.3",
|
"version": "2.13.4",
|
||||||
"resolved": "https://registry.npmjs.org/nitropack/-/nitropack-2.13.3.tgz",
|
"resolved": "https://registry.npmjs.org/nitropack/-/nitropack-2.13.4.tgz",
|
||||||
"integrity": "sha512-C8vO7RxkU0AQ3HbYUumuG6MVM5JjRaBchke/rYFOp3EvrLtTBHZYhDVGECdpa27vNuOYRzm3GtQMn2YDOjDJLA==",
|
"integrity": "sha512-tX7bT6zxNeMwkc6hxHiZeUoTOjVrcjoh1Z3cmxOlodIqjl4HISgqfGOmkWSayky3Nv9Z5+KQH52F8nmXJY5AAA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@cloudflare/kv-asset-handler": "^0.4.2",
|
"@cloudflare/kv-asset-handler": "^0.4.2",
|
||||||
@@ -8018,18 +8018,18 @@
|
|||||||
"croner": "^10.0.1",
|
"croner": "^10.0.1",
|
||||||
"crossws": "^0.3.5",
|
"crossws": "^0.3.5",
|
||||||
"db0": "^0.3.4",
|
"db0": "^0.3.4",
|
||||||
"defu": "^6.1.6",
|
"defu": "^6.1.7",
|
||||||
"destr": "^2.0.5",
|
"destr": "^2.0.5",
|
||||||
"dot-prop": "^10.1.0",
|
"dot-prop": "^10.1.0",
|
||||||
"esbuild": "^0.27.5",
|
"esbuild": "^0.28.0",
|
||||||
"escape-string-regexp": "^5.0.0",
|
"escape-string-regexp": "^5.0.0",
|
||||||
"etag": "^1.8.1",
|
"etag": "^1.8.1",
|
||||||
"exsolve": "^1.0.8",
|
"exsolve": "^1.0.8",
|
||||||
"globby": "^16.2.0",
|
"globby": "^16.2.0",
|
||||||
"gzip-size": "^7.0.0",
|
"gzip-size": "^7.0.0",
|
||||||
"h3": "^1.15.10",
|
"h3": "^1.15.11",
|
||||||
"hookable": "^5.5.3",
|
"hookable": "^5.5.3",
|
||||||
"httpxy": "^0.5.0",
|
"httpxy": "^0.5.1",
|
||||||
"ioredis": "^5.10.1",
|
"ioredis": "^5.10.1",
|
||||||
"jiti": "^2.6.1",
|
"jiti": "^2.6.1",
|
||||||
"klona": "^2.0.6",
|
"klona": "^2.0.6",
|
||||||
@@ -8045,23 +8045,23 @@
|
|||||||
"ohash": "^2.0.11",
|
"ohash": "^2.0.11",
|
||||||
"pathe": "^2.0.3",
|
"pathe": "^2.0.3",
|
||||||
"perfect-debounce": "^2.1.0",
|
"perfect-debounce": "^2.1.0",
|
||||||
"pkg-types": "^2.3.0",
|
"pkg-types": "^2.3.1",
|
||||||
"pretty-bytes": "^7.1.0",
|
"pretty-bytes": "^7.1.0",
|
||||||
"radix3": "^1.1.2",
|
"radix3": "^1.1.2",
|
||||||
"rollup": "^4.60.1",
|
"rollup": "^4.60.2",
|
||||||
"rollup-plugin-visualizer": "^7.0.1",
|
"rollup-plugin-visualizer": "^7.0.1",
|
||||||
"scule": "^1.3.0",
|
"scule": "^1.3.0",
|
||||||
"semver": "^7.7.4",
|
"semver": "^7.7.4",
|
||||||
"serve-placeholder": "^2.0.2",
|
"serve-placeholder": "^2.0.2",
|
||||||
"serve-static": "^2.2.1",
|
"serve-static": "^2.2.1",
|
||||||
"source-map": "^0.7.6",
|
"source-map": "^0.7.6",
|
||||||
"std-env": "^4.0.0",
|
"std-env": "^4.1.0",
|
||||||
"ufo": "^1.6.3",
|
"ufo": "^1.6.4",
|
||||||
"ultrahtml": "^1.6.0",
|
"ultrahtml": "^1.6.0",
|
||||||
"uncrypto": "^0.1.3",
|
"uncrypto": "^0.1.3",
|
||||||
"unctx": "^2.5.0",
|
"unctx": "^2.5.0",
|
||||||
"unenv": "2.0.0-rc.24",
|
"unenv": "2.0.0-rc.24",
|
||||||
"unimport": "^6.0.2",
|
"unimport": "^6.2.0",
|
||||||
"unplugin-utils": "^0.3.1",
|
"unplugin-utils": "^0.3.1",
|
||||||
"unstorage": "^1.17.5",
|
"unstorage": "^1.17.5",
|
||||||
"untyped": "^2.0.0",
|
"untyped": "^2.0.0",
|
||||||
@@ -8085,12 +8085,469 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/nitropack/node_modules/@esbuild/aix-ppc64": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"aix"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/nitropack/node_modules/@esbuild/android-arm": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/nitropack/node_modules/@esbuild/android-arm64": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/nitropack/node_modules/@esbuild/android-x64": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/nitropack/node_modules/@esbuild/darwin-arm64": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/nitropack/node_modules/@esbuild/darwin-x64": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/nitropack/node_modules/@esbuild/freebsd-arm64": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/nitropack/node_modules/@esbuild/freebsd-x64": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/nitropack/node_modules/@esbuild/linux-arm": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/nitropack/node_modules/@esbuild/linux-arm64": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/nitropack/node_modules/@esbuild/linux-ia32": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/nitropack/node_modules/@esbuild/linux-loong64": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==",
|
||||||
|
"cpu": [
|
||||||
|
"loong64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/nitropack/node_modules/@esbuild/linux-mips64el": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==",
|
||||||
|
"cpu": [
|
||||||
|
"mips64el"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/nitropack/node_modules/@esbuild/linux-ppc64": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/nitropack/node_modules/@esbuild/linux-riscv64": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==",
|
||||||
|
"cpu": [
|
||||||
|
"riscv64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/nitropack/node_modules/@esbuild/linux-s390x": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==",
|
||||||
|
"cpu": [
|
||||||
|
"s390x"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/nitropack/node_modules/@esbuild/linux-x64": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/nitropack/node_modules/@esbuild/netbsd-arm64": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"netbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/nitropack/node_modules/@esbuild/netbsd-x64": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"netbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/nitropack/node_modules/@esbuild/openbsd-arm64": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/nitropack/node_modules/@esbuild/openbsd-x64": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/nitropack/node_modules/@esbuild/openharmony-arm64": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openharmony"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/nitropack/node_modules/@esbuild/sunos-x64": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"sunos"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/nitropack/node_modules/@esbuild/win32-arm64": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/nitropack/node_modules/@esbuild/win32-ia32": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/nitropack/node_modules/@esbuild/win32-x64": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/nitropack/node_modules/cookie-es": {
|
"node_modules/nitropack/node_modules/cookie-es": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/cookie-es/-/cookie-es-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/cookie-es/-/cookie-es-2.0.1.tgz",
|
||||||
"integrity": "sha512-aVf4A4hI2w70LnF7GG+7xDQUkliwiXWXFvTjkip4+b64ygDQ2sJPRSKFDHbxn8o0xu9QzPkMuuiWIXyFSE2slA==",
|
"integrity": "sha512-aVf4A4hI2w70LnF7GG+7xDQUkliwiXWXFvTjkip4+b64ygDQ2sJPRSKFDHbxn8o0xu9QzPkMuuiWIXyFSE2slA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/nitropack/node_modules/esbuild": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==",
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"esbuild": "bin/esbuild"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@esbuild/aix-ppc64": "0.28.0",
|
||||||
|
"@esbuild/android-arm": "0.28.0",
|
||||||
|
"@esbuild/android-arm64": "0.28.0",
|
||||||
|
"@esbuild/android-x64": "0.28.0",
|
||||||
|
"@esbuild/darwin-arm64": "0.28.0",
|
||||||
|
"@esbuild/darwin-x64": "0.28.0",
|
||||||
|
"@esbuild/freebsd-arm64": "0.28.0",
|
||||||
|
"@esbuild/freebsd-x64": "0.28.0",
|
||||||
|
"@esbuild/linux-arm": "0.28.0",
|
||||||
|
"@esbuild/linux-arm64": "0.28.0",
|
||||||
|
"@esbuild/linux-ia32": "0.28.0",
|
||||||
|
"@esbuild/linux-loong64": "0.28.0",
|
||||||
|
"@esbuild/linux-mips64el": "0.28.0",
|
||||||
|
"@esbuild/linux-ppc64": "0.28.0",
|
||||||
|
"@esbuild/linux-riscv64": "0.28.0",
|
||||||
|
"@esbuild/linux-s390x": "0.28.0",
|
||||||
|
"@esbuild/linux-x64": "0.28.0",
|
||||||
|
"@esbuild/netbsd-arm64": "0.28.0",
|
||||||
|
"@esbuild/netbsd-x64": "0.28.0",
|
||||||
|
"@esbuild/openbsd-arm64": "0.28.0",
|
||||||
|
"@esbuild/openbsd-x64": "0.28.0",
|
||||||
|
"@esbuild/openharmony-arm64": "0.28.0",
|
||||||
|
"@esbuild/sunos-x64": "0.28.0",
|
||||||
|
"@esbuild/win32-arm64": "0.28.0",
|
||||||
|
"@esbuild/win32-ia32": "0.28.0",
|
||||||
|
"@esbuild/win32-x64": "0.28.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/node-addon-api": {
|
"node_modules/node-addon-api": {
|
||||||
"version": "7.1.1",
|
"version": "7.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz",
|
||||||
@@ -11041,9 +11498,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/ufo": {
|
"node_modules/ufo": {
|
||||||
"version": "1.6.3",
|
"version": "1.6.4",
|
||||||
"resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.3.tgz",
|
"resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.4.tgz",
|
||||||
"integrity": "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==",
|
"integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/ultrahtml": {
|
"node_modules/ultrahtml": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "sori.studio",
|
"name": "sori.studio",
|
||||||
"version": "0.0.102",
|
"version": "1.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"imports": {
|
"imports": {
|
||||||
@@ -12,6 +12,9 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "node scripts/dev-server.js",
|
"dev": "node scripts/dev-server.js",
|
||||||
"build": "nuxt build",
|
"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",
|
"preview": "nuxt preview --dotenv .env.development --host 127.0.0.1 --port 43117",
|
||||||
"db:migrate:dev": "node scripts/migrate-development-db.js",
|
"db:migrate:dev": "node scripts/migrate-development-db.js",
|
||||||
"postinstall": "nuxt prepare"
|
"postinstall": "nuxt prepare"
|
||||||
|
|||||||
44
pages/admin/members/[id].vue
Normal file
44
pages/admin/members/[id].vue
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
<script setup>
|
||||||
|
definePageMeta({
|
||||||
|
layout: 'admin'
|
||||||
|
})
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const memberId = computed(() => String(route.params.id || ''))
|
||||||
|
|
||||||
|
const { data: member, error } = await useFetch(() => `/admin/api/members/${memberId.value}`, {
|
||||||
|
default: () => null
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 저장된 회원 정보로 화면 상태를 갱신한다.
|
||||||
|
* @param {Object} savedMember - 저장된 회원
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const handleMemberSaved = (savedMember) => {
|
||||||
|
member.value = savedMember
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 회원 삭제 후 목록 화면으로 이동한다.
|
||||||
|
* @returns {Promise<void>} 이동 처리
|
||||||
|
*/
|
||||||
|
const handleMemberDeleted = async () => {
|
||||||
|
await navigateTo('/admin/members')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<AdminMemberForm
|
||||||
|
v-if="member"
|
||||||
|
:member="member"
|
||||||
|
mode="edit"
|
||||||
|
@saved="handleMemberSaved"
|
||||||
|
@deleted="handleMemberDeleted"
|
||||||
|
/>
|
||||||
|
<section v-else class="admin-member-detail bg-paper p-6">
|
||||||
|
<div class="rounded-xl border border-line bg-white px-5 py-8 text-sm text-muted">
|
||||||
|
{{ error?.data?.message || '회원 정보를 불러올 수 없습니다.' }}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -3,24 +3,311 @@ definePageMeta({
|
|||||||
layout: 'admin'
|
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', {
|
const { data: members } = await useFetch('/admin/api/members', {
|
||||||
default: () => []
|
default: () => []
|
||||||
})
|
})
|
||||||
const roleSavingIds = ref([])
|
|
||||||
const roleMessage = ref('')
|
|
||||||
|
|
||||||
const roleOptions = [
|
const dateFilterFields = ['lastSeenAt', 'createdAt']
|
||||||
{ value: 'owner', label: '소유자' },
|
|
||||||
{ value: 'admin', label: '관리자' },
|
const filterFieldOptions = [
|
||||||
{ value: 'member', label: '멤버' }
|
{ value: 'name', label: '이름' },
|
||||||
|
{ value: 'email', label: '이메일' },
|
||||||
|
{ value: 'label', label: '레이블' },
|
||||||
|
{ value: 'status', label: '상태' },
|
||||||
|
{ value: 'lastSeenAt', label: '최근 접속' },
|
||||||
|
{ value: 'createdAt', label: '가입일' }
|
||||||
|
]
|
||||||
|
|
||||||
|
const operatorOptionsByFieldType = {
|
||||||
|
text: [
|
||||||
|
{ value: 'contains', label: '포함' },
|
||||||
|
{ value: 'notContains', label: '포함하지 않음' },
|
||||||
|
{ value: 'is', label: '일치' },
|
||||||
|
{ value: 'startsWith', label: '시작' },
|
||||||
|
{ value: 'endsWith', label: '끝남' }
|
||||||
|
],
|
||||||
|
status: [
|
||||||
|
{ value: 'is', label: '일치' },
|
||||||
|
{ value: 'isNot', label: '일치하지 않음' }
|
||||||
|
],
|
||||||
|
date: [
|
||||||
|
{ value: 'before', label: '이전' },
|
||||||
|
{ value: 'after', label: '이후' },
|
||||||
|
{ value: 'empty', label: '없음' },
|
||||||
|
{ value: 'notEmpty', label: '있음' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusFilterOptions = [
|
||||||
|
{ value: '활성', label: '활성' },
|
||||||
|
{ value: '비활성', label: '비활성' }
|
||||||
]
|
]
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 최근 접속 시각 표시 문자열을 반환한다.
|
* 필터 필드 타입을 반환한다.
|
||||||
* @param {string | null} value - ISO 시각
|
* @param {string} field - 필터 필드
|
||||||
* @returns {string} 표시 문자열
|
* @returns {'text'|'status'|'date'} 필드 타입
|
||||||
*/
|
*/
|
||||||
const formatLastSeen = (value) => {
|
const getFilterFieldType = (field) => {
|
||||||
|
if (field === 'status') {
|
||||||
|
return 'status'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dateFilterFields.includes(field)) {
|
||||||
|
return 'date'
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'text'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 필터 필드에 맞는 연산자 목록을 반환한다.
|
||||||
|
* @param {string} field - 필터 필드
|
||||||
|
* @returns {Array<{ value: string, label: string }>} 연산자 목록
|
||||||
|
*/
|
||||||
|
const getFilterOperatorOptions = (field) => operatorOptionsByFieldType[getFilterFieldType(field)]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 필터 대상 회원 값을 반환한다.
|
||||||
|
* @param {Object} member - 회원 정보
|
||||||
|
* @param {string} field - 필터 필드
|
||||||
|
* @returns {string} 필터 대상 값
|
||||||
|
*/
|
||||||
|
const getFilterMemberValue = (member, field) => {
|
||||||
|
if (field === 'name') {
|
||||||
|
return member.username || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
if (field === 'label') {
|
||||||
|
return Array.isArray(member.labels) ? member.labels.join(', ') : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
if (field === 'status') {
|
||||||
|
return member.activityStatus || '비활성'
|
||||||
|
}
|
||||||
|
|
||||||
|
return member[field] || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 텍스트 필터 조건을 검사한다.
|
||||||
|
* @param {string} source - 원본 값
|
||||||
|
* @param {string} operator - 연산자
|
||||||
|
* @param {string} target - 비교 값
|
||||||
|
* @returns {boolean} 조건 충족 여부
|
||||||
|
*/
|
||||||
|
const matchesTextFilter = (source, operator, target) => {
|
||||||
|
const sourceText = source.toLowerCase()
|
||||||
|
const targetText = target.toLowerCase()
|
||||||
|
|
||||||
|
if (!targetText) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (operator === 'is') {
|
||||||
|
return sourceText === targetText
|
||||||
|
}
|
||||||
|
|
||||||
|
if (operator === 'notContains') {
|
||||||
|
return !sourceText.includes(targetText)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (operator === 'startsWith') {
|
||||||
|
return sourceText.startsWith(targetText)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (operator === 'endsWith') {
|
||||||
|
return sourceText.endsWith(targetText)
|
||||||
|
}
|
||||||
|
|
||||||
|
return sourceText.includes(targetText)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 날짜 필터 조건을 검사한다.
|
||||||
|
* @param {string | null} value - ISO 날짜 문자열
|
||||||
|
* @param {string} operator - 연산자
|
||||||
|
* @param {string} target - yyyy-mm-dd 비교 값
|
||||||
|
* @returns {boolean} 조건 충족 여부
|
||||||
|
*/
|
||||||
|
const matchesDateFilter = (value, operator, target) => {
|
||||||
|
if (operator === 'empty') {
|
||||||
|
return !value
|
||||||
|
}
|
||||||
|
|
||||||
|
if (operator === 'notEmpty') {
|
||||||
|
return Boolean(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!value || !target) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceTime = new Date(value).getTime()
|
||||||
|
const targetTime = new Date(`${target}T00:00:00`).getTime()
|
||||||
|
|
||||||
|
if (Number.isNaN(sourceTime) || Number.isNaN(targetTime)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return operator === 'before'
|
||||||
|
? sourceTime < targetTime
|
||||||
|
: sourceTime >= targetTime
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 회원이 단일 필터 조건을 만족하는지 확인한다.
|
||||||
|
* @param {Object} member - 회원 정보
|
||||||
|
* @param {Object} filter - 필터 조건
|
||||||
|
* @returns {boolean} 조건 충족 여부
|
||||||
|
*/
|
||||||
|
const matchesMemberFilter = (member, filter) => {
|
||||||
|
const fieldType = getFilterFieldType(filter.field)
|
||||||
|
const source = String(getFilterMemberValue(member, filter.field) || '')
|
||||||
|
|
||||||
|
if (fieldType === 'date') {
|
||||||
|
return matchesDateFilter(source, filter.operator, filter.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fieldType === 'status') {
|
||||||
|
return filter.operator === 'isNot'
|
||||||
|
? source !== filter.value
|
||||||
|
: source === filter.value
|
||||||
|
}
|
||||||
|
|
||||||
|
return matchesTextFilter(source, filter.operator, filter.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 필터 조건 필드를 변경한다.
|
||||||
|
* @param {Object} filter - 필터 조건
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const changeFilterField = (filter) => {
|
||||||
|
const fieldType = getFilterFieldType(filter.field)
|
||||||
|
filter.operator = getFilterOperatorOptions(filter.field)[0].value
|
||||||
|
filter.value = fieldType === 'status' ? '활성' : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 새 필터 조건을 추가한다.
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const addFilter = () => {
|
||||||
|
filterDrafts.value.push({
|
||||||
|
id: nextFilterId,
|
||||||
|
field: 'email',
|
||||||
|
operator: 'contains',
|
||||||
|
value: ''
|
||||||
|
})
|
||||||
|
nextFilterId += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 필터 조건을 삭제한다.
|
||||||
|
* @param {number} id - 필터 ID
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const removeFilter = (id) => {
|
||||||
|
filterDrafts.value = filterDrafts.value.filter((filter) => filter.id !== id)
|
||||||
|
|
||||||
|
if (!filterDrafts.value.length) {
|
||||||
|
addFilter()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 필터 조건을 적용한다.
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const applyFilters = () => {
|
||||||
|
activeFilters.value = filterDrafts.value
|
||||||
|
.filter((filter) => ['empty', 'notEmpty'].includes(filter.operator) || String(filter.value || '').trim())
|
||||||
|
.map((filter) => ({ ...filter }))
|
||||||
|
isFilterOpen.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 모든 필터 조건을 초기화한다.
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const resetFilters = () => {
|
||||||
|
filterDrafts.value = [
|
||||||
|
{
|
||||||
|
id: nextFilterId,
|
||||||
|
field: 'email',
|
||||||
|
operator: 'contains',
|
||||||
|
value: ''
|
||||||
|
}
|
||||||
|
]
|
||||||
|
nextFilterId += 1
|
||||||
|
activeFilters.value = []
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredMembers = computed(() => {
|
||||||
|
const query = memberSearchQuery.value.trim().toLowerCase()
|
||||||
|
const searchedMembers = !query
|
||||||
|
? members.value
|
||||||
|
: members.value.filter((member) => [
|
||||||
|
member.username,
|
||||||
|
member.email,
|
||||||
|
member.lastSeenIp,
|
||||||
|
member.activityStatus
|
||||||
|
].some((value) => String(value || '').toLowerCase().includes(query)))
|
||||||
|
|
||||||
|
if (!activeFilters.value.length) {
|
||||||
|
return searchedMembers
|
||||||
|
}
|
||||||
|
|
||||||
|
return searchedMembers.filter((member) => activeFilters.value.every((filter) => matchesMemberFilter(member, filter)))
|
||||||
|
})
|
||||||
|
|
||||||
|
const memberCountLabel = computed(() => {
|
||||||
|
const count = filteredMembers.value.length
|
||||||
|
return `${count}명`
|
||||||
|
})
|
||||||
|
|
||||||
|
const activeFilterCount = computed(() => activeFilters.value.length)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 회원 상세 화면으로 이동한다.
|
||||||
|
* @param {Object} member - 회원 정보
|
||||||
|
* @returns {Promise<void>} 이동 처리
|
||||||
|
*/
|
||||||
|
const navigateToMember = async (member) => {
|
||||||
|
if (!member?.id) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await navigateTo(`/admin/members/${member.id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 회원 이니셜을 반환한다.
|
||||||
|
* @param {Object} member - 회원 정보
|
||||||
|
* @returns {string} 이니셜
|
||||||
|
*/
|
||||||
|
const getMemberInitial = (member) => String(member?.username || member?.email || '?').slice(0, 1).toUpperCase()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 날짜 표시 형식 변환
|
||||||
|
* @param {string | null} value - ISO 날짜 문자열
|
||||||
|
* @returns {string} 화면 표시 날짜
|
||||||
|
*/
|
||||||
|
const formatDate = (value) => {
|
||||||
if (!value) {
|
if (!value) {
|
||||||
return '-'
|
return '-'
|
||||||
}
|
}
|
||||||
@@ -30,154 +317,261 @@ const formatLastSeen = (value) => {
|
|||||||
return '-'
|
return '-'
|
||||||
}
|
}
|
||||||
|
|
||||||
return date.toLocaleString('ko-KR', {
|
const year = date.getFullYear()
|
||||||
year: 'numeric',
|
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||||
month: '2-digit',
|
const day = String(date.getDate()).padStart(2, '0')
|
||||||
day: '2-digit',
|
|
||||||
hour: '2-digit',
|
return `${year}.${month}.${day}`
|
||||||
minute: '2-digit'
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 권한 저장 진행 여부를 확인한다.
|
* 최근 활동 시각을 상대 시간으로 표시한다.
|
||||||
* @param {string} memberId - 회원 ID
|
* @param {string | null} value - ISO 시각
|
||||||
* @returns {boolean} 진행 여부
|
* @returns {string} 상대 시간
|
||||||
*/
|
*/
|
||||||
const isSavingRole = (memberId) => roleSavingIds.value.includes(memberId)
|
const formatRelativeTime = (value) => {
|
||||||
|
if (!value) {
|
||||||
/**
|
return '최근 활동 없음'
|
||||||
* 회원 권한을 변경한다.
|
|
||||||
* @param {Object} member - 회원 정보
|
|
||||||
* @returns {Promise<void>}
|
|
||||||
*/
|
|
||||||
const updateRole = async (member) => {
|
|
||||||
if (!member?.id || isSavingRole(member.id)) {
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
roleSavingIds.value = [...roleSavingIds.value, member.id]
|
const date = new Date(value)
|
||||||
roleMessage.value = ''
|
if (Number.isNaN(date.getTime())) {
|
||||||
|
return '최근 활동 없음'
|
||||||
try {
|
|
||||||
const result = await $fetch(`/admin/api/members/${member.id}/role`, {
|
|
||||||
method: 'PUT',
|
|
||||||
body: {
|
|
||||||
role: member.roleCode
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
members.value = members.value.map((item) => {
|
|
||||||
if (item.id !== member.id) {
|
|
||||||
return item
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
...item,
|
|
||||||
role: result.role,
|
|
||||||
roleCode: result.roleCode,
|
|
||||||
isAdmin: Boolean(result.isAdmin)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
roleMessage.value = '권한이 변경되었습니다.'
|
|
||||||
} catch (error) {
|
|
||||||
roleMessage.value = error?.data?.message || '권한 변경에 실패했습니다.'
|
|
||||||
} finally {
|
|
||||||
roleSavingIds.value = roleSavingIds.value.filter((id) => id !== member.id)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const 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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section class="admin-members min-h-screen bg-paper">
|
<section class="admin-members bg-paper p-6">
|
||||||
<div class="border-b border-line bg-paper px-6 py-5">
|
<div class="admin-members__header flex flex-col gap-5 pb-6 xl:flex-row xl:items-center xl:justify-between">
|
||||||
<p class="text-xs font-semibold uppercase text-muted">Admin</p>
|
<div>
|
||||||
<h1 class="mt-2 text-2xl font-semibold text-ink">멤버</h1>
|
<p class="admin-members__eyebrow text-xs font-semibold uppercase text-muted">
|
||||||
|
Members
|
||||||
|
</p>
|
||||||
|
<h1 class="admin-members__title mt-2 text-3xl font-semibold tracking-[-0.01em] text-[#15171a]">
|
||||||
|
멤버
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<div class="admin-members__actions flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||||
|
<label class="admin-members__search relative block w-full sm:w-[290px]">
|
||||||
|
<span class="sr-only">멤버 검색</span>
|
||||||
|
<svg class="admin-members__search-icon pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[#9aa4b2]" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="M23.245 23.996a.743.743 0 01-.53-.22L16.2 17.26a9.824 9.824 0 01-2.553 1.579 9.766 9.766 0 01-7.51.069 9.745 9.745 0 01-5.359-5.262c-1.025-2.412-1.05-5.08-.069-7.51S3.558 1.802 5.97.777a9.744 9.744 0 017.51-.069 9.745 9.745 0 015.359 5.262 9.748 9.748 0 01.069 7.51 9.807 9.807 0 01-1.649 2.718l6.517 6.518a.75.75 0 01-.531 1.28zM9.807 1.49a8.259 8.259 0 00-3.25.667 8.26 8.26 0 00-4.458 4.54 8.26 8.26 0 00.058 6.362 8.26 8.26 0 004.54 4.458 8.259 8.259 0 006.362-.059 8.285 8.285 0 002.594-1.736.365.365 0 01.077-.076 8.245 8.245 0 001.786-2.728 8.255 8.255 0 00-.059-6.362 8.257 8.257 0 00-4.54-4.458 8.28 8.28 0 00-3.11-.608z" fill="currentColor" />
|
||||||
|
</svg>
|
||||||
|
<input
|
||||||
|
v-model="memberSearchQuery"
|
||||||
|
class="admin-members__search-input h-11 w-full rounded-md border border-transparent bg-[#eef1f4] pl-10 pr-3 text-sm text-[#15171a] outline-none transition focus:border-[#c5ccd5] focus:bg-white focus:ring-2 focus:ring-[#dce2e8]"
|
||||||
|
type="search"
|
||||||
|
placeholder="멤버 검색..."
|
||||||
|
aria-label="멤버 검색"
|
||||||
|
>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
class="admin-members__filter inline-flex h-11 items-center justify-center gap-2 rounded-md border border-line bg-white px-4 text-sm font-semibold text-[#394047] transition hover:bg-[#eff1f2] hover:text-black"
|
||||||
|
type="button"
|
||||||
|
:aria-expanded="isFilterOpen"
|
||||||
|
@click="isFilterOpen = !isFilterOpen"
|
||||||
|
>
|
||||||
|
<svg class="h-4 w-4" viewBox="0 0 18 18" fill="none" aria-hidden="true">
|
||||||
|
<path d="M6.5 13.502H12M4 9.004L14 9M1.688 4.502h14.624" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
|
</svg>
|
||||||
|
<span>필터</span>
|
||||||
|
<span v-if="activeFilterCount" class="admin-members__filter-count rounded-full bg-[#15171a] px-1.5 py-0.5 text-[11px] leading-none text-white">
|
||||||
|
{{ activeFilterCount }}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<NuxtLink
|
||||||
|
class="admin-members__new inline-flex h-11 items-center rounded-md bg-[#15171a] px-4 text-sm font-semibold text-white transition hover:bg-[#2b2f35] focus-visible:outline focus-visible:ring-2 focus-visible:ring-[#8e9cac] focus-visible:ring-offset-2"
|
||||||
|
to="/admin/members/new"
|
||||||
|
>
|
||||||
|
멤버 추가
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="px-6 py-5">
|
<section v-if="isFilterOpen" class="admin-members__filter-panel mt-2 rounded-lg border border-line bg-white p-6 shadow-xl">
|
||||||
<div class="overflow-x-auto rounded-[10px] border border-line bg-white">
|
<h2 class="admin-members__filter-title text-2xl font-semibold tracking-[-0.01em] text-[#15171a]">
|
||||||
<table class="min-w-full text-left text-sm">
|
필터 목록
|
||||||
<thead class="bg-[#f7f7f5] text-xs uppercase text-muted">
|
</h2>
|
||||||
<tr>
|
<div class="admin-members__filter-body mt-6 rounded-md bg-[#eef1f4] p-5">
|
||||||
<th class="px-3 py-2.5">닉네임</th>
|
<div class="admin-members__filter-rules grid gap-3">
|
||||||
<th class="px-3 py-2.5">권한</th>
|
<div
|
||||||
<th class="px-3 py-2.5">이메일</th>
|
v-for="filter in filterDrafts"
|
||||||
<th class="px-3 py-2.5">최근 활동</th>
|
:key="filter.id"
|
||||||
<th class="px-3 py-2.5">접속 IP</th>
|
class="admin-members__filter-row grid gap-2 lg:grid-cols-[minmax(0,1fr)_220px_minmax(0,1fr)_36px]"
|
||||||
<th class="px-3 py-2.5">활동 현황</th>
|
>
|
||||||
<th class="px-3 py-2.5 text-right">댓글 개수</th>
|
<label class="admin-members__filter-field">
|
||||||
<th class="px-3 py-2.5 text-right">권한 변경</th>
|
<span class="sr-only">필터 항목</span>
|
||||||
</tr>
|
<select
|
||||||
</thead>
|
v-model="filter.field"
|
||||||
<tbody>
|
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]"
|
||||||
<tr v-for="member in members" :key="member.id" class="border-t border-line/70">
|
@change="changeFilterField(filter)"
|
||||||
<td class="px-3 py-3">
|
>
|
||||||
<div class="flex items-center gap-2">
|
<option v-for="option in filterFieldOptions" :key="option.value" :value="option.value">
|
||||||
<img
|
{{ option.label }}
|
||||||
v-if="member.avatarUrl"
|
</option>
|
||||||
:src="member.avatarUrl"
|
</select>
|
||||||
:alt="member.username"
|
</label>
|
||||||
class="h-7 w-7 rounded-full object-cover"
|
<label class="admin-members__filter-operator">
|
||||||
>
|
<span class="sr-only">필터 조건</span>
|
||||||
<span v-else class="grid h-7 w-7 place-items-center rounded-full bg-[#efefec] text-xs font-semibold text-ink">
|
<select
|
||||||
{{ (member.username || '?').slice(0, 1).toUpperCase() }}
|
v-model="filter.operator"
|
||||||
</span>
|
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]"
|
||||||
<span>{{ member.username }}</span>
|
>
|
||||||
</div>
|
<option v-for="option in getFilterOperatorOptions(filter.field)" :key="option.value" :value="option.value">
|
||||||
</td>
|
{{ option.label }}
|
||||||
<td class="px-3 py-3">
|
</option>
|
||||||
<span
|
</select>
|
||||||
class="rounded-full border px-2 py-0.5 text-xs"
|
</label>
|
||||||
:class="member.roleCode === 'owner'
|
<label class="admin-members__filter-value">
|
||||||
? 'border-[#8a56ff]/35 text-[#8a56ff]'
|
<span class="sr-only">필터 값</span>
|
||||||
: member.roleCode === 'admin'
|
<select
|
||||||
? 'border-[#ff4f2e]/30 text-[#ff4f2e]'
|
v-if="getFilterFieldType(filter.field) === 'status'"
|
||||||
: 'border-line text-muted'"
|
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]"
|
||||||
{{ member.role || '멤버' }}
|
>
|
||||||
</span>
|
<option v-for="option in statusFilterOptions" :key="option.value" :value="option.value">
|
||||||
</td>
|
{{ option.label }}
|
||||||
<td class="px-3 py-3 text-muted">{{ member.email }}</td>
|
</option>
|
||||||
<td class="px-3 py-3 text-muted">{{ formatLastSeen(member.lastSeenAt) }}</td>
|
</select>
|
||||||
<td class="px-3 py-3 text-muted">{{ member.lastSeenIp || '-' }}</td>
|
<input
|
||||||
<td class="px-3 py-3">
|
v-else-if="getFilterFieldType(filter.field) === 'date' && !['empty', 'notEmpty'].includes(filter.operator)"
|
||||||
<span class="rounded-full border border-line px-2 py-0.5 text-xs">{{ member.activityStatus }}</span>
|
v-model="filter.value"
|
||||||
</td>
|
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]"
|
||||||
<td class="px-3 py-3 text-right font-semibold text-ink">{{ member.commentCount }}</td>
|
type="date"
|
||||||
<td class="px-3 py-3">
|
>
|
||||||
<div class="flex justify-end gap-2">
|
<input
|
||||||
<select
|
v-else-if="getFilterFieldType(filter.field) !== 'date'"
|
||||||
v-model="member.roleCode"
|
v-model="filter.value"
|
||||||
class="rounded border border-line bg-white px-2 py-1 text-xs"
|
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]"
|
||||||
:disabled="isSavingRole(member.id)"
|
type="text"
|
||||||
>
|
placeholder="값 입력"
|
||||||
<option v-for="option in roleOptions" :key="option.value" :value="option.value">
|
>
|
||||||
{{ option.label }}
|
<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]">
|
||||||
</option>
|
값 필요 없음
|
||||||
</select>
|
</span>
|
||||||
<button
|
</label>
|
||||||
type="button"
|
<button
|
||||||
class="rounded border border-line px-2 py-1 text-xs font-semibold hover:bg-paper disabled:opacity-50"
|
class="admin-members__filter-delete grid h-11 w-9 place-items-center rounded-md text-[#657080] transition hover:bg-white hover:text-[#15171a]"
|
||||||
:disabled="isSavingRole(member.id)"
|
type="button"
|
||||||
@click="updateRole(member)"
|
aria-label="필터 삭제"
|
||||||
>
|
@click="removeFilter(filter.id)"
|
||||||
{{ isSavingRole(member.id) ? '저장 중' : '저장' }}
|
>
|
||||||
</button>
|
<svg class="h-4 w-4" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
</div>
|
<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" />
|
||||||
</td>
|
</svg>
|
||||||
</tr>
|
</button>
|
||||||
<tr v-if="members.length === 0">
|
</div>
|
||||||
<td colspan="8" class="px-3 py-6 text-center text-sm text-muted">등록된 회원이 없습니다.</td>
|
</div>
|
||||||
</tr>
|
<button
|
||||||
</tbody>
|
class="admin-members__filter-add mt-5 inline-flex items-center gap-2 text-sm font-semibold text-[#17a62b]"
|
||||||
</table>
|
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>
|
||||||
<p v-if="roleMessage" class="mt-3 text-xs text-muted">
|
<div class="admin-members__filter-footer mt-5 flex items-center justify-between">
|
||||||
{{ roleMessage }}
|
<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">
|
||||||
</p>
|
모두 초기화
|
||||||
|
</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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</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>
|
||||||
@@ -95,6 +95,7 @@ const savePost = async (payload) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
post.value = updatedPost
|
post.value = updatedPost
|
||||||
|
postForm.value?.markSaved()
|
||||||
postForm.value?.clearAutosave()
|
postForm.value?.clearAutosave()
|
||||||
showToast('success', '변경 내용이 저장되었습니다.')
|
showToast('success', '변경 내용이 저장되었습니다.')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -122,6 +123,7 @@ const deletePost = async () => {
|
|||||||
await $fetch(`/admin/api/posts/${id.value}`, {
|
await $fetch(`/admin/api/posts/${id.value}`, {
|
||||||
method: 'DELETE'
|
method: 'DELETE'
|
||||||
})
|
})
|
||||||
|
postForm.value?.allowNextRouteLeave()
|
||||||
await navigateTo('/admin/posts')
|
await navigateTo('/admin/posts')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
errorMessage.value = error?.data?.message || '글을 삭제하지 못했습니다.'
|
errorMessage.value = error?.data?.message || '글을 삭제하지 못했습니다.'
|
||||||
|
|||||||
@@ -141,7 +141,16 @@ const deletePost = async (post) => {
|
|||||||
</p>
|
</p>
|
||||||
</td>
|
</td>
|
||||||
<td class="admin-posts__cell px-4 py-4">
|
<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>
|
||||||
<td class="admin-posts__cell px-4 py-4">
|
<td class="admin-posts__cell px-4 py-4">
|
||||||
{{ formatDate(post.updatedAt) }}
|
{{ formatDate(post.updatedAt) }}
|
||||||
|
|||||||
@@ -52,7 +52,9 @@ const savePost = async (payload) => {
|
|||||||
body: payload
|
body: payload
|
||||||
})
|
})
|
||||||
|
|
||||||
|
postForm.value?.markSaved()
|
||||||
postForm.value?.clearAutosave()
|
postForm.value?.clearAutosave()
|
||||||
|
postForm.value?.allowNextRouteLeave()
|
||||||
sessionStorage.setItem('SORI_ADMIN_POST_TOAST', JSON.stringify({
|
sessionStorage.setItem('SORI_ADMIN_POST_TOAST', JSON.stringify({
|
||||||
type: 'success',
|
type: 'success',
|
||||||
message: '글이 저장되었습니다.'
|
message: '글이 저장되었습니다.'
|
||||||
|
|||||||
@@ -4,17 +4,11 @@ definePageMeta({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
const loadingProfile = ref(true)
|
const uploadingLogo = ref(false)
|
||||||
const savingProfile = ref(false)
|
|
||||||
const savingPassword = ref(false)
|
|
||||||
const uploadingAvatar = ref(false)
|
|
||||||
const removingAvatar = ref(false)
|
|
||||||
const errorMessage = ref('')
|
const errorMessage = ref('')
|
||||||
const profileMessage = ref('')
|
|
||||||
const passwordMessage = ref('')
|
|
||||||
const toast = ref(null)
|
const toast = ref(null)
|
||||||
|
const logoInputRef = ref(null)
|
||||||
let toastTimer = null
|
let toastTimer = null
|
||||||
const avatarInputRef = ref(null)
|
|
||||||
|
|
||||||
const { data: settings } = await useFetch('/admin/api/settings')
|
const { data: settings } = await useFetch('/admin/api/settings')
|
||||||
|
|
||||||
@@ -23,207 +17,11 @@ const form = reactive({
|
|||||||
description: settings.value?.description || '',
|
description: settings.value?.description || '',
|
||||||
siteUrl: settings.value?.siteUrl || 'https://sori.studio',
|
siteUrl: settings.value?.siteUrl || 'https://sori.studio',
|
||||||
logoText: settings.value?.logoText || '井',
|
logoText: settings.value?.logoText || '井',
|
||||||
|
logoUrl: settings.value?.logoUrl || '',
|
||||||
|
faviconUrl: settings.value?.faviconUrl || '',
|
||||||
copyrightText: settings.value?.copyrightText || '©2026 sori.studio'
|
copyrightText: settings.value?.copyrightText || '©2026 sori.studio'
|
||||||
})
|
})
|
||||||
|
|
||||||
const profileForm = reactive({
|
|
||||||
email: '',
|
|
||||||
username: '',
|
|
||||||
avatarUrl: ''
|
|
||||||
})
|
|
||||||
|
|
||||||
const passwordForm = reactive({
|
|
||||||
currentPassword: '',
|
|
||||||
nextPassword: '',
|
|
||||||
nextPasswordConfirm: ''
|
|
||||||
})
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 관리자 프로필을 조회한다.
|
|
||||||
* @returns {Promise<void>}
|
|
||||||
*/
|
|
||||||
const loadAdminProfile = async () => {
|
|
||||||
loadingProfile.value = true
|
|
||||||
profileMessage.value = ''
|
|
||||||
|
|
||||||
try {
|
|
||||||
const profile = await $fetch('/api/auth/profile')
|
|
||||||
profileForm.email = profile.email || ''
|
|
||||||
profileForm.username = profile.username || ''
|
|
||||||
profileForm.avatarUrl = profile.avatarUrl || ''
|
|
||||||
} catch {
|
|
||||||
profileMessage.value = '관리자 프로필을 불러오지 못했습니다. 다시 로그인해 주세요.'
|
|
||||||
} finally {
|
|
||||||
loadingProfile.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 닉네임 중복 여부를 확인한다.
|
|
||||||
* @returns {Promise<boolean>} 사용 가능 여부
|
|
||||||
*/
|
|
||||||
const checkUsernameAvailable = async () => {
|
|
||||||
const username = profileForm.username.trim()
|
|
||||||
if (!username) {
|
|
||||||
profileMessage.value = '관리자 이름을 입력해 주세요.'
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await $fetch('/api/auth/check-username', {
|
|
||||||
query: {
|
|
||||||
username
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!result.available) {
|
|
||||||
profileMessage.value = '이미 사용 중인 이름입니다.'
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
profileMessage.value = error?.data?.message || '이름 중복 확인에 실패했습니다.'
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 관리자 프로필을 저장한다.
|
|
||||||
* @returns {Promise<void>}
|
|
||||||
*/
|
|
||||||
const saveAdminProfile = async () => {
|
|
||||||
const available = await checkUsernameAvailable()
|
|
||||||
if (!available) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
savingProfile.value = true
|
|
||||||
profileMessage.value = ''
|
|
||||||
|
|
||||||
try {
|
|
||||||
await $fetch('/api/auth/profile', {
|
|
||||||
method: 'PUT',
|
|
||||||
body: {
|
|
||||||
username: profileForm.username.trim(),
|
|
||||||
avatarUrl: profileForm.avatarUrl.trim()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
profileMessage.value = '관리자 프로필이 저장되었습니다.'
|
|
||||||
} catch (error) {
|
|
||||||
profileMessage.value = error?.data?.message || '관리자 프로필 저장에 실패했습니다.'
|
|
||||||
} finally {
|
|
||||||
savingProfile.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 관리자 썸네일 파일 선택창을 연다.
|
|
||||||
* @returns {void}
|
|
||||||
*/
|
|
||||||
const openAvatarFilePicker = () => {
|
|
||||||
avatarInputRef.value?.click()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 관리자 썸네일을 업로드한다.
|
|
||||||
* @param {Event} event - 파일 선택 이벤트
|
|
||||||
* @returns {Promise<void>}
|
|
||||||
*/
|
|
||||||
const uploadAvatar = async (event) => {
|
|
||||||
const target = /** @type {HTMLInputElement | null} */ (event.target instanceof HTMLInputElement ? event.target : null)
|
|
||||||
const file = target?.files?.[0]
|
|
||||||
|
|
||||||
if (!file || uploadingAvatar.value) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
uploadingAvatar.value = true
|
|
||||||
profileMessage.value = ''
|
|
||||||
|
|
||||||
try {
|
|
||||||
const formData = new FormData()
|
|
||||||
formData.append('file', file)
|
|
||||||
const result = await $fetch('/api/auth/avatar', {
|
|
||||||
method: 'POST',
|
|
||||||
body: formData
|
|
||||||
})
|
|
||||||
profileForm.avatarUrl = result.avatarUrl || ''
|
|
||||||
profileMessage.value = '관리자 썸네일이 업로드되었습니다.'
|
|
||||||
} catch (error) {
|
|
||||||
profileMessage.value = error?.data?.message || '관리자 썸네일 업로드에 실패했습니다.'
|
|
||||||
} finally {
|
|
||||||
uploadingAvatar.value = false
|
|
||||||
if (target) {
|
|
||||||
target.value = ''
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 관리자 썸네일을 제거한다.
|
|
||||||
* @returns {Promise<void>}
|
|
||||||
*/
|
|
||||||
const removeAvatar = async () => {
|
|
||||||
if (removingAvatar.value) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
removingAvatar.value = true
|
|
||||||
profileMessage.value = ''
|
|
||||||
|
|
||||||
try {
|
|
||||||
await $fetch('/api/auth/avatar', {
|
|
||||||
method: 'DELETE'
|
|
||||||
})
|
|
||||||
profileForm.avatarUrl = ''
|
|
||||||
profileMessage.value = '관리자 썸네일이 제거되었습니다.'
|
|
||||||
} catch (error) {
|
|
||||||
profileMessage.value = error?.data?.message || '관리자 썸네일 제거에 실패했습니다.'
|
|
||||||
} finally {
|
|
||||||
removingAvatar.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 관리자 비밀번호를 변경한다.
|
|
||||||
* @returns {Promise<void>}
|
|
||||||
*/
|
|
||||||
const saveAdminPassword = async () => {
|
|
||||||
passwordMessage.value = ''
|
|
||||||
|
|
||||||
if (!passwordForm.currentPassword || !passwordForm.nextPassword || !passwordForm.nextPasswordConfirm) {
|
|
||||||
passwordMessage.value = '모든 비밀번호 입력값을 작성해 주세요.'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (passwordForm.nextPassword !== passwordForm.nextPasswordConfirm) {
|
|
||||||
passwordMessage.value = '새 비밀번호와 확인 값이 일치하지 않습니다.'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
savingPassword.value = true
|
|
||||||
|
|
||||||
try {
|
|
||||||
await $fetch('/api/auth/password', {
|
|
||||||
method: 'PUT',
|
|
||||||
body: {
|
|
||||||
currentPassword: passwordForm.currentPassword,
|
|
||||||
nextPassword: passwordForm.nextPassword
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
passwordForm.currentPassword = ''
|
|
||||||
passwordForm.nextPassword = ''
|
|
||||||
passwordForm.nextPasswordConfirm = ''
|
|
||||||
passwordMessage.value = '관리자 비밀번호가 변경되었습니다.'
|
|
||||||
} catch (error) {
|
|
||||||
passwordMessage.value = error?.data?.message || '관리자 비밀번호 변경에 실패했습니다.'
|
|
||||||
} finally {
|
|
||||||
savingPassword.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 저장 상태 토스트 표시
|
* 저장 상태 토스트 표시
|
||||||
* @param {'success'|'error'|'info'} type - 토스트 타입
|
* @param {'success'|'error'|'info'} type - 토스트 타입
|
||||||
@@ -238,6 +36,51 @@ const showToast = (type, message) => {
|
|||||||
}, 3200)
|
}, 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>} 저장 결과
|
* @returns {Promise<void>} 저장 결과
|
||||||
@@ -254,7 +97,9 @@ const saveSettings = async () => {
|
|||||||
title: form.title,
|
title: form.title,
|
||||||
description: form.description,
|
description: form.description,
|
||||||
siteUrl: form.siteUrl,
|
siteUrl: form.siteUrl,
|
||||||
logoText: form.logoText,
|
logoText: form.logoText || '井',
|
||||||
|
logoUrl: form.logoUrl,
|
||||||
|
faviconUrl: form.faviconUrl,
|
||||||
copyrightText: form.copyrightText
|
copyrightText: form.copyrightText
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -272,8 +117,6 @@ const saveSettings = async () => {
|
|||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
window.clearTimeout(toastTimer)
|
window.clearTimeout(toastTimer)
|
||||||
})
|
})
|
||||||
|
|
||||||
onMounted(loadAdminProfile)
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -291,140 +134,47 @@ onMounted(loadAdminProfile)
|
|||||||
{{ errorMessage }}
|
{{ errorMessage }}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<section class="admin-settings__profile mb-6 max-w-3xl rounded border border-line bg-white p-5">
|
<form class="admin-settings__form grid max-w-4xl gap-6" @submit.prevent="saveSettings">
|
||||||
<h2 class="text-base font-semibold text-ink">관리자 프로필</h2>
|
<section class="admin-settings__logo rounded-xl border border-line bg-white p-5">
|
||||||
<p class="mt-1 text-xs text-muted">
|
<div class="flex flex-col gap-5 md:flex-row md:items-center md:justify-between">
|
||||||
썸네일과 이름을 수정할 수 있습니다.
|
<div class="flex items-center gap-4">
|
||||||
</p>
|
<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">
|
||||||
|
|
||||||
<div v-if="loadingProfile" class="mt-4 text-sm text-muted">
|
|
||||||
관리자 프로필을 불러오는 중입니다.
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else class="mt-4 flex flex-col gap-4">
|
|
||||||
<div class="flex flex-col gap-3 rounded border border-line bg-paper p-3 md:flex-row md:items-center">
|
|
||||||
<div class="relative w-fit shrink-0">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="group relative h-24 w-24 overflow-hidden rounded-full border border-line bg-white"
|
|
||||||
:disabled="uploadingAvatar || removingAvatar"
|
|
||||||
@click="openAvatarFilePicker"
|
|
||||||
>
|
|
||||||
<img
|
<img
|
||||||
v-if="profileForm.avatarUrl"
|
v-if="form.logoUrl"
|
||||||
:src="profileForm.avatarUrl"
|
|
||||||
alt="관리자 썸네일"
|
|
||||||
class="h-full w-full object-cover"
|
class="h-full w-full object-cover"
|
||||||
|
:src="form.logoUrl"
|
||||||
|
alt="사이트 로고"
|
||||||
>
|
>
|
||||||
<span
|
<span v-else class="text-2xl font-semibold text-muted">
|
||||||
v-else
|
{{ form.logoText || '井' }}
|
||||||
class="grid h-full w-full place-items-center text-2xl font-semibold text-muted"
|
|
||||||
>
|
|
||||||
{{ (profileForm.username || profileForm.email || '@').slice(0, 1).toUpperCase() }}
|
|
||||||
</span>
|
</span>
|
||||||
<span class="pointer-events-none absolute inset-0 grid place-items-center bg-black/45 text-[11px] font-medium text-white opacity-0 transition-opacity duration-200 group-hover:opacity-100">
|
</div>
|
||||||
{{ profileForm.avatarUrl ? '이미지 변경' : '썸네일 등록' }}
|
<div>
|
||||||
</span>
|
<h2 class="text-base font-semibold text-ink">로고</h2>
|
||||||
</button>
|
<p class="mt-1 max-w-md text-sm leading-6 text-muted">
|
||||||
<button
|
1:1 비율 이미지로 등록합니다. 같은 이미지가 공개 로고와 파비콘으로 함께 사용됩니다.
|
||||||
v-if="profileForm.avatarUrl"
|
</p>
|
||||||
type="button"
|
</div>
|
||||||
class="absolute right-0 top-0 grid h-6 w-6 -translate-y-1/3 translate-x-1/3 place-items-center rounded-full border border-line bg-paper text-xs text-muted transition-opacity hover:opacity-80 disabled:opacity-50"
|
|
||||||
:disabled="removingAvatar"
|
|
||||||
@click.stop="removeAvatar"
|
|
||||||
>
|
|
||||||
{{ removingAvatar ? '...' : 'X' }}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="grid min-w-0 flex-1 gap-3">
|
|
||||||
<label class="grid gap-1 text-sm">
|
|
||||||
<span class="text-xs text-muted">관리자 이름</span>
|
|
||||||
<input
|
|
||||||
v-model="profileForm.username"
|
|
||||||
type="text"
|
|
||||||
class="rounded border border-line bg-white px-3 py-2"
|
|
||||||
>
|
|
||||||
</label>
|
|
||||||
<label class="grid gap-1 text-sm">
|
|
||||||
<span class="text-xs text-muted">관리자 이메일</span>
|
|
||||||
<input
|
|
||||||
:value="profileForm.email"
|
|
||||||
type="text"
|
|
||||||
class="rounded border border-line bg-[#f7f7f5] px-3 py-2 text-muted"
|
|
||||||
readonly
|
|
||||||
>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<input
|
|
||||||
ref="avatarInputRef"
|
|
||||||
type="file"
|
|
||||||
accept="image/jpeg,image/png,image/webp,image/gif"
|
|
||||||
class="hidden"
|
|
||||||
:disabled="uploadingAvatar"
|
|
||||||
@change="uploadAvatar"
|
|
||||||
>
|
|
||||||
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<button
|
<button
|
||||||
class="rounded bg-[#15171a] px-4 py-2 text-sm font-semibold text-white disabled:opacity-50"
|
class="admin-settings__logo-button h-10 rounded-md border border-line bg-white px-4 text-sm font-semibold text-[#15171a] transition hover:bg-[#f3f5f7] disabled:opacity-50"
|
||||||
type="button"
|
type="button"
|
||||||
:disabled="savingProfile"
|
:disabled="uploadingLogo"
|
||||||
@click="saveAdminProfile"
|
@click="openLogoFilePicker"
|
||||||
>
|
>
|
||||||
{{ savingProfile ? '저장 중' : '관리자 프로필 저장' }}
|
{{ uploadingLogo ? '업로드 중' : form.logoUrl ? '로고 변경' : '로고 등록' }}
|
||||||
</button>
|
</button>
|
||||||
<p v-if="profileMessage" class="text-xs text-muted">
|
<input
|
||||||
{{ profileMessage }}
|
ref="logoInputRef"
|
||||||
</p>
|
class="hidden"
|
||||||
|
type="file"
|
||||||
|
accept="image/jpeg,image/png,image/webp"
|
||||||
|
:disabled="uploadingLogo"
|
||||||
|
@change="uploadLogo"
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="admin-settings__password mb-6 max-w-3xl rounded border border-line bg-white p-5">
|
|
||||||
<h2 class="text-base font-semibold text-ink">관리자 비밀번호 변경</h2>
|
|
||||||
<p class="mt-1 text-xs text-muted">
|
|
||||||
현재 비밀번호 확인 후 새 비밀번호로 변경할 수 있습니다.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="mt-4 grid gap-3">
|
|
||||||
<input
|
|
||||||
v-model="passwordForm.currentPassword"
|
|
||||||
type="password"
|
|
||||||
class="rounded border border-line bg-white px-3 py-2 text-sm"
|
|
||||||
placeholder="현재 비밀번호"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
v-model="passwordForm.nextPassword"
|
|
||||||
type="password"
|
|
||||||
class="rounded border border-line bg-white px-3 py-2 text-sm"
|
|
||||||
placeholder="새 비밀번호"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
v-model="passwordForm.nextPasswordConfirm"
|
|
||||||
type="password"
|
|
||||||
class="rounded border border-line bg-white px-3 py-2 text-sm"
|
|
||||||
placeholder="새 비밀번호 확인"
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-4 flex items-center gap-2">
|
|
||||||
<button
|
|
||||||
class="rounded bg-[#15171a] px-4 py-2 text-sm font-semibold text-white disabled:opacity-50"
|
|
||||||
type="button"
|
|
||||||
:disabled="savingPassword"
|
|
||||||
@click="saveAdminPassword"
|
|
||||||
>
|
|
||||||
{{ savingPassword ? '변경 중' : '관리자 비밀번호 변경' }}
|
|
||||||
</button>
|
|
||||||
<p v-if="passwordMessage" class="text-xs text-muted">
|
|
||||||
{{ passwordMessage }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<form class="admin-settings__form grid max-w-3xl gap-6" @submit.prevent="saveSettings">
|
|
||||||
<label class="admin-settings__field grid gap-2 text-sm">
|
<label class="admin-settings__field grid gap-2 text-sm">
|
||||||
<span class="admin-settings__label font-medium">사이트 이름</span>
|
<span class="admin-settings__label font-medium">사이트 이름</span>
|
||||||
<input
|
<input
|
||||||
@@ -440,6 +190,7 @@ onMounted(loadAdminProfile)
|
|||||||
<textarea
|
<textarea
|
||||||
v-model="form.description"
|
v-model="form.description"
|
||||||
class="admin-settings__textarea min-h-28 resize-y rounded border border-line bg-white px-3 py-2"
|
class="admin-settings__textarea min-h-28 resize-y rounded border border-line bg-white px-3 py-2"
|
||||||
|
required
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
@@ -453,36 +204,29 @@ onMounted(loadAdminProfile)
|
|||||||
>
|
>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<div class="admin-settings__grid grid gap-4 md:grid-cols-2">
|
<label class="admin-settings__field grid gap-2 text-sm">
|
||||||
<label class="admin-settings__field grid gap-2 text-sm">
|
<span class="admin-settings__label font-medium">저작권 문구</span>
|
||||||
<span class="admin-settings__label font-medium">로고 텍스트</span>
|
<input
|
||||||
<input
|
v-model="form.copyrightText"
|
||||||
v-model="form.logoText"
|
class="admin-settings__input rounded border border-line bg-white px-3 py-2"
|
||||||
class="admin-settings__input rounded border border-line bg-white px-3 py-2"
|
type="text"
|
||||||
type="text"
|
required
|
||||||
maxlength="8"
|
>
|
||||||
required
|
</label>
|
||||||
>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label class="admin-settings__field grid gap-2 text-sm">
|
<div class="admin-settings__preview rounded-xl border border-line bg-white p-5">
|
||||||
<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">
|
|
||||||
<p class="admin-settings__preview-label text-xs font-semibold uppercase text-muted">
|
<p class="admin-settings__preview-label text-xs font-semibold uppercase text-muted">
|
||||||
공개 화면 미리보기
|
공개 화면 미리보기
|
||||||
</p>
|
</p>
|
||||||
<div class="admin-settings__preview-body mt-4 flex items-center gap-3">
|
<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">
|
<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">
|
||||||
{{ form.logoText || '井' }}
|
<img
|
||||||
|
v-if="form.logoUrl"
|
||||||
|
class="h-full w-full object-cover"
|
||||||
|
:src="form.logoUrl"
|
||||||
|
alt=""
|
||||||
|
>
|
||||||
|
<span v-else>{{ form.logoText || '井' }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p class="admin-settings__preview-title font-semibold">
|
<p class="admin-settings__preview-title font-semibold">
|
||||||
|
|||||||
@@ -274,7 +274,7 @@ useHead(() => ({
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section id="comments" class="mb-6 border-y border-[var(--site-line)] bg-[var(--site-panel-strong)] py-5 scroll-mt-14">
|
<section id="comments" class="mt-12 mb-6 border-y border-[var(--site-line)] bg-[var(--site-panel-strong)] py-5 scroll-mt-14">
|
||||||
<div class="mx-auto max-w-[720px] px-4 text-sm sm:px-5">
|
<div class="mx-auto max-w-[720px] px-4 text-sm sm:px-5">
|
||||||
<PostComments :slug="post.slug" />
|
<PostComments :slug="post.slug" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,11 +8,20 @@ const removingAvatar = ref(false)
|
|||||||
const profileMessage = ref('')
|
const profileMessage = ref('')
|
||||||
const passwordMessage = ref('')
|
const passwordMessage = ref('')
|
||||||
const deleteMessage = ref('')
|
const deleteMessage = ref('')
|
||||||
|
const actionMenuOpen = ref(false)
|
||||||
|
const passwordModalOpen = ref(false)
|
||||||
|
const deleteModalOpen = ref(false)
|
||||||
|
|
||||||
const profileForm = reactive({
|
const profileForm = reactive({
|
||||||
email: '',
|
email: '',
|
||||||
username: '',
|
username: '',
|
||||||
avatarUrl: ''
|
avatarUrl: '',
|
||||||
|
createdAt: '',
|
||||||
|
updatedAt: '',
|
||||||
|
lastSeenAt: '',
|
||||||
|
previousLastSeenAt: '',
|
||||||
|
previousLastSeenIp: '',
|
||||||
|
commentCount: 0
|
||||||
})
|
})
|
||||||
|
|
||||||
const avatarInputRef = ref(null)
|
const avatarInputRef = ref(null)
|
||||||
@@ -27,6 +36,72 @@ const deleteForm = reactive({
|
|||||||
password: ''
|
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>}
|
* @returns {Promise<void>}
|
||||||
@@ -37,6 +112,12 @@ const loadProfile = async () => {
|
|||||||
profileForm.email = profile.email || ''
|
profileForm.email = profile.email || ''
|
||||||
profileForm.username = profile.username || ''
|
profileForm.username = profile.username || ''
|
||||||
profileForm.avatarUrl = profile.avatarUrl || ''
|
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 {
|
} catch {
|
||||||
await navigateTo('/signin')
|
await navigateTo('/signin')
|
||||||
} finally {
|
} finally {
|
||||||
@@ -102,6 +183,14 @@ const saveProfile = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 썸네일 파일 선택창을 연다.
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const openAvatarFilePicker = () => {
|
||||||
|
avatarInputRef.value?.click()
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 썸네일을 제거한다.
|
* 썸네일을 제거한다.
|
||||||
* @returns {Promise<void>}
|
* @returns {Promise<void>}
|
||||||
@@ -163,11 +252,67 @@ const uploadAvatar = async (event) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 썸네일 파일 선택창을 연다.
|
* 작업 메뉴를 토글한다.
|
||||||
* @returns {void}
|
* @returns {void}
|
||||||
*/
|
*/
|
||||||
const openAvatarFilePicker = () => {
|
const toggleActionMenu = () => {
|
||||||
avatarInputRef.value?.click()
|
actionMenuOpen.value = !actionMenuOpen.value
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 작업 메뉴를 닫는다.
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const closeActionMenu = () => {
|
||||||
|
actionMenuOpen.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 비밀번호 변경 모달을 연다.
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const openPasswordModal = () => {
|
||||||
|
passwordForm.currentPassword = ''
|
||||||
|
passwordForm.nextPassword = ''
|
||||||
|
passwordForm.nextPasswordConfirm = ''
|
||||||
|
passwordMessage.value = ''
|
||||||
|
passwordModalOpen.value = true
|
||||||
|
closeActionMenu()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 회원 탈퇴 모달을 연다.
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const openDeleteModal = () => {
|
||||||
|
deleteForm.password = ''
|
||||||
|
deleteMessage.value = ''
|
||||||
|
deleteModalOpen.value = true
|
||||||
|
closeActionMenu()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 비밀번호 변경 모달을 닫는다.
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const closePasswordModal = () => {
|
||||||
|
if (savingPassword.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
passwordModalOpen.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 회원 탈퇴 모달을 닫는다.
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const closeDeleteModal = () => {
|
||||||
|
if (deletingAccount.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteModalOpen.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -197,7 +342,8 @@ const savePassword = async () => {
|
|||||||
passwordForm.currentPassword = ''
|
passwordForm.currentPassword = ''
|
||||||
passwordForm.nextPassword = ''
|
passwordForm.nextPassword = ''
|
||||||
passwordForm.nextPasswordConfirm = ''
|
passwordForm.nextPasswordConfirm = ''
|
||||||
passwordMessage.value = '비밀번호가 변경되었습니다.'
|
passwordModalOpen.value = false
|
||||||
|
profileMessage.value = '비밀번호가 변경되었습니다.'
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
passwordMessage.value = error?.data?.message || '비밀번호 변경에 실패했습니다.'
|
passwordMessage.value = error?.data?.message || '비밀번호 변경에 실패했습니다.'
|
||||||
} finally {
|
} finally {
|
||||||
@@ -236,149 +382,198 @@ onMounted(loadProfile)
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section class="settings-page mx-auto w-full max-w-[720px] px-4 py-8 sm:px-5">
|
<section class="settings-page mx-auto w-full max-w-[1180px] px-4 py-8 sm:px-6 lg:px-8">
|
||||||
<h1 class="text-xl font-semibold">사용자 설정</h1>
|
<div class="settings-page__header flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<p class="settings-page__breadcrumb text-sm text-[var(--site-muted)]">내 계정</p>
|
||||||
|
<h1 class="settings-page__title mt-3 text-3xl font-semibold tracking-[-0.01em]">
|
||||||
|
{{ displayName }}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<div class="settings-page__actions relative">
|
||||||
|
<button class="settings-page__settings-button grid h-11 w-11 place-items-center rounded-[8px] border border-[var(--site-line)] bg-[var(--site-bg)] transition hover:bg-[var(--site-panel)]" type="button" aria-label="계정 작업" :aria-expanded="actionMenuOpen" @click="toggleActionMenu">
|
||||||
|
<svg class="h-5 w-5" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="M10.546 2.438a1.957 1.957 0 002.908 0L14.4 1.4a1.959 1.959 0 013.41 1.413l-.071 1.4a1.958 1.958 0 002.051 2.054l1.4-.071a1.959 1.959 0 011.41 3.41l-1.042.94a1.96 1.96 0 000 2.909l1.042.94a1.959 1.959 0 01-1.413 3.41l-1.4-.071a1.958 1.958 0 00-2.056 2.056l.071 1.4A1.959 1.959 0 0114.4 22.6l-.941-1.041a1.959 1.959 0 00-2.908 0L9.606 22.6A1.959 1.959 0 016.2 21.192l.072-1.4a1.958 1.958 0 00-2.056-2.056l-1.4.071A1.958 1.958 0 011.4 14.4l1.041-.94a1.96 1.96 0 000-2.909L1.4 9.606A1.958 1.958 0 012.809 6.2l1.4.071a1.958 1.958 0 002.058-2.06L6.2 2.81A1.959 1.959 0 019.606 1.4z" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
|
||||||
|
<circle cx="12" cy="12.001" r="4.5" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<div v-if="actionMenuOpen" class="settings-page__action-menu absolute right-0 top-12 z-20 grid w-52 overflow-hidden rounded-[12px] border border-[var(--site-line)] bg-[var(--site-bg)] py-2 text-sm shadow-[0_18px_50px_rgba(15,23,42,0.16)]">
|
||||||
|
<button class="settings-page__action-item px-4 py-2.5 text-left transition hover:bg-[var(--site-panel)]" type="button" @click="openPasswordModal">
|
||||||
|
비밀번호 변경
|
||||||
|
</button>
|
||||||
|
<button class="settings-page__action-item px-4 py-2.5 text-left text-red-500 transition hover:bg-red-500/10" type="button" @click="openDeleteModal">
|
||||||
|
회원 탈퇴
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div v-if="loading" class="mt-4 text-sm site-muted">
|
<div v-if="loading" class="settings-page__loading mt-8 text-sm site-muted">
|
||||||
설정 정보를 불러오는 중입니다.
|
설정 정보를 불러오는 중입니다.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="mt-5 flex flex-col gap-5">
|
<div v-else class="settings-page__body mt-10 grid gap-8">
|
||||||
<section class="rounded-[10px] border border-[var(--site-line)] bg-[var(--site-bg)] p-4">
|
<aside class="settings-page__summary rounded-[12px] border border-[var(--site-line)] bg-[var(--site-bg)] p-5 sm:p-6">
|
||||||
<h2 class="text-sm font-semibold">프로필</h2>
|
<div class="settings-page__identity flex items-center gap-4">
|
||||||
<div class="mt-3 flex flex-col gap-4">
|
<div class="settings-page__avatar-control group relative h-24 w-24 shrink-0">
|
||||||
<div class="settings-profile-account flex flex-col gap-3 rounded-[12px] border border-[var(--site-line)] bg-[var(--site-panel)] p-3 md:flex-row md:items-center md:gap-4">
|
<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">
|
||||||
<div class="relative w-fit shrink-0">
|
<img v-if="profileForm.avatarUrl" class="settings-page__avatar h-full w-full object-cover" :src="profileForm.avatarUrl" alt="프로필 썸네일">
|
||||||
<button
|
<span v-else class="settings-page__avatar-initial grid h-full w-full place-items-center text-2xl font-semibold site-muted">
|
||||||
type="button"
|
{{ avatarInitial }}
|
||||||
class="group relative h-24 w-24 overflow-hidden rounded-full border border-[var(--site-line)] bg-[var(--site-bg)]"
|
</span>
|
||||||
:disabled="uploadingAvatar || removingAvatar"
|
<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">
|
||||||
@click="openAvatarFilePicker"
|
{{ uploadingAvatar ? '업로드 중' : profileForm.avatarUrl ? '썸네일 변경' : '썸네일 등록' }}
|
||||||
>
|
</span>
|
||||||
<img
|
</button>
|
||||||
v-if="profileForm.avatarUrl"
|
<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">
|
||||||
:src="profileForm.avatarUrl"
|
<svg class="h-3 w-3" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
alt="프로필 썸네일"
|
<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" />
|
||||||
class="h-full w-full object-cover"
|
</svg>
|
||||||
>
|
</button>
|
||||||
<span
|
<input ref="avatarInputRef" class="hidden" type="file" accept="image/jpeg,image/png,image/webp,image/gif" :disabled="uploadingAvatar" @change="uploadAvatar">
|
||||||
v-else
|
</div>
|
||||||
class="grid h-full w-full place-items-center text-2xl font-semibold site-muted"
|
<div class="min-w-0">
|
||||||
>
|
<h2 class="truncate text-lg font-semibold">{{ displayName }}</h2>
|
||||||
{{ (profileForm.username || profileForm.email || '@').slice(0, 1).toUpperCase() }}
|
<p class="mt-1 truncate text-sm site-muted">{{ profileForm.email }}</p>
|
||||||
</span>
|
</div>
|
||||||
<span class="pointer-events-none absolute inset-0 grid place-items-center bg-black/45 text-[11px] font-medium text-white opacity-0 transition-opacity duration-200 group-hover:opacity-100">
|
</div>
|
||||||
{{ profileForm.avatarUrl ? '이미지 변경' : '썸네일 등록' }}
|
|
||||||
</span>
|
<div class="settings-page__summary-grid mt-6 grid gap-4 border-t border-[var(--site-line)] pt-5 sm:grid-cols-2">
|
||||||
</button>
|
<section class="settings-page__side-section">
|
||||||
<button
|
<h3 class="text-xs font-semibold uppercase tracking-[0.04em]">가입 정보</h3>
|
||||||
v-if="profileForm.avatarUrl"
|
<p class="mt-3 text-sm site-muted">
|
||||||
type="button"
|
생성됨 — <strong class="text-[var(--site-text)]">{{ formatDate(profileForm.createdAt) }}</strong>
|
||||||
class="absolute right-0 top-0 grid h-6 w-6 -translate-y-1/3 translate-x-1/3 place-items-center rounded-full border border-[var(--site-line)] bg-[var(--site-panel-strong)] text-xs site-muted transition-opacity hover:opacity-80 disabled:opacity-50"
|
</p>
|
||||||
:disabled="removingAvatar"
|
</section>
|
||||||
@click.stop="removeAvatar"
|
|
||||||
>
|
<section class="settings-page__side-section">
|
||||||
{{ removingAvatar ? '...' : 'X' }}
|
<h3 class="text-xs font-semibold uppercase tracking-[0.04em]">참여도</h3>
|
||||||
</button>
|
<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>
|
||||||
<div class="flex min-w-0 flex-1 flex-col gap-2">
|
<div class="settings-page__activity-row flex items-center justify-between gap-4 py-5 text-sm">
|
||||||
<div class="rounded-[12px] border border-[var(--site-line)] bg-[var(--site-bg)] px-3 py-2.5">
|
<span class="flex items-center gap-3">
|
||||||
<p class="text-[11px] site-muted">닉네임</p>
|
<svg class="h-5 w-5 site-muted" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||||
<input
|
<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" />
|
||||||
v-model="profileForm.username"
|
<path d="M17.54 16.077V23m-3.463-3.46H21" stroke="#30CF43" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
type="text"
|
</svg>
|
||||||
class="mt-1 h-7 w-full border-none bg-transparent p-0 text-sm font-semibold outline-none focus-visible:ring-0"
|
가입
|
||||||
>
|
</span>
|
||||||
</div>
|
<span class="text-right site-muted">
|
||||||
<div class="rounded-[12px] border border-[var(--site-line)] bg-[var(--site-bg)] px-3 py-2.5">
|
{{ formatRelativeTime(profileForm.createdAt) }}
|
||||||
<p class="text-[11px] site-muted">ID (이메일)</p>
|
</span>
|
||||||
<p class="mt-1 truncate text-sm font-semibold">
|
|
||||||
{{ profileForm.email }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<input
|
</section>
|
||||||
ref="avatarInputRef"
|
</div>
|
||||||
type="file"
|
|
||||||
accept="image/jpeg,image/png,image/webp,image/gif"
|
|
||||||
class="hidden"
|
|
||||||
:disabled="uploadingAvatar"
|
|
||||||
@change="uploadAvatar"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="site-accent-button w-fit rounded-[10px] px-3 py-1.5 text-xs font-semibold disabled:opacity-60"
|
|
||||||
:disabled="savingProfile"
|
|
||||||
@click="saveProfile"
|
|
||||||
>
|
|
||||||
{{ savingProfile ? '저장 중...' : '프로필 저장' }}
|
|
||||||
</button>
|
|
||||||
<p v-if="profileMessage" class="text-xs site-muted">
|
|
||||||
{{ profileMessage }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="rounded-[10px] border border-[var(--site-line)] bg-[var(--site-bg)] p-4">
|
|
||||||
<h2 class="text-sm font-semibold">비밀번호 변경</h2>
|
|
||||||
<div class="mt-3 flex flex-col gap-3">
|
|
||||||
<input
|
|
||||||
v-model="passwordForm.currentPassword"
|
|
||||||
type="password"
|
|
||||||
class="h-10 rounded-[8px] border border-[var(--site-line)] bg-transparent px-3 text-sm outline-none focus-visible:border-[var(--site-accent)]"
|
|
||||||
placeholder="현재 비밀번호"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
v-model="passwordForm.nextPassword"
|
|
||||||
type="password"
|
|
||||||
class="h-10 rounded-[8px] border border-[var(--site-line)] bg-transparent px-3 text-sm outline-none focus-visible:border-[var(--site-accent)]"
|
|
||||||
placeholder="새 비밀번호"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
v-model="passwordForm.nextPasswordConfirm"
|
|
||||||
type="password"
|
|
||||||
class="h-10 rounded-[8px] border border-[var(--site-line)] bg-transparent px-3 text-sm outline-none focus-visible:border-[var(--site-accent)]"
|
|
||||||
placeholder="새 비밀번호 확인"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="site-accent-button mt-1 w-fit rounded-[10px] px-3 py-1.5 text-xs font-semibold disabled:opacity-60"
|
|
||||||
:disabled="savingPassword"
|
|
||||||
@click="savePassword"
|
|
||||||
>
|
|
||||||
{{ savingPassword ? '변경 중...' : '비밀번호 변경' }}
|
|
||||||
</button>
|
|
||||||
<p v-if="passwordMessage" class="text-xs site-muted">
|
|
||||||
{{ passwordMessage }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="rounded-[10px] border border-[var(--site-line)] bg-[var(--site-bg)] p-4">
|
|
||||||
<h2 class="text-sm font-semibold text-red-500/70">회원 탈퇴</h2>
|
|
||||||
<p class="mt-2 text-xs site-muted">
|
|
||||||
탈퇴 시 작성한 댓글과 계정 정보가 삭제됩니다.
|
|
||||||
</p>
|
|
||||||
<input
|
|
||||||
v-model="deleteForm.password"
|
|
||||||
type="password"
|
|
||||||
class="mt-3 h-10 w-full rounded-[8px] border border-[var(--site-line)] bg-transparent px-3 text-sm outline-none focus-visible:border-red-400"
|
|
||||||
placeholder="비밀번호 확인"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="mt-3 rounded-[10px] border border-red-400/40 px-3 py-1.5 text-xs text-red-500/70 transition-opacity hover:opacity-80 disabled:opacity-50"
|
|
||||||
:disabled="deletingAccount"
|
|
||||||
@click="removeAccount"
|
|
||||||
>
|
|
||||||
{{ deletingAccount ? '처리 중...' : '회원 탈퇴' }}
|
|
||||||
</button>
|
|
||||||
<p v-if="deleteMessage" class="mt-2 text-xs site-muted">
|
|
||||||
{{ deleteMessage }}
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
</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>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -29,8 +29,8 @@ const tagPosts = computed(() => posts.value
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<MainColumn>
|
<MainColumn>
|
||||||
<section class="pt-4 sm:pt-5">
|
<section class="tag-posts-header site-section">
|
||||||
<div class="mx-auto flex max-w-[720px] flex-col gap-4 border-b border-[var(--site-line)] pb-4 sm:pb-5">
|
<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">
|
<div class="flex flex-col gap-1">
|
||||||
<h1 class="text-lg font-medium leading-tight sm:text-xl">
|
<h1 class="text-lg font-medium leading-tight sm:text-xl">
|
||||||
{{ tag?.name || slug.toUpperCase() }}
|
{{ tag?.name || slug.toUpperCase() }}
|
||||||
@@ -42,93 +42,95 @@ const tagPosts = computed(() => posts.value
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section>
|
<section class="tag-posts-list">
|
||||||
<div class="mx-auto flex max-w-[720px] flex-col gap-8">
|
<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
|
<article
|
||||||
v-for="post in tagPosts"
|
v-for="post in tagPosts"
|
||||||
:key="post.to"
|
: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
|
<div class="tag-posts-list__body site-section-body flex flex-row gap-3">
|
||||||
:to="post.to"
|
<NuxtLink
|
||||||
class="relative aspect-square min-w-16 flex-1 sm:aspect-video"
|
: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"
|
|
||||||
>
|
>
|
||||||
<svg
|
<figure class="overflow-hidden rounded-[10px]">
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
<img
|
||||||
viewBox="0 0 24 24"
|
v-if="post.featuredImage"
|
||||||
fill="none"
|
:src="post.featuredImage"
|
||||||
stroke="currentColor"
|
:alt="post.title"
|
||||||
stroke-width="2"
|
class="aspect-square w-full rounded-[inherit] object-cover transition-opacity duration-200 group-hover:opacity-90 sm:aspect-video"
|
||||||
stroke-linecap="round"
|
loading="lazy"
|
||||||
stroke-linejoin="round"
|
>
|
||||||
class="h-4 w-4"
|
<div
|
||||||
|
v-else
|
||||||
|
class="aspect-square w-full rounded-[inherit] bg-[linear-gradient(135deg,#253444,#8f9dad)] sm:aspect-video"
|
||||||
|
/>
|
||||||
|
</figure>
|
||||||
|
</NuxtLink>
|
||||||
|
|
||||||
|
<div class="relative flex-[3] md:flex-[4]">
|
||||||
|
<div class="flex h-full flex-col gap-1.5">
|
||||||
|
<h2 class="max-w-[90%] text-sm font-medium leading-tight">
|
||||||
|
<NuxtLink :to="post.to" class="flex items-center transition-opacity duration-200 hover:opacity-75">
|
||||||
|
<span v-if="post.isFeatured" class="post-feed__featured-icon mr-1 inline-flex h-4 w-4 items-center justify-center text-[var(--site-accent)]">
|
||||||
|
<svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M13 3v7h6l-8 11v-7H5l8-11" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
{{ post.title }}
|
||||||
|
</NuxtLink>
|
||||||
|
</h2>
|
||||||
|
<p class="flex-1 line-clamp-2 text-[0.8rem] leading-tight site-muted">
|
||||||
|
{{ post.excerpt }}
|
||||||
|
</p>
|
||||||
|
<div class="flex flex-wrap items-center gap-2 text-xs site-muted sm:gap-1.5">
|
||||||
|
<time v-if="post.publishedAt" :datetime="post.publishedAtIso">{{ post.publishedAt }}</time>
|
||||||
|
<span class="text-[var(--site-line)]">/</span>
|
||||||
|
<span
|
||||||
|
class="rounded-md px-1.5 py-px font-medium text-[var(--site-text)]"
|
||||||
|
:style="{ backgroundColor: `${post.tagColor}1a` }"
|
||||||
|
>
|
||||||
|
{{ post.tag }}
|
||||||
|
</span>
|
||||||
|
<span class="text-[var(--site-line)]">/</span>
|
||||||
|
<span class="flex items-center gap-0.75">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="-mt-px">
|
||||||
|
<path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z" />
|
||||||
|
</svg>
|
||||||
|
<span>0</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="absolute top-0 right-0 flex cursor-pointer items-center gap-1 hover:opacity-75 md:top-auto md:right-0 md:bottom-0 md:invisible md:opacity-0 md:transition-[opacity,visibility] md:duration-200 md:group-hover:visible md:group-hover:opacity-100"
|
||||||
|
type="button"
|
||||||
|
aria-label="Share this post"
|
||||||
>
|
>
|
||||||
<path d="M17 7 7 17" />
|
<svg
|
||||||
<path d="M8 7h9v9" />
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
</svg>
|
viewBox="0 0 24 24"
|
||||||
</button>
|
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>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section v-if="tagPosts.length === 0" class="py-4">
|
<section v-if="tagPosts.length === 0" class="tag-posts-empty site-section">
|
||||||
<div class="mx-auto max-w-[720px] text-sm site-muted">
|
<div class="tag-posts-empty__body site-section-body mx-auto max-w-[720px] text-sm site-muted">
|
||||||
이 태그에 연결된 글이 없습니다.
|
이 태그에 연결된 글이 없습니다.
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
72
scripts/check-js-syntax.js
Normal file
72
scripts/check-js-syntax.js
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import { readdirSync, statSync } from 'node:fs'
|
||||||
|
import { join, relative } from 'node:path'
|
||||||
|
import { spawnSync } from 'node:child_process'
|
||||||
|
|
||||||
|
const ignoredDirectories = new Set([
|
||||||
|
'.git',
|
||||||
|
'.nuxt',
|
||||||
|
'.output',
|
||||||
|
'node_modules'
|
||||||
|
])
|
||||||
|
|
||||||
|
const checkedExtensions = new Set(['.js', '.mjs', '.cjs'])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 점검 대상 JavaScript 파일인지 확인한다.
|
||||||
|
* @param {string} filePath - 파일 경로
|
||||||
|
* @returns {boolean} 점검 대상 여부
|
||||||
|
*/
|
||||||
|
const isCheckedFile = (filePath) => {
|
||||||
|
const extension = filePath.slice(filePath.lastIndexOf('.'))
|
||||||
|
return checkedExtensions.has(extension)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 디렉터리에서 JavaScript 파일 목록을 재귀적으로 수집한다.
|
||||||
|
* @param {string} directory - 탐색할 디렉터리
|
||||||
|
* @returns {string[]} JavaScript 파일 경로 목록
|
||||||
|
*/
|
||||||
|
const collectJavaScriptFiles = (directory) => {
|
||||||
|
const entries = readdirSync(directory)
|
||||||
|
const files = []
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (ignoredDirectories.has(entry)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const fullPath = join(directory, entry)
|
||||||
|
const stat = statSync(fullPath)
|
||||||
|
|
||||||
|
if (stat.isDirectory()) {
|
||||||
|
files.push(...collectJavaScriptFiles(fullPath))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stat.isFile() && isCheckedFile(fullPath)) {
|
||||||
|
files.push(fullPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return files
|
||||||
|
}
|
||||||
|
|
||||||
|
const rootDirectory = process.cwd()
|
||||||
|
const files = collectJavaScriptFiles(rootDirectory)
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
const result = spawnSync(process.execPath, ['--check', file], {
|
||||||
|
encoding: 'utf8'
|
||||||
|
})
|
||||||
|
|
||||||
|
if (result.status !== 0) {
|
||||||
|
const displayPath = relative(rootDirectory, file)
|
||||||
|
process.stderr.write(`JavaScript 문법 오류: ${displayPath}\n`)
|
||||||
|
if (result.stderr) {
|
||||||
|
process.stderr.write(result.stderr)
|
||||||
|
}
|
||||||
|
process.exit(result.status || 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
process.stdout.write(`JavaScript 문법 점검 완료: ${files.length}개 파일\n`)
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import bcrypt from 'bcrypt'
|
import bcrypt from 'bcrypt'
|
||||||
import { createError, readBody } from 'h3'
|
import { createError, readBody } from 'h3'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { deleteMember, getUserByIdWithPassword } from '../../repositories/member-repository'
|
import { countOwnerMembers, deleteMember, getUserByIdWithPassword, MEMBER_ROLE } from '../../repositories/member-repository'
|
||||||
|
import { clearAdminSession } from '../../utils/admin-auth'
|
||||||
import { clearMemberSession, requireMemberSession } from '../../utils/member-auth'
|
import { clearMemberSession, requireMemberSession } from '../../utils/member-auth'
|
||||||
import { removeManagedAvatarAsset } from '../../utils/member-avatar'
|
import { removeManagedAvatarAsset } from '../../utils/member-avatar'
|
||||||
|
|
||||||
@@ -41,13 +42,20 @@ export default defineEventHandler(async (event) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (user.role === MEMBER_ROLE.OWNER && (await countOwnerMembers()) <= 1) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
message: '최소 1명의 소유자 권한은 유지되어야 합니다.'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
if (user.avatarUrl) {
|
if (user.avatarUrl) {
|
||||||
await removeManagedAvatarAsset(user.avatarUrl)
|
await removeManagedAvatarAsset(user.avatarUrl)
|
||||||
}
|
}
|
||||||
|
|
||||||
await deleteMember(session.userId)
|
await deleteMember(session.userId)
|
||||||
clearMemberSession(event)
|
clearMemberSession(event)
|
||||||
|
clearAdminSession(event)
|
||||||
|
|
||||||
return { ok: true }
|
return { ok: true }
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ import {
|
|||||||
countOtpSendsLastHour,
|
countOtpSendsLastHour,
|
||||||
hasRecentOtpSend,
|
hasRecentOtpSend,
|
||||||
insertOtpChallenge,
|
insertOtpChallenge,
|
||||||
invalidatePendingOtpChallenges
|
deleteOtpChallengeById,
|
||||||
|
invalidatePendingOtpChallenges,
|
||||||
|
invalidatePendingOtpChallengesExcept
|
||||||
} from '../../../repositories/email-otp-repository'
|
} from '../../../repositories/email-otp-repository'
|
||||||
import { generateSixDigitOtp, hashOtpCode, normalizeOtpEmail } from '../../../utils/email-otp'
|
import { generateSixDigitOtp, hashOtpCode, normalizeOtpEmail } from '../../../utils/email-otp'
|
||||||
import { isResendConfigured, sendResendEmail } from '../../../utils/resend-mail'
|
import { isResendConfigured, sendResendEmail } from '../../../utils/resend-mail'
|
||||||
@@ -129,8 +131,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
const expiresAt = new Date(Date.now() + OTP_TTL_MS)
|
const expiresAt = new Date(Date.now() + OTP_TTL_MS)
|
||||||
const createdIp = String(getRequestIP(event) || '')
|
const createdIp = String(getRequestIP(event) || '')
|
||||||
|
|
||||||
await invalidatePendingOtpChallenges(sql, email, purpose)
|
const challengeId = await insertOtpChallenge(sql, {
|
||||||
await insertOtpChallenge(sql, {
|
|
||||||
email,
|
email,
|
||||||
purpose,
|
purpose,
|
||||||
codeHash,
|
codeHash,
|
||||||
@@ -147,13 +148,20 @@ export default defineEventHandler(async (event) => {
|
|||||||
? `<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>`
|
||||||
: `<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>`
|
||||||
|
|
||||||
await sendResendEmail({
|
try {
|
||||||
apiKey: String(config.resendApiKey).trim(),
|
await sendResendEmail({
|
||||||
from: String(config.resendFromEmail).trim(),
|
apiKey: String(config.resendApiKey).trim(),
|
||||||
to: email,
|
from: String(config.resendFromEmail).trim(),
|
||||||
subject,
|
to: email,
|
||||||
html
|
subject,
|
||||||
})
|
html
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
await deleteOtpChallengeById(sql, challengeId)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
await invalidatePendingOtpChallengesExcept(sql, email, purpose, challengeId)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { getUserById, touchUserActivity } from '../../repositories/member-repository'
|
import { getUserById } from '../../repositories/member-repository'
|
||||||
import { requireMemberSession } from '../../utils/member-auth'
|
import { requireMemberSession } from '../../utils/member-auth'
|
||||||
import { getRequestIP } from 'h3'
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 회원 세션 조회 API
|
* 회원 세션 조회 API
|
||||||
@@ -9,10 +8,6 @@ import { getRequestIP } from 'h3'
|
|||||||
*/
|
*/
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
const session = requireMemberSession(event)
|
const session = requireMemberSession(event)
|
||||||
await touchUserActivity({
|
|
||||||
userId: session.userId,
|
|
||||||
ip: String(getRequestIP(event) || '')
|
|
||||||
})
|
|
||||||
const user = await getUserById(session.userId)
|
const user = await getUserById(session.userId)
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
@@ -31,4 +26,3 @@ export default defineEventHandler(async (event) => {
|
|||||||
avatarUrl: user.avatarUrl || ''
|
avatarUrl: user.avatarUrl || ''
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
import { getUserById } from '../../repositories/member-repository'
|
import { getUserProfileById } from '../../repositories/member-repository'
|
||||||
import { requireMemberSession } from '../../utils/member-auth'
|
import { requireMemberSession } from '../../utils/member-auth'
|
||||||
import { createError } from 'h3'
|
import { createError } from 'h3'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 회원 프로필 조회 API
|
* 회원 프로필 조회 API
|
||||||
* @param {import('h3').H3Event} event - 요청 이벤트
|
* @param {import('h3').H3Event} event - 요청 이벤트
|
||||||
* @returns {Promise<{ id: string, email: string, username: string, avatarUrl: string }>} 회원 프로필
|
* @returns {Promise<{ id: string, email: string, username: string, avatarUrl: string, createdAt: string, updatedAt: string, lastSeenAt: string | null, previousLastSeenAt: string | null, previousLastSeenIp: string, commentCount: number }>} 회원 프로필
|
||||||
*/
|
*/
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
const session = requireMemberSession(event)
|
const session = requireMemberSession(event)
|
||||||
const user = await getUserById(session.userId)
|
const user = await getUserProfileById(session.userId)
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
throw createError({
|
throw createError({
|
||||||
@@ -22,7 +22,12 @@ export default defineEventHandler(async (event) => {
|
|||||||
id: user.id,
|
id: user.id,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
username: user.username,
|
username: user.username,
|
||||||
avatarUrl: user.avatarUrl || ''
|
avatarUrl: user.avatarUrl || '',
|
||||||
|
createdAt: user.createdAt.toISOString(),
|
||||||
|
updatedAt: user.updatedAt.toISOString(),
|
||||||
|
lastSeenAt: user.lastSeenAt ? user.lastSeenAt.toISOString() : null,
|
||||||
|
previousLastSeenAt: user.previousLastSeenAt ? user.previousLastSeenAt.toISOString() : null,
|
||||||
|
previousLastSeenIp: user.previousLastSeenIp || '',
|
||||||
|
commentCount: Number(user.commentCount || 0)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
34
server/middleware/admin-api-session.js
Normal file
34
server/middleware/admin-api-session.js
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { createError, getRequestURL } from 'h3'
|
||||||
|
import { getAdminSession } from '../utils/admin-auth'
|
||||||
|
import { isPrivilegedMember } from '../repositories/member-repository'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 관리자 API 요청마다 현재 DB 권한을 다시 확인한다.
|
||||||
|
* @param {import('h3').H3Event} event - 요청 이벤트
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const pathname = getRequestURL(event).pathname
|
||||||
|
|
||||||
|
if (!pathname.startsWith('/admin/api/') || pathname === '/admin/api/auth/login' || pathname === '/admin/api/auth/logout') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = getAdminSession(event)
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 401,
|
||||||
|
message: '관리자 로그인이 필요합니다.'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const stillPrivileged = await isPrivilegedMember(session.userId)
|
||||||
|
|
||||||
|
if (!stillPrivileged) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 403,
|
||||||
|
message: '현재 관리자 권한이 없습니다. 다시 로그인해 주세요.'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -74,6 +74,8 @@ const mapSiteSettingsRow = (row) => ({
|
|||||||
description: row.description,
|
description: row.description,
|
||||||
siteUrl: row.site_url,
|
siteUrl: row.site_url,
|
||||||
logoText: row.logo_text,
|
logoText: row.logo_text,
|
||||||
|
logoUrl: row.logo_url || '',
|
||||||
|
faviconUrl: row.favicon_url || '',
|
||||||
copyrightText: row.copyright_text,
|
copyrightText: row.copyright_text,
|
||||||
updatedAt: row.updated_at.toISOString()
|
updatedAt: row.updated_at.toISOString()
|
||||||
})
|
})
|
||||||
@@ -749,6 +751,8 @@ export const updateSiteSettings = async (input) => {
|
|||||||
description,
|
description,
|
||||||
site_url,
|
site_url,
|
||||||
logo_text,
|
logo_text,
|
||||||
|
logo_url,
|
||||||
|
favicon_url,
|
||||||
copyright_text,
|
copyright_text,
|
||||||
updated_at
|
updated_at
|
||||||
)
|
)
|
||||||
@@ -757,7 +761,9 @@ export const updateSiteSettings = async (input) => {
|
|||||||
${input.title},
|
${input.title},
|
||||||
${input.description},
|
${input.description},
|
||||||
${input.siteUrl},
|
${input.siteUrl},
|
||||||
${input.logoText},
|
${input.logoText || '井'},
|
||||||
|
${input.logoUrl || ''},
|
||||||
|
${input.faviconUrl || ''},
|
||||||
${input.copyrightText},
|
${input.copyrightText},
|
||||||
now()
|
now()
|
||||||
)
|
)
|
||||||
@@ -767,6 +773,8 @@ export const updateSiteSettings = async (input) => {
|
|||||||
description = EXCLUDED.description,
|
description = EXCLUDED.description,
|
||||||
site_url = EXCLUDED.site_url,
|
site_url = EXCLUDED.site_url,
|
||||||
logo_text = EXCLUDED.logo_text,
|
logo_text = EXCLUDED.logo_text,
|
||||||
|
logo_url = EXCLUDED.logo_url,
|
||||||
|
favicon_url = EXCLUDED.favicon_url,
|
||||||
copyright_text = EXCLUDED.copyright_text,
|
copyright_text = EXCLUDED.copyright_text,
|
||||||
updated_at = now()
|
updated_at = now()
|
||||||
RETURNING *
|
RETURNING *
|
||||||
@@ -775,6 +783,42 @@ export const updateSiteSettings = async (input) => {
|
|||||||
return mapSiteSettingsRow(rows[0])
|
return mapSiteSettingsRow(rows[0])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 사이트 로고 URL을 수정한다.
|
||||||
|
* @param {{ logoUrl: string, faviconUrl: string }} input - 로고 URL
|
||||||
|
* @returns {Promise<Object>} 수정된 사이트 설정
|
||||||
|
*/
|
||||||
|
export const updateSiteLogo = async (input) => {
|
||||||
|
const sql = getPostgresClient()
|
||||||
|
|
||||||
|
if (!sql) {
|
||||||
|
throw new Error('DATABASE_REQUIRED')
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await sql`
|
||||||
|
INSERT INTO site_settings (
|
||||||
|
id,
|
||||||
|
logo_url,
|
||||||
|
favicon_url,
|
||||||
|
updated_at
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
1,
|
||||||
|
${input.logoUrl},
|
||||||
|
${input.faviconUrl},
|
||||||
|
now()
|
||||||
|
)
|
||||||
|
ON CONFLICT (id) DO UPDATE
|
||||||
|
SET
|
||||||
|
logo_url = EXCLUDED.logo_url,
|
||||||
|
favicon_url = EXCLUDED.favicon_url,
|
||||||
|
updated_at = now()
|
||||||
|
RETURNING *
|
||||||
|
`
|
||||||
|
|
||||||
|
return mapSiteSettingsRow(rows[0])
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 네비게이션 항목 목록 조회
|
* 네비게이션 항목 목록 조회
|
||||||
* @param {Object} options - 조회 옵션
|
* @param {Object} options - 조회 옵션
|
||||||
|
|||||||
@@ -37,6 +37,38 @@ export const invalidatePendingOtpChallenges = async (sql, email, purpose) => {
|
|||||||
`
|
`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 특정 OTP를 제외한 동일 이메일·용도의 미소진 OTP를 무효화한다.
|
||||||
|
* @param {import('postgres').TransactionSql} sql - sql 또는 트랜잭션
|
||||||
|
* @param {string} email - 정규화된 이메일
|
||||||
|
* @param {string} purpose - signup | password_reset
|
||||||
|
* @param {string} keepId - 유지할 챌린지 ID
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
export const invalidatePendingOtpChallengesExcept = async (sql, email, purpose, keepId) => {
|
||||||
|
await sql`
|
||||||
|
UPDATE email_otp_challenges
|
||||||
|
SET consumed_at = now()
|
||||||
|
WHERE lower(email) = lower(${email})
|
||||||
|
AND purpose = ${purpose}
|
||||||
|
AND id <> ${keepId}
|
||||||
|
AND consumed_at IS NULL
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 특정 OTP 챌린지를 삭제한다.
|
||||||
|
* @param {import('postgres').TransactionSql} sql - sql 또는 트랜잭션
|
||||||
|
* @param {string} id - 챌린지 ID
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
export const deleteOtpChallengeById = async (sql, id) => {
|
||||||
|
await sql`
|
||||||
|
DELETE FROM email_otp_challenges
|
||||||
|
WHERE id = ${id}
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 최근 짧은 시간 내 동일 이메일·용도 발송이 있는지 확인한다.
|
* 최근 짧은 시간 내 동일 이메일·용도 발송이 있는지 확인한다.
|
||||||
* @param {import('postgres').Sql} sql - sql
|
* @param {import('postgres').Sql} sql - sql
|
||||||
|
|||||||
@@ -9,6 +9,51 @@ export const MEMBER_ROLE = {
|
|||||||
|
|
||||||
const PRIVILEGED_ROLES = [MEMBER_ROLE.OWNER, MEMBER_ROLE.ADMIN]
|
const PRIVILEGED_ROLES = [MEMBER_ROLE.OWNER, MEMBER_ROLE.ADMIN]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 회원 권한 표시 문자열을 반환한다.
|
||||||
|
* @param {string} roleCode - 권한 코드
|
||||||
|
* @returns {string} 권한 표시 문자열
|
||||||
|
*/
|
||||||
|
const getMemberRoleLabel = (roleCode) => roleCode === MEMBER_ROLE.OWNER
|
||||||
|
? '소유자'
|
||||||
|
: roleCode === MEMBER_ROLE.ADMIN
|
||||||
|
? '관리자'
|
||||||
|
: '멤버'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 관리자 회원 행을 응답 객체로 변환한다.
|
||||||
|
* @param {Object} row - DB 회원 행
|
||||||
|
* @returns {Object} 관리자 회원 응답
|
||||||
|
*/
|
||||||
|
const mapAdminMemberRow = (row) => {
|
||||||
|
const lastSeenAt = row.lastSeenAt ? row.lastSeenAt.toISOString() : null
|
||||||
|
const previousLastSeenAt = row.previousLastSeenAt ? row.previousLastSeenAt.toISOString() : null
|
||||||
|
const isActive = row.lastSeenAt
|
||||||
|
? Date.now() - new Date(row.lastSeenAt).getTime() <= 1000 * 60 * 60 * 24 * 30
|
||||||
|
: false
|
||||||
|
const roleCode = String(row.roleCode || MEMBER_ROLE.MEMBER)
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
username: row.username,
|
||||||
|
email: row.email,
|
||||||
|
avatarUrl: row.avatarUrl || '',
|
||||||
|
labels: Array.isArray(row.labels) ? row.labels : [],
|
||||||
|
note: row.note || '',
|
||||||
|
isAdmin: Boolean(row.isAdmin),
|
||||||
|
roleCode,
|
||||||
|
createdAt: row.createdAt.toISOString(),
|
||||||
|
updatedAt: row.updatedAt.toISOString(),
|
||||||
|
lastSeenAt,
|
||||||
|
lastSeenIp: row.lastSeenIp || '',
|
||||||
|
previousLastSeenAt,
|
||||||
|
previousLastSeenIp: row.previousLastSeenIp || '',
|
||||||
|
commentCount: Number(row.commentCount || 0),
|
||||||
|
activityStatus: isActive ? '활성' : '비활성',
|
||||||
|
role: getMemberRoleLabel(roleCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @typedef {Object} MemberUser
|
* @typedef {Object} MemberUser
|
||||||
* @property {string} id - 사용자 ID
|
* @property {string} id - 사용자 ID
|
||||||
@@ -22,6 +67,8 @@ const PRIVILEGED_ROLES = [MEMBER_ROLE.OWNER, MEMBER_ROLE.ADMIN]
|
|||||||
* @property {string} updatedAt - 수정 시각(ISO)
|
* @property {string} updatedAt - 수정 시각(ISO)
|
||||||
* @property {string | null} lastSeenAt - 최근 접속 시각(ISO)
|
* @property {string | null} lastSeenAt - 최근 접속 시각(ISO)
|
||||||
* @property {string} lastSeenIp - 최근 접속 IP
|
* @property {string} lastSeenIp - 최근 접속 IP
|
||||||
|
* @property {string | null} previousLastSeenAt - 이전 로그인 시각(ISO)
|
||||||
|
* @property {string} previousLastSeenIp - 이전 로그인 IP
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -58,7 +105,9 @@ export const getUserByEmail = async (email) => {
|
|||||||
created_at AS "createdAt",
|
created_at AS "createdAt",
|
||||||
updated_at AS "updatedAt",
|
updated_at AS "updatedAt",
|
||||||
last_seen_at AS "lastSeenAt",
|
last_seen_at AS "lastSeenAt",
|
||||||
last_seen_ip AS "lastSeenIp"
|
last_seen_ip AS "lastSeenIp",
|
||||||
|
previous_last_seen_at AS "previousLastSeenAt",
|
||||||
|
previous_last_seen_ip AS "previousLastSeenIp"
|
||||||
FROM users
|
FROM users
|
||||||
WHERE lower(email) = lower(${email})
|
WHERE lower(email) = lower(${email})
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
@@ -85,7 +134,9 @@ export const getUserById = async (id) => {
|
|||||||
created_at AS "createdAt",
|
created_at AS "createdAt",
|
||||||
updated_at AS "updatedAt",
|
updated_at AS "updatedAt",
|
||||||
last_seen_at AS "lastSeenAt",
|
last_seen_at AS "lastSeenAt",
|
||||||
last_seen_ip AS "lastSeenIp"
|
last_seen_ip AS "lastSeenIp",
|
||||||
|
previous_last_seen_at AS "previousLastSeenAt",
|
||||||
|
previous_last_seen_ip AS "previousLastSeenIp"
|
||||||
FROM users
|
FROM users
|
||||||
WHERE id = ${id}
|
WHERE id = ${id}
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
@@ -94,6 +145,38 @@ export const getUserById = async (id) => {
|
|||||||
return rows?.[0] || null
|
return rows?.[0] || null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 사용자 설정 화면용 회원 프로필을 조회한다.
|
||||||
|
* @param {string} id - 사용자 ID
|
||||||
|
* @returns {Promise<(Omit<MemberUser, 'passwordHash'> & { commentCount: number }) | null>} 회원 프로필
|
||||||
|
*/
|
||||||
|
export const getUserProfileById = async (id) => {
|
||||||
|
const sql = requireSql()
|
||||||
|
const rows = await sql`
|
||||||
|
SELECT
|
||||||
|
users.id,
|
||||||
|
users.username,
|
||||||
|
users.email,
|
||||||
|
users.avatar_url AS "avatarUrl",
|
||||||
|
users.is_admin AS "isAdmin",
|
||||||
|
users.user_role AS "role",
|
||||||
|
users.created_at AS "createdAt",
|
||||||
|
users.updated_at AS "updatedAt",
|
||||||
|
users.last_seen_at AS "lastSeenAt",
|
||||||
|
users.last_seen_ip AS "lastSeenIp",
|
||||||
|
users.previous_last_seen_at AS "previousLastSeenAt",
|
||||||
|
users.previous_last_seen_ip AS "previousLastSeenIp",
|
||||||
|
COALESCE(count(comments.id), 0)::int AS "commentCount"
|
||||||
|
FROM users
|
||||||
|
LEFT JOIN comments ON comments.user_id = users.id AND comments.status = 'published'
|
||||||
|
WHERE users.id = ${id}
|
||||||
|
GROUP BY users.id
|
||||||
|
LIMIT 1
|
||||||
|
`
|
||||||
|
|
||||||
|
return rows?.[0] || null
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ID로 회원 조회(비밀번호 포함)
|
* ID로 회원 조회(비밀번호 포함)
|
||||||
* @param {string} id - 사용자 ID
|
* @param {string} id - 사용자 ID
|
||||||
@@ -113,7 +196,9 @@ export const getUserByIdWithPassword = async (id) => {
|
|||||||
created_at AS "createdAt",
|
created_at AS "createdAt",
|
||||||
updated_at AS "updatedAt",
|
updated_at AS "updatedAt",
|
||||||
last_seen_at AS "lastSeenAt",
|
last_seen_at AS "lastSeenAt",
|
||||||
last_seen_ip AS "lastSeenIp"
|
last_seen_ip AS "lastSeenIp",
|
||||||
|
previous_last_seen_at AS "previousLastSeenAt",
|
||||||
|
previous_last_seen_ip AS "previousLastSeenIp"
|
||||||
FROM users
|
FROM users
|
||||||
WHERE id = ${id}
|
WHERE id = ${id}
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
@@ -130,31 +215,37 @@ export const getUserByIdWithPassword = async (id) => {
|
|||||||
export const createUser = async (input) => {
|
export const createUser = async (input) => {
|
||||||
const sql = requireSql()
|
const sql = requireSql()
|
||||||
|
|
||||||
const rows = await sql`
|
const rows = await sql.begin(async (tx) => {
|
||||||
INSERT INTO users (username, email, password_hash, avatar_url, is_admin, user_role)
|
await tx`LOCK TABLE users IN SHARE ROW EXCLUSIVE MODE`
|
||||||
VALUES (
|
|
||||||
${input.username},
|
return tx`
|
||||||
${input.email},
|
INSERT INTO users (username, email, password_hash, avatar_url, is_admin, user_role)
|
||||||
${input.passwordHash},
|
VALUES (
|
||||||
'',
|
${input.username},
|
||||||
NOT EXISTS (SELECT 1 FROM users),
|
${input.email},
|
||||||
CASE
|
${input.passwordHash},
|
||||||
WHEN NOT EXISTS (SELECT 1 FROM users) THEN ${MEMBER_ROLE.OWNER}
|
'',
|
||||||
ELSE ${MEMBER_ROLE.MEMBER}
|
NOT EXISTS (SELECT 1 FROM users),
|
||||||
END
|
CASE
|
||||||
)
|
WHEN NOT EXISTS (SELECT 1 FROM users) THEN ${MEMBER_ROLE.OWNER}
|
||||||
RETURNING
|
ELSE ${MEMBER_ROLE.MEMBER}
|
||||||
id,
|
END
|
||||||
username,
|
)
|
||||||
email,
|
RETURNING
|
||||||
avatar_url AS "avatarUrl",
|
id,
|
||||||
is_admin AS "isAdmin",
|
username,
|
||||||
user_role AS "role",
|
email,
|
||||||
created_at AS "createdAt",
|
avatar_url AS "avatarUrl",
|
||||||
updated_at AS "updatedAt",
|
is_admin AS "isAdmin",
|
||||||
last_seen_at AS "lastSeenAt",
|
user_role AS "role",
|
||||||
last_seen_ip AS "lastSeenIp"
|
created_at AS "createdAt",
|
||||||
`
|
updated_at AS "updatedAt",
|
||||||
|
last_seen_at AS "lastSeenAt",
|
||||||
|
last_seen_ip AS "lastSeenIp",
|
||||||
|
previous_last_seen_at AS "previousLastSeenAt",
|
||||||
|
previous_last_seen_ip AS "previousLastSeenIp"
|
||||||
|
`
|
||||||
|
})
|
||||||
|
|
||||||
const created = rows?.[0]
|
const created = rows?.[0]
|
||||||
if (!created) {
|
if (!created) {
|
||||||
@@ -177,6 +268,8 @@ export const touchUserActivity = async (input) => {
|
|||||||
await sql`
|
await sql`
|
||||||
UPDATE users
|
UPDATE users
|
||||||
SET
|
SET
|
||||||
|
previous_last_seen_at = last_seen_at,
|
||||||
|
previous_last_seen_ip = last_seen_ip,
|
||||||
last_seen_at = now(),
|
last_seen_at = now(),
|
||||||
last_seen_ip = ${input.ip},
|
last_seen_ip = ${input.ip},
|
||||||
updated_at = now()
|
updated_at = now()
|
||||||
@@ -208,7 +301,9 @@ export const updateMemberProfile = async (input) => {
|
|||||||
created_at AS "createdAt",
|
created_at AS "createdAt",
|
||||||
updated_at AS "updatedAt",
|
updated_at AS "updatedAt",
|
||||||
last_seen_at AS "lastSeenAt",
|
last_seen_at AS "lastSeenAt",
|
||||||
last_seen_ip AS "lastSeenIp"
|
last_seen_ip AS "lastSeenIp",
|
||||||
|
previous_last_seen_at AS "previousLastSeenAt",
|
||||||
|
previous_last_seen_ip AS "previousLastSeenIp"
|
||||||
`
|
`
|
||||||
|
|
||||||
return rows?.[0] || null
|
return rows?.[0] || null
|
||||||
@@ -262,6 +357,53 @@ export const deleteMember = async (userId) => {
|
|||||||
`
|
`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 관리자 화면에서 회원을 삭제한다.
|
||||||
|
* @param {{ actorUserId: string, targetUserId: string }} input - 삭제 정보
|
||||||
|
* @returns {Promise<Object>} 삭제된 회원
|
||||||
|
*/
|
||||||
|
export const deleteMemberByAdmin = async (input) => {
|
||||||
|
const target = await getMemberForAdmin(input.targetUserId)
|
||||||
|
if (!target) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 404,
|
||||||
|
message: '회원을 찾을 수 없습니다.'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (target.id === input.actorUserId) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
message: '현재 로그인한 계정은 여기서 삭제할 수 없습니다.'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (target.roleCode === MEMBER_ROLE.OWNER && (await countOwnerMembers()) <= 1) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
message: '최소 1명의 소유자 권한은 유지되어야 합니다.'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
await deleteMember(input.targetUserId)
|
||||||
|
return target
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 소유자 권한 회원 수를 조회한다.
|
||||||
|
* @returns {Promise<number>} 소유자 회원 수
|
||||||
|
*/
|
||||||
|
export const countOwnerMembers = async () => {
|
||||||
|
const sql = requireSql()
|
||||||
|
const rows = await sql`
|
||||||
|
SELECT COUNT(*)::int AS "ownerCount"
|
||||||
|
FROM users
|
||||||
|
WHERE user_role = ${MEMBER_ROLE.OWNER}
|
||||||
|
`
|
||||||
|
|
||||||
|
return Number(rows?.[0]?.ownerCount || 0)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 사용자명 중복 확인
|
* 사용자명 중복 확인
|
||||||
* @param {{ username: string, excludeUserId?: string }} input - 사용자명과 제외 사용자 ID
|
* @param {{ username: string, excludeUserId?: string }} input - 사용자명과 제외 사용자 ID
|
||||||
@@ -287,9 +429,34 @@ export const isUsernameTaken = async (input) => {
|
|||||||
return Boolean(rows?.[0])
|
return Boolean(rows?.[0])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 이메일 중복 확인
|
||||||
|
* @param {{ email: string, excludeUserId?: string }} input - 이메일과 제외 사용자 ID
|
||||||
|
* @returns {Promise<boolean>} 중복 여부
|
||||||
|
*/
|
||||||
|
export const isEmailTaken = async (input) => {
|
||||||
|
const sql = requireSql()
|
||||||
|
const rows = input.excludeUserId
|
||||||
|
? await sql`
|
||||||
|
SELECT id
|
||||||
|
FROM users
|
||||||
|
WHERE lower(email) = lower(${input.email})
|
||||||
|
AND id <> ${input.excludeUserId}
|
||||||
|
LIMIT 1
|
||||||
|
`
|
||||||
|
: await sql`
|
||||||
|
SELECT id
|
||||||
|
FROM users
|
||||||
|
WHERE lower(email) = lower(${input.email})
|
||||||
|
LIMIT 1
|
||||||
|
`
|
||||||
|
|
||||||
|
return Boolean(rows?.[0])
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 관리자용 회원 목록 조회(댓글 활동 포함)
|
* 관리자용 회원 목록 조회(댓글 활동 포함)
|
||||||
* @returns {Promise<Array<{ id: string, username: string, email: string, avatarUrl: string, isAdmin: boolean, roleCode: string, createdAt: string, updatedAt: string, lastSeenAt: string | null, lastSeenIp: string, commentCount: number, activityStatus: string, role: string }>>} 회원 목록
|
* @returns {Promise<Array<{ id: string, username: string, email: string, avatarUrl: string, isAdmin: boolean, roleCode: string, createdAt: string, updatedAt: string, lastSeenAt: string | null, lastSeenIp: string, previousLastSeenAt: string | null, previousLastSeenIp: string, commentCount: number, activityStatus: string, role: string }>>} 회원 목록
|
||||||
*/
|
*/
|
||||||
export const listMembersForAdmin = async () => {
|
export const listMembersForAdmin = async () => {
|
||||||
const sql = requireSql()
|
const sql = requireSql()
|
||||||
@@ -299,12 +466,16 @@ export const listMembersForAdmin = async () => {
|
|||||||
users.username,
|
users.username,
|
||||||
users.email,
|
users.email,
|
||||||
users.avatar_url AS "avatarUrl",
|
users.avatar_url AS "avatarUrl",
|
||||||
|
users.member_labels AS "labels",
|
||||||
|
users.member_note AS "note",
|
||||||
users.is_admin AS "isAdmin",
|
users.is_admin AS "isAdmin",
|
||||||
users.user_role AS "roleCode",
|
users.user_role AS "roleCode",
|
||||||
users.created_at AS "createdAt",
|
users.created_at AS "createdAt",
|
||||||
users.updated_at AS "updatedAt",
|
users.updated_at AS "updatedAt",
|
||||||
users.last_seen_at AS "lastSeenAt",
|
users.last_seen_at AS "lastSeenAt",
|
||||||
users.last_seen_ip AS "lastSeenIp",
|
users.last_seen_ip AS "lastSeenIp",
|
||||||
|
users.previous_last_seen_at AS "previousLastSeenAt",
|
||||||
|
users.previous_last_seen_ip AS "previousLastSeenIp",
|
||||||
COALESCE(count(comments.id), 0)::int AS "commentCount"
|
COALESCE(count(comments.id), 0)::int AS "commentCount"
|
||||||
FROM users
|
FROM users
|
||||||
LEFT JOIN comments ON comments.user_id = users.id AND comments.status = 'published'
|
LEFT JOIN comments ON comments.user_id = users.id AND comments.status = 'published'
|
||||||
@@ -312,32 +483,106 @@ export const listMembersForAdmin = async () => {
|
|||||||
ORDER BY users.created_at DESC
|
ORDER BY users.created_at DESC
|
||||||
`
|
`
|
||||||
|
|
||||||
return rows.map((row) => {
|
return rows.map(mapAdminMemberRow)
|
||||||
const lastSeenAt = row.lastSeenAt ? row.lastSeenAt.toISOString() : null
|
}
|
||||||
const isActive = row.lastSeenAt
|
|
||||||
? Date.now() - new Date(row.lastSeenAt).getTime() <= 1000 * 60 * 60 * 24 * 30
|
|
||||||
: false
|
|
||||||
|
|
||||||
return {
|
/**
|
||||||
id: row.id,
|
* 관리자용 회원 상세 조회
|
||||||
username: row.username,
|
* @param {string} memberId - 회원 ID
|
||||||
email: row.email,
|
* @returns {Promise<Object | null>} 회원 상세
|
||||||
avatarUrl: row.avatarUrl || '',
|
*/
|
||||||
isAdmin: Boolean(row.isAdmin),
|
export const getMemberForAdmin = async (memberId) => {
|
||||||
roleCode: String(row.roleCode || MEMBER_ROLE.MEMBER),
|
const sql = requireSql()
|
||||||
createdAt: row.createdAt.toISOString(),
|
const rows = await sql`
|
||||||
updatedAt: row.updatedAt.toISOString(),
|
SELECT
|
||||||
lastSeenAt,
|
users.id,
|
||||||
lastSeenIp: row.lastSeenIp || '',
|
users.username,
|
||||||
commentCount: Number(row.commentCount || 0),
|
users.email,
|
||||||
activityStatus: isActive ? '활성' : '비활성',
|
users.avatar_url AS "avatarUrl",
|
||||||
role: row.roleCode === MEMBER_ROLE.OWNER
|
users.member_labels AS "labels",
|
||||||
? '소유자'
|
users.member_note AS "note",
|
||||||
: row.roleCode === MEMBER_ROLE.ADMIN
|
users.is_admin AS "isAdmin",
|
||||||
? '관리자'
|
users.user_role AS "roleCode",
|
||||||
: '멤버'
|
users.created_at AS "createdAt",
|
||||||
}
|
users.updated_at AS "updatedAt",
|
||||||
})
|
users.last_seen_at AS "lastSeenAt",
|
||||||
|
users.last_seen_ip AS "lastSeenIp",
|
||||||
|
users.previous_last_seen_at AS "previousLastSeenAt",
|
||||||
|
users.previous_last_seen_ip AS "previousLastSeenIp",
|
||||||
|
COALESCE(count(comments.id), 0)::int AS "commentCount"
|
||||||
|
FROM users
|
||||||
|
LEFT JOIN comments ON comments.user_id = users.id AND comments.status = 'published'
|
||||||
|
WHERE users.id = ${memberId}
|
||||||
|
GROUP BY users.id
|
||||||
|
LIMIT 1
|
||||||
|
`
|
||||||
|
|
||||||
|
return rows?.[0] ? mapAdminMemberRow(rows[0]) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 관리자 화면에서 회원을 생성한다.
|
||||||
|
* @param {{ username: string, email: string, passwordHash: string, avatarUrl: string, labels: string[], note: string }} input - 생성 값
|
||||||
|
* @returns {Promise<Object>} 생성된 회원
|
||||||
|
*/
|
||||||
|
export const createMemberByAdmin = async (input) => {
|
||||||
|
const sql = requireSql()
|
||||||
|
const rows = await sql`
|
||||||
|
INSERT INTO users (
|
||||||
|
username,
|
||||||
|
email,
|
||||||
|
password_hash,
|
||||||
|
avatar_url,
|
||||||
|
member_labels,
|
||||||
|
member_note,
|
||||||
|
is_admin,
|
||||||
|
user_role
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
${input.username},
|
||||||
|
${input.email},
|
||||||
|
${input.passwordHash},
|
||||||
|
${input.avatarUrl},
|
||||||
|
${input.labels},
|
||||||
|
${input.note},
|
||||||
|
false,
|
||||||
|
${MEMBER_ROLE.MEMBER}
|
||||||
|
)
|
||||||
|
RETURNING id
|
||||||
|
`
|
||||||
|
|
||||||
|
const created = rows?.[0]
|
||||||
|
if (!created) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 500,
|
||||||
|
message: '회원 생성에 실패했습니다.'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return getMemberForAdmin(created.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 관리자 화면에서 회원 기본 정보를 수정한다.
|
||||||
|
* @param {{ memberId: string, username: string, email: string, avatarUrl: string, labels: string[], note: string }} input - 수정 값
|
||||||
|
* @returns {Promise<Object | null>} 수정된 회원
|
||||||
|
*/
|
||||||
|
export const updateMemberByAdmin = async (input) => {
|
||||||
|
const sql = requireSql()
|
||||||
|
const rows = await sql`
|
||||||
|
UPDATE users
|
||||||
|
SET
|
||||||
|
username = ${input.username},
|
||||||
|
email = ${input.email},
|
||||||
|
avatar_url = ${input.avatarUrl},
|
||||||
|
member_labels = ${input.labels},
|
||||||
|
member_note = ${input.note},
|
||||||
|
updated_at = now()
|
||||||
|
WHERE id = ${input.memberId}
|
||||||
|
RETURNING id
|
||||||
|
`
|
||||||
|
|
||||||
|
return rows?.[0] ? getMemberForAdmin(rows[0].id) : null
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -359,7 +604,9 @@ export const getAdminUserByEmail = async (email) => {
|
|||||||
created_at AS "createdAt",
|
created_at AS "createdAt",
|
||||||
updated_at AS "updatedAt",
|
updated_at AS "updatedAt",
|
||||||
last_seen_at AS "lastSeenAt",
|
last_seen_at AS "lastSeenAt",
|
||||||
last_seen_ip AS "lastSeenIp"
|
last_seen_ip AS "lastSeenIp",
|
||||||
|
previous_last_seen_at AS "previousLastSeenAt",
|
||||||
|
previous_last_seen_ip AS "previousLastSeenIp"
|
||||||
FROM users
|
FROM users
|
||||||
WHERE lower(email) = lower(${email})
|
WHERE lower(email) = lower(${email})
|
||||||
AND user_role = ANY(${PRIVILEGED_ROLES})
|
AND user_role = ANY(${PRIVILEGED_ROLES})
|
||||||
@@ -500,4 +747,3 @@ export const updateMemberRoleByAdmin = async (input) => {
|
|||||||
isAdmin: Boolean(updated.isAdmin)
|
isAdmin: Boolean(updated.isAdmin)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,12 @@ import postgres from 'postgres'
|
|||||||
|
|
||||||
let client = null
|
let client = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 현재 실행 환경이 운영인지 확인한다.
|
||||||
|
* @returns {boolean} 운영 환경 여부
|
||||||
|
*/
|
||||||
|
const isProductionRuntime = () => process.env.NODE_ENV === 'production'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PostgreSQL 클라이언트 조회
|
* PostgreSQL 클라이언트 조회
|
||||||
* @returns {ReturnType<typeof postgres> | null} PostgreSQL 클라이언트
|
* @returns {ReturnType<typeof postgres> | null} PostgreSQL 클라이언트
|
||||||
@@ -10,6 +16,10 @@ export const getPostgresClient = () => {
|
|||||||
const config = useRuntimeConfig()
|
const config = useRuntimeConfig()
|
||||||
|
|
||||||
if (!config.databaseUrl) {
|
if (!config.databaseUrl) {
|
||||||
|
if (isProductionRuntime()) {
|
||||||
|
throw new Error('DATABASE_URL_REQUIRED')
|
||||||
|
}
|
||||||
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { createError, readBody } from 'h3'
|
import { createError, getRequestIP, readBody } from 'h3'
|
||||||
import bcrypt from 'bcrypt'
|
import bcrypt from 'bcrypt'
|
||||||
import { setAdminSession } from '../../../../utils/admin-auth'
|
import { setAdminSession } from '../../../../utils/admin-auth'
|
||||||
import { getAdminUserByEmail } from '../../../../repositories/member-repository'
|
import { getAdminUserByEmail, touchUserActivity } from '../../../../repositories/member-repository'
|
||||||
import { setMemberSession } from '../../../../utils/member-auth'
|
import { setMemberSession } from '../../../../utils/member-auth'
|
||||||
|
|
||||||
const loginSchema = z.object({
|
const loginSchema = z.object({
|
||||||
@@ -47,6 +47,10 @@ export default defineEventHandler(async (event) => {
|
|||||||
userId: adminUser.id,
|
userId: adminUser.id,
|
||||||
email: adminUser.email
|
email: adminUser.email
|
||||||
})
|
})
|
||||||
|
await touchUserActivity({
|
||||||
|
userId: adminUser.id,
|
||||||
|
ip: String(getRequestIP(event) || '')
|
||||||
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
userId: adminUser.id,
|
userId: adminUser.id,
|
||||||
|
|||||||
59
server/routes/admin/api/members.post.js
Normal file
59
server/routes/admin/api/members.post.js
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { randomBytes } from 'node:crypto'
|
||||||
|
import bcrypt from 'bcrypt'
|
||||||
|
import { createError, readBody } from 'h3'
|
||||||
|
import { z } from 'zod'
|
||||||
|
import { requireAdminSession } from '../../../utils/admin-auth'
|
||||||
|
import { createMemberByAdmin, isEmailTaken, isUsernameTaken } from '../../../repositories/member-repository'
|
||||||
|
|
||||||
|
const memberInputSchema = z.object({
|
||||||
|
username: z.string().trim().min(1).max(60),
|
||||||
|
email: z.string().trim().email().max(254),
|
||||||
|
avatarUrl: z.string().trim().max(500).optional().default(''),
|
||||||
|
labels: z.array(z.string().trim().min(1).max(40)).max(20).optional().default([]),
|
||||||
|
note: z.string().max(500).optional().default('')
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 관리자 회원 생성 API
|
||||||
|
* @param {import('h3').H3Event} event - 요청 이벤트
|
||||||
|
* @returns {Promise<Object>} 생성된 회원
|
||||||
|
*/
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
requireAdminSession(event)
|
||||||
|
const parsedBody = memberInputSchema.safeParse(await readBody(event))
|
||||||
|
|
||||||
|
if (!parsedBody.success) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
message: '회원 생성 요청 형식이 올바르지 않습니다.'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = parsedBody.data
|
||||||
|
const email = body.email.toLowerCase()
|
||||||
|
|
||||||
|
if (await isUsernameTaken({ username: body.username })) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 409,
|
||||||
|
message: '이미 사용 중인 이름입니다.'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (await isEmailTaken({ email })) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 409,
|
||||||
|
message: '이미 사용 중인 이메일입니다.'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const passwordHash = await bcrypt.hash(randomBytes(32).toString('hex'), 12)
|
||||||
|
|
||||||
|
return createMemberByAdmin({
|
||||||
|
username: body.username,
|
||||||
|
email,
|
||||||
|
passwordHash,
|
||||||
|
avatarUrl: body.avatarUrl,
|
||||||
|
labels: [...new Set(body.labels)],
|
||||||
|
note: body.note
|
||||||
|
})
|
||||||
|
})
|
||||||
32
server/routes/admin/api/members/[id].delete.js
Normal file
32
server/routes/admin/api/members/[id].delete.js
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { createError, getRouterParam } from 'h3'
|
||||||
|
import { requireAdminSession } from '../../../../utils/admin-auth'
|
||||||
|
import { deleteMemberByAdmin } from '../../../../repositories/member-repository'
|
||||||
|
import { removeManagedAvatarAsset } from '../../../../utils/member-avatar'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 관리자 회원 삭제 API
|
||||||
|
* @param {import('h3').H3Event} event - 요청 이벤트
|
||||||
|
* @returns {Promise<{ ok: true }>} 삭제 결과
|
||||||
|
*/
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const session = requireAdminSession(event)
|
||||||
|
const memberId = String(getRouterParam(event, 'id') || '')
|
||||||
|
|
||||||
|
if (!memberId) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
message: '회원 ID가 필요합니다.'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const deletedMember = await deleteMemberByAdmin({
|
||||||
|
actorUserId: session.userId,
|
||||||
|
targetUserId: memberId
|
||||||
|
})
|
||||||
|
|
||||||
|
if (deletedMember.avatarUrl) {
|
||||||
|
await removeManagedAvatarAsset(deletedMember.avatarUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: true }
|
||||||
|
})
|
||||||
30
server/routes/admin/api/members/[id].get.js
Normal file
30
server/routes/admin/api/members/[id].get.js
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { createError, getRouterParam } from 'h3'
|
||||||
|
import { requireAdminSession } from '../../../../utils/admin-auth'
|
||||||
|
import { getMemberForAdmin } from '../../../../repositories/member-repository'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 관리자 회원 상세 조회 API
|
||||||
|
* @param {import('h3').H3Event} event - 요청 이벤트
|
||||||
|
* @returns {Promise<Object>} 회원 상세
|
||||||
|
*/
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
requireAdminSession(event)
|
||||||
|
const memberId = String(getRouterParam(event, 'id') || '')
|
||||||
|
|
||||||
|
if (!memberId) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
message: '회원 ID가 필요합니다.'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const member = await getMemberForAdmin(memberId)
|
||||||
|
if (!member) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 404,
|
||||||
|
message: '회원을 찾을 수 없습니다.'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return member
|
||||||
|
})
|
||||||
80
server/routes/admin/api/members/[id].put.js
Normal file
80
server/routes/admin/api/members/[id].put.js
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import { createError, getRouterParam, readBody } from 'h3'
|
||||||
|
import { z } from 'zod'
|
||||||
|
import { requireAdminSession } from '../../../../utils/admin-auth'
|
||||||
|
import { getMemberForAdmin, isEmailTaken, isUsernameTaken, updateMemberByAdmin } from '../../../../repositories/member-repository'
|
||||||
|
|
||||||
|
const memberInputSchema = z.object({
|
||||||
|
username: z.string().trim().min(1).max(60),
|
||||||
|
email: z.string().trim().email().max(254),
|
||||||
|
avatarUrl: z.string().trim().max(500).optional().default(''),
|
||||||
|
labels: z.array(z.string().trim().min(1).max(40)).max(20).optional().default([]),
|
||||||
|
note: z.string().max(500).optional().default('')
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 관리자 회원 기본 정보 수정 API
|
||||||
|
* @param {import('h3').H3Event} event - 요청 이벤트
|
||||||
|
* @returns {Promise<Object>} 수정된 회원
|
||||||
|
*/
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
requireAdminSession(event)
|
||||||
|
const memberId = String(getRouterParam(event, 'id') || '')
|
||||||
|
const parsedBody = memberInputSchema.safeParse(await readBody(event))
|
||||||
|
|
||||||
|
if (!memberId) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
message: '회원 ID가 필요합니다.'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!parsedBody.success) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
message: '회원 수정 요청 형식이 올바르지 않습니다.'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await getMemberForAdmin(memberId)
|
||||||
|
if (!existing) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 404,
|
||||||
|
message: '회원을 찾을 수 없습니다.'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = parsedBody.data
|
||||||
|
const email = body.email.toLowerCase()
|
||||||
|
|
||||||
|
if (await isUsernameTaken({ username: body.username, excludeUserId: memberId })) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 409,
|
||||||
|
message: '이미 사용 중인 이름입니다.'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (await isEmailTaken({ email, excludeUserId: memberId })) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 409,
|
||||||
|
message: '이미 사용 중인 이메일입니다.'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await updateMemberByAdmin({
|
||||||
|
memberId,
|
||||||
|
username: body.username,
|
||||||
|
email,
|
||||||
|
avatarUrl: body.avatarUrl,
|
||||||
|
labels: [...new Set(body.labels)],
|
||||||
|
note: body.note
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!updated) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 500,
|
||||||
|
message: '회원 수정에 실패했습니다.'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return updated
|
||||||
|
})
|
||||||
50
server/routes/admin/api/members/[id]/password.put.js
Normal file
50
server/routes/admin/api/members/[id]/password.put.js
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import bcrypt from 'bcrypt'
|
||||||
|
import { createError, getRouterParam, readBody } from 'h3'
|
||||||
|
import { z } from 'zod'
|
||||||
|
import { requireAdminSession } from '../../../../../utils/admin-auth'
|
||||||
|
import { getMemberForAdmin, updateMemberPassword } from '../../../../../repositories/member-repository'
|
||||||
|
|
||||||
|
const adminMemberPasswordSchema = z.object({
|
||||||
|
password: z.string().min(8).max(32)
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 관리자 회원 비밀번호 직접 변경 API
|
||||||
|
* @param {import('h3').H3Event} event - 요청 이벤트
|
||||||
|
* @returns {Promise<{ ok: true }>} 변경 결과
|
||||||
|
*/
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
requireAdminSession(event)
|
||||||
|
const memberId = String(getRouterParam(event, 'id') || '')
|
||||||
|
const parsedBody = adminMemberPasswordSchema.safeParse(await readBody(event))
|
||||||
|
|
||||||
|
if (!memberId) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
message: '회원 ID가 필요합니다.'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!parsedBody.success) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
message: '비밀번호 변경 요청 형식이 올바르지 않습니다.'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const member = await getMemberForAdmin(memberId)
|
||||||
|
if (!member) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 404,
|
||||||
|
message: '회원을 찾을 수 없습니다.'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const passwordHash = await bcrypt.hash(parsedBody.data.password, 12)
|
||||||
|
await updateMemberPassword({
|
||||||
|
userId: memberId,
|
||||||
|
passwordHash
|
||||||
|
})
|
||||||
|
|
||||||
|
return { ok: true }
|
||||||
|
})
|
||||||
118
server/routes/admin/api/settings/logo.post.js
Normal file
118
server/routes/admin/api/settings/logo.post.js
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
import { mkdir, writeFile } from 'node:fs/promises'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
import { createError, readMultipartFormData } from 'h3'
|
||||||
|
import sharp from 'sharp'
|
||||||
|
import { requireAdminSession } from '../../../../utils/admin-auth'
|
||||||
|
import { updateSiteLogo } from '../../../../repositories/content-repository'
|
||||||
|
import { upsertMediaMetadataCategory } from '../../../../utils/media-library'
|
||||||
|
|
||||||
|
const allowedImageTypes = new Set(['image/jpeg', 'image/png', 'image/webp'])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 숫자 설정값을 최소/최대 범위로 보정한다.
|
||||||
|
* @param {number} value - 원본 값
|
||||||
|
* @param {number} minimum - 최소값
|
||||||
|
* @param {number} maximum - 최대값
|
||||||
|
* @returns {number} 보정된 값
|
||||||
|
*/
|
||||||
|
const clampNumber = (value, minimum, maximum) => {
|
||||||
|
if (!Number.isFinite(value)) {
|
||||||
|
return minimum
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value < minimum) {
|
||||||
|
return minimum
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value > maximum) {
|
||||||
|
return maximum
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.round(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 사이트 로고 업로드 API
|
||||||
|
* @param {import('h3').H3Event} event - 요청 이벤트
|
||||||
|
* @returns {Promise<Object>} 수정된 사이트 설정
|
||||||
|
*/
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
requireAdminSession(event)
|
||||||
|
|
||||||
|
const config = useRuntimeConfig()
|
||||||
|
const maxFileSize = Number(config.maxFileSize || 10485760)
|
||||||
|
const logoSize = clampNumber(Number(config.siteLogoSize || 512), 128, 2048)
|
||||||
|
const formData = await readMultipartFormData(event)
|
||||||
|
const file = (formData || []).find((part) => part.name === 'file' && part.filename)
|
||||||
|
|
||||||
|
if (!file) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
message: '업로드할 로고 이미지가 없습니다.'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!allowedImageTypes.has(file.type)) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
message: 'JPG, PNG, WebP 이미지만 로고로 사용할 수 있습니다.'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file.data.length > maxFileSize) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 413,
|
||||||
|
message: '업로드 가능한 파일 크기를 초과했습니다.'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const metadata = await sharp(file.data).metadata()
|
||||||
|
if (!metadata.width || !metadata.height) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
message: '이미지 메타데이터를 읽을 수 없습니다.'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const uploadBaseUrl = String(config.uploadDir || '/uploads').replace(/\/+$/g, '') || '/uploads'
|
||||||
|
const publicBasePath = uploadBaseUrl.replace(/^\/+/, '')
|
||||||
|
const directoryPath = join(process.cwd(), 'public', publicBasePath, 'system')
|
||||||
|
const logoPath = join(directoryPath, 'logo.webp')
|
||||||
|
const faviconPath = join(directoryPath, 'favicon.png')
|
||||||
|
const logoUrl = `${uploadBaseUrl}/system/logo.webp`
|
||||||
|
const faviconUrl = `${uploadBaseUrl}/system/favicon.png`
|
||||||
|
|
||||||
|
await mkdir(directoryPath, { recursive: true })
|
||||||
|
|
||||||
|
const logoBuffer = await sharp(file.data)
|
||||||
|
.rotate()
|
||||||
|
.resize({
|
||||||
|
width: logoSize,
|
||||||
|
height: logoSize,
|
||||||
|
fit: 'cover',
|
||||||
|
position: 'centre'
|
||||||
|
})
|
||||||
|
.webp({ quality: 90 })
|
||||||
|
.toBuffer()
|
||||||
|
|
||||||
|
const faviconBuffer = await sharp(file.data)
|
||||||
|
.rotate()
|
||||||
|
.resize({
|
||||||
|
width: 256,
|
||||||
|
height: 256,
|
||||||
|
fit: 'cover',
|
||||||
|
position: 'centre'
|
||||||
|
})
|
||||||
|
.png()
|
||||||
|
.toBuffer()
|
||||||
|
|
||||||
|
await writeFile(logoPath, logoBuffer)
|
||||||
|
await writeFile(faviconPath, faviconBuffer)
|
||||||
|
await upsertMediaMetadataCategory(logoUrl, '시스템')
|
||||||
|
await upsertMediaMetadataCategory(faviconUrl, '시스템')
|
||||||
|
|
||||||
|
return updateSiteLogo({
|
||||||
|
logoUrl,
|
||||||
|
faviconUrl
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -2,9 +2,11 @@ import { z } from 'zod'
|
|||||||
|
|
||||||
export const adminSiteSettingsInputSchema = z.object({
|
export const adminSiteSettingsInputSchema = z.object({
|
||||||
title: z.string().trim().min(1),
|
title: z.string().trim().min(1),
|
||||||
description: z.string().trim().default(''),
|
description: z.string().trim().min(1),
|
||||||
siteUrl: z.string().trim().url(),
|
siteUrl: z.string().trim().url(),
|
||||||
logoText: z.string().trim().min(1).max(8),
|
logoText: z.string().trim().max(8).optional().default('井'),
|
||||||
|
logoUrl: z.string().trim().max(500).optional().default(''),
|
||||||
|
faviconUrl: z.string().trim().max(500).optional().default(''),
|
||||||
copyrightText: z.string().trim().min(1)
|
copyrightText: z.string().trim().min(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -10,13 +10,12 @@ const sessionMaxAge = 60 * 60 * 24 * 14
|
|||||||
*/
|
*/
|
||||||
const getSessionSecret = () => {
|
const getSessionSecret = () => {
|
||||||
const config = useRuntimeConfig()
|
const config = useRuntimeConfig()
|
||||||
const fallbackSecret = String(config.adminPassword || '')
|
const sessionSecret = String(config.memberSessionSecret || '').trim()
|
||||||
const sessionSecret = String(config.memberSessionSecret || '').trim() || fallbackSecret
|
|
||||||
|
|
||||||
if (!sessionSecret) {
|
if (!sessionSecret) {
|
||||||
throw createError({
|
throw createError({
|
||||||
statusCode: 500,
|
statusCode: 500,
|
||||||
message: '회원 세션 비밀값 환경 변수가 없습니다. (MEMBER_SESSION_SECRET 또는 ADMIN_PASSWORD)'
|
message: '회원 세션 비밀값 환경 변수가 없습니다. MEMBER_SESSION_SECRET을 설정해 주세요.'
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,4 +148,3 @@ export const requireMemberSession = (event) => {
|
|||||||
|
|
||||||
return session
|
return session
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ export const getDefaultSiteSettings = () => {
|
|||||||
description: 'sori.studio 개인 블로그',
|
description: 'sori.studio 개인 블로그',
|
||||||
siteUrl: config.public.siteUrl || 'https://sori.studio',
|
siteUrl: config.public.siteUrl || 'https://sori.studio',
|
||||||
logoText: '井',
|
logoText: '井',
|
||||||
|
logoUrl: '',
|
||||||
|
faviconUrl: '',
|
||||||
copyrightText: `©${new Date().getFullYear()} ${title}`,
|
copyrightText: `©${new Date().getFullYear()} ${title}`,
|
||||||
updatedAt: null
|
updatedAt: null
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user