diff --git a/.gitignore b/.gitignore index 3dd4cd6..24201fa 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ node_modules/ .output/ .nuxt/ dist/ +public/uploads/ # Environment .env @@ -26,4 +27,4 @@ Thumbs.db npm-debug.log* # Test -coverage/ \ No newline at end of file +coverage/ diff --git a/components/admin/AdminBlockEditor.vue b/components/admin/AdminBlockEditor.vue index 0f48aca..5fa5c5c 100644 --- a/components/admin/AdminBlockEditor.vue +++ b/components/admin/AdminBlockEditor.vue @@ -15,8 +15,15 @@ const slashQuery = ref('') const slashMenuDirection = ref('down') const highlightedCommandIndex = ref(0) const isApplyingExternalValue = ref(false) +const uploadingBlockIds = ref([]) let blockIdSeed = 0 +const imageWidthOptions = [ + { value: 'regular', label: '기본' }, + { value: 'wide', label: '와이드' }, + { value: 'full', label: '풀사이즈' } +] + const blockCommands = [ { type: 'paragraph', @@ -38,6 +45,18 @@ const blockCommands = [ description: '작은 섹션 제목', keywords: ['h3', 'heading', 'subtitle', '제목'] }, + { + type: 'image', + label: '이미지', + description: '단일 이미지 업로드', + keywords: ['image', 'photo', '이미지', '사진'] + }, + { + type: 'gallery', + label: '갤러리', + description: '여러 이미지 업로드', + keywords: ['gallery', 'images', '갤러리', '사진'] + }, { type: 'quote', label: '인용', @@ -70,15 +89,39 @@ const blockCommands = [ * @param {string} text - 블록 텍스트 * @param {number|null} level - 제목 레벨 * @param {string} id - 블록 ID - * @returns {{ id: string, type: string, text: string, level: number|null }} 에디터 블록 + * @param {Object} options - 추가 블록 옵션 + * @returns {Object} 에디터 블록 */ -const createEditorBlock = (type = 'paragraph', text = '', level = null, id = '') => ({ +const createEditorBlock = (type = 'paragraph', text = '', level = null, id = '', options = {}) => ({ id: id || `editor-block-new-${blockIdSeed += 1}`, type, text, - level + level, + url: options.url || '', + alt: options.alt || '', + width: options.width || 'regular', + images: options.images || [] }) +/** + * 이미지 마크다운 행을 블록 옵션으로 변환 + * @param {string} line - 마크다운 행 + * @returns {Object|null} 이미지 블록 옵션 + */ +const parseImageLine = (line) => { + const match = line.trim().match(/^!\[([^\]]*)\]\(([^)]+)\)(?:\{width=(regular|wide|full)\})?$/) + + if (!match) { + return null + } + + return { + alt: match[1], + url: match[2], + width: match[3] || 'regular' + } +} + /** * 저장된 마크다운 문자열을 에디터 블록으로 변환 * @param {string} markdown - 마크다운 문자열 @@ -98,6 +141,33 @@ const parseMarkdownToBlocks = (markdown) => { continue } + if (trimmedLine === ':::gallery') { + const images = [] + index += 1 + + while (index < lines.length && lines[index].trim() !== ':::') { + const image = parseImageLine(lines[index]) + + if (image) { + images.push(image) + } + + index += 1 + } + + blocks.push(createEditorBlock('gallery', '', null, `editor-block-${blocks.length}`, { images })) + index += 1 + continue + } + + const image = parseImageLine(trimmedLine) + + if (image) { + blocks.push(createEditorBlock('image', '', null, `editor-block-${blocks.length}`, image)) + index += 1 + continue + } + if (trimmedLine.startsWith('```')) { const codeLines = [] index += 1 @@ -145,6 +215,19 @@ const parseMarkdownToBlocks = (markdown) => { return blocks.length ? blocks : [createEditorBlock('paragraph', '', null, 'editor-block-0')] } +/** + * 이미지 블록을 마크다운 문자열로 변환 + * @param {Object} image - 이미지 데이터 + * @returns {string} 이미지 마크다운 + */ +const serializeImage = (image) => { + const width = image.width && image.width !== 'regular' + ? `{width=${image.width}}` + : '' + + return `![${image.alt || ''}](${image.url})${width}` +} + /** * 에디터 블록 목록을 저장용 마크다운으로 변환 * @returns {string} 마크다운 문자열 @@ -158,6 +241,25 @@ const serializeBlocks = () => { return { type: block.type, value: '---' } } + if (block.type === 'image') { + return block.url + ? { type: block.type, value: serializeImage(block) } + : null + } + + if (block.type === 'gallery') { + const images = block.images.filter((image) => image.url) + + if (!images.length) { + return null + } + + return { + type: block.type, + value: [':::gallery', ...images.map((image) => serializeImage(image)), ':::'].join('\n') + } + } + if (!text) { return null } @@ -204,6 +306,13 @@ const emitContent = () => { emit('update:modelValue', serializeBlocks()) } +/** + * 텍스트 입력 블록 여부 반환 + * @param {Object} block - 에디터 블록 + * @returns {boolean} 텍스트 입력 블록 여부 + */ +const isTextBlock = (block) => !['divider', 'image', 'gallery'].includes(block.type) + /** * 블록 DOM 요소를 저장 * @param {Element|null} element - 블록 DOM 요소 @@ -214,7 +323,7 @@ const setBlockRef = (element, index) => { if (element) { blockRefs.value[index] = element - if (element.innerText !== editorBlocks.value[index]?.text) { + if (isTextBlock(editorBlocks.value[index]) && element.innerText !== editorBlocks.value[index]?.text) { element.innerText = editorBlocks.value[index]?.text || '' } } @@ -305,6 +414,23 @@ const getBlockClass = (block) => [ } ] +/** + * 이미지 너비 클래스 반환 + * @param {string} width - 이미지 너비 옵션 + * @returns {string} 클래스 문자열 + */ +const getImageWidthClass = (width) => { + if (width === 'full') { + return 'admin-block-editor__image--full lg:-mx-20' + } + + if (width === 'wide') { + return 'admin-block-editor__image--wide lg:-mx-10' + } + + return 'admin-block-editor__image--regular' +} + /** * 블록 텍스트 입력 처리 * @param {InputEvent} event - 입력 이벤트 @@ -411,6 +537,10 @@ const applyCommand = (command) => { block.type = command.type block.level = command.level || null block.text = '' + block.url = '' + block.alt = '' + block.width = 'regular' + block.images = [] const element = blockRefs.value[index] if (element) { @@ -427,7 +557,132 @@ const applyCommand = (command) => { } emitContent() - focusBlock(index) + + if (isTextBlock(block)) { + focusBlock(index) + } +} + +/** + * 블록 업로드 진행 상태 설정 + * @param {string} blockId - 블록 ID + * @param {boolean} isUploading - 업로드 진행 여부 + * @returns {void} + */ +const setUploading = (blockId, isUploading) => { + uploadingBlockIds.value = isUploading + ? [...new Set([...uploadingBlockIds.value, blockId])] + : uploadingBlockIds.value.filter((id) => id !== blockId) +} + +/** + * 블록 업로드 진행 상태 반환 + * @param {string} blockId - 블록 ID + * @returns {boolean} 업로드 진행 여부 + */ +const isUploading = (blockId) => uploadingBlockIds.value.includes(blockId) + +/** + * 이미지 파일 업로드 + * @param {FileList|Array} files - 업로드 파일 목록 + * @returns {Promise>} 업로드된 파일 목록 + */ +const uploadImages = async (files) => { + const formData = new FormData() + + Array.from(files).forEach((file) => { + formData.append('files', file) + }) + + const result = await $fetch('/admin/api/uploads', { + method: 'POST', + body: formData + }) + + return result.files || [] +} + +/** + * 단일 이미지 파일 선택 처리 + * @param {Event} event - 파일 입력 이벤트 + * @param {Object} block - 이미지 블록 + * @returns {Promise} + */ +const handleImageUpload = async (event, block) => { + const files = event.target.files + + if (!files?.length) { + return + } + + setUploading(block.id, true) + + try { + const [file] = await uploadImages(files) + + if (file) { + block.url = file.url + block.alt = block.alt || file.name.replace(/\.[^.]+$/g, '') + emitContent() + } + } finally { + event.target.value = '' + setUploading(block.id, false) + } +} + +/** + * 갤러리 이미지 파일 선택 처리 + * @param {Event} event - 파일 입력 이벤트 + * @param {Object} block - 갤러리 블록 + * @returns {Promise} + */ +const handleGalleryUpload = async (event, block) => { + const files = event.target.files + + if (!files?.length) { + return + } + + setUploading(block.id, true) + + try { + const uploadedFiles = await uploadImages(files) + block.images = [ + ...block.images, + ...uploadedFiles.map((file) => ({ + url: file.url, + alt: file.name.replace(/\.[^.]+$/g, ''), + width: 'regular' + })) + ] + emitContent() + } finally { + event.target.value = '' + setUploading(block.id, false) + } +} + +/** + * 이미지 너비 옵션 변경 + * @param {Object} block - 이미지 블록 + * @param {string} width - 너비 옵션 + * @returns {void} + */ +const updateImageWidth = (block, width) => { + block.width = width + emitContent() +} + +/** + * 갤러리 이미지 삭제 + * @param {Object} block - 갤러리 블록 + * @param {number} imageIndex - 이미지 인덱스 + * @returns {void} + */ +const removeGalleryImage = (block, imageIndex) => { + block.images.splice(imageIndex, 1) + emitContent() } /** @@ -481,7 +736,7 @@ const handleEnter = (event, index) => { event.preventDefault() - if (currentBlock.type === 'divider') { + if (['divider', 'image', 'gallery'].includes(currentBlock.type)) { editorBlocks.value.splice(index + 1, 0, createEditorBlock()) emitContent() focusBlock(index + 1) @@ -511,7 +766,11 @@ const handleEnter = (event, index) => { const handleBackspace = (event, index) => { const block = editorBlocks.value[index] - if (block.text || editorBlocks.value.length <= 1) { + if ((isTextBlock(block) && block.text) || editorBlocks.value.length <= 1) { + return + } + + if (!isTextBlock(block) && (block.url || block.images.length)) { return } @@ -579,6 +838,80 @@ defineExpose({ class="admin-block-editor__row relative" >
+ +
+
+ +
+ +
+
+ + +
+ + + 구분선 diff --git a/components/content/ContentMarkdownRenderer.vue b/components/content/ContentMarkdownRenderer.vue index 6846853..a8a8728 100644 --- a/components/content/ContentMarkdownRenderer.vue +++ b/components/content/ContentMarkdownRenderer.vue @@ -6,21 +6,48 @@ const props = defineProps({ } }) +const activeLightboxImages = ref([]) +const activeLightboxIndex = ref(0) + /** * 마크다운 블록을 생성 * @param {string} type - 블록 타입 * @param {string|Array} text - 블록 텍스트 * @param {number|null} level - 제목 레벨 * @param {string} id - 블록 ID - * @returns {{ id: string, type: string, text: string|Array, level: number|null }} 블록 + * @param {Object} options - 추가 블록 옵션 + * @returns {Object} 블록 */ -const createBlock = (type = 'paragraph', text = '', level = null, id = '') => ({ +const createBlock = (type = 'paragraph', text = '', level = null, id = '', options = {}) => ({ id, type, text, - level + level, + url: options.url || '', + alt: options.alt || '', + width: options.width || 'regular', + images: options.images || [] }) +/** + * 이미지 마크다운 행을 이미지 데이터로 변환 + * @param {string} line - 마크다운 행 + * @returns {Object|null} 이미지 데이터 + */ +const parseImageLine = (line) => { + const match = line.trim().match(/^!\[([^\]]*)\]\(([^)]+)\)(?:\{width=(regular|wide|full)\})?$/) + + if (!match) { + return null + } + + return { + alt: match[1], + url: match[2], + width: match[3] || 'regular' + } +} + /** * 마크다운 문자열을 렌더링용 블록 목록으로 변환 * @param {string} markdown - 마크다운 문자열 @@ -40,6 +67,33 @@ const parseMarkdownBlocks = (markdown) => { continue } + if (trimmedLine === ':::gallery') { + const images = [] + index += 1 + + while (index < lines.length && lines[index].trim() !== ':::') { + const image = parseImageLine(lines[index]) + + if (image) { + images.push(image) + } + + index += 1 + } + + blocks.push(createBlock('gallery', '', null, `block-${blocks.length}`, { images })) + index += 1 + continue + } + + const image = parseImageLine(trimmedLine) + + if (image) { + blocks.push(createBlock('image', '', null, `block-${blocks.length}`, image)) + index += 1 + continue + } + if (trimmedLine.startsWith('```')) { const codeLines = [] index += 1 @@ -94,6 +148,45 @@ const parseMarkdownBlocks = (markdown) => { } const blocks = computed(() => parseMarkdownBlocks(props.content)) +const activeLightboxImage = computed(() => activeLightboxImages.value[activeLightboxIndex.value]) + +/** + * 라이트박스를 연다 + * @param {Array} images - 이미지 목록 + * @param {number} index - 시작 인덱스 + * @returns {void} + */ +const openLightbox = (images, index) => { + activeLightboxImages.value = images + activeLightboxIndex.value = index +} + +/** + * 라이트박스를 닫는다 + * @returns {void} + */ +const closeLightbox = () => { + activeLightboxImages.value = [] + activeLightboxIndex.value = 0 +} + +/** + * 라이트박스 이전 이미지로 이동 + * @returns {void} + */ +const showPreviousImage = () => { + activeLightboxIndex.value = activeLightboxIndex.value === 0 + ? activeLightboxImages.value.length - 1 + : activeLightboxIndex.value - 1 +} + +/** + * 라이트박스 다음 이미지로 이동 + * @returns {void} + */ +const showNextImage = () => { + activeLightboxIndex.value = (activeLightboxIndex.value + 1) % activeLightboxImages.value.length +} + + diff --git a/docs/deploy.md b/docs/deploy.md index ec4e58c..97c2372 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -141,9 +141,18 @@ docker run -d -p 3000:3000 sori.studio:latest - 로컬 개발 Docker Compose 실행 시 `ENV_FILE=.env.development`와 `--env-file .env.development`를 함께 사용 - 로컬 개발 DB 마이그레이션은 `npm run db:migrate:dev`로 실행 +## 업로드 파일 + +- 관리자 글쓰기에서 업로드한 이미지는 `/uploads/posts/YYYY/MM/` URL로 제공한다. +- 로컬 개발에서는 실제 파일이 `public/uploads/posts/YYYY/MM/` 아래 저장된다. +- `public/uploads/`는 Git에 포함하지 않는다. +- NAS 운영에서는 업로드 파일이 컨테이너 재생성으로 사라지지 않도록 별도 볼륨 연결을 확정해야 한다. +- `MAX_FILE_SIZE` 환경 변수로 관리자 이미지 업로드 최대 크기를 제한한다. + ## 사용자 액션 필요 항목 - NAS SSH 접속 주소 확인. - NAS 프로젝트 루트 경로 확정. - 운영 DB 이름, 계정, 권한 확정. +- 운영 업로드 볼륨 경로 확정. - 도메인 `sori.studio`의 NAS 연결 방식 확정. diff --git a/docs/history.md b/docs/history.md index c7e374e..57a7a89 100644 --- a/docs/history.md +++ b/docs/history.md @@ -1,5 +1,13 @@ # 의사결정 이력 +## 2026-05-01 v0.0.14 + +### 이미지와 갤러리 블록 구현 범위 결정 + +관리자 글쓰기의 이미지 기능은 기존 `content` 필드를 유지하면서 마크다운 확장 문법으로 저장한다. 단일 이미지는 기본 마크다운 이미지 문법에 `{width=wide}` 같은 너비 옵션만 붙이고, 갤러리는 `:::gallery` fenced block 안에 여러 이미지 행을 넣는다. 이렇게 하면 DB 구조를 바꾸지 않고 공개 렌더러와 관리자 에디터를 함께 확장할 수 있다. + +이번 단계에서는 게시물 작성 중 새 이미지를 업로드하고 글에 삽입하는 흐름을 먼저 구현한다. 워드프레스처럼 이미 업로드된 미디어를 선택하거나 파일명 변경, 개별 삭제, 카테고리 분류를 관리하는 기능은 별도 미디어 라이브러리 메뉴에서 다룬다. 글쓰기 화면이 이후 미디어 라이브러리를 호출할 수 있도록 업로드 API와 저장 URL 기준을 먼저 고정한다. + ## 2026-05-01 v0.0.13 ### 개발 서버 로그 요약 방식 결정 diff --git a/docs/map.md b/docs/map.md index adc8494..e7addff 100644 --- a/docs/map.md +++ b/docs/map.md @@ -27,7 +27,7 @@ | 파일 | 화면 위치 | |------|-----------| | components/admin/AdminPostForm.vue | 관리자 글 작성/수정 폼 | -| components/admin/AdminBlockEditor.vue | 관리자 글 블록형 에디터 | +| components/admin/AdminBlockEditor.vue | 관리자 글 블록형 에디터, 이미지/갤러리 블록 | | components/admin/AdminTagForm.vue | 관리자 태그 생성/수정 폼 | ## 콘텐츠 컴포넌트 @@ -91,6 +91,7 @@ | server/routes/admin/api/posts/[id].get.js | 관리자 게시물 상세 API | | server/routes/admin/api/posts/[id].put.js | 관리자 게시물 수정 API | | server/routes/admin/api/posts/[id].delete.js | 관리자 게시물 삭제 API | +| server/routes/admin/api/uploads.post.js | 관리자 이미지 업로드 API | | server/routes/admin/api/tags.get.js | 관리자 태그 목록 API | | server/routes/admin/api/tags.post.js | 관리자 태그 생성 API | | server/routes/admin/api/tags/[id].get.js | 관리자 태그 상세 API | diff --git a/docs/spec.md b/docs/spec.md index 7801ea4..86a96c4 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -188,6 +188,7 @@ components/content/ - `GET /admin/api/posts/:id` - 글 상세 - `PUT /admin/api/posts/:id` - 글 수정 - `DELETE /admin/api/posts/:id` - 글 삭제 +- `POST /admin/api/uploads` - 관리자 이미지 업로드 - `GET /admin/api/tags` - 태그 목록 - `POST /admin/api/tags` - 태그 생성 - `GET /admin/api/tags/:id` - 태그 상세 @@ -207,7 +208,7 @@ components/content/ - `/` 명령 메뉴는 화면 하단 공간이 부족하면 현재 블록 위쪽으로 표시한다. - `/` 명령 메뉴가 열린 상태에서 Enter를 누르면 현재 강조된 메뉴 항목을 적용한다. - `/` 명령 메뉴가 열린 상태에서 위/아래 방향키로 강조 항목을 이동한다. -- 블록 메뉴는 문단, 제목 2, 제목 3, 인용, 목록, 코드, 구분선을 제공한다. +- 블록 메뉴는 문단, 제목 2, 제목 3, 이미지, 갤러리, 인용, 목록, 코드, 구분선을 제공한다. - `#`, `##`, `###`, `>`, `-` 입력 후 공백을 누르면 현재 블록 타입을 즉시 변환한다. - 빈 문단에서 Enter를 누르면 다음 빈 문단 블록을 생성한다. - 빈 블록 placeholder는 현재 활성 블록 또는 첫 빈 블록에만 표시한다. @@ -215,6 +216,11 @@ components/content/ - 제목 입력에서 Enter를 누르면 본문 첫 블록으로 포커스를 이동한다. - 관리자 글 에디터의 실제 입력 텍스트 색상은 placeholder보다 진하게 표시한다. - 관리자 작성 화면과 공개 본문은 같은 마크다운 렌더링 기준을 사용한다. +- 이미지 블록은 관리자 업로드 API로 이미지를 업로드하고 `![alt](url){width=wide}` 형식으로 저장한다. +- 이미지 블록 표시 옵션은 `regular`, `wide`, `full` 값을 사용하며 `regular`는 width 옵션을 생략한다. +- 갤러리 블록은 `:::gallery` fenced block 안에 이미지 마크다운 행을 여러 개 저장한다. +- 공개 갤러리는 한 줄에 2~3개 이미지 그리드로 표시하고 클릭 시 라이트박스로 크게 확인한다. +- 현재 글쓰기 화면은 새 이미지 업로드를 우선 지원하며, 기존 업로드 미디어 선택은 미디어 라이브러리 구현 시 추가한다. ### 관리자 인증 @@ -236,6 +242,11 @@ components/content/ /uploads/system/favicon.png ``` +- 관리자 이미지 업로드 API는 `image/jpeg`, `image/png`, `image/webp`, `image/gif`만 허용한다. +- 업로드 파일 크기 제한은 `MAX_FILE_SIZE` 환경 변수를 따른다. +- 로컬 개발 업로드 파일은 `public/uploads/posts/YYYY/MM/` 아래 저장하고 `/uploads/posts/YYYY/MM/filename` URL로 제공한다. +- 향후 미디어 라이브러리는 업로드 이미지 목록, 기존 미디어 선택, 파일명 변경, 개별 삭제, 카테고리 분류를 제공한다. + --- ## 환경 변수 (.env) diff --git a/docs/todo.md b/docs/todo.md index 9ee2d73..bcdb4f0 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -4,7 +4,8 @@ - [ ] 블록 에디터 브라우저 수동 QA: 빈 줄 Enter, `/` 메뉴 필터, 방향키, Enter 선택, 한글 조합 입력 확인 - [ ] 블록 에디터 저장/수정 왕복 QA: 기존 글 수정 시 블록 파싱, 저장 후 다시 열기 확인 -- [ ] 이미지 업로드 블록 구현 +- [ ] 이미지/갤러리 블록 브라우저 수동 QA: 업로드, 너비 옵션, 저장 후 공개 렌더링, 갤러리 라이트박스 확인 +- [ ] 게시물 작성 시 기존 미디어 선택 또는 새 미디어 업로드 선택 흐름 추가 - [ ] 콜아웃, 토글, 임베드 블록 추가 - [ ] 글 작성 중 자동 저장 @@ -13,7 +14,7 @@ - [ ] 페이지 관리 (CRUD) - [ ] 사이트 설정 - [ ] 메뉴/네비게이션 관리 -- [ ] 미디어 라이브러리 +- [ ] 미디어 라이브러리: 업로드 이미지 목록, 검색, 선택, 파일명 변경, 개별 삭제, 카테고리 분류 ## 3차 관리자 개발 diff --git a/docs/update.md b/docs/update.md index 699bda7..eebab03 100644 --- a/docs/update.md +++ b/docs/update.md @@ -1,5 +1,17 @@ # 업데이트 이력 +## v0.0.14 + +- 관리자 블록 에디터에 단일 이미지 블록 추가. +- 관리자 블록 에디터에 복수 이미지 갤러리 블록 추가. +- 이미지 블록의 기본/와이드/풀사이즈 표시 옵션 추가. +- 관리자 이미지 업로드 API 추가. +- 공개 본문 렌더러에 이미지와 갤러리 렌더링 추가. +- 공개 갤러리 이미지 클릭 시 라이트박스로 크게 보는 기능 추가. +- 업로드 파일이 Git에 포함되지 않도록 `public/uploads/` 제외 처리. +- 향후 미디어 라이브러리 관리 기능 범위 정리. +- 패키지 버전을 0.0.14로 갱신. + ## v0.0.13 - 개발 서버 실행 로그를 프로젝트 전용 링크 요약 출력으로 정리. diff --git a/package-lock.json b/package-lock.json index 2c589a2..d6a366d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "sori.studio", - "version": "0.0.13", + "version": "0.0.14", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sori.studio", - "version": "0.0.13", + "version": "0.0.14", "hasInstallScript": true, "dependencies": { "@nuxtjs/tailwindcss": "^6.14.0", diff --git a/package.json b/package.json index 4518389..22307ee 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sori.studio", - "version": "0.0.13", + "version": "0.0.14", "private": true, "type": "module", "scripts": { diff --git a/server/routes/admin/api/uploads.post.js b/server/routes/admin/api/uploads.post.js new file mode 100644 index 0000000..7e7718d --- /dev/null +++ b/server/routes/admin/api/uploads.post.js @@ -0,0 +1,102 @@ +import { randomUUID } from 'node:crypto' +import { mkdir, writeFile } from 'node:fs/promises' +import { extname, join } from 'node:path' +import { createError, readMultipartFormData } from 'h3' +import { requireAdminSession } from '../../../utils/admin-auth' + +const allowedImageTypes = new Map([ + ['image/jpeg', '.jpg'], + ['image/png', '.png'], + ['image/webp', '.webp'], + ['image/gif', '.gif'] +]) + +/** + * 업로드 경로 조각을 URL 안전 문자열로 정리 + * @param {string} value - 원본 경로 조각 + * @returns {string} 정리된 경로 조각 + */ +const sanitizePathPart = (value) => value + .replace(/[^a-zA-Z0-9가-힣._-]/g, '-') + .replace(/-+/g, '-') + .replace(/^-|-$/g, '') + +/** + * 파일 확장자 조회 + * @param {Object} file - multipart 파일 파트 + * @returns {string} 확장자 + */ +const getUploadExtension = (file) => { + const extension = extname(file.filename || '').toLowerCase() + + if (allowedImageTypes.has(file.type)) { + return allowedImageTypes.get(file.type) + } + + return extension +} + +/** + * 관리자 이미지 업로드 API + * @param {import('h3').H3Event} event - 요청 이벤트 + * @returns {Promise<{ files: Array<{ url: string, name: string, size: number }> }>} 업로드 결과 + */ +export default defineEventHandler(async (event) => { + requireAdminSession(event) + + const config = useRuntimeConfig() + const maxFileSize = Number(config.maxFileSize || 10485760) + const formData = await readMultipartFormData(event) + const files = (formData || []).filter((part) => part.name === 'files' && part.filename) + + if (!files.length) { + throw createError({ + statusCode: 400, + message: '업로드할 이미지가 없습니다.' + }) + } + + const now = new Date() + const year = String(now.getFullYear()) + const month = String(now.getMonth() + 1).padStart(2, '0') + const uploadBaseUrl = String(config.uploadDir || '/uploads').replace(/\/+$/g, '') || '/uploads' + const publicBasePath = uploadBaseUrl.replace(/^\/+/, '') + const directoryPath = join(process.cwd(), 'public', publicBasePath, 'posts', year, month) + + await mkdir(directoryPath, { recursive: true }) + + const uploadedFiles = [] + + for (const file of files) { + if (!allowedImageTypes.has(file.type)) { + throw createError({ + statusCode: 400, + message: '이미지 파일만 업로드할 수 있습니다.' + }) + } + + if (file.data.length > maxFileSize) { + throw createError({ + statusCode: 413, + message: '업로드 가능한 파일 크기를 초과했습니다.' + }) + } + + const originalName = sanitizePathPart(file.filename.replace(/\.[^.]+$/g, '')) || 'image' + const extension = getUploadExtension(file) + const fileName = `${originalName}-${randomUUID()}${extension}` + const filePath = join(directoryPath, fileName) + + await writeFile(filePath, file.data) + + uploadedFiles.push({ + url: `${uploadBaseUrl}/posts/${year}/${month}/${fileName}`, + name: file.filename, + size: file.data.length + }) + } + + return { + files: uploadedFiles + } +})