운영 시작 버전 v1.0.0 정리

This commit is contained in:
2026-05-14 10:49:25 +09:00
parent 069d1bfbd4
commit 021eeaebe0
18 changed files with 1679 additions and 94 deletions

View File

@@ -28,8 +28,13 @@ const isLoadingMedia = ref(false)
const isComposingText = ref(false)
const isNormalizingTrailingBlock = ref(false)
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
const imageWidthOptions = [
@@ -38,6 +43,16 @@ const imageWidthOptions = [
{ 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 = [
{
type: 'paragraph',
@@ -135,9 +150,52 @@ const createEditorBlock = (type = 'paragraph', text = '', level = null, id = '',
alt: options.alt || '',
title: options.title || '',
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 - 마크다운 행
@@ -219,9 +277,9 @@ const parseMarkdownToBlocks = (markdown) => {
continue
}
if (trimmedLine === ':::callout') {
if (trimmedLine.startsWith(':::callout')) {
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
continue
}
@@ -343,8 +401,14 @@ const serializeBlocks = () => {
}
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
? { type: block.type, value: `:::callout\n${text}\n:::` }
? { type: block.type, value: `:::callout emoji=${emoji} bg=${bg}\n${text}\n:::` }
: null
}
@@ -415,6 +479,27 @@ const emitContent = () => {
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 - 에디터 블록
@@ -682,7 +767,6 @@ const getBlockClass = (block) => [
'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__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__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'
}
@@ -712,6 +796,8 @@ const getImageWidthClass = (width) => {
* @returns {void}
*/
const updateBlockText = (event, index) => {
enableKeyboardPriorityMode()
const block = editorBlocks.value[index]
const text = getTextBlockDomText(index)
@@ -745,26 +831,49 @@ const startTextComposition = () => {
const finishTextComposition = (event, index) => {
isComposingText.value = false
nextTick(() => {
window.setTimeout(() => {
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
}
const syncAfterComposition = () => {
const block = syncTextBlockFromDom(index)
if (!block) {
pendingSoftLineBreakIndex.value = -1
emitContent()
}, 0)
pendingSlashCommandIndex.value = -1
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)
})
}
@@ -856,6 +965,21 @@ const visibleCommands = computed(() => {
})
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
})
/**
* 슬래시 메뉴 명령 적용
@@ -1336,6 +1460,8 @@ const navigateAcrossBlocks = (event, index, direction) => {
* @returns {void}
*/
const handleEnter = (event, index) => {
enableKeyboardPriorityMode()
const currentBlock = syncTextBlockFromDom(index)
if (isComposingText.value || event.isComposing || event.keyCode === 229) {
@@ -1345,6 +1471,12 @@ const handleEnter = (event, index) => {
pendingSoftLineBreakIndex.value = index
}
if (!event.shiftKey && (currentBlock.text || '').startsWith('/')) {
pendingSlashCommandIndex.value = index
} else {
pendingSlashCommandIndex.value = -1
}
return
}
@@ -1358,7 +1490,13 @@ const handleEnter = (event, index) => {
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()
const command = highlightedCommand.value || visibleCommands.value[0]
@@ -1379,7 +1517,7 @@ const handleEnter = (event, index) => {
return
}
if (!currentBlock.text.trim() && currentBlock.type !== 'paragraph') {
if (!currentBlock.text.trim() && !['paragraph', 'callout'].includes(currentBlock.type)) {
currentBlock.type = 'paragraph'
currentBlock.level = null
normalizeTrailingTextBlock()
@@ -1426,6 +1564,51 @@ const handleBackspace = (event, index) => {
*/
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 - 선택할 블록
@@ -1580,6 +1763,8 @@ const finishBlockDrag = () => {
* @returns {void}
*/
const activateBlock = (block) => {
cleanupUnusedStructuredBlockOnActivate(block.id)
const index = editorBlocks.value.findIndex((item) => item.id === block.id)
activeBlockId.value = block.id
selectedBlockId.value = ''
@@ -1607,6 +1792,123 @@ const updateStructuredBlock = () => {
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) => {
if (isApplyingExternalValue.value) {
return
@@ -1637,13 +1939,24 @@ watch(
}
)
watch(activeCalloutBlock, (nextBlock) => {
if (!nextBlock) {
calloutEmojiPickerBlockId.value = ''
calloutColorPopoverBlockId.value = ''
}
})
defineExpose({
focusFirstBlock: () => focusBlock(0)
})
</script>
<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
v-for="(block, index) in editorBlocks"
@@ -1654,6 +1967,7 @@ defineExpose({
'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-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--text': isTextBlock(block),
'admin-block-editor__row--structure': !isTextBlock(block)
@@ -1663,7 +1977,7 @@ defineExpose({
@drop="dropBlock($event, index)"
>
<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"
draggable="true"
aria-label="블록 이동 선택"
@@ -1784,6 +2098,138 @@ defineExpose({
</div>
</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
v-else-if="block.type === 'toggle'"
class="admin-block-editor__toggle rounded border border-line bg-paper p-5"
@@ -1986,13 +2432,18 @@ defineExpose({
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 {
background: #eff1f2;
opacity: 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 {
top: -18px;
opacity: 1;
@@ -2009,6 +2460,10 @@ defineExpose({
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 {
display: flex;
width: 16px;
@@ -2031,7 +2486,7 @@ defineExpose({
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__handle:focus .admin-block-editor__handle-grabber {
height: 100%;
@@ -2057,6 +2512,11 @@ defineExpose({
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;

View File

@@ -51,6 +51,11 @@ const isTitleInputComposing = ref(false)
const activeMediaPickerTab = ref('upload')
const selectedMediaPickerUrl = ref('')
const savedPostSnapshot = ref('')
const isPublishModalOpen = ref(false)
const publishStatus = ref('draft')
const publishTiming = ref('now')
const scheduledPublishAt = ref('')
const publishModalExpandedSection = ref(null)
/**
* ISO 날짜를 datetime-local 입력값으로 변환
@@ -236,6 +241,49 @@ const editorStatusLabel = computed(() => {
return '초안'
})
/**
* 발행 모달에 표시할 게시 상태 요약 문구
* @returns {string} 요약 문구
*/
const publishStatusSummaryLabel = computed(() => {
if (publishStatus.value === 'published') {
return '발행'
}
if (publishStatus.value === 'private') {
return '비공개'
}
return '초안'
})
/**
* 발행 모달에 표시할 발행 시점 요약 문구
* @returns {string} 요약 문구
*/
const publishTimingSummaryLabel = computed(() => {
if (publishTiming.value === 'now') {
return '지금 바로'
}
const raw = scheduledPublishAt.value
if (!raw) {
return '예약'
}
const date = new Date(raw)
if (Number.isNaN(date.getTime())) {
return '예약'
}
return new Intl.DateTimeFormat('ko-KR', {
dateStyle: 'medium',
timeStyle: 'short'
}).format(date)
})
/**
* 게시물 입력값 생성
* @returns {Object} 게시물 입력값
@@ -580,6 +628,7 @@ const focusContentEditor = (event) => {
* @returns {void}
*/
const submitPost = () => {
isPublishModalOpen.value = false
emit('submit', createPostPayload())
}
@@ -607,6 +656,100 @@ const toggleSettingsPanel = () => {
isSettingsOpen.value = !isSettingsOpen.value
}
/**
* 발행 모달을 현재 폼 상태로 초기화한다.
* @returns {void}
*/
const syncPublishModalStateFromForm = () => {
publishStatus.value = form.status || 'draft'
scheduledPublishAt.value = form.publishedAt || toDateTimeLocalValue(new Date(Date.now() + 3600000).toISOString())
publishTiming.value = isScheduledPost() ? 'schedule' : 'now'
}
/**
* 발행 모달 열기
* @returns {void}
*/
const openPublishModal = () => {
syncPublishModalStateFromForm()
publishModalExpandedSection.value = null
isPublishModalOpen.value = true
}
/**
* 발행 모달 닫기
* @returns {void}
*/
const closePublishModal = () => {
isPublishModalOpen.value = false
}
/**
* 발행 모달에서 선택한 값을 폼에 반영
* @returns {void}
*/
const applyPublishSelectionToForm = () => {
form.status = publishStatus.value
if (publishStatus.value !== 'published') {
form.publishedAt = ''
return
}
if (publishTiming.value === 'schedule') {
form.publishedAt = scheduledPublishAt.value || toDateTimeLocalValue(new Date(Date.now() + 3600000).toISOString())
return
}
form.publishedAt = toDateTimeLocalValue(new Date().toISOString())
}
/**
* 발행 모달에서 최종 저장/발행 확정
* @returns {void}
*/
const submitFromPublishModal = () => {
applyPublishSelectionToForm()
submitPost()
}
/**
* 발행 모달에서 설정 행 펼침을 토글한다.
* @param {'status' | 'timing'} section - 펼칠 행
* @returns {void}
*/
const togglePublishModalSection = (section) => {
publishModalExpandedSection.value =
publishModalExpandedSection.value === section ? null : section
}
/**
* 발행 모달에서 게시 상태를 선택한다.
* @param {'published' | 'draft' | 'private'} status - 선택 상태
* @returns {void}
*/
const selectPublishStatus = (status) => {
publishStatus.value = status
publishModalExpandedSection.value = null
if (status !== 'published') {
publishTiming.value = 'now'
}
}
/**
* 발행 모달에서 발행 시점을 선택한다.
* @param {'now' | 'schedule'} timing - 즉시 또는 예약
* @returns {void}
*/
const selectPublishTiming = (timing) => {
publishTiming.value = timing
if (timing === 'now') {
publishModalExpandedSection.value = null
}
}
/**
* 현재 입력값을 저장 완료 기준점으로 표시한다.
* @returns {void}
@@ -615,6 +758,16 @@ const markSaved = () => {
savedPostSnapshot.value = serializePostPayload()
}
watch(publishStatus, (next) => {
if (next !== 'published') {
publishTiming.value = 'now'
if (publishModalExpandedSection.value === 'timing') {
publishModalExpandedSection.value = null
}
}
})
watch(form, scheduleAutosave, { deep: true })
onMounted(() => {
@@ -652,18 +805,40 @@ defineExpose({
</script>
<template>
<form class="admin-post-form flex h-screen min-h-screen overflow-hidden bg-white" @submit.prevent="submitPost">
<form class="admin-post-form flex h-screen min-h-screen overflow-hidden bg-white" @submit.prevent="openPublishModal">
<div class="admin-post-form__workspace flex min-w-0 flex-1 flex-col bg-white">
<header class="admin-post-form__toolbar flex h-[56px] shrink-0 items-center bg-white px-8">
<div class="admin-post-form__toolbar-inner flex h-[34px] min-w-0 flex-1 items-center justify-between">
<div class="admin-post-form__toolbar-left flex h-full min-w-0 items-center gap-3">
<NuxtLink class="admin-post-form__toolbar-link inline-flex items-center gap-2 rounded px-2 py-1.5 text-sm font-medium text-[#394047] transition-colors hover:bg-[#eff1f2] hover:text-black" to="/admin/posts">
<div class="admin-post-form__toolbar-left flex h-full min-w-0 flex-1 items-center gap-3">
<NuxtLink class="admin-post-form__toolbar-link inline-flex shrink-0 items-center gap-2 rounded px-2 py-1.5 text-sm font-medium text-[#394047] transition-colors hover:bg-[#eff1f2] hover:text-black" to="/admin/posts">
<span class="admin-post-form__toolbar-back text-lg leading-none" aria-hidden="true">&lt;</span>
<span>Posts</span>
</NuxtLink>
<span class="admin-post-form__toolbar-status truncate rounded px-2 py-1.5 text-sm text-[#8e9cac]">
{{ editorStatusLabel }}
</span>
<div class="admin-post-form__toolbar-status-row flex min-w-0 flex-1 items-center gap-2">
<span class="admin-post-form__toolbar-status truncate rounded px-2 py-1.5 text-sm text-[#8e9cac]">
{{ editorStatusLabel }}
</span>
<div
v-if="autosaveNotice"
class="admin-post-form__toolbar-autosave-actions flex shrink-0 items-center gap-1.5"
>
<button
class="admin-post-form__toolbar-autosave-restore rounded px-2 py-1 text-xs font-semibold text-[#15171a] ring-1 ring-inset ring-[#d7dde2] transition-colors hover:bg-[#eff1f2]"
type="button"
@click="restoreAutosave"
>
복원
</button>
<button
class="admin-post-form__toolbar-autosave-discard rounded px-2 py-1 text-xs font-semibold text-[#8e9cac] transition-colors hover:bg-[#eff1f2] hover:text-[#394047]"
type="button"
title="이 기기에만 있는 자동 저장 초안을 삭제합니다"
@click="discardAutosave"
>
무시
</button>
</div>
</div>
</div>
<div class="admin-post-form__toolbar-actions flex h-full shrink-0 items-center gap-2">
<button
@@ -675,8 +850,9 @@ defineExpose({
</button>
<button
class="admin-post-form__toolbar-submit rounded px-3 py-1.5 text-sm font-bold text-[#2bba3c] transition-colors hover:bg-[#eaf8ec] hover:text-[#159624] disabled:pointer-events-none disabled:text-[#8e9cac] disabled:opacity-60"
type="submit"
type="button"
:disabled="saving || !hasUnsavedPostChanges"
@click="openPublishModal"
>
{{ saving ? '저장 중' : submitLabel }}
</button>
@@ -728,23 +904,6 @@ defineExpose({
@compositionend="isTitleInputComposing = false"
>
<div
v-if="autosaveNotice"
class="admin-post-form__autosave-notice flex flex-wrap items-center justify-between gap-3 rounded border border-line bg-surface px-4 py-3 text-sm"
>
<p class="admin-post-form__autosave-message text-muted">
{{ formatAutosaveTime(autosaveNotice.savedAt) }} 저장된 작성 내용이 있습니다.
</p>
<div class="admin-post-form__autosave-actions flex gap-2">
<button class="admin-post-form__autosave-restore rounded bg-[#15171a] px-3 py-1.5 text-xs font-semibold text-white transition-colors hover:bg-black" type="button" @click="restoreAutosave">
복원
</button>
<button class="admin-post-form__autosave-discard rounded border border-line bg-white px-3 py-1.5 text-xs font-semibold transition-colors hover:bg-[#eff1f2]" type="button" @click="discardAutosave">
삭제
</button>
</div>
</div>
<div class="admin-post-form__field admin-post-form__content-editor text-sm">
<AdminBlockEditor ref="blockEditor" v-model="form.content" />
</div>
@@ -998,5 +1157,177 @@ defineExpose({
@stay="stayOnUnsavedPage"
@leave="leaveUnsavedPage"
/>
<div
v-if="isPublishModalOpen"
class="admin-post-form__publish-modal fixed inset-0 z-[70] flex flex-col bg-white"
role="dialog"
aria-modal="true"
aria-labelledby="admin-post-form-publish-modal-title"
>
<div class="flex min-h-0 flex-1 flex-col overflow-y-auto">
<header class="admin-post-form__publish-modal-header flex h-14 shrink-0 items-center justify-between px-6">
<h2 id="admin-post-form-publish-modal-title" class="text-[15px] font-semibold text-[#15171a]">
발행
</h2>
<div class="flex items-center gap-2">
<button class="rounded px-3 py-1.5 text-[13px] font-semibold text-[#394047] hover:bg-[#eff1f2]" type="button" @click="closePublishModal">
닫기
</button>
<button class="rounded border border-[#d7dde2] px-3 py-1.5 text-[13px] font-semibold text-[#394047] hover:bg-[#eff1f2]" type="button" @click="previewPost">
미리보기
</button>
</div>
</header>
<div class="admin-post-form__publish-modal-body flex flex-1 flex-col items-center px-6 pb-16 pt-10 sm:pt-14">
<div class="w-full max-w-[640px]">
<div class="admin-post-form__publish-modal-hero mb-10 sm:mb-12">
<p class="text-[clamp(28px,7vw,46px)] font-black text-[#2bba3c]">
준비됐어요, 발행하세요.
</p>
<p class="text-[clamp(28px,7vw,46px)] font-black leading-[0.95] text-[#15171a]">
세상과 공유해 보세요.
</p>
</div>
<div class="admin-post-form__publish-settings w-full">
<div class="admin-post-form__publish-setting">
<button
class="admin-post-form__publish-setting-title flex w-full items-center gap-3 py-4 text-left text-[15px] font-semibold text-[#15171a] transition-colors"
type="button"
:aria-expanded="publishModalExpandedSection === 'status'"
aria-controls="admin-post-form-publish-status-panel"
data-test-setting="publish-type"
@click="togglePublishModalSection('status')"
>
<span class="flex size-6 shrink-0 items-center justify-center text-[#959eab]" aria-hidden="true">
<svg class="size-4" fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M23 1L6.21 13.013v9.408L12 17.355" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
<path d="M1 9.105L23 1l-3.474 22L1 9.105z" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
</svg>
</span>
<span class="min-w-0 flex-1">{{ publishStatusSummaryLabel }}</span>
<span class="flex size-2.5 shrink-0 items-center justify-center text-[#959eab]" aria-hidden="true">
<svg
class="size-[22px] transition-transform duration-200 ease-out"
:class="{ 'rotate-180': publishModalExpandedSection === 'status' }"
fill="currentColor"
viewBox="0 0 26 24"
xmlns="http://www.w3.org/2000/svg"
>
<path clip-rule="evenodd" d="M1.043 6.604a1 1 0 011.414 0L13 17.146 23.543 6.604a1 1 0 011.414 1.414l-10.72 10.719a1.75 1.75 0 01-2.474 0L1.042 8.018a1 1 0 010-1.414zm11.78 10.72v-.001zm.355 0v-.001z" fill-rule="evenodd" />
</svg>
</span>
</button>
<div
v-show="publishModalExpandedSection === 'status'"
id="admin-post-form-publish-status-panel"
class="admin-post-form__publish-setting-panel px-4 pb-4"
>
<div class="flex flex-wrap gap-2">
<button
class="rounded-full border px-3 py-1.5 text-[13px] font-semibold"
:class="publishStatus === 'published' ? 'border-[#15171a] bg-[#15171a] text-white' : 'border-[#d7dde2] bg-white text-[#394047]'"
type="button"
@click="selectPublishStatus('published')"
>
발행
</button>
<button
class="rounded-full border px-3 py-1.5 text-[13px] font-semibold"
:class="publishStatus === 'draft' ? 'border-[#15171a] bg-[#15171a] text-white' : 'border-[#d7dde2] bg-white text-[#394047]'"
type="button"
@click="selectPublishStatus('draft')"
>
초안
</button>
<button
class="rounded-full border px-3 py-1.5 text-[13px] font-semibold"
:class="publishStatus === 'private' ? 'border-[#15171a] bg-[#15171a] text-white' : 'border-[#d7dde2] bg-white text-[#394047]'"
type="button"
@click="selectPublishStatus('private')"
>
비공개
</button>
</div>
</div>
</div>
<div v-if="publishStatus === 'published'" class="admin-post-form__publish-setting admin-post-form__publish-setting--timing border-t border-[#e3e6e8]">
<button
class="admin-post-form__publish-setting-title flex w-full items-center gap-3 py-4 text-left text-[15px] font-semibold text-[#15171a] transition-colors"
type="button"
:aria-expanded="publishModalExpandedSection === 'timing'"
aria-controls="admin-post-form-publish-timing-panel"
data-test-setting="publish-at"
@click="togglePublishModalSection('timing')"
>
<span class="flex size-6 shrink-0 items-center justify-center text-[#959eab]" aria-hidden="true">
<svg class="size-4" fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M12 23c6.075 0 11-4.925 11-11S18.075 1 12 1 1 5.925 1 12s4.925 11 11 11z" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
<path d="M12 6v6h6" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
</svg>
</span>
<span class="min-w-0 flex-1">{{ publishTimingSummaryLabel }}</span>
<span class="flex size-2.5 shrink-0 items-center justify-center text-[#959eab]" aria-hidden="true">
<svg
class="size-[22px] transition-transform duration-200 ease-out"
:class="{ 'rotate-180': publishModalExpandedSection === 'timing' }"
fill="currentColor"
viewBox="0 0 26 24"
xmlns="http://www.w3.org/2000/svg"
>
<path clip-rule="evenodd" d="M1.043 6.604a1 1 0 011.414 0L13 17.146 23.543 6.604a1 1 0 011.414 1.414l-10.72 10.719a1.75 1.75 0 01-2.474 0L1.042 8.018a1 1 0 010-1.414zm11.78 10.72v-.001zm.355 0v-.001z" fill-rule="evenodd" />
</svg>
</span>
</button>
<div
v-show="publishModalExpandedSection === 'timing'"
id="admin-post-form-publish-timing-panel"
class="admin-post-form__publish-setting-panel px-4 pb-4"
>
<div class="flex flex-wrap gap-2">
<button
class="rounded-full border px-3 py-1.5 text-[13px] font-semibold"
:class="publishTiming === 'now' ? 'border-[#15171a] bg-[#15171a] text-white' : 'border-[#d7dde2] bg-white text-[#394047]'"
type="button"
@click="selectPublishTiming('now')"
>
지금 바로
</button>
<button
class="rounded-full border px-3 py-1.5 text-[13px] font-semibold"
:class="publishTiming === 'schedule' ? 'border-[#15171a] bg-[#15171a] text-white' : 'border-[#d7dde2] bg-white text-[#394047]'"
type="button"
@click="selectPublishTiming('schedule')"
>
예약
</button>
</div>
<input
v-if="publishTiming === 'schedule'"
v-model="scheduledPublishAt"
class="admin-post-form__publish-schedule-input mt-3 h-[38px] w-full max-w-[320px] rounded border border-[#e3e6e8] bg-white px-3 py-2 text-[13px] text-[#15171a] outline-none focus:border-[#8e9cac]"
type="datetime-local"
>
</div>
</div>
</div>
<div class="admin-post-form__publish-modal-actions mt-10">
<button
class="rounded bg-[#15171a] px-5 py-2.5 text-[14px] font-semibold text-white transition-colors hover:bg-black disabled:cursor-not-allowed disabled:bg-[#d7dce0] disabled:text-[#8e9cac]"
type="button"
:disabled="saving"
@click="submitFromPublishModal"
>
{{ saving ? '저장 중…' : '최종 확인하고 저장 →' }}
</button>
</div>
</div>
</div>
</div>
</div>
</form>
</template>

View File

@@ -32,9 +32,54 @@ const createBlock = (type = 'paragraph', text = '', level = null, id = '', optio
ordered: options.ordered || false,
width: options.width || 'regular',
images: options.images || [],
meta: options.meta && typeof options.meta === 'object' ? { ...options.meta } : {}
meta: options.meta && typeof options.meta === 'object' ? { ...options.meta } : {},
calloutEmojiEnabled: options.calloutEmojiEnabled ?? true,
calloutEmoji: options.calloutEmoji || '💡',
calloutBackground: options.calloutBackground || 'blue'
})
const calloutBackgroundOptions = ['gray', 'blue', 'green', 'yellow', 'red', 'purple', 'pink']
/**
* 콜아웃 선언부 옵션을 파싱
* @param {string} line - 콜아웃 선언 라인
* @returns {{calloutEmojiEnabled: boolean, calloutEmoji: string, calloutBackground: string}} 콜아웃 옵션
*/
const parseCalloutOptions = (line) => {
const options = {
calloutEmojiEnabled: true,
calloutEmoji: '💡',
calloutBackground: 'blue'
}
const tokens = line.trim().split(/\s+/).slice(1)
tokens.forEach((token) => {
const [rawKey, ...rawValueParts] = token.split('=')
if (!rawKey || !rawValueParts.length) {
return
}
const key = rawKey.toLowerCase()
const value = rawValueParts.join('=').trim()
if (key === 'emoji') {
if (!value || value === 'none') {
options.calloutEmojiEnabled = false
options.calloutEmoji = '💡'
} else {
options.calloutEmojiEnabled = true
options.calloutEmoji = value
}
return
}
if (key === 'bg' && calloutBackgroundOptions.includes(value)) {
options.calloutBackground = value
}
})
return options
}
/**
* 이미지 마크다운 행을 이미지 데이터로 변환
* @param {string} line - 마크다운 행
@@ -240,9 +285,9 @@ const parseMarkdownBlocks = (markdown) => {
continue
}
if (trimmedLine === ':::callout') {
if (trimmedLine.startsWith(':::callout')) {
const { contentLines, nextIndex } = collectFencedLines(lines, index + 1)
blocks.push(createBlock('callout', contentLines.join('\n'), null, `block-${blocks.length}`))
blocks.push(createBlock('callout', contentLines.join('\n'), null, `block-${blocks.length}`, parseCalloutOptions(trimmedLine)))
index = nextIndex
continue
}
@@ -400,7 +445,12 @@ const showNextImage = () => {
<ProseImage v-else-if="block.type === 'image'" :src="block.url" :alt="block.alt" :variant="block.width">
{{ block.alt }}
</ProseImage>
<ProseCallout v-else-if="block.type === 'callout'">
<ProseCallout
v-else-if="block.type === 'callout'"
:emoji-enabled="block.calloutEmojiEnabled"
:emoji="block.calloutEmoji"
:background="block.calloutBackground"
>
{{ block.text }}
</ProseCallout>
<ProseToggle v-else-if="block.type === 'toggle'" :title="block.title || '더 보기'">

View File

@@ -1,7 +1,88 @@
<script setup>
const props = defineProps({
emojiEnabled: {
type: Boolean,
default: true
},
emoji: {
type: String,
default: '💡'
},
background: {
type: String,
default: 'blue'
}
})
const backgroundClass = computed(() => {
if (props.background === 'gray') {
return 'prose-callout--gray'
}
if (props.background === 'green') {
return 'prose-callout--green'
}
if (props.background === 'yellow') {
return 'prose-callout--yellow'
}
if (props.background === 'red') {
return 'prose-callout--red'
}
if (props.background === 'purple') {
return 'prose-callout--purple'
}
if (props.background === 'pink') {
return 'prose-callout--pink'
}
return 'prose-callout--blue'
})
</script>
<template>
<aside class="prose-callout prose-callout-card my-8 rounded-[10px] border border-[var(--site-line)] border-l-[3px] border-l-[var(--site-accent)] bg-[var(--site-panel)] p-5 pl-4 text-[15px] leading-8 text-[var(--site-text)]">
<div class="whitespace-pre-line">
<slot />
<aside
class="prose-callout prose-callout-card mt-8 rounded-[10px] p-5 text-[15px] leading-8 text-[var(--site-text)]"
:class="backgroundClass"
>
<div class="flex items-center gap-2">
<span v-if="emojiEnabled" class="inline-flex shrink-0 text-[20px] leading-none">{{ emoji || '💡' }}</span>
<div class="min-w-0 flex-1 whitespace-pre-line">
<slot />
</div>
</div>
</aside>
</template>
<style scoped>
.prose-callout--gray {
background: rgba(100, 116, 139, 0.12);
}
.prose-callout--blue {
background: rgba(59, 130, 246, 0.14);
}
.prose-callout--green {
background: rgba(34, 197, 94, 0.14);
}
.prose-callout--yellow {
background: rgba(245, 158, 11, 0.16);
}
.prose-callout--red {
background: rgba(239, 68, 68, 0.14);
}
.prose-callout--purple {
background: rgba(168, 85, 247, 0.14);
}
.prose-callout--pink {
background: rgba(236, 72, 153, 0.14);
}
</style>

View File

@@ -1,5 +1,14 @@
# 업데이트 요약
## v1.0.0
- 운영 시작 기준 버전.
- 운영 환경 DB 설정 누락 시 샘플 콘텐츠 대신 즉시 실패하도록 보강.
- 회원 세션 비밀값을 관리자 비밀번호와 분리.
- JavaScript 문법 점검과 프로덕션 빌드를 묶은 검증 스크립트 추가.
- Nitro 보안 권고 반영 및 취약점 0건 확인.
- Docker compose 설정과 앱 이미지 빌드 검증 완료.
## v0.0.6
- `.env.example`을 실제 비밀값이 없는 공유 템플릿으로 정리.

View File

@@ -55,3 +55,10 @@
- 하드코딩 금지
- 로컬 개발 설정과 NAS 운영 설정은 별도 환경 파일로 분리
- 운영 DB 접속 정보는 개발용 `.env`에 기록하지 않음
- 운영 환경에서는 `DATABASE_URL``MEMBER_SESSION_SECRET` 누락을 허용하지 않음
## 검증
- `npm run lint`: JavaScript 파일 문법 점검
- `npm run test`: Nuxt 프로덕션 빌드 기반 회귀 검증
- `npm run verify`: 문법 점검과 빌드를 함께 실행

View File

@@ -1,6 +1,6 @@
# 배포 가이드
> 현재 프로젝트는 Nuxt 3 초기 스캐폴딩 상태다. Docker 설정은 파일 기준 초안이 있으며 운영 DB 확정 후 NAS에서 검증한다.
> 로컬 기준 `npm run build`, `docker compose --env-file .env.production config --quiet`, `docker compose --env-file .env.production build sori-studio` 검증을 통과했다. NAS 실제 컨테이너 기동과 도메인/프록시 접속 검증은 운영 배포 단계에서 진행한다.
## 빌드 유형
@@ -8,6 +8,7 @@
|------|--------|------|
| 개발 | `npm run dev` | 로컬 테스트, 개발 서버 |
| 프로덕션 | `npm run build` | NAS 배포, 운영 서버 |
| 검증 | `npm run verify` | JavaScript 문법 점검 + 프로덕션 빌드 |
> `npm run dev`는 프로젝트 전용 실행 스크립트를 통해 개발 서버, Admin, Tailwind Viewer 링크만 요약 출력한다.
@@ -128,6 +129,7 @@ cd sori.studio
# .env.production은 Git에 올리지 않는 운영 전용 파일
cp .env.example .env.production
# .env.production 파일에 운영 DB 연결 정보, 운영 전용 랜덤 비밀번호, APP_PORT=43118 입력
# MEMBER_SESSION_SECRET은 ADMIN_PASSWORD와 다른 긴 난수 문자열로 반드시 입력
# Docker 내부 앱에서 PostgreSQL에 접근할 때는 sori-studio-db:5432 사용
# Docker 빌드 및 실행
@@ -163,8 +165,10 @@ docker run -d -p 3000:3000 sori.studio:latest
- NAS Docker 예시: `postgres://sori_studio:비밀번호@sori-studio-db:5432/sori_studio`
- `.env.example`에는 실제 비밀번호나 개인 이메일을 기록하지 않음
- 개발/운영 DB 비밀번호와 관리자 비밀번호는 서로 다른 랜덤 값을 사용
- 회원 세션 서명용 `MEMBER_SESSION_SECRET`은 관리자 비밀번호와 분리된 긴 난수 문자열을 사용
- 개발 DB와 운영 DB는 반드시 별도 인스턴스 또는 별도 데이터베이스로 분리
- 운영 DB는 로컬 개발 서버에서 직접 연결하지 않음
- 운영 환경에서 `DATABASE_URL`이 없으면 샘플 콘텐츠로 대체하지 않고 서버 오류로 실패
### 이메일 인증(Resend, 선택)
@@ -174,7 +178,7 @@ docker run -d -p 3000:3000 sori.studio:latest
|------|------|
| `RESEND_API_KEY` | [Resend](https://resend.com) API 키 |
| `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로 쓴다. |
`RESEND_API_KEY``RESEND_FROM_EMAIL`이 비어 있으면 기존처럼 이메일 OTP 없이 최초 관리자 가입·일반 가입(OTP 생략)이 동작한다.
@@ -218,6 +222,7 @@ test -f .env.production && rg -n "^(DATABASE_URL|POSTGRES_DB|POSTGRES_USER|APP_P
- NAS Docker 내부 실행 기준이면 `DATABASE_URL` 호스트가 `sori-studio-db`
- NAS 외부 DB를 별도 인스턴스로 쓰는 경우에도 로컬 개발 DB(`127.0.0.1:43119`)를 가리키지 않음
- `APP_PORT=43118`
- `MEMBER_SESSION_SECRET`이 비어 있지 않고 `ADMIN_PASSWORD`와 다름
- `.env.development`와 DB 비밀번호, 관리자 비밀번호가 서로 다름
3. 로컬 개발 DB 연결 확인.

View File

@@ -1,5 +1,45 @@
# 의사결정 이력
## 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
### 게시글 저장·삭제 액션 강조도 조정

View File

@@ -31,6 +31,12 @@
|------|------|
| 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`로 문법 점검 |
## 서버 미들웨어
| 파일 | 용도 |
@@ -56,9 +62,9 @@
| 파일 | 화면 위치 |
|------|-----------|
| components/admin/AdminPostForm.vue | 관리자 글 작성/수정 폼, Ghost 스타일 전체 화면 에디터, 변경사항 기반 저장 버튼 활성화, 좌우 분할 설정 패널, 설정 패널 전환 애니메이션, Post URL 보기 액션, 중립 톤 삭제 액션, 대표 이미지 hover 액션과 업로드/미디어 선택 확정, 제목 IME Enter 가드, SVG 닫기 아이콘 배지형 태그 입력(한글 유지), 로컬 자동 저장, 미저장 변경사항 이탈 확인, 예약 발행 시각 입력, 검색 노출 제외(noindex), 저장 시 제목·요약을 SEO 메타로 반영, 미리보기 요청 |
| components/admin/AdminPostForm.vue | 관리자 글 작성/수정 폼, Ghost 스타일 전체 화면 에디터, 변경사항 기반 저장 버튼 활성화, 저장 클릭 시 전체 화면 발행 모달(행 접기/펼침, Ghost 동일 SVG 아이콘, 상태 요약 후 발행/초안/비공개 선택, 발행 시에만 시점 행·즉시/예약·datetime-local), 좌우 분할 설정 패널, 설정 패널 전환 애니메이션, Post URL 보기 액션, 중립 톤 삭제 액션, 대표 이미지 hover 액션과 업로드/미디어 선택 확정, 제목 IME Enter 가드, SVG 닫기 아이콘 배지형 태그 입력(한글 유지), 로컬 자동 저장(툴바 상태 옆 복원·무시), 미저장 변경사항 이탈 확인, 검색 노출 제외(noindex), 저장 시 제목·요약을 SEO 메타로 반영, 미리보기 요청 |
| components/admin/AdminPageForm.vue | 관리자 페이지 작성/수정 폼, 대표 이미지 선택 |
| components/admin/AdminBlockEditor.vue | 관리자 글 블록형 에디터, 이미지/갤러리/콜아웃/토글/임베드 블록, 갤러리 복수 미디어 선택·이미지 수별 열 배치·삽입 위치 표시 드래그 순서 변경, 한글 조합 입력 처리, Shift+Enter 줄바꿈, 코드 블록 단축 변환, AFFiNE 참고 세로 막대형 블록 핸들 선택/삭제/드래그 이동과 삽입선 표시, 하단 빈 입력 블록 유지, 본문 placeholder 표시 |
| components/admin/AdminBlockEditor.vue | 관리자 글 블록형 에디터, 이미지/갤러리/콜아웃/토글/임베드 블록, 콜아웃 Emoji on/off·이모지 프리셋·배경 프리셋 선택(우측 고정 설정 패널), 갤러리 복수 미디어 선택·이미지 수별 열 배치·삽입 위치 표시 드래그 순서 변경, 한글 조합 입력 처리, Shift+Enter 줄바꿈, 코드 블록 단축 변환, AFFiNE 참고 세로 막대형 블록 핸들 선택/삭제/드래그 이동과 삽입선 표시, 하단 빈 입력 블록 유지, 본문 placeholder 표시 |
| components/admin/AdminNavPrimaryBranch.vue | 관리자 상단 네비 트리(테이블·태그와 동일한 행 드래그 하이라이트, 하위·삭제) |
| components/admin/AdminTagForm.vue | 관리자 태그 생성/수정 폼(이름/슬러그/설명/색상만 편집) |
| components/admin/AdminMemberForm.vue | 관리자 멤버 추가/수정 폼(Ghost형 3분할, 요약 1fr·입력 2fr, 원형 썸네일 hover 등록·변경·삭제, 기본 정보·레이블·관리자 노트·활동 요약, 설정 메뉴의 비밀번호 변경·멤버 삭제 모달, 미저장 변경사항 이탈 확인) |
@@ -81,7 +87,7 @@
| components/content/ProseList.vue | 목록 |
| components/content/ProseBlockquote.vue | 인용구 |
| components/content/ProseButton.vue | 버튼 |
| components/content/ProseCallout.vue | Callout 카드 |
| components/content/ProseCallout.vue | Callout 카드(Emoji 표시/숨김, 배경 프리셋, 상단 여백 중심) |
| components/content/ProseToggle.vue | Toggle 카드 |
| components/content/ProseVideo.vue | 비디오 |
| components/content/ProseAudio.vue | 오디오 |

View File

@@ -58,6 +58,7 @@
- 게시물 화면은 레이아웃 그리드의 좌우 패딩을 기본으로 사용하고, 섹션 단위 `px-*`는 내부 래퍼로만 두어 패딩이 2중 적용되지 않게 한다.
- 댓글 시작 섹션의 구분선(`border-y`)은 패딩 없이 전체 폭으로 표시하고, 내용만 내부 래퍼에 패딩을 둔다.
- 본문 끝과 댓글 섹션 시작 사이 간격은 `48px`(`mt-12`)로 유지한다.
- 댓글은 로그인 회원만 작성 가능하며, 대댓글은 1단까지만 허용한다.
- 댓글은 작성자 썸네일(없으면 이니셜)과 좋아요 수를 표시한다.
- 댓글 시간은 24시간 이내일 때 상대 시간(분/시간 전), 이후에는 날짜로 표시한다.
@@ -101,6 +102,7 @@
- 로그인·회원가입(2단계) 비밀번호 입력은 `AuthPasswordVisibilityToggle` SVG(눈 열림/가림) 토글로 표시 여부를 바꾼다. 스크린 리더용 `aria-label`은 필드별 `field-name`으로 구분한다. 텍스트 입력은 `.auth-form-input`으로 글자색·캐럿 등을 보정한다.
- 인증 화면 상태 메시지는 오류/안내를 분리해 `aria-live`로 노출한다.
- 회원가입 1단계의 타이틀/설명은 `GET /api/site-settings``title`, `description` 값을 우선 사용한다.
- 회원 세션 쿠키 서명에는 `MEMBER_SESSION_SECRET`만 사용하며, 관리자 비밀번호를 fallback으로 쓰지 않는다.
### 레이아웃 파일
@@ -196,6 +198,7 @@ components/content/
- 로컬 개발 환경과 NAS 운영 환경은 서로 다른 DB를 사용
- 로컬 개발 서버는 개발 DB만 연결
- NAS 배포 환경은 운영 DB만 연결
- 운영 환경(`NODE_ENV=production`)에서는 `DATABASE_URL` 누락 시 샘플 콘텐츠로 대체하지 않고 서버 오류로 즉시 실패
- 운영 DB 접속 정보는 로컬 기본 `.env`에 기록하지 않음
- DB 관리 도구는 CloudBeaver 등을 사용할 수 있도록 접속 정보를 환경별로 분리
@@ -468,6 +471,7 @@ components/content/
- 에디터 마지막에는 클릭 가능한 빈 문단 블록을 항상 유지하며, 해당 블록이 비어 있으면 저장 콘텐츠에는 포함하지 않는다.
- 제목은 별도 라벨 영역이 아니라 에디터 상단의 큰 제목 입력으로 표시한다.
- 글 작성/수정 화면은 페이지 제목 헤더를 별도로 두지 않고, 폼 상단의 Ghost 스타일 도구막대에서 목록 이동, 상태, 미리보기, 저장, 설정 패널 토글을 제공한다.
- 글 작성/수정 화면의 저장 버튼은 즉시 저장하지 않고, 전체 화면 발행 모달에서 상태(발행/초안/비공개)와 발행 시점(즉시/예약)을 최종 선택한 뒤 확정한다. 모달에서는 행마다 접힌 상태로 현재 선택 요약만 보이고, 행을 눌러 펼친 뒤 버튼으로 값을 고른다. 게시 상태 행에는 Ghost와 같은 종이비행기 아이콘·펼침 화살표 SVG를 쓰고, 발행 시점 행에는 시계 아이콘·동일 화살표를 쓴다. 발행이 아닌 상태에서는 발행 시점 행 자체를 숨긴다.
- 글 작성/수정 화면의 저장 버튼은 현재 입력값이 마지막 저장 기준점과 다를 때만 활성화한다.
- 글 작성/수정 화면은 관리자 사이드바 네비게이션을 숨기고, 관리자 공통 내부 패딩 없이 전체 화면 편집 모드로 표시한다.
- 글 작성/수정 화면은 브라우저 문서가 아니라 에디터 작업 영역 내부에서만 세로 스크롤한다.
@@ -483,7 +487,7 @@ components/content/
- 새 글 작성처럼 본문이 비어 있는 경우에도 빈 문단 블록을 먼저 생성한다.
- 글 작성/수정 중인 입력값은 브라우저 `localStorage`에 자동 저장한다.
- 자동 저장 키는 `SORI_ADMIN_POST_AUTOSAVE:new` 또는 `SORI_ADMIN_POST_AUTOSAVE:{postId}` 형식을 사용한다.
- 자동 저장본이 있으면 작성 화면에서 복원 또는 삭제를 선택할 수 있다.
- 자동 저장본이 있으면 상단 툴바의 상태 문구 옆에서 복원 또는 무시(로컬 초안 삭제)를 선택할 수 있다.
- 글 저장 성공 시 해당 자동 저장본은 삭제한다.
- 미리보기 버튼은 현재 작성 폼 값을 `SORI_ADMIN_POST_PREVIEW` 브라우저 저장소에 기록한 뒤 `/admin/posts/preview` 새 탭에서 렌더링한다.
- 미리보기 화면은 공개 게시물 상세와 같은 본문 렌더링 컴포넌트를 사용하되, 데이터베이스에는 임시 글을 저장하지 않는다.
@@ -518,6 +522,9 @@ components/content/
- 공개 갤러리는 한 줄에 2~3개 이미지 그리드로 표시하고 클릭 시 라이트박스로 크게 확인한다.
- 이미지와 갤러리 블록은 기존 업로드 미디어 선택 또는 새 이미지 업로드를 제공한다.
- 콜아웃 블록은 `:::callout` fenced block 안에 본문을 저장한다.
- 콜아웃 블록은 선언부 옵션으로 `emoji`, `bg`를 저장할 수 있다. 예: `:::callout emoji=💡 bg=blue`
- `emoji=none`이면 공개 렌더러에서 이모지를 숨긴다.
- 콜아웃 배경 프리셋은 `gray`, `blue`, `green`, `yellow`, `red`, `purple`, `pink`를 지원한다.
- 토글 블록은 `:::toggle 제목` fenced block 안에 펼침 본문을 저장한다.
- 임베드 블록은 `:::embed` fenced block 안에 URL을 저장한다.
- YouTube 임베드 URL은 공개 화면에서 iframe으로 렌더링한다.
@@ -585,7 +592,7 @@ components/content/
- 회원 로그인 성공 시 `previous_last_seen_at`/`previous_last_seen_ip`에 직전 로그인 값을 보존한 뒤 `last_seen_at`/`last_seen_ip`를 현재 로그인으로 갱신한다. `/api/auth/me`는 세션 확인만 수행하고 로그인 이력을 갱신하지 않는다.
- 사용자 설정 화면은 공개 본문 폭에 맞춰 프로필 요약을 상단에 두고, 프로필 입력과 활동 정보를 하단에 배치한다. 비밀번호 변경과 회원 탈퇴는 설정 버튼의 모달 액션으로만 노출한다. 활동 정보의 `마지막 로그인`은 현재 로그인 이전에 저장된 `previous_last_seen_at`을 표시한다.
- 첫 회원가입 시 관리자 세션도 함께 설정되어 관리자 화면(`/admin`)으로 바로 진입할 수 있다.
- 회원 세션 서명은 `MEMBER_SESSION_SECRET`을 우선 사용하, 값이 없으면 개발 편의를 위해 `ADMIN_PASSWORD`를 fallback으로 사용한다.
- 회원 세션 서명은 `MEMBER_SESSION_SECRET` 사용하, 값이 없으면 서버 오류로 실패한다.
---

View File

@@ -31,6 +31,7 @@
- [ ] ProseFile 실제 파일 데이터 연결
- [ ] ProseProduct 실제 상품 카드 데이터 연결
- [ ] ProseHeaderCard Simple/Wide/Full-width/Split 변형 구현
## 데이터베이스
- [ ] NAS 운영 DB 연결 설정 실제 값 작성
@@ -38,7 +39,5 @@
## 배포
- [ ] UGREEN NAS Docker 배포 가이드 작성
- [ ] Docker 빌드 검증
- [ ] `.env.production` 작성 후 `docker compose --env-file .env.production config` 검증
- [ ] NAS 운영 환경 변수 작성
- [ ] NAS 운영 환경 변수 최종 점검
- [ ] NAS 실제 컨테이너 기동 및 도메인/프록시 접속 QA

View File

@@ -1,5 +1,55 @@
# 업데이트 이력
## 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
- 관리자 게시글 저장 버튼을 변경사항이 있을 때만 활성화하도록 수정.

491
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "sori.studio",
"version": "0.0.118",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "sori.studio",
"version": "0.0.118",
"version": "1.0.0",
"hasInstallScript": true,
"dependencies": {
"@nuxtjs/tailwindcss": "^6.14.0",
@@ -7993,9 +7993,9 @@
}
},
"node_modules/nitropack": {
"version": "2.13.3",
"resolved": "https://registry.npmjs.org/nitropack/-/nitropack-2.13.3.tgz",
"integrity": "sha512-C8vO7RxkU0AQ3HbYUumuG6MVM5JjRaBchke/rYFOp3EvrLtTBHZYhDVGECdpa27vNuOYRzm3GtQMn2YDOjDJLA==",
"version": "2.13.4",
"resolved": "https://registry.npmjs.org/nitropack/-/nitropack-2.13.4.tgz",
"integrity": "sha512-tX7bT6zxNeMwkc6hxHiZeUoTOjVrcjoh1Z3cmxOlodIqjl4HISgqfGOmkWSayky3Nv9Z5+KQH52F8nmXJY5AAA==",
"license": "MIT",
"dependencies": {
"@cloudflare/kv-asset-handler": "^0.4.2",
@@ -8018,18 +8018,18 @@
"croner": "^10.0.1",
"crossws": "^0.3.5",
"db0": "^0.3.4",
"defu": "^6.1.6",
"defu": "^6.1.7",
"destr": "^2.0.5",
"dot-prop": "^10.1.0",
"esbuild": "^0.27.5",
"esbuild": "^0.28.0",
"escape-string-regexp": "^5.0.0",
"etag": "^1.8.1",
"exsolve": "^1.0.8",
"globby": "^16.2.0",
"gzip-size": "^7.0.0",
"h3": "^1.15.10",
"h3": "^1.15.11",
"hookable": "^5.5.3",
"httpxy": "^0.5.0",
"httpxy": "^0.5.1",
"ioredis": "^5.10.1",
"jiti": "^2.6.1",
"klona": "^2.0.6",
@@ -8045,23 +8045,23 @@
"ohash": "^2.0.11",
"pathe": "^2.0.3",
"perfect-debounce": "^2.1.0",
"pkg-types": "^2.3.0",
"pkg-types": "^2.3.1",
"pretty-bytes": "^7.1.0",
"radix3": "^1.1.2",
"rollup": "^4.60.1",
"rollup": "^4.60.2",
"rollup-plugin-visualizer": "^7.0.1",
"scule": "^1.3.0",
"semver": "^7.7.4",
"serve-placeholder": "^2.0.2",
"serve-static": "^2.2.1",
"source-map": "^0.7.6",
"std-env": "^4.0.0",
"ufo": "^1.6.3",
"std-env": "^4.1.0",
"ufo": "^1.6.4",
"ultrahtml": "^1.6.0",
"uncrypto": "^0.1.3",
"unctx": "^2.5.0",
"unenv": "2.0.0-rc.24",
"unimport": "^6.0.2",
"unimport": "^6.2.0",
"unplugin-utils": "^0.3.1",
"unstorage": "^1.17.5",
"untyped": "^2.0.0",
@@ -8085,12 +8085,469 @@
}
}
},
"node_modules/nitropack/node_modules/@esbuild/aix-ppc64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz",
"integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==",
"cpu": [
"ppc64"
],
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/nitropack/node_modules/@esbuild/android-arm": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz",
"integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/nitropack/node_modules/@esbuild/android-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz",
"integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/nitropack/node_modules/@esbuild/android-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz",
"integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/nitropack/node_modules/@esbuild/darwin-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz",
"integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/nitropack/node_modules/@esbuild/darwin-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz",
"integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/nitropack/node_modules/@esbuild/freebsd-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz",
"integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/nitropack/node_modules/@esbuild/freebsd-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz",
"integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/nitropack/node_modules/@esbuild/linux-arm": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz",
"integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/nitropack/node_modules/@esbuild/linux-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz",
"integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/nitropack/node_modules/@esbuild/linux-ia32": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz",
"integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==",
"cpu": [
"ia32"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/nitropack/node_modules/@esbuild/linux-loong64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz",
"integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==",
"cpu": [
"loong64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/nitropack/node_modules/@esbuild/linux-mips64el": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz",
"integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==",
"cpu": [
"mips64el"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/nitropack/node_modules/@esbuild/linux-ppc64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz",
"integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==",
"cpu": [
"ppc64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/nitropack/node_modules/@esbuild/linux-riscv64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz",
"integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==",
"cpu": [
"riscv64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/nitropack/node_modules/@esbuild/linux-s390x": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz",
"integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==",
"cpu": [
"s390x"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/nitropack/node_modules/@esbuild/linux-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz",
"integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/nitropack/node_modules/@esbuild/netbsd-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz",
"integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/nitropack/node_modules/@esbuild/netbsd-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz",
"integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/nitropack/node_modules/@esbuild/openbsd-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz",
"integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/nitropack/node_modules/@esbuild/openbsd-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz",
"integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/nitropack/node_modules/@esbuild/openharmony-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz",
"integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": ">=18"
}
},
"node_modules/nitropack/node_modules/@esbuild/sunos-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz",
"integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/nitropack/node_modules/@esbuild/win32-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz",
"integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/nitropack/node_modules/@esbuild/win32-ia32": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz",
"integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==",
"cpu": [
"ia32"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/nitropack/node_modules/@esbuild/win32-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz",
"integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/nitropack/node_modules/cookie-es": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/cookie-es/-/cookie-es-2.0.1.tgz",
"integrity": "sha512-aVf4A4hI2w70LnF7GG+7xDQUkliwiXWXFvTjkip4+b64ygDQ2sJPRSKFDHbxn8o0xu9QzPkMuuiWIXyFSE2slA==",
"license": "MIT"
},
"node_modules/nitropack/node_modules/esbuild": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz",
"integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==",
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.28.0",
"@esbuild/android-arm": "0.28.0",
"@esbuild/android-arm64": "0.28.0",
"@esbuild/android-x64": "0.28.0",
"@esbuild/darwin-arm64": "0.28.0",
"@esbuild/darwin-x64": "0.28.0",
"@esbuild/freebsd-arm64": "0.28.0",
"@esbuild/freebsd-x64": "0.28.0",
"@esbuild/linux-arm": "0.28.0",
"@esbuild/linux-arm64": "0.28.0",
"@esbuild/linux-ia32": "0.28.0",
"@esbuild/linux-loong64": "0.28.0",
"@esbuild/linux-mips64el": "0.28.0",
"@esbuild/linux-ppc64": "0.28.0",
"@esbuild/linux-riscv64": "0.28.0",
"@esbuild/linux-s390x": "0.28.0",
"@esbuild/linux-x64": "0.28.0",
"@esbuild/netbsd-arm64": "0.28.0",
"@esbuild/netbsd-x64": "0.28.0",
"@esbuild/openbsd-arm64": "0.28.0",
"@esbuild/openbsd-x64": "0.28.0",
"@esbuild/openharmony-arm64": "0.28.0",
"@esbuild/sunos-x64": "0.28.0",
"@esbuild/win32-arm64": "0.28.0",
"@esbuild/win32-ia32": "0.28.0",
"@esbuild/win32-x64": "0.28.0"
}
},
"node_modules/node-addon-api": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz",
@@ -11041,9 +11498,9 @@
"license": "MIT"
},
"node_modules/ufo": {
"version": "1.6.3",
"resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.3.tgz",
"integrity": "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==",
"version": "1.6.4",
"resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.4.tgz",
"integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==",
"license": "MIT"
},
"node_modules/ultrahtml": {

View File

@@ -1,6 +1,6 @@
{
"name": "sori.studio",
"version": "0.0.118",
"version": "1.0.0",
"private": true,
"type": "module",
"imports": {
@@ -12,6 +12,9 @@
"scripts": {
"dev": "node scripts/dev-server.js",
"build": "nuxt build",
"lint": "node scripts/check-js-syntax.js",
"test": "npm run build",
"verify": "npm run lint && npm run test",
"preview": "nuxt preview --dotenv .env.development --host 127.0.0.1 --port 43117",
"db:migrate:dev": "node scripts/migrate-development-db.js",
"postinstall": "nuxt prepare"

View File

@@ -274,7 +274,7 @@ useHead(() => ({
</div>
</section>
<section id="comments" class="mb-6 border-y border-[var(--site-line)] bg-[var(--site-panel-strong)] py-5 scroll-mt-14">
<section id="comments" class="mt-12 mb-6 border-y border-[var(--site-line)] bg-[var(--site-panel-strong)] py-5 scroll-mt-14">
<div class="mx-auto max-w-[720px] px-4 text-sm sm:px-5">
<PostComments :slug="post.slug" />
</div>

View 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`)

View File

@@ -2,6 +2,12 @@ import postgres from 'postgres'
let client = null
/**
* 현재 실행 환경이 운영인지 확인한다.
* @returns {boolean} 운영 환경 여부
*/
const isProductionRuntime = () => process.env.NODE_ENV === 'production'
/**
* PostgreSQL 클라이언트 조회
* @returns {ReturnType<typeof postgres> | null} PostgreSQL 클라이언트
@@ -10,6 +16,10 @@ export const getPostgresClient = () => {
const config = useRuntimeConfig()
if (!config.databaseUrl) {
if (isProductionRuntime()) {
throw new Error('DATABASE_URL_REQUIRED')
}
return null
}

View File

@@ -10,13 +10,12 @@ const sessionMaxAge = 60 * 60 * 24 * 14
*/
const getSessionSecret = () => {
const config = useRuntimeConfig()
const fallbackSecret = String(config.adminPassword || '')
const sessionSecret = String(config.memberSessionSecret || '').trim() || fallbackSecret
const sessionSecret = String(config.memberSessionSecret || '').trim()
if (!sessionSecret) {
throw createError({
statusCode: 500,
message: '회원 세션 비밀값 환경 변수가 없습니다. (MEMBER_SESSION_SECRET 또는 ADMIN_PASSWORD)'
message: '회원 세션 비밀값 환경 변수가 없습니다. MEMBER_SESSION_SECRET을 설정해 주세요.'
})
}
@@ -149,4 +148,3 @@ export const requireMemberSession = (event) => {
return session
}