From bc531f81db9aafc87ddb8bb5498e6081d4cf725d Mon Sep 17 00:00:00 2001 From: zenn Date: Fri, 1 May 2026 23:42:03 +0900 Subject: [PATCH] =?UTF-8?q?=EA=B4=80=EB=A6=AC=EC=9E=90=20=EB=AF=B8?= =?UTF-8?q?=EB=94=94=EC=96=B4=20=EB=9D=BC=EC=9D=B4=EB=B8=8C=EB=9F=AC?= =?UTF-8?q?=EB=A6=AC=20=EA=B8=B0=EB=B3=B8=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- components/admin/AdminBlockEditor.vue | 148 ++++++++++++++++-- docs/deploy.md | 1 + docs/history.md | 8 + docs/map.md | 5 + docs/spec.md | 9 +- docs/todo.md | 5 +- docs/update.md | 11 ++ layouts/admin.vue | 3 + package-lock.json | 4 +- package.json | 2 +- pages/admin/media/index.vue | 190 ++++++++++++++++++++++++ server/routes/admin/api/media.delete.js | 20 +++ server/routes/admin/api/media.get.js | 13 ++ server/routes/admin/api/media.put.js | 16 ++ server/utils/media-library.js | 137 +++++++++++++++++ 15 files changed, 553 insertions(+), 19 deletions(-) create mode 100644 pages/admin/media/index.vue create mode 100644 server/routes/admin/api/media.delete.js create mode 100644 server/routes/admin/api/media.get.js create mode 100644 server/routes/admin/api/media.put.js create mode 100644 server/utils/media-library.js diff --git a/components/admin/AdminBlockEditor.vue b/components/admin/AdminBlockEditor.vue index 5fa5c5c..bddd31d 100644 --- a/components/admin/AdminBlockEditor.vue +++ b/components/admin/AdminBlockEditor.vue @@ -16,6 +16,10 @@ const slashMenuDirection = ref('down') const highlightedCommandIndex = ref(0) const isApplyingExternalValue = ref(false) const uploadingBlockIds = ref([]) +const mediaItems = ref([]) +const mediaPickerTarget = ref(null) +const isMediaPickerOpen = ref(false) +const isLoadingMedia = ref(false) let blockIdSeed = 0 const imageWidthOptions = [ @@ -602,6 +606,73 @@ const uploadImages = async (files) => { return result.files || [] } +/** + * 미디어 라이브러리 목록 조회 + * @returns {Promise} + */ +const fetchMediaItems = async () => { + isLoadingMedia.value = true + + try { + mediaItems.value = await $fetch('/admin/api/media') + } finally { + isLoadingMedia.value = false + } +} + +/** + * 미디어 선택 창 열기 + * @param {Object} block - 대상 블록 + * @returns {Promise} + */ +const openMediaPicker = async (block) => { + mediaPickerTarget.value = { + blockId: block.id, + type: block.type + } + isMediaPickerOpen.value = true + await fetchMediaItems() +} + +/** + * 미디어 선택 창 닫기 + * @returns {void} + */ +const closeMediaPicker = () => { + isMediaPickerOpen.value = false + mediaPickerTarget.value = null +} + +/** + * 선택한 미디어를 블록에 적용 + * @param {Object} mediaItem - 미디어 항목 + * @returns {void} + */ +const selectMediaItem = (mediaItem) => { + const block = editorBlocks.value.find((item) => item.id === mediaPickerTarget.value?.blockId) + + if (!block) { + return + } + + if (mediaPickerTarget.value.type === 'gallery') { + block.images = [ + ...block.images, + { + url: mediaItem.url, + alt: mediaItem.title, + width: 'regular' + } + ] + } else { + block.url = mediaItem.url + block.alt = mediaItem.title + } + + emitContent() + closeMediaPicker() +} + /** * 단일 이미지 파일 선택 처리 * @param {Event} event - 파일 입력 이벤트 @@ -852,6 +923,13 @@ defineExpose({
+
- +
+ + +
- + - {{ command.label }} + {{ command.label }} {{ command.description }} + + diff --git a/docs/deploy.md b/docs/deploy.md index 97c2372..ff20f85 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -148,6 +148,7 @@ docker run -d -p 3000:3000 sori.studio:latest - `public/uploads/`는 Git에 포함하지 않는다. - NAS 운영에서는 업로드 파일이 컨테이너 재생성으로 사라지지 않도록 별도 볼륨 연결을 확정해야 한다. - `MAX_FILE_SIZE` 환경 변수로 관리자 이미지 업로드 최대 크기를 제한한다. +- 관리자 미디어 화면은 현재 업로드 파일 시스템을 기준으로 목록, 파일명 변경, 삭제를 처리한다. ## 사용자 액션 필요 항목 diff --git a/docs/history.md b/docs/history.md index 57a7a89..54d3b32 100644 --- a/docs/history.md +++ b/docs/history.md @@ -1,5 +1,13 @@ # 의사결정 이력 +## 2026-05-01 v0.0.15 + +### 미디어 라이브러리 1차 범위 결정 + +글쓰기 화면에서 이미지를 매번 로컬 업로드만 하는 흐름은 장기적으로 불편하므로, 먼저 업로드된 파일을 다시 선택할 수 있는 미디어 선택 창을 붙인다. 관리자 사이드바에는 미디어 메뉴를 추가하고, 업로드된 이미지 목록, 파일명 변경, 삭제를 1차 기능으로 제공한다. + +미디어 데이터는 아직 별도 DB 테이블을 만들지 않고 `public/uploads` 아래 실제 파일 시스템을 기준으로 읽는다. 카테고리 분류와 이미지 사용처 추적은 파일만으로 안정적으로 관리하기 어렵기 때문에 이후 미디어 메타데이터 테이블을 만들 때 함께 확장한다. + ## 2026-05-01 v0.0.14 ### 이미지와 갤러리 블록 구현 범위 결정 diff --git a/docs/map.md b/docs/map.md index e7addff..86a53f8 100644 --- a/docs/map.md +++ b/docs/map.md @@ -60,6 +60,7 @@ | pages/admin/posts/new.vue | 글 작성 | | pages/admin/posts/[id].vue | 글 수정 | | pages/admin/pages/index.vue | 페이지 목록 | +| pages/admin/media/index.vue | 미디어 관리 | | pages/admin/tags/index.vue | 태그 관리 | | pages/admin/tags/new.vue | 태그 생성 | | pages/admin/tags/[id].vue | 태그 수정 | @@ -91,6 +92,9 @@ | 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/media.get.js | 관리자 미디어 목록 API | +| server/routes/admin/api/media.put.js | 관리자 미디어 파일명 변경 API | +| server/routes/admin/api/media.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 | @@ -102,6 +106,7 @@ | server/utils/admin-auth.js | 관리자 세션 쿠키 인증 유틸리티 | | server/utils/admin-post-input.js | 관리자 게시물 입력값 검증 스키마 | | server/utils/admin-tag-input.js | 관리자 태그 입력값 검증 스키마 | +| server/utils/media-library.js | 업로드 미디어 파일 관리 유틸리티 | | server/repositories/postgres-client.js | PostgreSQL 클라이언트 | | server/repositories/content-repository.js | 콘텐츠 조회 저장소 | diff --git a/docs/spec.md b/docs/spec.md index 86a96c4..330a5a6 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -188,6 +188,9 @@ components/content/ - `GET /admin/api/posts/:id` - 글 상세 - `PUT /admin/api/posts/:id` - 글 수정 - `DELETE /admin/api/posts/:id` - 글 삭제 +- `GET /admin/api/media` - 업로드 미디어 목록 +- `PUT /admin/api/media` - 업로드 미디어 파일명 변경 +- `DELETE /admin/api/media` - 업로드 미디어 삭제 - `POST /admin/api/uploads` - 관리자 이미지 업로드 - `GET /admin/api/tags` - 태그 목록 - `POST /admin/api/tags` - 태그 생성 @@ -220,7 +223,7 @@ components/content/ - 이미지 블록 표시 옵션은 `regular`, `wide`, `full` 값을 사용하며 `regular`는 width 옵션을 생략한다. - 갤러리 블록은 `:::gallery` fenced block 안에 이미지 마크다운 행을 여러 개 저장한다. - 공개 갤러리는 한 줄에 2~3개 이미지 그리드로 표시하고 클릭 시 라이트박스로 크게 확인한다. -- 현재 글쓰기 화면은 새 이미지 업로드를 우선 지원하며, 기존 업로드 미디어 선택은 미디어 라이브러리 구현 시 추가한다. +- 이미지와 갤러리 블록은 기존 업로드 미디어 선택 또는 새 이미지 업로드를 제공한다. ### 관리자 인증 @@ -245,7 +248,9 @@ components/content/ - 관리자 이미지 업로드 API는 `image/jpeg`, `image/png`, `image/webp`, `image/gif`만 허용한다. - 업로드 파일 크기 제한은 `MAX_FILE_SIZE` 환경 변수를 따른다. - 로컬 개발 업로드 파일은 `public/uploads/posts/YYYY/MM/` 아래 저장하고 `/uploads/posts/YYYY/MM/filename` URL로 제공한다. -- 향후 미디어 라이브러리는 업로드 이미지 목록, 기존 미디어 선택, 파일명 변경, 개별 삭제, 카테고리 분류를 제공한다. +- 관리자 미디어 화면은 업로드 이미지 목록, 검색, 파일명 변경, 개별 삭제를 제공한다. +- 글쓰기 미디어 선택 창은 업로드 미디어 목록에서 이미지를 선택해 단일 이미지 또는 갤러리에 삽입한다. +- 향후 미디어 라이브러리는 카테고리 분류와 이미지 사용처 추적을 제공한다. --- diff --git a/docs/todo.md b/docs/todo.md index bcdb4f0..69dfb94 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -5,7 +5,7 @@ - [ ] 블록 에디터 브라우저 수동 QA: 빈 줄 Enter, `/` 메뉴 필터, 방향키, Enter 선택, 한글 조합 입력 확인 - [ ] 블록 에디터 저장/수정 왕복 QA: 기존 글 수정 시 블록 파싱, 저장 후 다시 열기 확인 - [ ] 이미지/갤러리 블록 브라우저 수동 QA: 업로드, 너비 옵션, 저장 후 공개 렌더링, 갤러리 라이트박스 확인 -- [ ] 게시물 작성 시 기존 미디어 선택 또는 새 미디어 업로드 선택 흐름 추가 +- [ ] 미디어 선택 창 브라우저 수동 QA: 기존 이미지 선택, 갤러리 추가, 빈 미디어 상태 확인 - [ ] 콜아웃, 토글, 임베드 블록 추가 - [ ] 글 작성 중 자동 저장 @@ -14,7 +14,8 @@ - [ ] 페이지 관리 (CRUD) - [ ] 사이트 설정 - [ ] 메뉴/네비게이션 관리 -- [ ] 미디어 라이브러리: 업로드 이미지 목록, 검색, 선택, 파일명 변경, 개별 삭제, 카테고리 분류 +- [ ] 미디어 라이브러리 카테고리 분류 +- [ ] 미디어 라이브러리 이미지 사용처 추적 ## 3차 관리자 개발 diff --git a/docs/update.md b/docs/update.md index eebab03..7dd046d 100644 --- a/docs/update.md +++ b/docs/update.md @@ -1,5 +1,16 @@ # 업데이트 이력 +## v0.0.15 + +- 관리자 블록 에디터 `/` 메뉴 항목 제목 색상을 진한 본문색으로 수정. +- 관리자 미디어 목록 API 추가. +- 관리자 미디어 파일명 변경 API 추가. +- 관리자 미디어 삭제 API 추가. +- 관리자 미디어 관리 화면 추가. +- 관리자 사이드바에 미디어 메뉴 추가. +- 글쓰기 이미지/갤러리 블록에서 기존 업로드 미디어 선택 기능 추가. +- 패키지 버전을 0.0.15로 갱신. + ## v0.0.14 - 관리자 블록 에디터에 단일 이미지 블록 추가. diff --git a/layouts/admin.vue b/layouts/admin.vue index ef81799..e1e90aa 100644 --- a/layouts/admin.vue +++ b/layouts/admin.vue @@ -27,6 +27,9 @@ const logoutAdmin = async () => { 태그 + + 미디어 + 설정 diff --git a/package-lock.json b/package-lock.json index d6a366d..a72bdba 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "sori.studio", - "version": "0.0.14", + "version": "0.0.15", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sori.studio", - "version": "0.0.14", + "version": "0.0.15", "hasInstallScript": true, "dependencies": { "@nuxtjs/tailwindcss": "^6.14.0", diff --git a/package.json b/package.json index 22307ee..5f80415 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sori.studio", - "version": "0.0.14", + "version": "0.0.15", "private": true, "type": "module", "scripts": { diff --git a/pages/admin/media/index.vue b/pages/admin/media/index.vue new file mode 100644 index 0000000..f1318d6 --- /dev/null +++ b/pages/admin/media/index.vue @@ -0,0 +1,190 @@ + + + diff --git a/server/routes/admin/api/media.delete.js b/server/routes/admin/api/media.delete.js new file mode 100644 index 0000000..21c07ea --- /dev/null +++ b/server/routes/admin/api/media.delete.js @@ -0,0 +1,20 @@ +import { readBody } from 'h3' +import { requireAdminSession } from '../../../utils/admin-auth' +import { deleteMediaItem } from '../../../utils/media-library' + +/** + * 관리자 미디어 삭제 API + * @param {import('h3').H3Event} event - 요청 이벤트 + * @returns {Promise<{ ok: boolean }>} 삭제 결과 + */ +export default defineEventHandler(async (event) => { + requireAdminSession(event) + + const body = await readBody(event) + + await deleteMediaItem(body?.url) + + return { + ok: true + } +}) diff --git a/server/routes/admin/api/media.get.js b/server/routes/admin/api/media.get.js new file mode 100644 index 0000000..2a68062 --- /dev/null +++ b/server/routes/admin/api/media.get.js @@ -0,0 +1,13 @@ +import { requireAdminSession } from '../../../utils/admin-auth' +import { listMediaItems } from '../../../utils/media-library' + +/** + * 관리자 미디어 목록 API + * @param {import('h3').H3Event} event - 요청 이벤트 + * @returns {Promise>} 미디어 목록 + */ +export default defineEventHandler(async (event) => { + requireAdminSession(event) + + return listMediaItems() +}) diff --git a/server/routes/admin/api/media.put.js b/server/routes/admin/api/media.put.js new file mode 100644 index 0000000..2fc9868 --- /dev/null +++ b/server/routes/admin/api/media.put.js @@ -0,0 +1,16 @@ +import { readBody } from 'h3' +import { requireAdminSession } from '../../../utils/admin-auth' +import { renameMediaItem } from '../../../utils/media-library' + +/** + * 관리자 미디어 파일명 변경 API + * @param {import('h3').H3Event} event - 요청 이벤트 + * @returns {Promise} 변경된 미디어 항목 + */ +export default defineEventHandler(async (event) => { + requireAdminSession(event) + + const body = await readBody(event) + + return renameMediaItem(body?.url, body?.name || '') +}) diff --git a/server/utils/media-library.js b/server/utils/media-library.js new file mode 100644 index 0000000..dc78a38 --- /dev/null +++ b/server/utils/media-library.js @@ -0,0 +1,137 @@ +import { readdir, rename, rm, stat } from 'node:fs/promises' +import { basename, dirname, extname, join, relative } from 'node:path' +import { createError } from 'h3' + +const uploadRoot = join(process.cwd(), 'public', 'uploads') + +/** + * 미디어 파일명 조각을 안전하게 정리 + * @param {string} value - 원본 파일명 + * @returns {string} 정리된 파일명 + */ +export const sanitizeMediaName = (value) => value + .replace(/[^a-zA-Z0-9가-힣._-]/g, '-') + .replace(/-+/g, '-') + .replace(/^-|-$/g, '') + +/** + * 업로드 URL을 실제 파일 경로로 변환 + * @param {string} url - 업로드 URL + * @returns {string} 파일 경로 + */ +export const resolveMediaPath = (url) => { + if (!url?.startsWith('/uploads/')) { + throw createError({ + statusCode: 400, + message: '미디어 URL 형식이 올바르지 않습니다.' + }) + } + + const decodedUrl = decodeURIComponent(url) + const filePath = join(process.cwd(), 'public', decodedUrl) + const relativePath = relative(uploadRoot, filePath) + + if (relativePath.startsWith('..') || relativePath === '') { + throw createError({ + statusCode: 400, + message: '미디어 경로가 올바르지 않습니다.' + }) + } + + return filePath +} + +/** + * 파일 경로를 미디어 항목으로 변환 + * @param {string} filePath - 파일 경로 + * @returns {Promise} 미디어 항목 + */ +const createMediaItem = async (filePath) => { + const fileStat = await stat(filePath) + const relativePath = relative(uploadRoot, filePath) + const url = `/uploads/${relativePath.split('/').join('/')}` + + return { + url, + name: basename(filePath), + title: basename(filePath, extname(filePath)), + size: fileStat.size, + updatedAt: fileStat.mtime.toISOString(), + category: relativePath.split('/')[0] || 'uploads' + } +} + +/** + * 업로드 디렉토리의 이미지 파일을 재귀적으로 조회 + * @param {string} directoryPath - 조회할 디렉토리 + * @returns {Promise>} 미디어 항목 목록 + */ +const readMediaDirectory = async (directoryPath) => { + let entries = [] + + try { + entries = await readdir(directoryPath, { withFileTypes: true }) + } catch { + return [] + } + + const items = await Promise.all(entries.map(async (entry) => { + const entryPath = join(directoryPath, entry.name) + + if (entry.isDirectory()) { + return readMediaDirectory(entryPath) + } + + if (!/\.(jpe?g|png|webp|gif)$/i.test(entry.name)) { + return [] + } + + return [await createMediaItem(entryPath)] + })) + + return items.flat() +} + +/** + * 미디어 목록 조회 + * @returns {Promise>} 미디어 항목 목록 + */ +export const listMediaItems = async () => { + const items = await readMediaDirectory(uploadRoot) + + return items.sort((left, right) => new Date(right.updatedAt) - new Date(left.updatedAt)) +} + +/** + * 미디어 파일 삭제 + * @param {string} url - 삭제할 미디어 URL + * @returns {Promise} + */ +export const deleteMediaItem = async (url) => { + await rm(resolveMediaPath(url)) +} + +/** + * 미디어 파일명 변경 + * @param {string} url - 기존 미디어 URL + * @param {string} name - 새 파일명 + * @returns {Promise} 변경된 미디어 항목 + */ +export const renameMediaItem = async (url, name) => { + const currentPath = resolveMediaPath(url) + const currentExtension = extname(currentPath) + const cleanName = sanitizeMediaName(name.replace(/\.[^.]+$/g, '')) + + if (!cleanName) { + throw createError({ + statusCode: 400, + message: '파일명을 입력해 주세요.' + }) + } + + const nextPath = join(dirname(currentPath), `${cleanName}${currentExtension}`) + + await rename(currentPath, nextPath) + + return createMediaItem(nextPath) +}