관리자 미디어 라이브러리 기본 기능 추가

This commit is contained in:
2026-05-01 23:42:03 +09:00
parent 83ac51fd11
commit bc531f81db
15 changed files with 553 additions and 19 deletions

View File

@@ -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<void>}
*/
const fetchMediaItems = async () => {
isLoadingMedia.value = true
try {
mediaItems.value = await $fetch('/admin/api/media')
} finally {
isLoadingMedia.value = false
}
}
/**
* 미디어 선택 창 열기
* @param {Object} block - 대상 블록
* @returns {Promise<void>}
*/
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({
<div v-if="block.url" class="admin-block-editor__image-frame relative overflow-hidden rounded bg-surface">
<img class="admin-block-editor__image w-full object-cover" :src="block.url" :alt="block.alt">
<div class="admin-block-editor__media-toolbar absolute inset-x-3 top-3 flex flex-wrap items-center gap-2 opacity-0 transition-opacity group-hover:opacity-100 group-focus:opacity-100">
<button
class="admin-block-editor__media-option rounded bg-white/95 px-3 py-1 text-xs font-semibold text-ink shadow"
type="button"
@click="openMediaPicker(block)"
>
미디어 선택
</button>
<button
v-for="option in imageWidthOptions"
:key="option.value"
@@ -864,13 +942,15 @@ defineExpose({
</button>
</div>
</div>
<label
v-else
class="admin-block-editor__upload grid cursor-pointer place-items-center rounded border border-dashed border-line bg-surface px-6 py-14 text-center text-sm font-semibold text-muted"
>
{{ isUploading(block.id) ? '업로드 중' : '이미지 업로드' }}
<input class="sr-only" type="file" accept="image/*" @change="handleImageUpload($event, block)">
</label>
<div v-else class="admin-block-editor__upload grid gap-3 rounded border border-dashed border-line bg-surface px-6 py-14 text-center text-sm font-semibold text-muted">
<button class="admin-block-editor__media-select rounded border border-line bg-white px-3 py-2 text-ink" type="button" @click="openMediaPicker(block)">
미디어 선택
</button>
<label class="admin-block-editor__upload-label cursor-pointer rounded bg-[#15171a] px-3 py-2 text-white">
{{ isUploading(block.id) ? '업로드 중' : '새 이미지 업로드' }}
<input class="sr-only" type="file" accept="image/*" @change="handleImageUpload($event, block)">
</label>
</div>
<input
v-if="block.url"
v-model="block.alt"
@@ -906,10 +986,15 @@ defineExpose({
</button>
</div>
</div>
<label class="admin-block-editor__gallery-upload mt-3 inline-flex cursor-pointer rounded border border-line bg-white px-3 py-2 text-sm font-semibold text-ink">
{{ isUploading(block.id) ? '업로드 중' : block.images.length ? '이미지 추가' : '갤러리 이미지 업로드' }}
<input class="sr-only" type="file" accept="image/*" multiple @change="handleGalleryUpload($event, block)">
</label>
<div class="admin-block-editor__gallery-actions mt-3 flex flex-wrap gap-2">
<button class="admin-block-editor__gallery-select rounded border border-line bg-white px-3 py-2 text-sm font-semibold text-ink" type="button" @click="openMediaPicker(block)">
미디어 선택
</button>
<label class="admin-block-editor__gallery-upload inline-flex cursor-pointer rounded bg-[#15171a] px-3 py-2 text-sm font-semibold text-white">
{{ isUploading(block.id) ? '업로드 중' : block.images.length ? '새 이미지 추가' : '갤러리 이미지 업로드' }}
<input class="sr-only" type="file" accept="image/*" multiple @change="handleGalleryUpload($event, block)">
</label>
</div>
</figure>
<component
@@ -953,12 +1038,51 @@ defineExpose({
type="button"
@mousedown.prevent="applyCommand(command)"
>
<span class="admin-block-editor__slash-label text-sm font-semibold">{{ command.label }}</span>
<span class="admin-block-editor__slash-label text-sm font-semibold text-ink">{{ command.label }}</span>
<span class="admin-block-editor__slash-description text-xs text-muted">{{ command.description }}</span>
</button>
</div>
</div>
</div>
<div
v-if="isMediaPickerOpen"
class="admin-block-editor__media-picker fixed inset-0 z-50 grid place-items-center bg-black/40 px-5 py-8"
role="dialog"
aria-modal="true"
@click.self="closeMediaPicker"
>
<section class="admin-block-editor__media-picker-panel max-h-[80vh] w-full max-w-4xl overflow-hidden bg-white text-ink shadow-xl">
<div class="admin-block-editor__media-picker-header flex items-center justify-between border-b border-line px-5 py-4">
<h2 class="admin-block-editor__media-picker-title text-lg font-semibold">
미디어 선택
</h2>
<button class="admin-block-editor__media-picker-close rounded border border-line px-3 py-1.5 text-sm font-semibold" type="button" @click="closeMediaPicker">
닫기
</button>
</div>
<div class="admin-block-editor__media-picker-body max-h-[62vh] overflow-y-auto p-5">
<p v-if="isLoadingMedia" class="admin-block-editor__media-picker-loading text-sm text-muted">
미디어를 불러오는 중입니다.
</p>
<div v-else-if="mediaItems.length" class="admin-block-editor__media-picker-grid grid grid-cols-2 gap-3 md:grid-cols-4">
<button
v-for="item in mediaItems"
:key="item.url"
class="admin-block-editor__media-picker-item overflow-hidden border border-line bg-white text-left"
type="button"
@click="selectMediaItem(item)"
>
<img class="admin-block-editor__media-picker-image aspect-[4/3] w-full bg-surface object-cover" :src="item.url" :alt="item.title">
<span class="admin-block-editor__media-picker-name block truncate px-3 py-2 text-xs font-semibold text-ink">{{ item.name }}</span>
</button>
</div>
<p v-else class="admin-block-editor__media-picker-empty border border-dashed border-line p-8 text-center text-sm text-muted">
선택할 미디어가 없습니다.
</p>
</div>
</section>
</div>
</div>
</template>

View File

@@ -148,6 +148,7 @@ docker run -d -p 3000:3000 sori.studio:latest
- `public/uploads/`는 Git에 포함하지 않는다.
- NAS 운영에서는 업로드 파일이 컨테이너 재생성으로 사라지지 않도록 별도 볼륨 연결을 확정해야 한다.
- `MAX_FILE_SIZE` 환경 변수로 관리자 이미지 업로드 최대 크기를 제한한다.
- 관리자 미디어 화면은 현재 업로드 파일 시스템을 기준으로 목록, 파일명 변경, 삭제를 처리한다.
## 사용자 액션 필요 항목

View File

@@ -1,5 +1,13 @@
# 의사결정 이력
## 2026-05-01 v0.0.15
### 미디어 라이브러리 1차 범위 결정
글쓰기 화면에서 이미지를 매번 로컬 업로드만 하는 흐름은 장기적으로 불편하므로, 먼저 업로드된 파일을 다시 선택할 수 있는 미디어 선택 창을 붙인다. 관리자 사이드바에는 미디어 메뉴를 추가하고, 업로드된 이미지 목록, 파일명 변경, 삭제를 1차 기능으로 제공한다.
미디어 데이터는 아직 별도 DB 테이블을 만들지 않고 `public/uploads` 아래 실제 파일 시스템을 기준으로 읽는다. 카테고리 분류와 이미지 사용처 추적은 파일만으로 안정적으로 관리하기 어렵기 때문에 이후 미디어 메타데이터 테이블을 만들 때 함께 확장한다.
## 2026-05-01 v0.0.14
### 이미지와 갤러리 블록 구현 범위 결정

View File

@@ -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 | 콘텐츠 조회 저장소 |

View File

@@ -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로 제공한다.
- 향후 미디어 라이브러리는 업로드 이미지 목록, 기존 미디어 선택, 파일명 변경, 개별 삭제, 카테고리 분류를 제공한다.
- 관리자 미디어 화면은 업로드 이미지 목록, 검색, 파일명 변경, 개별 삭제를 제공한다.
- 글쓰기 미디어 선택 창은 업로드 미디어 목록에서 이미지를 선택해 단일 이미지 또는 갤러리에 삽입한다.
- 향후 미디어 라이브러리는 카테고리 분류와 이미지 사용처 추적을 제공한다.
---

View File

@@ -5,7 +5,7 @@
- [ ] 블록 에디터 브라우저 수동 QA: 빈 줄 Enter, `/` 메뉴 필터, 방향키, Enter 선택, 한글 조합 입력 확인
- [ ] 블록 에디터 저장/수정 왕복 QA: 기존 글 수정 시 블록 파싱, 저장 후 다시 열기 확인
- [ ] 이미지/갤러리 블록 브라우저 수동 QA: 업로드, 너비 옵션, 저장 후 공개 렌더링, 갤러리 라이트박스 확인
- [ ] 게시물 작성 시 기존 미디어 선택 또는 새 미디어 업로드 선택 흐름 추가
- [ ] 미디어 선택 창 브라우저 수동 QA: 기존 이미지 선택, 갤러리 추가, 빈 미디어 상태 확인
- [ ] 콜아웃, 토글, 임베드 블록 추가
- [ ] 글 작성 중 자동 저장
@@ -14,7 +14,8 @@
- [ ] 페이지 관리 (CRUD)
- [ ] 사이트 설정
- [ ] 메뉴/네비게이션 관리
- [ ] 미디어 라이브러리: 업로드 이미지 목록, 검색, 선택, 파일명 변경, 개별 삭제, 카테고리 분류
- [ ] 미디어 라이브러리 카테고리 분류
- [ ] 미디어 라이브러리 이미지 사용처 추적
## 3차 관리자 개발

View File

@@ -1,5 +1,16 @@
# 업데이트 이력
## v0.0.15
- 관리자 블록 에디터 `/` 메뉴 항목 제목 색상을 진한 본문색으로 수정.
- 관리자 미디어 목록 API 추가.
- 관리자 미디어 파일명 변경 API 추가.
- 관리자 미디어 삭제 API 추가.
- 관리자 미디어 관리 화면 추가.
- 관리자 사이드바에 미디어 메뉴 추가.
- 글쓰기 이미지/갤러리 블록에서 기존 업로드 미디어 선택 기능 추가.
- 패키지 버전을 0.0.15로 갱신.
## v0.0.14
- 관리자 블록 에디터에 단일 이미지 블록 추가.

View File

@@ -27,6 +27,9 @@ const logoutAdmin = async () => {
<NuxtLink class="admin-layout__nav-link rounded px-3 py-2 hover:bg-white/10 hover:text-white" to="/admin/tags">
태그
</NuxtLink>
<NuxtLink class="admin-layout__nav-link rounded px-3 py-2 hover:bg-white/10 hover:text-white" to="/admin/media">
미디어
</NuxtLink>
<NuxtLink class="admin-layout__nav-link rounded px-3 py-2 hover:bg-white/10 hover:text-white" to="/admin/settings">
설정
</NuxtLink>

4
package-lock.json generated
View File

@@ -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",

View File

@@ -1,6 +1,6 @@
{
"name": "sori.studio",
"version": "0.0.14",
"version": "0.0.15",
"private": true,
"type": "module",
"scripts": {

190
pages/admin/media/index.vue Normal file
View File

@@ -0,0 +1,190 @@
<script setup>
definePageMeta({
layout: 'admin'
})
const searchText = ref('')
const editingUrl = ref('')
const editingName = ref('')
const deletingUrl = ref('')
const errorMessage = ref('')
const { data: mediaItems, refresh } = await useFetch('/admin/api/media', {
default: () => []
})
const filteredMediaItems = computed(() => {
const query = searchText.value.trim().toLowerCase()
if (!query) {
return mediaItems.value
}
return mediaItems.value.filter((item) => [
item.name,
item.url,
item.category
].some((value) => value.toLowerCase().includes(query)))
})
/**
* 파일 크기 표시 문자열 생성
* @param {number} size - 파일 크기
* @returns {string} 표시 문자열
*/
const formatFileSize = (size) => {
if (size < 1024) {
return `${size} B`
}
if (size < 1024 * 1024) {
return `${(size / 1024).toFixed(1)} KB`
}
return `${(size / 1024 / 1024).toFixed(1)} MB`
}
/**
* 미디어 수정 상태 시작
* @param {Object} item - 미디어 항목
* @returns {void}
*/
const startRename = (item) => {
editingUrl.value = item.url
editingName.value = item.title
errorMessage.value = ''
}
/**
* 미디어 파일명 변경 취소
* @returns {void}
*/
const cancelRename = () => {
editingUrl.value = ''
editingName.value = ''
}
/**
* 미디어 파일명 변경
* @returns {Promise<void>}
*/
const renameMedia = async () => {
errorMessage.value = ''
try {
await $fetch('/admin/api/media', {
method: 'PUT',
body: {
url: editingUrl.value,
name: editingName.value
}
})
cancelRename()
await refresh()
} catch (error) {
errorMessage.value = error?.data?.message || '파일명을 변경하지 못했습니다.'
}
}
/**
* 미디어 삭제
* @param {Object} item - 미디어 항목
* @returns {Promise<void>}
*/
const deleteMedia = async (item) => {
if (!confirm(`"${item.name}" 파일을 삭제할까요?`)) {
return
}
deletingUrl.value = item.url
errorMessage.value = ''
try {
await $fetch('/admin/api/media', {
method: 'DELETE',
body: {
url: item.url
}
})
await refresh()
} catch (error) {
errorMessage.value = error?.data?.message || '파일을 삭제하지 못했습니다.'
} finally {
deletingUrl.value = ''
}
}
</script>
<template>
<section class="admin-media bg-paper p-6">
<div class="admin-media__header flex flex-wrap items-center justify-between gap-4">
<div>
<p class="admin-media__eyebrow text-xs font-semibold uppercase text-muted">
Media
</p>
<h1 class="admin-media__title mt-2 text-3xl font-semibold">
미디어
</h1>
</div>
<input
v-model="searchText"
class="admin-media__search w-full rounded border border-line bg-white px-3 py-2 text-sm md:w-72"
type="search"
placeholder="파일명 또는 경로 검색"
>
</div>
<p v-if="errorMessage" class="admin-media__error mt-6 rounded border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
{{ errorMessage }}
</p>
<div v-if="filteredMediaItems.length" class="admin-media__grid mt-8 grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
<article v-for="item in filteredMediaItems" :key="item.url" class="admin-media__item border border-line bg-white">
<img class="admin-media__image aspect-[4/3] w-full bg-surface object-cover" :src="item.url" :alt="item.title">
<div class="admin-media__body grid gap-3 p-4 text-sm">
<div v-if="editingUrl === item.url" class="admin-media__rename grid gap-2">
<input
v-model="editingName"
class="admin-media__rename-input rounded border border-line px-3 py-2 text-sm"
type="text"
@keydown.enter.prevent="renameMedia"
>
<div class="admin-media__rename-actions flex gap-2">
<button class="admin-media__rename-save rounded bg-[#15171a] px-3 py-1.5 text-xs font-semibold text-white" type="button" @click="renameMedia">
저장
</button>
<button class="admin-media__rename-cancel rounded border border-line px-3 py-1.5 text-xs font-semibold" type="button" @click="cancelRename">
취소
</button>
</div>
</div>
<template v-else>
<strong class="admin-media__name break-all text-ink">{{ item.name }}</strong>
<p class="admin-media__path break-all text-xs text-muted">{{ item.url }}</p>
</template>
<p class="admin-media__meta text-xs text-muted">
{{ item.category }} · {{ formatFileSize(item.size) }}
</p>
<div class="admin-media__actions flex gap-2">
<button class="admin-media__rename-button rounded border border-line px-3 py-1.5 text-xs font-semibold" type="button" @click="startRename(item)">
파일명 변경
</button>
<button
class="admin-media__delete rounded border border-red-200 px-3 py-1.5 text-xs font-semibold text-red-700 disabled:opacity-50"
type="button"
:disabled="deletingUrl === item.url"
@click="deleteMedia(item)"
>
{{ deletingUrl === item.url ? '삭제 중' : '삭제' }}
</button>
</div>
</div>
</article>
</div>
<p v-else class="admin-media__empty mt-8 border border-dashed border-line bg-white p-8 text-center text-sm text-muted">
표시할 미디어가 없습니다.
</p>
</section>
</template>

View File

@@ -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
}
})

View File

@@ -0,0 +1,13 @@
import { requireAdminSession } from '../../../utils/admin-auth'
import { listMediaItems } from '../../../utils/media-library'
/**
* 관리자 미디어 목록 API
* @param {import('h3').H3Event} event - 요청 이벤트
* @returns {Promise<Array<Object>>} 미디어 목록
*/
export default defineEventHandler(async (event) => {
requireAdminSession(event)
return listMediaItems()
})

View File

@@ -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<Object>} 변경된 미디어 항목
*/
export default defineEventHandler(async (event) => {
requireAdminSession(event)
const body = await readBody(event)
return renameMediaItem(body?.url, body?.name || '')
})

View File

@@ -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<Object>} 미디어 항목
*/
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<Array<Object>>} 미디어 항목 목록
*/
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<Array<Object>>} 미디어 항목 목록
*/
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<void>}
*/
export const deleteMediaItem = async (url) => {
await rm(resolveMediaPath(url))
}
/**
* 미디어 파일명 변경
* @param {string} url - 기존 미디어 URL
* @param {string} name - 새 파일명
* @returns {Promise<Object>} 변경된 미디어 항목
*/
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)
}