관리자 미디어 라이브러리 기본 기능 추가
This commit is contained in:
@@ -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>
|
||||
|
||||
|
||||
@@ -148,6 +148,7 @@ docker run -d -p 3000:3000 sori.studio:latest
|
||||
- `public/uploads/`는 Git에 포함하지 않는다.
|
||||
- NAS 운영에서는 업로드 파일이 컨테이너 재생성으로 사라지지 않도록 별도 볼륨 연결을 확정해야 한다.
|
||||
- `MAX_FILE_SIZE` 환경 변수로 관리자 이미지 업로드 최대 크기를 제한한다.
|
||||
- 관리자 미디어 화면은 현재 업로드 파일 시스템을 기준으로 목록, 파일명 변경, 삭제를 처리한다.
|
||||
|
||||
## 사용자 액션 필요 항목
|
||||
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
# 의사결정 이력
|
||||
|
||||
## 2026-05-01 v0.0.15
|
||||
|
||||
### 미디어 라이브러리 1차 범위 결정
|
||||
|
||||
글쓰기 화면에서 이미지를 매번 로컬 업로드만 하는 흐름은 장기적으로 불편하므로, 먼저 업로드된 파일을 다시 선택할 수 있는 미디어 선택 창을 붙인다. 관리자 사이드바에는 미디어 메뉴를 추가하고, 업로드된 이미지 목록, 파일명 변경, 삭제를 1차 기능으로 제공한다.
|
||||
|
||||
미디어 데이터는 아직 별도 DB 테이블을 만들지 않고 `public/uploads` 아래 실제 파일 시스템을 기준으로 읽는다. 카테고리 분류와 이미지 사용처 추적은 파일만으로 안정적으로 관리하기 어렵기 때문에 이후 미디어 메타데이터 테이블을 만들 때 함께 확장한다.
|
||||
|
||||
## 2026-05-01 v0.0.14
|
||||
|
||||
### 이미지와 갤러리 블록 구현 범위 결정
|
||||
|
||||
@@ -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 | 콘텐츠 조회 저장소 |
|
||||
|
||||
|
||||
@@ -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로 제공한다.
|
||||
- 향후 미디어 라이브러리는 업로드 이미지 목록, 기존 미디어 선택, 파일명 변경, 개별 삭제, 카테고리 분류를 제공한다.
|
||||
- 관리자 미디어 화면은 업로드 이미지 목록, 검색, 파일명 변경, 개별 삭제를 제공한다.
|
||||
- 글쓰기 미디어 선택 창은 업로드 미디어 목록에서 이미지를 선택해 단일 이미지 또는 갤러리에 삽입한다.
|
||||
- 향후 미디어 라이브러리는 카테고리 분류와 이미지 사용처 추적을 제공한다.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
- [ ] 블록 에디터 브라우저 수동 QA: 빈 줄 Enter, `/` 메뉴 필터, 방향키, Enter 선택, 한글 조합 입력 확인
|
||||
- [ ] 블록 에디터 저장/수정 왕복 QA: 기존 글 수정 시 블록 파싱, 저장 후 다시 열기 확인
|
||||
- [ ] 이미지/갤러리 블록 브라우저 수동 QA: 업로드, 너비 옵션, 저장 후 공개 렌더링, 갤러리 라이트박스 확인
|
||||
- [ ] 게시물 작성 시 기존 미디어 선택 또는 새 미디어 업로드 선택 흐름 추가
|
||||
- [ ] 미디어 선택 창 브라우저 수동 QA: 기존 이미지 선택, 갤러리 추가, 빈 미디어 상태 확인
|
||||
- [ ] 콜아웃, 토글, 임베드 블록 추가
|
||||
- [ ] 글 작성 중 자동 저장
|
||||
|
||||
@@ -14,7 +14,8 @@
|
||||
- [ ] 페이지 관리 (CRUD)
|
||||
- [ ] 사이트 설정
|
||||
- [ ] 메뉴/네비게이션 관리
|
||||
- [ ] 미디어 라이브러리: 업로드 이미지 목록, 검색, 선택, 파일명 변경, 개별 삭제, 카테고리 분류
|
||||
- [ ] 미디어 라이브러리 카테고리 분류
|
||||
- [ ] 미디어 라이브러리 이미지 사용처 추적
|
||||
|
||||
## 3차 관리자 개발
|
||||
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
# 업데이트 이력
|
||||
|
||||
## v0.0.15
|
||||
|
||||
- 관리자 블록 에디터 `/` 메뉴 항목 제목 색상을 진한 본문색으로 수정.
|
||||
- 관리자 미디어 목록 API 추가.
|
||||
- 관리자 미디어 파일명 변경 API 추가.
|
||||
- 관리자 미디어 삭제 API 추가.
|
||||
- 관리자 미디어 관리 화면 추가.
|
||||
- 관리자 사이드바에 미디어 메뉴 추가.
|
||||
- 글쓰기 이미지/갤러리 블록에서 기존 업로드 미디어 선택 기능 추가.
|
||||
- 패키지 버전을 0.0.15로 갱신.
|
||||
|
||||
## v0.0.14
|
||||
|
||||
- 관리자 블록 에디터에 단일 이미지 블록 추가.
|
||||
|
||||
@@ -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
4
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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
190
pages/admin/media/index.vue
Normal 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>
|
||||
20
server/routes/admin/api/media.delete.js
Normal file
20
server/routes/admin/api/media.delete.js
Normal 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
|
||||
}
|
||||
})
|
||||
13
server/routes/admin/api/media.get.js
Normal file
13
server/routes/admin/api/media.get.js
Normal 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()
|
||||
})
|
||||
16
server/routes/admin/api/media.put.js
Normal file
16
server/routes/admin/api/media.put.js
Normal 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 || '')
|
||||
})
|
||||
137
server/utils/media-library.js
Normal file
137
server/utils/media-library.js
Normal 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)
|
||||
}
|
||||
Reference in New Issue
Block a user