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

@@ -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]