v1.4.1: 관리자 미디어 업로드 한도·라이브 에디터 UX 개선

종류별 업로드 크기 한도와 413 안내를 추가하고, 임베드·미디어 라이브 프리뷰·제목 Enter 포커스·스크롤 동작을 보정한다.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-21 15:33:23 +09:00
parent f8e04003fd
commit 095a8fa5f0
25 changed files with 1445 additions and 103 deletions

View File

@@ -15,6 +15,9 @@ MEMBER_SESSION_SECRET=replace-with-random-password
# Upload
UPLOAD_DIR=/uploads
MAX_FILE_SIZE=10485760
MAX_VIDEO_FILE_SIZE=209715200
MAX_AUDIO_FILE_SIZE=52428800
MAX_DOCUMENT_FILE_SIZE=52428800
AVATAR_MIN_WIDTH=96
AVATAR_MIN_HEIGHT=96
AVATAR_MAX_WIDTH=512

View File

@@ -421,7 +421,7 @@ const serializeBlocks = () => {
if (block.type === 'embed') {
return block.url.trim()
? { type: block.type, value: `:::embed\n${block.url.trim()}\n:::` }
? { type: block.type, value: block.url.trim() }
: null
}

View File

@@ -10,8 +10,23 @@ import {
resolveSlashCommand
} from '../../lib/markdown-slash-commands.js'
import { getTextareaCaretCoordinates } from '../../lib/textarea-caret-coordinates.js'
import {
buildDefaultUploadSizeLimits,
formatUploadSizeLimit,
getMaxUploadBytesForKind,
getUploadKind
} from '../../lib/upload-size-limit.js'
import AdminSlashCommandIcon from './AdminSlashCommandIcon.vue'
const { toast, showToast } = useAdminToast()
const runtimeConfig = useRuntimeConfig()
const uploadSizeLimits = computed(() => buildDefaultUploadSizeLimits({
image: Number(runtimeConfig.public.maxFileSize || 10485760),
video: Number(runtimeConfig.public.maxVideoFileSize || 209715200),
audio: Number(runtimeConfig.public.maxAudioFileSize || 52428800),
document: Number(runtimeConfig.public.maxDocumentFileSize || 52428800)
}))
const props = defineProps({
modelValue: {
type: [String, Array, Object],
@@ -75,6 +90,21 @@ const liveSlashVisible = computed(() => liveSlashSourceLine.value !== null)
const liveSlashSuppressedLineList = computed(() => [...liveSlashSuppressedLines.value])
const visibleLiveSlashCommands = computed(() => filterSlashCommands(liveSlashQuery.value))
const mediaPickerAccept = computed(() => {
if (['image', 'gallery', 'active-gallery'].includes(mediaPickerTarget.value)) {
return 'image/*'
}
if (mediaPickerTarget.value === 'video') {
return 'video/*'
}
if (mediaPickerTarget.value === 'audio') {
return 'audio/*'
}
return '.pdf,.zip,.txt,.csv,.docx,.xlsx,.pptx'
})
/** 작성 textarea 최소 높이(px) */
const MIN_TEXTAREA_HEIGHT_PX = 620
@@ -346,15 +376,65 @@ watch(activeMode, (mode) => {
nextTick(() => {
syncTextareaHeight()
restoreTextareaFocus()
if (textareaRef.value) {
textareaRef.value.scrollTop = 0
}
})
return
}
nextTick(() => {
previewRef.value?.focus()
scrollPreviewToTop()
})
})
/**
* 라이브 미리보기 스크롤을 맨 위로 맞춘다.
* @returns {void}
*/
const scrollPreviewToTop = () => {
if (previewRef.value) {
previewRef.value.scrollTop = 0
}
}
/**
* 본문 작성 시작 시 포커스할 첫 줄 인덱스를 반환한다.
* @returns {number} 줄 번호(0-based)
*/
const getInitialContentFocusLineIndex = () => {
const lines = (markdownValue.value ?? '').split('\n')
if (!lines.length) {
return 0
}
const firstLine = lines[0]?.trim() ?? ''
if (/^#{1,6}\s+/.test(firstLine)) {
return 1
}
return 0
}
/**
* 본문 작성 시작 줄이 없으면 빈 줄을 추가한다.
* @param {number} lineIndex - 포커스할 줄
* @returns {number} 보정된 줄 번호
*/
const ensureContentFocusLineExists = (lineIndex) => {
const lines = (markdownValue.value ?? '').split('\n')
if (lineIndex < lines.length) {
return lineIndex
}
markdownValue.value = lines.length ? `${markdownValue.value}\n` : ''
return lines.length
}
/**
* 작성 모드와 미리보기 모드를 전환한다.
* @returns {void}
@@ -484,16 +564,36 @@ onMounted(() => {
})
/**
* 본문 에디터에 포커스한다.
* 본문 에디터에 포커스한다. 현재 모드(write/preview)를 유지한다.
* @returns {void}
*/
const focusFirstBlock = () => {
if (activeMode.value !== 'write') {
activeMode.value = 'write'
const focusLine = ensureContentFocusLineExists(getInitialContentFocusLineIndex())
if (activeMode.value === 'preview') {
scrollPreviewToTop()
nextTick(() => {
nextTick(() => {
previewRendererRef.value?.focusEditableAtLine(focusLine, 0, 'start')
})
})
return
}
nextTick(() => {
textareaRef.value?.focus()
const textarea = textareaRef.value
const lines = (markdownValue.value ?? '').split('\n')
const lineStart = lines.slice(0, focusLine).join('\n').length + (focusLine > 0 ? 1 : 0)
if (textarea) {
textarea.focus()
textarea.setSelectionRange(lineStart, lineStart)
textarea.scrollTop = 0
}
activeLogicalLineIndex.value = focusLine
refreshCaretLogicalLine()
})
}
@@ -647,6 +747,120 @@ const insertCodeBlock = () => {
*/
const createImageMarkdown = (image) => serializeImageMarkdown(image)
/**
* 파일 크기를 표시용 문자열로 변환한다.
* @param {number} size - 바이트 크기
* @returns {string} 표시용 크기
*/
const formatMediaFileSize = (size) => {
const value = Number(size || 0)
if (!Number.isFinite(value) || value <= 0) {
return ''
}
if (value < 1024) {
return `${value} B`
}
if (value < 1024 * 1024) {
return `${Math.round(value / 1024)} KB`
}
return `${(value / 1024 / 1024).toFixed(1).replace(/\.0$/, '')} MB`
}
/**
* 미디어 항목 종류를 반환한다.
* @param {Object} item - 미디어 항목
* @returns {'image'|'video'|'audio'|'file'} 미디어 종류
*/
const getMediaItemKind = (item) => {
if (item?.kind) {
return item.kind
}
const source = `${item?.extension || ''} ${item?.url || ''}`.toLowerCase()
if (/\.(jpe?g|png|webp|gif)(\?|$)/i.test(source)) {
return 'image'
}
if (/\.(mp4|webm|mov)(\?|$)/i.test(source)) {
return 'video'
}
if (/\.(mp3|wav|ogg|m4a)(\?|$)/i.test(source)) {
return 'audio'
}
return 'file'
}
/**
* 미디어 항목이 현재 선택 대상과 맞는지 확인한다.
* @param {Object} item - 미디어 항목
* @returns {boolean} 선택 가능 여부
*/
const isMediaItemSelectableForTarget = (item) => {
const kind = getMediaItemKind(item)
if (['image', 'gallery', 'active-gallery'].includes(mediaPickerTarget.value)) {
return kind === 'image'
}
return kind === mediaPickerTarget.value
}
/**
* 드롭된 파일이 현재 미디어 선택 대상에 맞는지 확인한다.
* @param {File} file - 브라우저 파일
* @returns {boolean} 허용 여부
*/
const isUploadFileAllowedForPicker = (file) => {
if (['image', 'gallery', 'active-gallery'].includes(mediaPickerTarget.value)) {
return file.type.startsWith('image/')
}
if (mediaPickerTarget.value === 'video') {
return file.type.startsWith('video/')
}
if (mediaPickerTarget.value === 'audio') {
return file.type.startsWith('audio/')
}
return !file.type.startsWith('image/') && !file.type.startsWith('video/') && !file.type.startsWith('audio/')
}
/**
* 미디어 항목으로 Prose 미디어 블록을 만든다.
* @param {'video'|'audio'|'file'} blockType - 블록 유형
* @param {Object} item - 미디어 항목
* @returns {string[]} 마크다운 줄
*/
const createMediaBlockMarkdown = (blockType, item) => {
const title = item.title || item.name || ''
if (blockType === 'video') {
return [':::video', `url=${item.url}`, `title=${title}`, 'caption=', ':::']
}
if (blockType === 'audio') {
return [':::audio', `url=${item.url}`, `title=${title}`, 'description=', ':::']
}
return [
':::file',
`url=${item.url}`,
`title=${title}`,
'description=',
`name=${item.name || title}`,
`size=${formatMediaFileSize(item.size)}`,
':::'
]
}
/**
* 지정 줄 범위를 새 줄 목록으로 교체한다.
* @param {number} startLine - 시작 줄
@@ -1143,11 +1357,11 @@ const applyLiveSlashCommand = (command) => {
const line = liveSlashSourceLine.value
if (command.action === 'media-image' || command.action === 'media-gallery') {
if (['media-image', 'media-gallery', 'media-video', 'media-audio', 'media-file'].includes(command.action)) {
liveSlashInsertLine.value = line
replaceLineRange(line, line, [], false)
onLiveSlashEnd()
openMediaPicker(command.action === 'media-image' ? 'image' : 'gallery')
openMediaPicker(command.action.replace('media-', ''))
return
}
@@ -1295,11 +1509,7 @@ const updateActiveEmbedUrl = (url) => {
return
}
replaceLineRange(block.startLine, block.endLine, [
':::embed',
trimmed,
':::'
], false)
replaceLineRange(block.startLine, block.endLine, [trimmed], false)
}
/**
@@ -1400,12 +1610,13 @@ const mediaItemMatchesQuery = (item, query) => {
*/
const filteredMediaItems = computed(() => {
const query = mediaSearchQuery.value.trim().toLowerCase()
const targetItems = mediaItems.value.filter(isMediaItemSelectableForTarget)
if (!query) {
return mediaItems.value
return targetItems
}
return mediaItems.value.filter((item) => mediaItemMatchesQuery(item, query))
return targetItems.filter((item) => mediaItemMatchesQuery(item, query))
})
/**
@@ -1414,7 +1625,7 @@ const filteredMediaItems = computed(() => {
* @returns {void}
*/
const toggleMediaSelection = (mediaItem) => {
if (mediaPickerTarget.value === 'image') {
if (!isGalleryMediaPicker.value) {
selectedMediaUrls.value = [mediaItem.url]
return
}
@@ -1425,14 +1636,11 @@ const toggleMediaSelection = (mediaItem) => {
}
/**
* 선택한 미디어를 마크다운에 삽입한다.
* 미디어 항목 목록을 마크다운에 삽입한다.
* @param {Array<Object>} selectedItems - 선택 또는 업로드된 미디어 항목
* @returns {void}
*/
const applyMediaSelection = () => {
const selectedItems = selectedMediaUrls.value
.map((url) => mediaItems.value.find((item) => item.url === url))
.filter(Boolean)
const insertSelectedMediaItems = (selectedItems) => {
if (liveSlashInsertLine.value !== null) {
const line = liveSlashInsertLine.value
@@ -1446,7 +1654,7 @@ const applyMediaSelection = () => {
caption: ''
})])
}
} else if (selectedItems.length) {
} else if (mediaPickerTarget.value === 'gallery' && selectedItems.length) {
insertMarkdownAtLine(line, [
':::gallery',
...selectedItems.map((item) => createImageMarkdown({
@@ -1456,10 +1664,15 @@ const applyMediaSelection = () => {
})),
':::'
])
} else if (['video', 'audio', 'file'].includes(mediaPickerTarget.value)) {
const [item] = selectedItems
if (item) {
insertMarkdownAtLine(line, createMediaBlockMarkdown(mediaPickerTarget.value, item))
}
}
liveSlashInsertLine.value = null
closeMediaPicker()
return
}
@@ -1478,23 +1691,87 @@ const applyMediaSelection = () => {
useAlt: false,
caption: ''
})))
} else {
} else if (mediaPickerTarget.value === 'gallery') {
insertGallery(selectedItems.map((item) => ({
url: item.url,
useAlt: false,
caption: ''
})))
}
} else if (['video', 'audio', 'file'].includes(mediaPickerTarget.value)) {
const [item] = selectedItems
if (item) {
insertBlockSnippet(createMediaBlockMarkdown(mediaPickerTarget.value, item).join('\n'))
}
}
}
/**
* 선택한 미디어를 마크다운에 삽입한다.
* @returns {void}
*/
const applyMediaSelection = () => {
const selectedItems = selectedMediaUrls.value
.map((url) => mediaItems.value.find((item) => item.url === url))
.filter(Boolean)
insertSelectedMediaItems(selectedItems)
closeMediaPicker()
}
/**
* 이미지 파일을 업로드한다.
* 업로드 API 오류 메시지를 사용자용 문장으로 변환한다.
* @param {unknown} error - fetch 오류
* @returns {string} 오류 메시지
*/
const resolveUploadFetchErrorMessage = (error) => {
const responseMessage = error?.data?.message || error?.response?._data?.message
if (typeof responseMessage === 'string' && responseMessage.trim()) {
return responseMessage.trim()
}
if (error?.statusCode === 413 || error?.status === 413) {
return '업로드 가능한 파일 크기를 초과했습니다.'
}
if (error instanceof Error && error.message) {
return error.message
}
return '파일 업로드에 실패했습니다.'
}
/**
* 선택 파일이 업로드 한도를 넘지 않는지 검사한다.
* @param {FileList|Array<File>} files - 업로드 파일 목록
* @returns {string|null} 초과 시 오류 메시지, 아니면 null
*/
const findUploadSizeLimitError = (files) => {
for (const file of Array.from(files)) {
const uploadKind = getUploadKind(file.type, file.name)
const maxBytes = getMaxUploadBytesForKind(uploadKind, uploadSizeLimits.value)
if (file.size > maxBytes) {
return `파일 크기가 너무 큽니다. (최대 ${formatUploadSizeLimit(maxBytes)})`
}
}
return null
}
/**
* 미디어 파일을 업로드한다.
* @param {FileList|Array<File>} files - 업로드 파일 목록
* @returns {Promise<Array<Object>>} 업로드된 파일 목록
*/
const uploadImages = async (files) => {
const uploadMediaFiles = async (files) => {
const sizeLimitError = findUploadSizeLimitError(files)
if (sizeLimitError) {
throw new Error(sizeLimitError)
}
const formData = new FormData()
Array.from(files).forEach((file) => {
formData.append('files', file)
@@ -1511,7 +1788,7 @@ const uploadImages = async (files) => {
/**
* 업로드 파일을 현재 커서 위치에 삽입한다.
* @param {FileList|Array<File>} files - 파일 목록
* @param {'image'|'gallery'} target - 삽입 대상
* @param {'image'|'gallery'|'video'|'audio'|'file'} target - 삽입 대상
* @returns {Promise<void>}
*/
const uploadAndInsert = async (files, target = 'image') => {
@@ -1522,20 +1799,24 @@ const uploadAndInsert = async (files, target = 'image') => {
isUploading.value = true
try {
const uploadedFiles = await uploadImages(files)
const images = uploadedFiles.map((file) => ({
const uploadedFiles = await uploadMediaFiles(files)
const imageItems = uploadedFiles.map((file) => ({
url: file.url,
useAlt: false,
caption: ''
}))
if (target === 'gallery') {
insertGallery(images)
} else if (images[0]) {
insertImage(images[0])
insertGallery(imageItems)
} else if (target === 'image' && imageItems[0]) {
insertImage(imageItems[0])
} else if (['video', 'audio', 'file'].includes(target) && uploadedFiles[0]) {
insertBlockSnippet(createMediaBlockMarkdown(target, uploadedFiles[0]).join('\n'))
}
mediaItems.value = await $fetch('/admin/api/media').catch(() => mediaItems.value)
} catch (error) {
showToast('error', resolveUploadFetchErrorMessage(error))
} finally {
isUploading.value = false
}
@@ -1544,7 +1825,7 @@ const uploadAndInsert = async (files, target = 'image') => {
/**
* 파일 입력 변경 처리
* @param {Event} event - 파일 입력 이벤트
* @param {'image'|'gallery'} target - 삽입 대상
* @param {'image'|'gallery'|'video'|'audio'|'file'} target - 삽입 대상
* @returns {Promise<void>}
*/
const handleFileInput = async (event, target) => {
@@ -1564,10 +1845,23 @@ const uploadFromMediaModal = async (files) => {
const target = mediaPickerTarget.value === 'gallery' || mediaPickerTarget.value === 'active-gallery'
? 'gallery'
: 'image'
: mediaPickerTarget.value
await uploadAndInsert(files, target)
closeMediaPicker()
isUploading.value = true
try {
const uploadedFiles = await uploadMediaFiles(files)
mediaItems.value = await $fetch('/admin/api/media').catch(() => [
...uploadedFiles,
...mediaItems.value
])
insertSelectedMediaItems(target === 'gallery' ? uploadedFiles : uploadedFiles.slice(0, 1))
closeMediaPicker()
} catch (error) {
showToast('error', resolveUploadFetchErrorMessage(error))
} finally {
isUploading.value = false
}
}
/**
@@ -1576,7 +1870,7 @@ const uploadFromMediaModal = async (files) => {
* @returns {Promise<void>}
*/
const handleMediaModalDrop = async (event) => {
const files = Array.from(event.dataTransfer?.files || []).filter((file) => file.type.startsWith('image/'))
const files = Array.from(event.dataTransfer?.files || []).filter(isUploadFileAllowedForPicker)
if (!files.length) {
return
@@ -1595,6 +1889,18 @@ const mediaPickerTitle = computed(() => {
return '갤러리 이미지 선택'
}
if (mediaPickerTarget.value === 'video') {
return '비디오 선택'
}
if (mediaPickerTarget.value === 'audio') {
return '오디오 선택'
}
if (mediaPickerTarget.value === 'file') {
return '파일 선택'
}
return '이미지 선택'
})
@@ -1605,6 +1911,30 @@ const mediaPickerTitle = computed(() => {
const isGalleryMediaPicker = computed(() => mediaPickerTarget.value === 'gallery'
|| mediaPickerTarget.value === 'active-gallery')
/**
* 미디어 업로드 안내 문구를 반환한다.
* @returns {string} 안내 문구
*/
const mediaPickerUploadHint = computed(() => {
if (isGalleryMediaPicker.value) {
return `여러 이미지를 한 번에 선택할 수 있습니다. (최대 ${formatUploadSizeLimit(uploadSizeLimits.value.image)})`
}
if (mediaPickerTarget.value === 'video') {
return `MP4, WebM, MOV 비디오를 업로드할 수 있습니다. (최대 ${formatUploadSizeLimit(uploadSizeLimits.value.video)})`
}
if (mediaPickerTarget.value === 'audio') {
return `MP3, WAV, OGG, M4A 오디오를 업로드할 수 있습니다. (최대 ${formatUploadSizeLimit(uploadSizeLimits.value.audio)})`
}
if (mediaPickerTarget.value === 'file') {
return `PDF, ZIP, 문서 파일을 업로드할 수 있습니다. (최대 ${formatUploadSizeLimit(uploadSizeLimits.value.document)})`
}
return `단일 이미지가 본문에 삽입됩니다. (최대 ${formatUploadSizeLimit(uploadSizeLimits.value.image)})`
})
/**
* 붙여넣은 이미지 파일을 업로드한다.
* @param {ClipboardEvent} event - 붙여넣기 이벤트
@@ -1881,7 +2211,18 @@ const handleKeydown = (event) => {
type="button"
@click="toggleMediaSelection(item)"
>
<img class="admin-markdown-editor__media-thumb aspect-[4/3] w-full bg-[#f6f7f8] object-cover" :src="item.url" :alt="item.name || ''">
<img
v-if="getMediaItemKind(item) === 'image'"
class="admin-markdown-editor__media-thumb aspect-[4/3] w-full bg-[#f6f7f8] object-cover"
:src="item.url"
:alt="item.name || ''"
>
<span
v-else
class="admin-markdown-editor__media-thumb flex aspect-[4/3] w-full items-center justify-center bg-[#f6f7f8] text-xs font-bold uppercase tracking-[0.18em] text-[#6b7280]"
>
{{ getMediaItemKind(item) }}
</span>
<span class="admin-markdown-editor__media-name block truncate px-3 py-2 text-xs font-semibold text-[#394047]">
{{ item.name || item.url }}
</span>
@@ -1892,7 +2233,7 @@ const handleKeydown = (event) => {
{{ mediaSearchQuery.trim() }} 맞는 미디어가 없습니다.
</template>
<template v-else>
선택할 미디어가 없습니다. 업로드 탭에서 이미지를 추가할 있습니다.
선택할 미디어가 없습니다. 업로드 탭에서 파일을 추가할 있습니다.
</template>
</div>
</template>
@@ -1914,13 +2255,13 @@ const handleKeydown = (event) => {
<input
class="sr-only"
type="file"
accept="image/*"
:accept="mediaPickerAccept"
:multiple="isGalleryMediaPicker"
@change="uploadFromMediaModal($event.target.files); $event.target.value = ''"
>
</label>
<p class="admin-markdown-editor__media-upload-hint text-xs text-[#8e9cac]">
{{ isGalleryMediaPicker ? '여러 이미지를 한 번에 선택할 수 있습니다.' : '단일 이미지가 본문에 삽입됩니다.' }}
{{ mediaPickerUploadHint }}
</p>
</div>
</div>
@@ -1942,6 +2283,19 @@ const handleKeydown = (event) => {
</div>
</div>
</div>
<div
v-if="toast"
class="admin-markdown-editor__toast fixed right-5 top-5 z-[100] max-w-[min(24rem,calc(100vw-2.5rem))] rounded border px-4 py-3 text-sm font-semibold shadow-lg"
:class="{
'border-green-200 bg-green-50 text-green-800': toast.type === 'success',
'border-red-200 bg-red-50 text-red-800': toast.type === 'error',
'border-[#e3e6e8] bg-white text-[#15171a]': toast.type === 'info'
}"
role="status"
>
{{ toast.message }}
</div>
</template>
<style scoped>

View File

@@ -168,11 +168,15 @@ const isMarkdownBlockStart = (line) => {
trimmedLine === ':::bookmark' ||
trimmedLine === ':::signup' ||
trimmedLine === ':::gallery' ||
trimmedLine === ':::video' ||
trimmedLine === ':::audio' ||
trimmedLine === ':::file' ||
trimmedLine === ':::embed' ||
trimmedLine.startsWith(':::callout') ||
trimmedLine.startsWith(':::toggle') ||
trimmedLine.startsWith('```') ||
trimmedLine === '---' ||
isStandaloneUrlLine(trimmedLine) ||
/^(#{1,6})\s+(.+)$/.test(trimmedLine) ||
trimmedLine.startsWith('> ') ||
/^- /.test(trimmedLine) ||
@@ -187,6 +191,13 @@ const isMarkdownBlockStart = (line) => {
*/
const cleanParagraphLine = (line) => line.replace(/( {2,}|\\)$/, '').trim()
/**
* 단독 URL 행인지 확인한다.
* @param {string} line - 마크다운 행
* @returns {boolean} 단독 URL 여부
*/
const isStandaloneUrlLine = (line) => /^https?:\/\/\S+$/i.test(String(line || '').trim())
/**
* 빈 줄 공백 블록 높이를 반환한다.
* @param {Object} block - 렌더링 블록
@@ -305,6 +316,67 @@ const parseSignupMeta = (raw) => {
return meta
}
/**
* 미디어 fenced 블록 본문에서 URL과 표시 메타를 파싱한다.
* @param {string} raw - fenced 내부 텍스트
* @returns {{url: string, title: string, description: string, poster: string, caption: string, fileName: string, size: string}} 미디어 메타
*/
const parseMediaMeta = (raw) => {
const meta = {
url: '',
title: '',
description: '',
poster: '',
caption: '',
fileName: '',
size: ''
}
const lines = raw.split('\n').map((l) => l.trim()).filter(Boolean)
for (const line of lines) {
const kv = line.match(/^(\w+)=(.*)$/)
if (kv) {
const key = kv[1].toLowerCase()
const val = kv[2].trim()
if (key === 'url' || key === 'src') {
meta.url = val
} else if (key === 'title') {
meta.title = val
} else if (key === 'description' || key === 'desc') {
meta.description = val
} else if (key === 'poster' || key === 'thumbnail') {
meta.poster = val
} else if (key === 'caption') {
meta.caption = val
} else if (key === 'name' || key === 'filename' || key === 'file') {
meta.fileName = val
} else if (key === 'size') {
meta.size = val
}
continue
}
if (!meta.url && (/^https?:\/\//i.test(line) || line.startsWith('/'))) {
meta.url = line
continue
}
if (meta.url && !meta.title) {
meta.title = line
continue
}
if (meta.url && meta.title && !meta.description) {
meta.description = line
}
}
return meta
}
/**
* 마크다운 문자열을 렌더링용 블록 목록으로 변환
* @param {string} markdown - 마크다운 문자열
@@ -401,6 +473,22 @@ const parseMarkdownBlocks = (markdown) => {
continue
}
if ([':::video', ':::audio', ':::file'].includes(trimmedLine)) {
const startLine = index
const blockType = trimmedLine.replace(':::', '')
const { contentLines, nextIndex } = collectFencedLines(lines, index + 1)
const mediaMeta = parseMediaMeta(contentLines.join('\n'))
blocks.push(attachSourceRange(
createBlock(blockType, '', null, `block-${blocks.length}`, { meta: mediaMeta }),
startLine,
nextIndex
))
index = nextIndex
continue
}
if (trimmedLine.startsWith(':::callout')) {
const startLine = index
const { contentLines, nextIndex } = collectFencedLines(lines, index + 1)
@@ -438,6 +526,17 @@ const parseMarkdownBlocks = (markdown) => {
continue
}
if (isStandaloneUrlLine(trimmedLine)) {
const startLine = index
blocks.push(attachSourceRange(
createBlock('embed', '', null, `block-${blocks.length}`, { url: trimmedLine }),
startLine,
startLine
))
index += 1
continue
}
const image = parseImageLine(trimmedLine)
if (image) {
@@ -586,7 +685,13 @@ const focusEditableAtLine = (lineIndex, attempt = 0, cursorPosition = 'auto', ca
return
}
const element = rendererRootRef.value?.querySelector(`[data-source-line="${lineIndex}"]`)
const matches = rendererRootRef.value
? [...rendererRootRef.value.querySelectorAll(`[data-source-line="${lineIndex}"]`)]
: []
const element = matches.find((node) => node.getAttribute('contenteditable') === 'true')
|| matches[0]
|| null
if (!element) {
if (attempt < 8) {
@@ -602,12 +707,19 @@ const focusEditableAtLine = (lineIndex, attempt = 0, cursorPosition = 'auto', ca
const isBlankMarker = /^>\s*$/.test(line) || /^[-*+]\s*$/.test(line) || /^\d+\.\s*$/.test(line.trim())
if (isBlankMarker || !line.trim()) {
element.textContent = ''
element.innerHTML = ''
if (element.getAttribute('contenteditable') === 'true') {
element.textContent = ''
element.innerHTML = ''
}
}
element.focus({ preventScroll: true })
if (element.getAttribute('contenteditable') !== 'true') {
element.scrollIntoView({ block: 'nearest', inline: 'nearest' })
return
}
if (typeof caretOffset === 'number' && caretOffset >= 0) {
setEditableCaretOffset(/** @type {HTMLElement} */ (element), caretOffset)
return
@@ -625,6 +737,7 @@ const focusEditableAtLine = (lineIndex, attempt = 0, cursorPosition = 'auto', ca
}
setEditableCaretOffset(/** @type {HTMLElement} */ (element), 0)
element.scrollIntoView({ block: 'nearest', inline: 'nearest' })
}
defineExpose({
@@ -683,6 +796,18 @@ const getHeadingEditableClass = (level) => {
return `${base} text-[clamp(0.9rem,0.875rem+0.1vw,1rem)]`
}
/**
* 제목 블록 마크다운 줄을 만든다.
* @param {number} level - 제목 레벨
* @param {string} value - 제목 텍스트
* @returns {string} 제목 마크다운 줄
*/
const buildHeadingLine = (level, value) => {
const headingPrefix = `${'#'.repeat(Math.min(Math.max(level, 1), 6))} `
const cleanText = String(value ?? '').replace(/\s+/g, ' ').trim()
return `${headingPrefix}${cleanText}`.trimEnd() || headingPrefix.trim()
}
/**
* commit 이벤트 페이로드를 정규화한다.
* @param {string|{ value: string, raw?: boolean }} payload - 페이로드
@@ -1029,6 +1154,120 @@ const onInsertBelowBlock = (block, options = {}) => {
})
}
/**
* 선택형 프리뷰 카드 블록에 포커스를 준다.
* @param {Event} event - 포커스 유도 이벤트
* @returns {void}
*/
const focusPreviewCardBlock = (event) => {
const target = event.currentTarget
const element = target instanceof HTMLElement
? target.closest('[data-preview-card-block="true"]') || target
: null
if (element instanceof HTMLElement) {
element.focus({ preventScroll: true })
}
}
/**
* 선택형 프리뷰 카드에서 이전/다음 줄로 이동한다.
* @param {Object} block - 프리뷰 카드 블록
* @param {1|-1} direction - 이동 방향
* @returns {void}
*/
const moveFromPreviewCardBlock = (block, direction) => {
const startLine = block.meta?.startLine
const endLine = block.meta?.endLine ?? startLine
if (typeof startLine !== 'number' || typeof endLine !== 'number') {
return
}
if (direction > 0) {
const lines = String(props.content || '').split('\n')
const nextLine = endLine + 1
if (nextLine >= lines.length) {
onInsertBelowBlock(block)
return
}
focusEditableAtLine(nextLine, 0, 'start')
return
}
if (startLine <= 0) {
return
}
focusEditableAtLine(startLine - 1, 0, 'end')
}
/**
* 선택형 프리뷰 카드 블록 전체를 삭제한다.
* @param {Object} block - 프리뷰 카드 블록
* @returns {void}
*/
const deletePreviewCardBlock = (block) => {
if (!props.interactive || typeof block.meta?.startLine !== 'number') {
return
}
const startLine = block.meta.startLine
const endLine = block.meta.endLine ?? startLine
pendingFocusLine.value = startLine > 0 ? startLine - 1 : 0
pendingFocusPosition.value = startLine > 0 ? 'end' : 'start'
emit('block-content-change', {
startLine,
endLine,
replacementLines: []
})
}
/**
* 선택형 프리뷰 카드 블록 키보드 조작을 처리한다.
* @param {KeyboardEvent} event - 키보드 이벤트
* @param {Object} block - 프리뷰 카드 블록
* @returns {void}
*/
const onPreviewCardKeydown = (event, block) => {
const isDeleteShortcut = (event.metaKey || event.ctrlKey) && event.shiftKey && event.key.toLowerCase() === 'k'
const isActionButton = event.target instanceof HTMLElement
&& Boolean(event.target.closest('.content-markdown-renderer__preview-card-action'))
if (isActionButton && (event.key === 'Enter' || event.key === ' ')) {
return
}
if (event.key === 'Backspace' || event.key === 'Delete' || isDeleteShortcut) {
event.preventDefault()
event.stopPropagation()
deletePreviewCardBlock(block)
return
}
if (event.key === 'ArrowDown') {
event.preventDefault()
event.stopPropagation()
moveFromPreviewCardBlock(block, 1)
return
}
if (event.key === 'ArrowUp') {
event.preventDefault()
event.stopPropagation()
moveFromPreviewCardBlock(block, -1)
return
}
if (event.key === 'Enter') {
event.preventDefault()
event.stopPropagation()
onInsertBelowBlock(block)
}
}
/**
* 목록 항목 Enter — 빈 마커 줄이면 문단으로 탈출, 내용이 있으면 아래에 빈 줄만 삽입한다.
* @param {Object} block - 목록 블록
@@ -1118,9 +1357,20 @@ const onHeadingInlineCommit = (block, payload) => {
return
}
const headingPrefix = `${'#'.repeat(Math.min(Math.max(block.level, 1), 6))} `
const cleanText = String(value ?? '').replace(/\s+/g, ' ').trim()
commitInlineBlockLines(block, [`${headingPrefix}${cleanText}`.trimEnd() || headingPrefix.trim()])
commitInlineBlockLines(block, [buildHeadingLine(block.level, value)])
}
/**
* 제목 Enter — 현재 제목 값을 저장하고 아래에 빈 줄을 만든다.
* @param {Object} block - 제목 블록
* @param {string|Object} payload - insert-below 페이로드
* @returns {void}
*/
const onHeadingInsertBelow = (block, payload) => {
const { value } = normalizeInsertBelowPayload(payload)
pendingFocusLine.value = block.meta.startLine + 1
pendingFocusPosition.value = 'start'
commitInlineBlockLines(block, [buildHeadingLine(block.level, value), ''])
}
/**
@@ -1490,12 +1740,10 @@ onBeforeUnmount(() => {
:tag="`h${Math.min(Math.max(block.level, 1), 6)}`"
:block-class="getHeadingEditableClass(block.level)"
enter-mode="insert-below"
allow-raw-toggle
:raw-line="getMarkdownLine(block.meta.startLine)"
:source-line="block.meta.startLine"
:model-value="block.text"
@commit="onHeadingInlineCommit(block, $event)"
@insert-below="onInsertBelowBlock(block)"
@insert-below="onHeadingInsertBelow(block, $event)"
@delete-line="onDeleteLine"
@merge-with-previous="onMergeWithPreviousLine(block.meta.startLine, $event)"
/>
@@ -1658,6 +1906,89 @@ onBeforeUnmount(() => {
:button-label="block.meta.button"
:placeholder="block.meta.placeholder"
/>
<section
v-else-if="['video', 'audio', 'file'].includes(block.type) && interactive"
class="content-markdown-renderer__preview-card group relative my-8 rounded-[12px] outline-none transition-shadow focus-within:ring-2 focus-within:ring-[var(--site-accent)] focus-within:ring-offset-2 focus-visible:ring-2 focus-visible:ring-[var(--site-accent)] focus-visible:ring-offset-2"
:data-source-line="block.meta.startLine"
data-preview-card-block="true"
tabindex="0"
role="group"
:aria-label="`${block.type} 블록`"
@mousedown.capture="focusPreviewCardBlock"
@keydown="onPreviewCardKeydown($event, block)"
>
<button
class="content-markdown-renderer__preview-card-delete content-markdown-renderer__preview-card-action absolute right-2 top-2 z-10 rounded bg-[#15171a]/85 px-2.5 py-1 text-xs font-semibold text-white opacity-0 shadow transition-opacity hover:bg-[#15171a] focus:opacity-100 focus:outline-none focus:ring-2 focus:ring-[var(--site-accent)] group-hover:opacity-100 group-focus-within:opacity-100"
type="button"
:aria-label="`${block.type} 삭제`"
@click.stop="deletePreviewCardBlock(block)"
>
삭제
</button>
<ProseVideo
v-if="block.type === 'video'"
:src="block.meta.url"
:title="block.meta.title"
:poster="block.meta.poster"
:caption="block.meta.caption"
/>
<ProseAudio
v-else-if="block.type === 'audio'"
:src="block.meta.url"
:title="block.meta.title"
:description="block.meta.description"
/>
<ProseFile
v-else
:href="block.meta.url"
:title="block.meta.title"
:description="block.meta.description"
:file-name="block.meta.fileName"
:size="block.meta.size"
/>
</section>
<ProseVideo
v-else-if="block.type === 'video'"
:src="block.meta.url"
:title="block.meta.title"
:poster="block.meta.poster"
:caption="block.meta.caption"
/>
<ProseAudio
v-else-if="block.type === 'audio'"
:src="block.meta.url"
:title="block.meta.title"
:description="block.meta.description"
/>
<ProseFile
v-else-if="block.type === 'file'"
:href="block.meta.url"
:title="block.meta.title"
:description="block.meta.description"
:file-name="block.meta.fileName"
:size="block.meta.size"
/>
<section
v-else-if="block.type === 'embed' && interactive"
class="content-markdown-renderer__embed-live group relative my-8 rounded-[12px] outline-none transition-shadow focus-within:ring-2 focus-within:ring-[var(--site-accent)] focus-within:ring-offset-2 focus-visible:ring-2 focus-visible:ring-[var(--site-accent)] focus-visible:ring-offset-2"
:data-source-line="block.meta.startLine"
data-preview-card-block="true"
tabindex="0"
role="group"
aria-label="임베드 블록"
@mousedown.capture="focusPreviewCardBlock"
@keydown="onPreviewCardKeydown($event, block)"
>
<button
class="content-markdown-renderer__embed-delete content-markdown-renderer__preview-card-action absolute right-2 top-2 z-10 rounded bg-[#15171a]/85 px-2.5 py-1 text-xs font-semibold text-white opacity-0 shadow transition-opacity hover:bg-[#15171a] focus:opacity-100 focus:outline-none focus:ring-2 focus:ring-[var(--site-accent)] group-hover:opacity-100 group-focus-within:opacity-100"
type="button"
aria-label="임베드 삭제"
@click.stop="deletePreviewCardBlock(block)"
>
삭제
</button>
<ProseEmbed :url="block.url" />
</section>
<ProseEmbed v-else-if="block.type === 'embed'" :url="block.url" />
<div
v-else-if="block.type === 'gallery'"

View File

@@ -1,5 +1,60 @@
<script setup>
const props = defineProps({
src: {
type: String,
default: ''
},
title: {
type: String,
default: ''
},
description: {
type: String,
default: ''
}
})
/**
* 표시용 오디오 제목을 반환한다.
* @returns {string} 오디오 제목
*/
const displayTitle = computed(() => props.title || 'Audio')
/**
* 재생 가능한 오디오 URL인지 확인한다.
* @returns {boolean} 오디오 URL 여부
*/
const hasAudioSource = computed(() => Boolean(props.src && (props.src.startsWith('/') || /^https?:\/\//i.test(props.src))))
</script>
<template>
<div class="prose-audio my-8 rounded-[10px] border border-[var(--site-line)] bg-[var(--site-panel)] p-5">
<slot />
</div>
<section class="prose-audio my-8 rounded-[10px] border border-[var(--site-line)] bg-[var(--site-panel)] p-4 shadow-[0_14px_36px_rgba(15,23,42,0.06)] sm:p-5">
<div class="prose-audio__inner flex flex-col gap-4 sm:flex-row sm:items-center">
<div class="prose-audio__icon flex h-20 w-20 shrink-0 items-center justify-center rounded-[6px] bg-[var(--site-accent)] text-white sm:h-[86px] sm:w-[86px]">
<svg xmlns="http://www.w3.org/2000/svg" class="h-9 w-9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M9 18V5l10-2v13" />
<circle cx="6" cy="18" r="3" />
<circle cx="16" cy="16" r="3" />
</svg>
</div>
<div class="prose-audio__body min-w-0 flex-1">
<p class="prose-audio__title mb-2 text-base font-semibold leading-snug text-[var(--site-text)] sm:text-lg">
{{ displayTitle }}
</p>
<p v-if="description" class="prose-audio__description mb-3 text-sm leading-relaxed text-[var(--site-muted)]">
{{ description }}
</p>
<audio
v-if="hasAudioSource"
class="prose-audio__player w-full accent-[var(--site-accent)]"
:src="src"
controls
preload="metadata"
/>
<p v-else class="prose-audio__empty text-sm font-semibold text-[var(--site-muted)]">
오디오 URL이 없습니다.
</p>
</div>
</div>
</section>
</template>

View File

@@ -59,9 +59,48 @@ const getTweetId = (value) => {
return ''
}
/**
* Mastodon 공개 게시물 URL인지 확인하고 embed URL을 반환한다.
* @param {string} value - Mastodon 게시물 URL
* @returns {string} Mastodon embed URL
*/
const getMastodonEmbedUrl = (value) => {
try {
const parsedUrl = new URL(value.trim())
const path = parsedUrl.pathname.replace(/\/$/, '')
const isKnownNonMastodonHost = [
'twitter.com',
'x.com',
'mobile.twitter.com',
'youtube.com',
'www.youtube.com',
'youtu.be'
].includes(parsedUrl.hostname.replace(/^www\./, ''))
if (
isKnownNonMastodonHost ||
!['http:', 'https:'].includes(parsedUrl.protocol)
) {
return ''
}
if (/^\/@[^/]+\/\d+$/.test(path) || /^\/users\/[^/]+\/statuses\/\d+$/.test(path)) {
return `${parsedUrl.origin}${path}/embed`
}
} catch {
return ''
}
return ''
}
const youtubeId = computed(() => getYouTubeId(props.url))
const youtubeEmbedUrl = computed(() => youtubeId.value ? `https://www.youtube.com/embed/${youtubeId.value}` : '')
const tweetId = computed(() => getTweetId(props.url))
const mastodonEmbedUrl = computed(() => getMastodonEmbedUrl(props.url))
const mastodonIframeRef = ref(null)
const mastodonEmbedHeight = ref(640)
const mastodonEmbedId = ref(0)
/**
* 외부 링크로 열어도 되는 URL인지 확인한다.
@@ -92,10 +131,74 @@ const tweetEmbedUrl = computed(() => {
return `https://platform.twitter.com/embed/Tweet.html?id=${encodeURIComponent(tweetId.value)}&theme=${twitterTheme}&dnt=true`
})
/**
* Mastodon embed iframe에 실제 콘텐츠 높이 계산을 요청한다.
* @returns {void}
*/
const requestMastodonEmbedHeight = () => {
if (!mastodonIframeRef.value?.contentWindow || !mastodonEmbedId.value) {
return
}
mastodonIframeRef.value.contentWindow.postMessage({
type: 'setHeight',
id: mastodonEmbedId.value
}, '*')
}
/**
* Mastodon embed 높이 응답을 반영한다.
* @param {MessageEvent} event - iframe 메시지 이벤트
* @returns {void}
*/
const handleMastodonEmbedMessage = (event) => {
const data = event.data || {}
if (
!mastodonIframeRef.value ||
event.source !== mastodonIframeRef.value.contentWindow ||
typeof data !== 'object' ||
data.type !== 'setHeight' ||
data.id !== mastodonEmbedId.value ||
typeof data.height !== 'number'
) {
return
}
try {
const expectedOrigin = new URL(mastodonEmbedUrl.value).origin
if (event.origin !== expectedOrigin) {
return
}
} catch {
return
}
mastodonEmbedHeight.value = Math.max(320, Math.ceil(data.height))
}
onMounted(() => {
mastodonEmbedId.value = Math.floor(Math.random() * 1000000000) + 1
window.addEventListener('message', handleMastodonEmbedMessage)
})
onBeforeUnmount(() => {
window.removeEventListener('message', handleMastodonEmbedMessage)
})
watch(mastodonEmbedUrl, () => {
mastodonEmbedHeight.value = 640
requestMastodonEmbedHeight()
})
</script>
<template>
<div class="prose-embed prose-embed-card my-8 overflow-hidden rounded-[10px] border border-[var(--site-line)] bg-[var(--site-panel)]">
<div
class="prose-embed prose-embed-card my-8 overflow-hidden rounded-[10px]"
:class="tweetEmbedUrl ? 'mx-auto max-w-[550px]' : mastodonEmbedUrl ? 'mx-auto max-w-[560px] border border-[var(--site-line)] bg-[var(--site-panel)]' : 'border border-[var(--site-line)] bg-[var(--site-panel)]'"
>
<iframe
v-if="youtubeEmbedUrl"
class="prose-embed__frame aspect-video w-full"
@@ -108,11 +211,25 @@ const tweetEmbedUrl = computed(() => {
<iframe
v-else-if="tweetEmbedUrl"
:key="tweetEmbedUrl"
class="prose-embed__tweet min-h-[420px] w-full border-0 sm:min-h-[458px]"
class="prose-embed__tweet block min-h-[560px] w-full border-0 sm:min-h-[620px]"
:src="tweetEmbedUrl"
title="Embedded post"
loading="lazy"
/>
<iframe
v-else-if="mastodonEmbedUrl"
:key="mastodonEmbedUrl"
ref="mastodonIframeRef"
class="prose-embed__mastodon block w-full border-0"
:src="mastodonEmbedUrl"
:height="mastodonEmbedHeight"
title="Embedded Mastodon post"
allow="fullscreen"
loading="lazy"
sandbox="allow-scripts allow-same-origin allow-popups"
scrolling="no"
@load="requestMastodonEmbedHeight"
/>
<a
v-else-if="safeExternalUrl"
class="prose-embed__link block p-5 text-sm font-semibold text-[var(--site-text)] hover:opacity-70"

View File

@@ -1,5 +1,85 @@
<script setup>
const props = defineProps({
href: {
type: String,
default: ''
},
title: {
type: String,
default: ''
},
description: {
type: String,
default: ''
},
fileName: {
type: String,
default: ''
},
size: {
type: String,
default: ''
}
})
/**
* 다운로드 가능한 파일 URL인지 확인한다.
* @returns {boolean} 파일 URL 여부
*/
const isSafeFileUrl = computed(() => Boolean(props.href && (props.href.startsWith('/') || /^https?:\/\//i.test(props.href))))
/**
* 카드 제목을 반환한다.
* @returns {string} 제목
*/
const displayTitle = computed(() => props.title || props.fileName || 'File')
/**
* 표시 파일명을 반환한다.
* @returns {string} 파일명
*/
const displayFileName = computed(() => {
if (props.fileName) {
return props.fileName
}
try {
const parsedUrl = props.href.startsWith('/') ? new URL(props.href, 'https://local.invalid') : new URL(props.href)
const lastSegment = parsedUrl.pathname.split('/').filter(Boolean).pop()
return lastSegment ? decodeURIComponent(lastSegment) : ''
} catch {
return ''
}
})
</script>
<template>
<div class="prose-file my-8 rounded-[10px] border border-[var(--site-line)] bg-[var(--site-panel)] p-5">
<slot />
</div>
<a
v-if="isSafeFileUrl"
class="prose-file group my-8 flex items-center gap-4 rounded-[10px] border border-[var(--site-line)] bg-[var(--site-panel)] p-4 no-underline shadow-[0_14px_36px_rgba(15,23,42,0.06)] transition-[background-color,box-shadow] hover:bg-[color-mix(in_srgb,var(--site-panel)_86%,var(--site-text)_14%)] sm:p-5"
:href="href"
download
>
<span class="prose-file__body min-w-0 flex-1">
<span class="prose-file__title block text-base font-semibold leading-snug text-[var(--site-text)] sm:text-lg">
{{ displayTitle }}
</span>
<span v-if="description" class="prose-file__description mt-1 block text-sm leading-relaxed text-[var(--site-muted)]">
{{ description }}
</span>
<span class="prose-file__meta mt-3 block truncate text-sm font-semibold text-[var(--site-text)]">
{{ displayFileName || href }}<template v-if="size"> · {{ size }}</template>
</span>
</span>
<span class="prose-file__download flex h-20 w-20 shrink-0 items-center justify-center rounded-[6px] bg-[color-mix(in_srgb,var(--site-line)_36%,var(--site-panel))] text-[var(--site-accent)] transition-transform group-hover:scale-[1.02] sm:h-[86px] sm:w-[86px]">
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M12 4v11" />
<path d="m8 11 4 4 4-4" />
<path d="M5 20h14" />
</svg>
</span>
</a>
<p v-else class="prose-file prose-file-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>

View File

@@ -1,5 +1,54 @@
<script setup>
const props = defineProps({
src: {
type: String,
default: ''
},
title: {
type: String,
default: ''
},
poster: {
type: String,
default: ''
},
caption: {
type: String,
default: ''
}
})
/**
* 재생 가능한 비디오 URL인지 확인한다.
* @returns {boolean} 비디오 URL 여부
*/
const hasVideoSource = computed(() => Boolean(props.src && (props.src.startsWith('/') || /^https?:\/\//i.test(props.src))))
</script>
<template>
<div class="prose-video my-8 aspect-video overflow-hidden rounded-[10px] border border-[var(--site-line)] bg-[var(--site-panel)]">
<slot />
</div>
<figure class="prose-video my-8">
<div class="prose-video__shell overflow-hidden rounded-[10px] border border-[var(--site-line)] bg-[var(--site-panel)] shadow-[0_16px_40px_rgba(15,23,42,0.08)]">
<video
v-if="hasVideoSource"
class="prose-video__media aspect-video w-full bg-black object-cover"
:src="src"
:poster="poster || undefined"
:title="title || 'Video'"
controls
preload="metadata"
/>
<div
v-else
class="prose-video__empty flex aspect-video w-full items-center justify-center bg-[color-mix(in_srgb,var(--site-line)_45%,var(--site-panel))] text-sm font-semibold text-[var(--site-muted)]"
>
비디오 URL이 없습니다.
</div>
</div>
<figcaption
v-if="caption || title"
class="prose-video__caption mt-3 text-center text-sm leading-relaxed text-[var(--site-muted)]"
>
{{ caption || title }}
</figcaption>
</figure>
</template>

View File

@@ -1,5 +1,18 @@
# 업데이트 요약
## v1.4.1
- 관리자에서 비디오 등 대용량 미디어 업로드 시 적용되던 10MB 공통 한도를 종류별로 분리했다(비디오 기본 200MB).
- 새 임베드 저장 형식을 단독 URL 한 줄로 통일해 `:::embed`와 URL-only가 섞이는 문제를 줄였다.
- 글쓰기 라이브 모드에서 임베드·업로드 미디어 카드를 바로 프리뷰로 표시하고 방향키 이동, 버튼/키보드 삭제를 할 수 있게 했다.
- 글쓰기 라이브 모드에서 제목 입력 후 Enter가 원문 편집처럼 보이던 흐름을 수정했다.
## v1.4.0
- 본문에서 비디오, 오디오, 파일 다운로드 카드를 렌더링할 수 있도록 확장했다.
- X/Twitter 임베드 카드 폭을 조정하고 Mastodon 공개 게시물 임베드의 높이 자동 조절을 추가했다.
- 관리자 글쓰기에서 비디오·오디오·파일 업로드를 바로 연결하고, 단독 URL 한 줄을 자동 임베드로 표시한다.
## v1.2.0
- 관리자 글 목록 정렬·개수·추천 필터·별 표시, 슬러그·예약 시각 UX를 정리했다.

View File

@@ -344,7 +344,7 @@ docker compose --env-file .env.production restart sori-studio-db
- `public/uploads/`는 Git에 포함하지 않는다.
- NAS 운영에서는 `docker-compose.yml``./public/uploads:/app/public/uploads` 볼륨으로 업로드 파일을 유지한다.
- 운영 빌드에서는 `/uploads/**` 서버 라우트가 `.output/public`이 아니라 `/app/public/uploads` 볼륨의 실제 파일을 직접 제공한다.
- `MAX_FILE_SIZE` 환경 변수로 관리자 이미지 업로드 최대 크기를 제한한다.
- `MAX_FILE_SIZE`, `MAX_VIDEO_FILE_SIZE`, `MAX_AUDIO_FILE_SIZE`, `MAX_DOCUMENT_FILE_SIZE` 환경 변수로 관리자 미디어 업로드 최대 크기를 제한한다. 리버스 프록시(Nginx 등)를 쓰면 `client_max_body_size`가 앱 한도보다 작지 않은지 함께 확인한다.
- 관리자 미디어 화면은 현재 업로드 파일 시스템을 기준으로 목록, 파일명 변경, 삭제를 처리한다.
## 사용자 액션 필요 항목

View File

@@ -1,5 +1,35 @@
# 의사결정 이력
## 2026-05-21 v1.4.1 — 라이브 모드 임베드 즉시 프리뷰 전환
단독 URL 붙여넣기가 임베드 작성의 기본 흐름이 되면서 라이브 모드의 별도 URL 입력 카드는 같은 값을 다시 입력하게 만드는 중복 UI가 됐다. 임베드 블록은 즉시 실제 프리뷰로 보여주고, iframe 때문에 일반 텍스트처럼 커서를 둘 수 없는 문제는 프리뷰 래퍼 자체를 포커스 가능한 블록으로 만들어 삭제·아래 줄 추가 키보드 조작을 받도록 했다. 같은 문제가 업로드 비디오·오디오·파일 카드에도 적용되므로 공통 프리뷰 카드 동작으로 확장한다. iframe·audio 컨트롤 등이 내부 포커스를 가져갈 수 있으므로 hover/focus 삭제 버튼을 둔다. 선택된 프리뷰 카드에서 방향키가 페이지 스크롤로 빠지지 않도록 `ArrowUp`·`ArrowDown`은 이전/다음 편집 줄 포커스 이동으로 처리한다. 임베드 전용 왼쪽 핸들은 전체 블록 공통 정책이 아니므로 제거한다.
## 2026-05-21 v1.4.1 — 라이브 모드 제목 Enter 처리 보정
제목 블록에서 Enter를 누를 때 일반 블록 아래 삽입 로직만 타면 현재 제목 편집 값이 먼저 저장되지 않거나 다음 포커스가 원문 줄처럼 보이는 상태로 이어질 수 있었다. 제목 Enter는 제목 마크다운 줄과 아래 빈 줄을 한 번에 반영해, 제목은 유지하고 다음 빈 문단으로 자연스럽게 이동하도록 분리했다.
## 2026-05-21 v1.4.1 — 임베드 저장 형식 단독 URL 통일
단독 URL 한 줄을 자동 임베드로 해석하게 되면서 같은 외부 링크가 `https://...``:::embed` fenced block 두 형식으로 새로 저장될 수 있었다. 작성자가 원본 마크다운을 읽을 때 혼선이 생기지 않도록 새 임베드 삽입, 라이브 편집, 레거시 블록 변환의 저장 형식은 단독 URL 한 줄로 통일하고, 기존 `:::embed` 콘텐츠는 렌더링 호환만 유지한다.
## 2026-05-21 v1.4.1 — 미디어 업로드 크기 한도 분리
관리자 게시물 미디어 업로드 API가 `MAX_FILE_SIZE`(기본 10MB) 하나만 쓰고 있어 동영상 업로드가 413으로 실패했다. 아바타·로고 등 이미지 전용 한도는 유지하고, `POST /admin/api/uploads`만 비디오·오디오·문서별 환경 변수(`MAX_VIDEO_FILE_SIZE` 등)로 검사하도록 분리했다. 에디터 미디어 모달에는 최대 용량 안내와 413 토스트를 추가했다.
## 2026-05-20 v1.4.0
### 미디어 선택과 단독 URL을 작성 흐름에 연결
비디오·오디오·파일 블록을 템플릿만 삽입하면 작성자가 업로드 URL을 직접 복사해야 하므로 이미지/갤러리와 UX가 맞지 않는다. 기존 미디어 모달을 확장해 파일 종류별 선택·업로드 후 fenced block URL과 표시 메타를 자동으로 채우도록 했다. 외부 임베드는 작성자가 `:::embed`를 기억하지 않아도 되도록 단독 `http(s)` URL 한 줄을 같은 임베드 블록으로 해석한다.
### 외부 임베드를 플랫폼별 표시 정책으로 분리
YouTube는 본문 폭 16:9 영상이 자연스럽지만 X/Twitter 공식 iframe은 내부 카드 최대 폭이 고정되어 있어 전체 폭 iframe으로 두면 오른쪽 공백이 커지고 내용이 잘릴 수 있다. X/Twitter와 Mastodon은 소셜 카드로 보고 좁은 폭 중앙 정렬을 적용한다. Mastodon은 인스턴스별 공개 게시물 URL 뒤에 `/embed`를 붙이는 표준 경로를 우선 사용하고, 공식 embed 스크립트와 같은 `postMessage` 높이 요청으로 긴 글이 잘리지 않게 한다. 다만 인스턴스 정책에 따라 iframe 표시가 실패할 수 있으므로 링크 fallback 정책은 유지한다.
### 미디어 Prose 블록을 fenced block 렌더링으로 연결
비디오·오디오·파일은 기존 이미지/갤러리처럼 업로드 URL을 본문 마크다운에 저장하는 방식이 관리와 이식성이 가장 단순하다. 따라서 `:::video`, `:::audio`, `:::file` fenced block을 추가하고, 표시용 메타는 `url=`, `title=` 같은 키값으로 둔다. 공개 화면은 카드형 컴포넌트가 렌더링하고, 관리자 에디터는 슬래시 명령으로 기본 템플릿을 삽입해 이후 업로드/선택 UI 확장과 분리한다.
## 2026-05-20 v1.3.7
### NAS 마이그레이션 명령을 npm 없는 호스트 기준으로 보정

View File

@@ -23,9 +23,12 @@
| 파일 | 용도 |
|------|------|
| lib/upload-size-limit.js | 관리자 미디어 업로드 종류별 최대 바이트 판별·용량 문구(서버·클라이언트 공용) |
| lib/external-favicon-url.js | 외부 URL 호스트 기준 Google `s2/favicons` 썸네일 URL 생성(내부 경로는 빈 문자열) |
| lib/navigation-editor-tree.js | 관리자 메뉴 UI·서버 `renumberSortOrderByTree`가 쓰는 `buildNavigationEditorTree`, 관리자 상단 네비 평면 표용 `flattenNavigationEditorWrappers` |
| lib/markdown-content-normalizer.js | 관리자 Markdown-first 전환 후 레거시 블록 배열·객체 본문 값을 저장용 마크다운 문자열로 변환 |
| lib/markdown-block-context.js | 관리자 Markdown textarea 커서 위치 기준 이미지·갤러리·임베드 블록 설정 패널 대상 판별 |
| lib/markdown-slash-commands.js | 관리자 Markdown-first 에디터 슬래시 명령 목록과 삽입 줄 정의 |
| lib/analytics-shared.js | 통계 추적 경로 필터·체류/스크롤 상수(클라이언트·서버 공용) |
| lib/analytics.js | 서버 전용 visitor/session hash(`node:crypto`) |
@@ -74,7 +77,7 @@
| components/admin/AdminSettingsNavIcon.vue | 사이트 설정 좌측 내비 항목 아이콘(`iconId`별 SVG·미구현 placeholder) |
| components/admin/AdminPostForm.vue | 관리자 글 작성/수정 폼, Ghost형 툴바(영문 상태·Publish/Update/Unpublish/Unschedule, 서버 반영 상태 기준 분기), 초안만 서버 디바운스 자동 저장·신규 임시 슬러그·발행·예약 글은 Update로만 반영, 발행 모달(중앙 배치), 좌우 설정 패널, 미리보기 emit·미저장 이탈 가드, 추천 글 토글 등 |
| components/admin/AdminPageForm.vue | 관리자 페이지 작성/수정 폼, 대표 이미지 선택 |
| components/admin/AdminMarkdownEditor.vue | 관리자 글 Markdown-first 에디터, 라이브·소스 모드 `/` 슬래시 명령·미디어 모달, 커서 블록 컨텍스트·`block-panel` emit |
| components/admin/AdminMarkdownEditor.vue | 관리자 글 Markdown-first 에디터, 라이브·소스 모드 `/` 슬래시 명령·미디어 모달(이미지·갤러리·비디오·오디오·파일), 커서 블록 컨텍스트·`block-panel` emit |
| components/admin/AdminEditorBlockPanel.vue | 게시물 설정 사이드바 오버레이 블록 설정(이미지·갤러리·임베드) |
| components/admin/AdminBlockEditor.vue | 관리자 글 블록형 에디터, 이미지/갤러리/콜아웃/토글/임베드 블록, 콜아웃 Emoji on/off·이모지 프리셋·배경 프리셋 선택(우측 고정 설정 패널), 갤러리 복수 미디어 선택·이미지 수별 열 배치·삽입 위치 표시 드래그 순서 변경, 한글 조합 입력 처리, Shift+Enter 줄바꿈, 코드 블록 단축 변환, AFFiNE 참고 세로 막대형 블록 핸들 선택/삭제/드래그 이동과 삽입선 표시, 하단 빈 입력 블록 유지, 본문 placeholder 표시 |
| components/admin/AdminTagForm.vue | 관리자 태그 생성/수정 폼(이름/슬러그/설명/색상만 편집) |
@@ -103,14 +106,14 @@
| components/content/ProseCallout.vue | Callout 카드(Emoji 표시/숨김, 배경 프리셋, 상단 여백 중심) |
| components/content/ProseToggle.vue | Toggle 카드(펼침 애니메이션, chevron 트리거) |
| components/content/ContentMarkdownToggleEditor.vue | 라이브 모드 토글 제목·본문 인라인 편집 |
| components/content/ProseVideo.vue | 비디오 |
| components/content/ProseAudio.vue | 오디오 |
| components/content/ProseFile.vue | 파일 |
| components/content/ProseVideo.vue | `:::video` 공개 비디오 카드 |
| components/content/ProseAudio.vue | `:::audio` 공개 오디오 플레이어 카드 |
| components/content/ProseFile.vue | `:::file` 공개 다운로드 파일 카드 |
| components/content/ProseProduct.vue | 상품 카드 |
| components/content/ProseBookmark.vue | 본문 북마크 카드(썸네일·도메인·`http(s)` 외부 링크) |
| components/content/ProseSignup.vue | 본문 회원가입·뉴스레터 CTA 카드 |
| components/content/ProseHeaderCard.vue | 헤더 카드 |
| components/content/ProseEmbed.vue | YouTube·Twitter/X iframe 임베드, 기타 `http(s)` 외부 링크 |
| components/content/ProseEmbed.vue | YouTube 영상 iframe, X/Twitter 소셜 iframe, Mastodon `/embed` iframe, 기타 `http(s)` 외부 링크 |
## 관리자 페이지
@@ -125,7 +128,7 @@
| pages/admin/pages/index.vue | 페이지 목록, 행 more vert 메뉴(수정·삭제) |
| pages/admin/pages/new.vue | 페이지 작성, 저장 토스트 |
| pages/admin/pages/[id].vue | 페이지 수정, 저장/삭제 토스트 |
| pages/admin/media/index.vue | 미디어 관리, **미디어 라이브러리/썸네일** 탭, 폴더 트리 more vert(폴더 삭제), 검색·드래그·상세 모달 등 |
| pages/admin/media/index.vue | 미디어 관리, **미디어 라이브러리/썸네일** 탭, 이미지·비디오·오디오·파일 목록, 폴더 트리 more vert(폴더 삭제), 검색·드래그·상세 모달 등 |
| pages/admin/navigation/index.vue | 메뉴 관리: 상단/하단/추천 탭, 행 more vert(메뉴 항목 삭제), 드래그 정렬, `useAdminToast` |
| components/admin/AdminRowMoreMenu.vue | 관리자 행 more vert 메뉴(트리거·팝오버) |
| composables/useAdminRowMenu.js | 관리자 행 메뉴 열림 상태·바깥 클릭 닫기 |
@@ -205,7 +208,7 @@
| server/routes/admin/api/media.delete.js | 관리자 미디어 삭제 API |
| server/routes/admin/api/media-folders.get.js | 관리자 미디어 폴더 목록 API |
| server/routes/admin/api/media-folders.post.js | 관리자 미디어 폴더 생성 API |
| server/routes/admin/api/uploads.post.js | 관리자 게시물·페이지용 이미지 업로드 API(원본명 기반 파일명·충돌 시 넘버링, `/uploads/posts` 저장, 성공 시 `media_metadata``미분류`로 기록) |
| server/routes/admin/api/uploads.post.js | 관리자 게시물·페이지용 미디어 업로드 API(이미지·비디오·오디오·문서, 원본명 기반 파일명·충돌 시 넘버링, `/uploads/posts` 저장, 성공 시 `media_metadata``미분류`로 기록) |
| server/routes/admin/api/member-avatar.post.js | 관리자 새 회원 생성 전 썸네일 사전 업로드 API(`/uploads/members/avatars` 저장, WebP 변환·1:1 크롭) |
| server/routes/admin/api/tags.get.js | 관리자 태그 목록 API(`tagType`, `q`, `limit` 검색 옵션) |
| server/routes/admin/api/tags.post.js | 관리자 태그 생성 API |

View File

@@ -184,6 +184,12 @@ components/content/
- 이미지 갤러리
- `:::gallery` ~ `:::` fenced block 내부에 이미지 마크다운 행을 여러 개 작성
- 렌더링: `ContentMarkdownRenderer.vue` (그리드 + 라이트박스, Esc 닫기·←/→ 이전·다음)
- 비디오·오디오·파일 카드
- 비디오: `:::video` ~ `:::` (`url`, `title`, `poster`, `caption` 키값 또는 URL 단독 줄)
- 오디오: `:::audio` ~ `:::` (`url`, `title`, `description`)
- 파일: `:::file` ~ `:::` (`url`, `title`, `description`, `name`, `size`) — 다운로드 링크 카드
- 렌더링: `ProseVideo.vue`, `ProseAudio.vue`, `ProseFile.vue`
- 관리자 슬래시: `/video`, `/audio`, `/file`로 빈 템플릿 삽입 후 URL·메타 수정
- 문단과 줄바꿈
- 관리자 Markdown-first 에디터에서 Enter는 새 문단(마크다운 한 줄)만 사용한다. Shift+Enter·문단 내 hard break는 지원하지 않는다.
- 공개 본문 렌더러는 마크다운 한 줄을 한 문단으로 렌더링한다(레거시 줄끝 `\\`/공백 2개 표식은 표시 시 제거).
@@ -195,7 +201,7 @@ components/content/
- Toggle: `:::toggle 제목` ~ `:::`
- Bookmark: `:::bookmark` ~ `:::` (본문은 `url=`, `title=`, `description=`, `thumbnail=` 키값 또는 첫 줄 URL·이어지는 제목/설명 줄)
- Signup: `:::signup` ~ `:::` (선택: `title=`, `description=`, `button=`, `placeholder=`)
- Embed: `:::embed` ~ `:::` (YouTube·YouTube Shorts URL은 iframe, `twitter.com`·`x.com` 게시물 URL은 X 공식 embed iframe, 그 외는 외부 링크 카드)
- Embed: 단독 `http(s)` URL 한 줄(기존 `:::embed` ~ `:::`도 렌더링 호환). YouTube·YouTube Shorts URL은 iframe, `twitter.com`·`x.com` 게시물 URL은 X 공식 embed iframe, 그 외는 외부 링크 카드
- 렌더링: `ProseCallout.vue`, `ProseToggle.vue`, `ProseBookmark.vue`, `ProseSignup.vue`, `ProseEmbed.vue`
---
@@ -519,7 +525,7 @@ components/content/
- 툴바 `이미지`·`갤러리`는 미디어 모달을 연다. 모달 기본 탭은 **미디어 라이브러리**이며 **업로드** 탭에서 드래그·파일 선택 후 즉시 삽입한다.
- 미디어 라이브러리에서 단일 이미지를 선택하면 `![alt](url)` 형식으로 삽입한다.
- 미디어 라이브러리에서 여러 이미지를 선택하면 `:::gallery` fenced block으로 삽입한다.
- 작성 모드에서 커서가 이미지 마크다운 줄, `:::gallery`, `:::embed` 블록 안에 있고 textarea(또는 블록 패널)에 포커스가 있으면 게시물 설정 사이드바(420px) 위에 **블록 설정 패널**(`AdminEditorBlockPanel`)이 오른쪽에서 슬라이드 인한다. 본문 포커스가 완전히 이탈하면 슬라이드 아웃한다.
- 작성 모드에서 커서가 이미지 마크다운 줄, `:::gallery`, 단독 URL 임베드 줄 또는 기존 `:::embed` 블록 안에 있고 textarea(또는 블록 패널)에 포커스가 있으면 게시물 설정 사이드바(420px) 위에 **블록 설정 패널**(`AdminEditorBlockPanel`)이 오른쪽에서 슬라이드 인한다. 본문 포커스가 완전히 이탈하면 슬라이드 아웃한다.
- 블록 설정 패널: 이미지·갤러리(캡션, **파일명을 캡션으로 사용** 토글·기본 끔, URL, 갤러리 순서·삭제·추가), 임베드(URL). `AdminMarkdownEditor``block-panel` 이벤트로 상태를 `AdminPostForm`에 전달한다.
- 미디어 라이브러리 갤러리 다중 선택 시 선택 항목은 **주황(`#ff7a00`) 굵은 테두리**로 표시한다.
- 옵시디언식 토큰 숨김/백스페이스 복원 Live Preview는 후속 단계로 둔다.
@@ -574,15 +580,24 @@ components/content/
- 관리자 갤러리 블록의 이미지 순서는 드래그 앤 드롭으로 변경하며, 드래그 중 삽입 위치를 이미지 사이 라인으로 표시한다.
- 공개 갤러리는 한 줄에 2~3개 이미지 그리드로 표시하고 클릭 시 라이트박스로 크게 확인한다.
- 이미지와 갤러리 블록은 기존 업로드 미디어 선택 또는 새 이미지 업로드를 제공한다.
- 관리자 미디어 업로드 API는 이미지(`jpg`, `png`, `webp`, `gif`), 비디오(`mp4`, `webm`, `mov`), 오디오(`mp3`, `wav`, `ogg`, `m4a`), 파일(`pdf`, `zip`, `txt`, `csv`, `docx`, `xlsx`, `pptx`)을 지원한다.
- 콜아웃 블록은 `:::callout` fenced block 안에 본문을 저장한다.
- 콜아웃 블록은 선언부 옵션으로 `emoji`, `bg`를 저장할 수 있다. 예: `:::callout emoji=💡 bg=blue`
- `emoji=none`이면 공개 렌더러에서 이모지를 숨긴다.
- 콜아웃 배경 프리셋은 `gray`, `blue`, `green`, `yellow`, `red`, `purple`, `pink`를 지원한다.
- 토글 블록은 `:::toggle 제목` fenced block 안에 펼침 본문을 저장한다. 라이브 모드에서는 제목·본문을 인라인 편집하며, chevron으로 펼침·접힘 시 본문이 애니메이션된다.
- 임베드 블록은 `:::embed` fenced block 안에 URL을 저장한다.
- YouTube 임베드 URL은 공개 화면에서 iframe으로 렌더링한다.
- Twitter/X 게시물 URL(`twitter.com`·`x.com`·`mobile.twitter.com`, 경로에 `status` 포함)은 `platform.twitter.com/embed/Tweet.html` iframe으로 렌더링하며, 테마는 `useThemeMode()`와 동기화한다.
- 임베드 블록은 단독 `http(s)` URL 한 줄을 기본 저장 형식으로 사용한다.
- 기존 `:::embed` fenced block은 이전 콘텐츠 호환을 위해 계속 파싱·렌더링한다.
- 관리자 Markdown-first 에디터의 라이브/스타일 모드에서 임베드 블록은 URL 입력 카드 없이 즉시 실제 임베드 프리뷰로 표시된다. 임베드·비디오·오디오·파일 프리뷰 카드는 hover/focus 시 우측 상단 삭제 버튼을 표시한다. 블록 래퍼에 포커스한 상태에서 `Backspace`·`Delete`·`Ctrl/Cmd+Shift+K`로 삭제하고, `Enter`로 아래 빈 줄을 추가하며, `ArrowUp`·`ArrowDown`은 브라우저 스크롤 대신 이전/다음 편집 줄로 이동한다.
- 라이브/스타일 모드에서 제목 블록 Enter는 현재 제목 내용을 저장한 뒤 바로 아래 빈 문단을 추가하고, 원문 마크다운 편집 상태로 전환하지 않는다.
- 게시물 작성 화면 상단 제목 입력 후 Enter는 현재 에디터 모드를 유지한 채 본문 첫 줄(마크다운 첫 줄이 제목이면 그 다음 줄)로 포커스를 옮긴다. 라이브 모드 전환 시 미리보기 스크롤은 맨 위에서 시작한다.
- YouTube 임베드 URL은 공개 화면에서 본문 폭 기준 16:9 iframe으로 렌더링한다.
- Twitter/X 게시물 URL(`twitter.com`·`x.com`·`mobile.twitter.com`, 경로에 `status` 포함)은 `platform.twitter.com/embed/Tweet.html` iframe으로 렌더링하며, 테마는 `useThemeMode()`와 동기화한다. X 공식 iframe의 내부 최대 폭 때문에 공개 화면에서는 카드 폭을 좁혀 중앙 정렬한다.
- Mastodon 공개 게시물 URL(`/@user/id`, `/users/user/statuses/id`)은 `{원본 URL}/embed` iframe으로 렌더링한다. iframe 로드 후 Mastodon 공식 embed 방식과 같은 `postMessage` 높이 요청을 보내 응답 높이를 반영한다. 인스턴스가 embed를 차단하거나 지원하지 않으면 브라우저 iframe 정책에 따라 표시되지 않을 수 있다.
- 그 외 URL은 외부 링크 텍스트 카드로 표시한다.
- 비디오 블록은 `:::video` fenced block 안에 `url`, `title`, `poster`, `caption` 값을 저장하며 공개 화면에서 가로형 비디오 카드로 렌더링한다. 관리자 `/video` 슬래시 명령은 비디오 미디어 선택·업로드 모달을 열고 선택 파일 URL을 자동으로 채운다.
- 오디오 블록은 `:::audio` fenced block 안에 `url`, `title`, `description` 값을 저장하며 공개 화면에서 아이콘+플레이어 카드로 렌더링한다. 관리자 `/audio` 슬래시 명령은 오디오 미디어 선택·업로드 모달을 열고 선택 파일 URL을 자동으로 채운다.
- 파일 블록은 `:::file` fenced block 안에 `url`, `title`, `description`, `name`, `size` 값을 저장하며 공개 화면에서 다운로드 카드로 렌더링한다. 관리자 `/file` 슬래시 명령은 문서 파일 선택·업로드 모달을 열고 URL·파일명·크기를 자동으로 채운다.
- 북마크 블록은 `:::bookmark` fenced block으로 저장할 수 있으며 공개 화면에서 Thred형 가로 카드로 렌더링한다.
- 회원가입(뉴스레터) CTA는 `:::signup` fenced block으로 저장할 수 있으며 실제 폼 연동은 후속 작업으로 분리한다.
@@ -674,8 +689,8 @@ components/content/
- 사이트 로고와 파비콘은 `public/uploads/system/`에 저장되며, 업로드 시 `media_metadata.category`는 논리 폴더 **`시스템`**으로 저장한다. 현재 사이트 설정의 `logo_url` 또는 `favicon_url`이 가리키는 파일은 사용 중인 미디어로 표시하고 파일명 변경·삭제를 차단한다.
- 레거시 메타 값 `posts`, `회원/썸네일`은 마이그레이션 `016_media_category_normalize.sql` 및 서버 정규화로 각각 `미분류`, `썸네일`에 맞춘다.
- 관리자 이미지 업로드 API는 `image/jpeg`, `image/png`, `image/webp`, `image/gif`만 허용한다.
- 업로드 파일 크기 제한은 `MAX_FILE_SIZE` 환경 변수를 따른다.
- 관리자 미디어 업로드 API는 이미지·비디오·오디오·문서 확장자를 허용한다(에디터 슬래시·미디어 모달과 동일 목록).
- 업로드 파일 크기 제한은 종류별 환경 변수를 따른다. 이미지·아바타·로고 등은 `MAX_FILE_SIZE`(기본 10MB), 비디오는 `MAX_VIDEO_FILE_SIZE`(기본 200MB), 오디오는 `MAX_AUDIO_FILE_SIZE`(기본 50MB), 문서·ZIP 등은 `MAX_DOCUMENT_FILE_SIZE`(기본 50MB).
- 로컬 개발 업로드 파일은 `public/uploads/posts/YYYY/MM/` 아래 저장하고 `/uploads/posts/YYYY/MM/filename` URL로 제공한다.
- 관리자 미디어 화면 상단에 **미디어 라이브러리** 탭과 **썸네일** 탭을 두어, 라이브러리 탭에서는 게시물·기타 이미지만, 썸네일 탭에서는 `/members/avatars/` 파일만 검색·탐색한다.
- 미디어 라이브러리 탭은 왼쪽 폴더 트리와 오른쪽 고밀도 썸네일 갤러리, 검색, 파일명 변경, 개별 삭제를 제공한다.
@@ -716,6 +731,9 @@ ANALYTICS_HASH_SECRET=replace-with-random-password
# Upload
UPLOAD_DIR=/uploads
MAX_FILE_SIZE=10485760
MAX_VIDEO_FILE_SIZE=209715200
MAX_AUDIO_FILE_SIZE=52428800
MAX_DOCUMENT_FILE_SIZE=52428800
AVATAR_MIN_WIDTH=96
AVATAR_MIN_HEIGHT=96
AVATAR_MAX_WIDTH=512

View File

@@ -25,9 +25,6 @@
- [ ] ProseButton Left/Center 정렬 검증
- [ ] ProseCallout 실제 스타일 세부 조정
- [ ] ProseToggle 실제 스타일 세부 조정
- [ ] ProseVideo 실제 임베드 렌더링 연결
- [ ] ProseAudio 실제 오디오 렌더링 연결
- [ ] ProseFile 실제 파일 데이터 연결
- [ ] ProseProduct 실제 상품 카드 데이터 연결
- [ ] ProseHeaderCard Simple/Wide/Full-width/Split 변형 구현

View File

@@ -1,5 +1,25 @@
# 업데이트 이력
## v1.4.1
- 관리자 미디어 업로드: 이미지·비디오·오디오·문서별 최대 크기 한도 분리(`MAX_VIDEO_FILE_SIZE` 등). 기본 비디오 200MB.
- 관리자 글쓰기: 업로드 크기 초과·API 413 시 토스트 오류 표시, 미디어 모달에 최대 용량 안내 추가.
- `lib/upload-size-limit.js`: 클라이언트 번들에서 `node:path` 제거(500·hydration 경고 수정).
- 임베드 저장 형식: 새 임베드 삽입·라이브 편집·레거시 블록 변환 시 `:::embed` 대신 단독 URL 한 줄로 저장하도록 통일, 단독 URL 줄도 블록 설정 패널 대상으로 인식.
- 관리자 글쓰기: 라이브 모드 임베드를 URL 입력 카드 없이 즉시 프리뷰로 표시, 임베드·비디오·오디오·파일 카드 삭제 버튼과 방향키 이동·삭제·아래 줄 추가 키보드 조작 지원.
- 관리자 글쓰기: 라이브 모드 제목 Enter 시 현재 제목 저장 후 아래 빈 문단 추가, 원문 편집 상태로 전환되던 흐름 수정.
- 관리자 글쓰기: 게시물 상단 제목 Enter 시 소스 모드 강제 전환 제거, 라이브 모드 상단 스크롤·본문 첫 줄 포커스 보정.
## v1.4.0
- 콘텐츠 렌더러: `:::video`, `:::audio`, `:::file` fenced block 파싱과 `ProseVideo`·`ProseAudio`·`ProseFile` 카드 렌더링 연결.
- 관리자 글쓰기: 슬래시 명령에 비디오·오디오·파일 블록 템플릿 추가.
- 임베드: X/Twitter iframe 폭·높이·외곽 여백 보정, Mastodon 공개 게시물 `/embed` 렌더링과 postMessage 기반 자동 높이 조절 추가.
- 관리자 글쓰기: 라이브/스타일 모드의 `:::embed` 블록에 URL 입력 카드와 적용 미리보기 전환 버튼 표시.
- 관리자 글쓰기: `/video`, `/audio`, `/file` 슬래시 명령을 미디어 선택·업로드 모달과 연결해 업로드 후 URL 자동 채우기 지원.
- 콘텐츠 렌더러: 단독 `http(s)` URL 한 줄을 `:::embed` 블록과 동일하게 자동 임베드 렌더링.
- 미디어 라이브러리: 이미지 외 비디오·오디오·문서 파일 업로드와 목록 표시 지원.
## v1.3.9
- NAS 마이그레이션: `psql`이 while 루프 stdin을 소비해 001만 처리되던 `migrate-production-db.sh` 버그 수정.

View File

@@ -37,6 +37,13 @@ const findFencedBlockEnd = (lines, startLine) => {
return -1
}
/**
* 단독 URL 줄인지 확인한다.
* @param {string} line - 마크다운 줄
* @returns {boolean} 단독 URL 여부
*/
const isStandaloneUrlLine = (line) => /^https?:\/\/\S+$/i.test(String(line || '').trim())
/**
* 갤러리 fenced 블록을 파싱한다.
* @param {string[]} lines - 본문 줄 목록
@@ -74,6 +81,17 @@ const resolveGalleryBlock = (lines, currentLine) => {
* @returns {{ kind: 'embed', startLine: number, endLine: number, url: string }|null}
*/
const resolveEmbedBlock = (lines, currentLine) => {
const standaloneUrl = String(lines[currentLine] || '').trim()
if (isStandaloneUrlLine(standaloneUrl)) {
return {
kind: 'embed',
startLine: currentLine,
endLine: currentLine,
url: standaloneUrl
}
}
const embedStart = findFencedBlockStart(lines, currentLine, ':::embed')
if (embedStart === -1) {

View File

@@ -81,7 +81,7 @@ const serializeLegacyBlock = (block = {}, index = 0, total = 1) => {
const url = String(block.url || '').trim()
return url
? { type, value: `:::embed\n${url}\n:::` }
? { type, value: url }
: null
}

View File

@@ -5,7 +5,7 @@
* @property {string} label - 표시 이름
* @property {string} description - 설명
* @property {string[]} keywords - 검색 키워드
* @property {'media-image'|'media-gallery'|'lines'} action - 실행 유형
* @property {'media-image'|'media-gallery'|'media-video'|'media-audio'|'media-file'|'lines'} action - 실행 유형
* @property {string[]} [lines] - 삽입할 마크다운 줄(action이 lines일 때)
* @property {boolean} [showInDefaultMenu=true] - `/`만 입력했을 때 메뉴에 표시할지
*/
@@ -110,10 +110,31 @@ export const MARKDOWN_SLASH_COMMANDS = [
{
id: 'embed',
label: '임베드',
description: 'YouTube·X 등 외부 링크',
description: 'YouTube·X 등 외부 URL 한 줄',
keywords: ['embed', 'youtube', 'link', '임베드', '유튜브'],
action: 'lines',
lines: [':::embed', '', ':::']
lines: ['https://']
},
{
id: 'video',
label: '비디오',
description: '업로드 비디오 카드 선택',
keywords: ['video', 'movie', 'mp4', '비디오', '영상'],
action: 'media-video'
},
{
id: 'audio',
label: '오디오',
description: '오디오 플레이어 카드 선택',
keywords: ['audio', 'music', 'mp3', '오디오', '음악'],
action: 'media-audio'
},
{
id: 'file',
label: '파일',
description: '다운로드 파일 카드 선택',
keywords: ['file', 'download', 'pdf', '파일', '다운로드'],
action: 'media-file'
}
]

113
lib/upload-size-limit.js Normal file
View File

@@ -0,0 +1,113 @@
/**
* 파일명에서 확장자를 추출한다. (브라우저·서버 공용, node:path 미사용)
* @param {string} [filename] - 파일명
* @returns {string} 소문자 확장자(점 포함, 예: `.mp4`)
*/
const getFileExtension = (filename = '') => {
const normalized = String(filename).trim()
const lastDot = normalized.lastIndexOf('.')
if (lastDot <= 0 || lastDot === normalized.length - 1) {
return ''
}
return normalized.slice(lastDot).toLowerCase()
}
/** @type {Record<string, string>} 업로드 종류 */
export const UPLOAD_KIND = {
image: 'image',
video: 'video',
audio: 'audio',
document: 'document'
}
const VIDEO_EXTENSIONS = new Set(['.mp4', '.webm', '.mov'])
const AUDIO_EXTENSIONS = new Set(['.mp3', '.wav', '.ogg', '.m4a'])
const IMAGE_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.webp', '.gif'])
/** @type {Record<string, string>} 업로드 종류 한글 라벨 */
const UPLOAD_KIND_LABELS = {
[UPLOAD_KIND.image]: '이미지',
[UPLOAD_KIND.video]: '비디오',
[UPLOAD_KIND.audio]: '오디오',
[UPLOAD_KIND.document]: '파일'
}
/**
* 기본 업로드 크기 한도(바이트)를 만든다.
* @param {Object} [overrides] - 종류별 한도 덮어쓰기
* @returns {{ image: number, video: number, audio: number, document: number }} 종류별 한도
*/
export const buildDefaultUploadSizeLimits = (overrides = {}) => ({
image: 10485760,
video: 209715200,
audio: 52428800,
document: 52428800,
...overrides
})
/**
* MIME·파일명으로 업로드 종류를 판별한다.
* @param {string} [mimeType] - MIME 타입
* @param {string} [filename] - 파일명
* @returns {'image'|'video'|'audio'|'document'} 업로드 종류
*/
export const getUploadKind = (mimeType = '', filename = '') => {
const extension = getFileExtension(filename)
if (mimeType.startsWith('video/') || VIDEO_EXTENSIONS.has(extension)) {
return UPLOAD_KIND.video
}
if (mimeType.startsWith('audio/') || AUDIO_EXTENSIONS.has(extension)) {
return UPLOAD_KIND.audio
}
if (mimeType.startsWith('image/') || IMAGE_EXTENSIONS.has(extension)) {
return UPLOAD_KIND.image
}
return UPLOAD_KIND.document
}
/**
* 업로드 종류별 최대 바이트를 반환한다.
* @param {'image'|'video'|'audio'|'document'} kind - 업로드 종류
* @param {{ image: number, video: number, audio: number, document: number }} limits - 종류별 한도
* @returns {number} 최대 바이트
*/
export const getMaxUploadBytesForKind = (kind, limits) => limits[kind] ?? limits.image
/**
* 바이트를 사람이 읽기 쉬운 용량 문자열로 변환한다.
* @param {number} bytes - 바이트
* @returns {string} 용량 문자열
*/
export const formatUploadSizeLimit = (bytes) => {
if (!Number.isFinite(bytes) || bytes <= 0) {
return '0B'
}
if (bytes >= 1073741824) {
const gigabytes = bytes / 1073741824
return `${Number.isInteger(gigabytes) ? gigabytes : gigabytes.toFixed(1)}GB`
}
if (bytes >= 1048576) {
return `${Math.round(bytes / 1048576)}MB`
}
if (bytes >= 1024) {
return `${Math.round(bytes / 1024)}KB`
}
return `${bytes}B`
}
/**
* 업로드 종류 한글 라벨을 반환한다.
* @param {'image'|'video'|'audio'|'document'} kind - 업로드 종류
* @returns {string} 한글 라벨
*/
export const getUploadKindLabel = (kind) => UPLOAD_KIND_LABELS[kind] || UPLOAD_KIND_LABELS[UPLOAD_KIND.image]

View File

@@ -67,6 +67,9 @@ export default defineNuxtConfig({
emailOtpPepper: process.env.EMAIL_OTP_PEPPER || '',
uploadDir: process.env.UPLOAD_DIR || '/uploads',
maxFileSize: Number(process.env.MAX_FILE_SIZE || 10485760),
maxVideoFileSize: Number(process.env.MAX_VIDEO_FILE_SIZE || 209715200),
maxAudioFileSize: Number(process.env.MAX_AUDIO_FILE_SIZE || 52428800),
maxDocumentFileSize: Number(process.env.MAX_DOCUMENT_FILE_SIZE || 52428800),
avatarMinWidth: Number(process.env.AVATAR_MIN_WIDTH || 96),
avatarMinHeight: Number(process.env.AVATAR_MIN_HEIGHT || 96),
avatarMaxWidth: Number(process.env.AVATAR_MAX_WIDTH || 512),
@@ -74,7 +77,11 @@ export default defineNuxtConfig({
avatarWebpQuality: Number(process.env.AVATAR_WEBP_QUALITY || 82),
public: {
siteUrl: process.env.NUXT_PUBLIC_SITE_URL || 'https://sori.studio',
siteTitle: process.env.NUXT_PUBLIC_SITE_TITLE || 'sori.studio'
siteTitle: process.env.NUXT_PUBLIC_SITE_TITLE || 'sori.studio',
maxFileSize: Number(process.env.MAX_FILE_SIZE || 10485760),
maxVideoFileSize: Number(process.env.MAX_VIDEO_FILE_SIZE || 209715200),
maxAudioFileSize: Number(process.env.MAX_AUDIO_FILE_SIZE || 52428800),
maxDocumentFileSize: Number(process.env.MAX_DOCUMENT_FILE_SIZE || 52428800)
}
}
})

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "sori.studio",
"version": "1.3.9",
"version": "1.4.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "sori.studio",
"version": "1.3.9",
"version": "1.4.0",
"hasInstallScript": true,
"dependencies": {
"@nuxtjs/tailwindcss": "^6.14.0",

View File

@@ -1,6 +1,6 @@
{
"name": "sori.studio",
"version": "1.3.9",
"version": "1.4.1",
"private": true,
"type": "module",
"imports": {

View File

@@ -57,6 +57,13 @@ const isThumbnailDiskItem = (item) => Boolean(item?.url?.includes('/members/avat
*/
const isMediaItemLocked = (item) => Boolean(item?.usage?.length) || Boolean(item?.avatarOwner)
/**
* 미디어 항목 종류를 반환한다.
* @param {Object} item - 미디어 항목
* @returns {'image'|'video'|'audio'|'file'} 미디어 종류
*/
const getMediaItemKind = (item) => item?.kind || 'image'
const libraryMediaItems = computed(() => (mediaItems.value || []).filter((item) => !isThumbnailDiskItem(item)))
const thumbnailMediaItems = computed(() => (mediaItems.value || []).filter((item) => isThumbnailDiskItem(item)))
@@ -720,7 +727,18 @@ const deleteMedia = async (item) => {
type="button"
@click="openMediaDetail(item)"
>
<img class="admin-media__image aspect-square w-full bg-surface object-cover" :src="item.url" :alt="item.title">
<img
v-if="getMediaItemKind(item) === 'image'"
class="admin-media__image aspect-square w-full bg-surface object-cover"
:src="item.url"
:alt="item.title"
>
<span
v-else
class="admin-media__image flex aspect-square w-full items-center justify-center bg-surface text-xs font-bold uppercase tracking-[0.18em] text-muted"
>
{{ getMediaItemKind(item) }}
</span>
<span
v-if="item.avatarOwner"
class="admin-media__usage-badge pointer-events-none absolute right-1.5 top-1.5 rounded bg-emerald-800 px-1.5 py-0.5 text-[10px] font-semibold text-white"
@@ -796,7 +814,32 @@ const deleteMedia = async (item) => {
>
<section class="admin-media__modal-panel grid max-h-[86vh] w-full max-w-5xl overflow-hidden bg-white text-ink shadow-xl lg:grid-cols-[minmax(0,1fr)_22rem]">
<div class="admin-media__preview grid min-h-[20rem] place-items-center bg-[#f5f5f2] p-5">
<img class="admin-media__preview-image max-h-[72vh] max-w-full object-contain" :src="selectedMedia.url" :alt="selectedMedia.title">
<img
v-if="getMediaItemKind(selectedMedia) === 'image'"
class="admin-media__preview-image max-h-[72vh] max-w-full object-contain"
:src="selectedMedia.url"
:alt="selectedMedia.title"
>
<video
v-else-if="getMediaItemKind(selectedMedia) === 'video'"
class="admin-media__preview-image max-h-[72vh] max-w-full bg-black"
:src="selectedMedia.url"
controls
preload="metadata"
/>
<audio
v-else-if="getMediaItemKind(selectedMedia) === 'audio'"
class="w-full max-w-xl"
:src="selectedMedia.url"
controls
preload="metadata"
/>
<span
v-else
class="rounded border border-line bg-white px-5 py-4 text-sm font-semibold text-muted"
>
{{ selectedMedia.name }}
</span>
</div>
<aside class="admin-media__detail grid max-h-[86vh] content-start gap-5 overflow-y-auto border-l border-line p-5">

View File

@@ -1,14 +1,57 @@
import { mkdir, stat, writeFile } from 'node:fs/promises'
import { extname, join } from 'node:path'
import { createError, readMultipartFormData } from 'h3'
import {
buildDefaultUploadSizeLimits,
formatUploadSizeLimit,
getMaxUploadBytesForKind,
getUploadKind,
getUploadKindLabel
} from '../../../../lib/upload-size-limit.js'
import { requireAdminSession } from '../../../utils/admin-auth'
import { getRuntimeEnvNumber } from '../../../utils/runtime-env.js'
import { upsertMediaMetadataCategory } from '../../../utils/media-library'
const allowedImageTypes = new Map([
const allowedUploadTypes = new Map([
['image/jpeg', '.jpg'],
['image/png', '.png'],
['image/webp', '.webp'],
['image/gif', '.gif']
['image/gif', '.gif'],
['video/mp4', '.mp4'],
['video/webm', '.webm'],
['video/quicktime', '.mov'],
['audio/mpeg', '.mp3'],
['audio/wav', '.wav'],
['audio/ogg', '.ogg'],
['audio/mp4', '.m4a'],
['application/pdf', '.pdf'],
['application/zip', '.zip'],
['text/plain', '.txt'],
['text/csv', '.csv'],
['application/vnd.openxmlformats-officedocument.wordprocessingml.document', '.docx'],
['application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', '.xlsx'],
['application/vnd.openxmlformats-officedocument.presentationml.presentation', '.pptx']
])
const allowedUploadExtensions = new Set([
'.jpg',
'.jpeg',
'.png',
'.webp',
'.gif',
'.mp4',
'.webm',
'.mov',
'.mp3',
'.wav',
'.ogg',
'.m4a',
'.pdf',
'.zip',
'.txt',
'.csv',
'.docx',
'.xlsx',
'.pptx'
])
/**
@@ -29,13 +72,21 @@ const sanitizePathPart = (value) => value
const getUploadExtension = (file) => {
const extension = extname(file.filename || '').toLowerCase()
if (allowedImageTypes.has(file.type)) {
return allowedImageTypes.get(file.type)
if (allowedUploadTypes.has(file.type)) {
return allowedUploadTypes.get(file.type)
}
return extension
}
/**
* 업로드 허용 파일인지 확인한다.
* @param {Object} file - multipart 파일 파트
* @returns {boolean} 허용 여부
*/
const isAllowedUploadFile = (file) => allowedUploadTypes.has(file.type)
|| allowedUploadExtensions.has(extname(file.filename || '').toLowerCase())
/**
* 디렉터리 안에서 비어 있는 저장 파일명을 고른다. 동일 이름이 있으면 `이름-2`, `이름-3` 식으로 넘버링한다.
* @param {string} directoryPath - 저장 디렉터리 절대 경로
@@ -68,7 +119,7 @@ const pickUniqueDiskFileName = async (directoryPath, stem, extension) => {
}
/**
* 관리자 이미지 업로드 API
* 관리자 미디어 업로드 API
* @param {import('h3').H3Event} event - 요청 이벤트
* @returns {Promise<{ files: Array<{ url: string, name: string, size: number }> }>} 업로드 결과
*/
@@ -76,14 +127,19 @@ export default defineEventHandler(async (event) => {
requireAdminSession(event)
const config = useRuntimeConfig()
const maxFileSize = Number(config.maxFileSize || 10485760)
const uploadSizeLimits = buildDefaultUploadSizeLimits({
image: getRuntimeEnvNumber('MAX_FILE_SIZE', 'maxFileSize', 10485760),
video: getRuntimeEnvNumber('MAX_VIDEO_FILE_SIZE', 'maxVideoFileSize', 209715200),
audio: getRuntimeEnvNumber('MAX_AUDIO_FILE_SIZE', 'maxAudioFileSize', 52428800),
document: getRuntimeEnvNumber('MAX_DOCUMENT_FILE_SIZE', 'maxDocumentFileSize', 52428800)
})
const formData = await readMultipartFormData(event)
const files = (formData || []).filter((part) => part.name === 'files' && part.filename)
if (!files.length) {
throw createError({
statusCode: 400,
message: '업로드할 이미지가 없습니다.'
message: '업로드할 파일이 없습니다.'
})
}
@@ -99,17 +155,20 @@ export default defineEventHandler(async (event) => {
const uploadedFiles = []
for (const file of files) {
if (!allowedImageTypes.has(file.type)) {
if (!isAllowedUploadFile(file)) {
throw createError({
statusCode: 400,
message: '이미지 파일만 업로드할 수 있습니다.'
message: '지원하지 않는 파일 형식입니다.'
})
}
const uploadKind = getUploadKind(file.type, file.filename)
const maxFileSize = getMaxUploadBytesForKind(uploadKind, uploadSizeLimits)
if (file.data.length > maxFileSize) {
throw createError({
statusCode: 413,
message: '업로드 가능한 파일 크기를 초과했습니다.'
message: `${getUploadKindLabel(uploadKind)} 업로드 가능 크기를 초과했습니다. (최대 ${formatUploadSizeLimit(maxFileSize)})`
})
}

View File

@@ -5,6 +5,7 @@ import { getSiteSettings, listAdminPosts, listPages } from '../repositories/cont
import { getPostgresClient } from '../repositories/postgres-client'
const uploadRoot = join(process.cwd(), 'public', 'uploads')
const mediaFilePattern = /\.(jpe?g|png|webp|gif|mp4|webm|mov|mp3|wav|ogg|m4a|pdf|zip|txt|csv|docx|xlsx|pptx)$/i
/** 회원 프로필 이미지 전용 논리 폴더명(디스크 경로와 별도) */
export const MEDIA_THUMBNAIL_ROOT = '썸네일'
@@ -377,19 +378,29 @@ const createMediaItem = async (filePath) => {
const fileStat = await stat(filePath)
const relativePath = relative(uploadRoot, filePath)
const url = `/uploads/${relativePath.split('/').join('/')}`
const extension = extname(filePath).toLowerCase()
const kind = /\.(jpe?g|png|webp|gif)$/i.test(extension)
? 'image'
: /\.(mp4|webm|mov)$/i.test(extension)
? 'video'
: /\.(mp3|wav|ogg|m4a)$/i.test(extension)
? 'audio'
: 'file'
return {
url,
name: basename(filePath),
title: basename(filePath, extname(filePath)),
size: fileStat.size,
extension,
kind,
updatedAt: fileStat.mtime.toISOString(),
category: getDefaultMediaCategory(relativePath)
}
}
/**
* 업로드 디렉토리의 이미지 파일을 재귀적으로 조회
* 업로드 디렉토리의 미디어 파일을 재귀적으로 조회
* @param {string} directoryPath - 조회할 디렉토리
* @returns {Promise<Array<Object>>} 미디어 항목 목록
*/
@@ -409,7 +420,7 @@ const readMediaDirectory = async (directoryPath) => {
return readMediaDirectory(entryPath)
}
if (!/\.(jpe?g|png|webp|gif)$/i.test(entry.name)) {
if (!mediaFilePattern.test(entry.name)) {
return []
}