관리자 미디어 라이브러리 기본 기능 추가
This commit is contained in:
@@ -16,6 +16,10 @@ const slashMenuDirection = ref('down')
|
|||||||
const highlightedCommandIndex = ref(0)
|
const highlightedCommandIndex = ref(0)
|
||||||
const isApplyingExternalValue = ref(false)
|
const isApplyingExternalValue = ref(false)
|
||||||
const uploadingBlockIds = ref([])
|
const uploadingBlockIds = ref([])
|
||||||
|
const mediaItems = ref([])
|
||||||
|
const mediaPickerTarget = ref(null)
|
||||||
|
const isMediaPickerOpen = ref(false)
|
||||||
|
const isLoadingMedia = ref(false)
|
||||||
let blockIdSeed = 0
|
let blockIdSeed = 0
|
||||||
|
|
||||||
const imageWidthOptions = [
|
const imageWidthOptions = [
|
||||||
@@ -602,6 +606,73 @@ const uploadImages = async (files) => {
|
|||||||
return result.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 - 파일 입력 이벤트
|
* @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">
|
<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">
|
<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">
|
<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
|
<button
|
||||||
v-for="option in imageWidthOptions"
|
v-for="option in imageWidthOptions"
|
||||||
:key="option.value"
|
:key="option.value"
|
||||||
@@ -864,13 +942,15 @@ defineExpose({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<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">
|
||||||
v-else
|
<button class="admin-block-editor__media-select rounded border border-line bg-white px-3 py-2 text-ink" type="button" @click="openMediaPicker(block)">
|
||||||
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"
|
미디어 선택
|
||||||
>
|
</button>
|
||||||
{{ isUploading(block.id) ? '업로드 중' : '이미지 업로드' }}
|
<label class="admin-block-editor__upload-label cursor-pointer rounded bg-[#15171a] px-3 py-2 text-white">
|
||||||
<input class="sr-only" type="file" accept="image/*" @change="handleImageUpload($event, block)">
|
{{ isUploading(block.id) ? '업로드 중' : '새 이미지 업로드' }}
|
||||||
</label>
|
<input class="sr-only" type="file" accept="image/*" @change="handleImageUpload($event, block)">
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
<input
|
<input
|
||||||
v-if="block.url"
|
v-if="block.url"
|
||||||
v-model="block.alt"
|
v-model="block.alt"
|
||||||
@@ -906,10 +986,15 @@ defineExpose({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<div class="admin-block-editor__gallery-actions mt-3 flex flex-wrap gap-2">
|
||||||
{{ isUploading(block.id) ? '업로드 중' : block.images.length ? '이미지 추가' : '갤러리 이미지 업로드' }}
|
<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)">
|
||||||
<input class="sr-only" type="file" accept="image/*" multiple @change="handleGalleryUpload($event, block)">
|
미디어 선택
|
||||||
</label>
|
</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>
|
</figure>
|
||||||
|
|
||||||
<component
|
<component
|
||||||
@@ -953,12 +1038,51 @@ defineExpose({
|
|||||||
type="button"
|
type="button"
|
||||||
@mousedown.prevent="applyCommand(command)"
|
@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>
|
<span class="admin-block-editor__slash-description text-xs text-muted">{{ command.description }}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -148,6 +148,7 @@ docker run -d -p 3000:3000 sori.studio:latest
|
|||||||
- `public/uploads/`는 Git에 포함하지 않는다.
|
- `public/uploads/`는 Git에 포함하지 않는다.
|
||||||
- NAS 운영에서는 업로드 파일이 컨테이너 재생성으로 사라지지 않도록 별도 볼륨 연결을 확정해야 한다.
|
- NAS 운영에서는 업로드 파일이 컨테이너 재생성으로 사라지지 않도록 별도 볼륨 연결을 확정해야 한다.
|
||||||
- `MAX_FILE_SIZE` 환경 변수로 관리자 이미지 업로드 최대 크기를 제한한다.
|
- `MAX_FILE_SIZE` 환경 변수로 관리자 이미지 업로드 최대 크기를 제한한다.
|
||||||
|
- 관리자 미디어 화면은 현재 업로드 파일 시스템을 기준으로 목록, 파일명 변경, 삭제를 처리한다.
|
||||||
|
|
||||||
## 사용자 액션 필요 항목
|
## 사용자 액션 필요 항목
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
# 의사결정 이력
|
# 의사결정 이력
|
||||||
|
|
||||||
|
## 2026-05-01 v0.0.15
|
||||||
|
|
||||||
|
### 미디어 라이브러리 1차 범위 결정
|
||||||
|
|
||||||
|
글쓰기 화면에서 이미지를 매번 로컬 업로드만 하는 흐름은 장기적으로 불편하므로, 먼저 업로드된 파일을 다시 선택할 수 있는 미디어 선택 창을 붙인다. 관리자 사이드바에는 미디어 메뉴를 추가하고, 업로드된 이미지 목록, 파일명 변경, 삭제를 1차 기능으로 제공한다.
|
||||||
|
|
||||||
|
미디어 데이터는 아직 별도 DB 테이블을 만들지 않고 `public/uploads` 아래 실제 파일 시스템을 기준으로 읽는다. 카테고리 분류와 이미지 사용처 추적은 파일만으로 안정적으로 관리하기 어렵기 때문에 이후 미디어 메타데이터 테이블을 만들 때 함께 확장한다.
|
||||||
|
|
||||||
## 2026-05-01 v0.0.14
|
## 2026-05-01 v0.0.14
|
||||||
|
|
||||||
### 이미지와 갤러리 블록 구현 범위 결정
|
### 이미지와 갤러리 블록 구현 범위 결정
|
||||||
|
|||||||
@@ -60,6 +60,7 @@
|
|||||||
| pages/admin/posts/new.vue | 글 작성 |
|
| pages/admin/posts/new.vue | 글 작성 |
|
||||||
| pages/admin/posts/[id].vue | 글 수정 |
|
| pages/admin/posts/[id].vue | 글 수정 |
|
||||||
| pages/admin/pages/index.vue | 페이지 목록 |
|
| pages/admin/pages/index.vue | 페이지 목록 |
|
||||||
|
| pages/admin/media/index.vue | 미디어 관리 |
|
||||||
| pages/admin/tags/index.vue | 태그 관리 |
|
| pages/admin/tags/index.vue | 태그 관리 |
|
||||||
| pages/admin/tags/new.vue | 태그 생성 |
|
| pages/admin/tags/new.vue | 태그 생성 |
|
||||||
| pages/admin/tags/[id].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].get.js | 관리자 게시물 상세 API |
|
||||||
| server/routes/admin/api/posts/[id].put.js | 관리자 게시물 수정 API |
|
| server/routes/admin/api/posts/[id].put.js | 관리자 게시물 수정 API |
|
||||||
| server/routes/admin/api/posts/[id].delete.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/uploads.post.js | 관리자 이미지 업로드 API |
|
||||||
| server/routes/admin/api/tags.get.js | 관리자 태그 목록 API |
|
| server/routes/admin/api/tags.get.js | 관리자 태그 목록 API |
|
||||||
| server/routes/admin/api/tags.post.js | 관리자 태그 생성 API |
|
| server/routes/admin/api/tags.post.js | 관리자 태그 생성 API |
|
||||||
@@ -102,6 +106,7 @@
|
|||||||
| server/utils/admin-auth.js | 관리자 세션 쿠키 인증 유틸리티 |
|
| server/utils/admin-auth.js | 관리자 세션 쿠키 인증 유틸리티 |
|
||||||
| server/utils/admin-post-input.js | 관리자 게시물 입력값 검증 스키마 |
|
| server/utils/admin-post-input.js | 관리자 게시물 입력값 검증 스키마 |
|
||||||
| server/utils/admin-tag-input.js | 관리자 태그 입력값 검증 스키마 |
|
| server/utils/admin-tag-input.js | 관리자 태그 입력값 검증 스키마 |
|
||||||
|
| server/utils/media-library.js | 업로드 미디어 파일 관리 유틸리티 |
|
||||||
| server/repositories/postgres-client.js | PostgreSQL 클라이언트 |
|
| server/repositories/postgres-client.js | PostgreSQL 클라이언트 |
|
||||||
| server/repositories/content-repository.js | 콘텐츠 조회 저장소 |
|
| server/repositories/content-repository.js | 콘텐츠 조회 저장소 |
|
||||||
|
|
||||||
|
|||||||
@@ -188,6 +188,9 @@ components/content/
|
|||||||
- `GET /admin/api/posts/:id` - 글 상세
|
- `GET /admin/api/posts/:id` - 글 상세
|
||||||
- `PUT /admin/api/posts/:id` - 글 수정
|
- `PUT /admin/api/posts/:id` - 글 수정
|
||||||
- `DELETE /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` - 관리자 이미지 업로드
|
- `POST /admin/api/uploads` - 관리자 이미지 업로드
|
||||||
- `GET /admin/api/tags` - 태그 목록
|
- `GET /admin/api/tags` - 태그 목록
|
||||||
- `POST /admin/api/tags` - 태그 생성
|
- `POST /admin/api/tags` - 태그 생성
|
||||||
@@ -220,7 +223,7 @@ components/content/
|
|||||||
- 이미지 블록 표시 옵션은 `regular`, `wide`, `full` 값을 사용하며 `regular`는 width 옵션을 생략한다.
|
- 이미지 블록 표시 옵션은 `regular`, `wide`, `full` 값을 사용하며 `regular`는 width 옵션을 생략한다.
|
||||||
- 갤러리 블록은 `:::gallery` fenced block 안에 이미지 마크다운 행을 여러 개 저장한다.
|
- 갤러리 블록은 `:::gallery` fenced block 안에 이미지 마크다운 행을 여러 개 저장한다.
|
||||||
- 공개 갤러리는 한 줄에 2~3개 이미지 그리드로 표시하고 클릭 시 라이트박스로 크게 확인한다.
|
- 공개 갤러리는 한 줄에 2~3개 이미지 그리드로 표시하고 클릭 시 라이트박스로 크게 확인한다.
|
||||||
- 현재 글쓰기 화면은 새 이미지 업로드를 우선 지원하며, 기존 업로드 미디어 선택은 미디어 라이브러리 구현 시 추가한다.
|
- 이미지와 갤러리 블록은 기존 업로드 미디어 선택 또는 새 이미지 업로드를 제공한다.
|
||||||
|
|
||||||
### 관리자 인증
|
### 관리자 인증
|
||||||
|
|
||||||
@@ -245,7 +248,9 @@ components/content/
|
|||||||
- 관리자 이미지 업로드 API는 `image/jpeg`, `image/png`, `image/webp`, `image/gif`만 허용한다.
|
- 관리자 이미지 업로드 API는 `image/jpeg`, `image/png`, `image/webp`, `image/gif`만 허용한다.
|
||||||
- 업로드 파일 크기 제한은 `MAX_FILE_SIZE` 환경 변수를 따른다.
|
- 업로드 파일 크기 제한은 `MAX_FILE_SIZE` 환경 변수를 따른다.
|
||||||
- 로컬 개발 업로드 파일은 `public/uploads/posts/YYYY/MM/` 아래 저장하고 `/uploads/posts/YYYY/MM/filename` URL로 제공한다.
|
- 로컬 개발 업로드 파일은 `public/uploads/posts/YYYY/MM/` 아래 저장하고 `/uploads/posts/YYYY/MM/filename` URL로 제공한다.
|
||||||
- 향후 미디어 라이브러리는 업로드 이미지 목록, 기존 미디어 선택, 파일명 변경, 개별 삭제, 카테고리 분류를 제공한다.
|
- 관리자 미디어 화면은 업로드 이미지 목록, 검색, 파일명 변경, 개별 삭제를 제공한다.
|
||||||
|
- 글쓰기 미디어 선택 창은 업로드 미디어 목록에서 이미지를 선택해 단일 이미지 또는 갤러리에 삽입한다.
|
||||||
|
- 향후 미디어 라이브러리는 카테고리 분류와 이미지 사용처 추적을 제공한다.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
- [ ] 블록 에디터 브라우저 수동 QA: 빈 줄 Enter, `/` 메뉴 필터, 방향키, Enter 선택, 한글 조합 입력 확인
|
- [ ] 블록 에디터 브라우저 수동 QA: 빈 줄 Enter, `/` 메뉴 필터, 방향키, Enter 선택, 한글 조합 입력 확인
|
||||||
- [ ] 블록 에디터 저장/수정 왕복 QA: 기존 글 수정 시 블록 파싱, 저장 후 다시 열기 확인
|
- [ ] 블록 에디터 저장/수정 왕복 QA: 기존 글 수정 시 블록 파싱, 저장 후 다시 열기 확인
|
||||||
- [ ] 이미지/갤러리 블록 브라우저 수동 QA: 업로드, 너비 옵션, 저장 후 공개 렌더링, 갤러리 라이트박스 확인
|
- [ ] 이미지/갤러리 블록 브라우저 수동 QA: 업로드, 너비 옵션, 저장 후 공개 렌더링, 갤러리 라이트박스 확인
|
||||||
- [ ] 게시물 작성 시 기존 미디어 선택 또는 새 미디어 업로드 선택 흐름 추가
|
- [ ] 미디어 선택 창 브라우저 수동 QA: 기존 이미지 선택, 갤러리 추가, 빈 미디어 상태 확인
|
||||||
- [ ] 콜아웃, 토글, 임베드 블록 추가
|
- [ ] 콜아웃, 토글, 임베드 블록 추가
|
||||||
- [ ] 글 작성 중 자동 저장
|
- [ ] 글 작성 중 자동 저장
|
||||||
|
|
||||||
@@ -14,7 +14,8 @@
|
|||||||
- [ ] 페이지 관리 (CRUD)
|
- [ ] 페이지 관리 (CRUD)
|
||||||
- [ ] 사이트 설정
|
- [ ] 사이트 설정
|
||||||
- [ ] 메뉴/네비게이션 관리
|
- [ ] 메뉴/네비게이션 관리
|
||||||
- [ ] 미디어 라이브러리: 업로드 이미지 목록, 검색, 선택, 파일명 변경, 개별 삭제, 카테고리 분류
|
- [ ] 미디어 라이브러리 카테고리 분류
|
||||||
|
- [ ] 미디어 라이브러리 이미지 사용처 추적
|
||||||
|
|
||||||
## 3차 관리자 개발
|
## 3차 관리자 개발
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,16 @@
|
|||||||
# 업데이트 이력
|
# 업데이트 이력
|
||||||
|
|
||||||
|
## v0.0.15
|
||||||
|
|
||||||
|
- 관리자 블록 에디터 `/` 메뉴 항목 제목 색상을 진한 본문색으로 수정.
|
||||||
|
- 관리자 미디어 목록 API 추가.
|
||||||
|
- 관리자 미디어 파일명 변경 API 추가.
|
||||||
|
- 관리자 미디어 삭제 API 추가.
|
||||||
|
- 관리자 미디어 관리 화면 추가.
|
||||||
|
- 관리자 사이드바에 미디어 메뉴 추가.
|
||||||
|
- 글쓰기 이미지/갤러리 블록에서 기존 업로드 미디어 선택 기능 추가.
|
||||||
|
- 패키지 버전을 0.0.15로 갱신.
|
||||||
|
|
||||||
## v0.0.14
|
## 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 class="admin-layout__nav-link rounded px-3 py-2 hover:bg-white/10 hover:text-white" to="/admin/tags">
|
||||||
태그
|
태그
|
||||||
</NuxtLink>
|
</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 class="admin-layout__nav-link rounded px-3 py-2 hover:bg-white/10 hover:text-white" to="/admin/settings">
|
||||||
설정
|
설정
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
|
|||||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "sori.studio",
|
"name": "sori.studio",
|
||||||
"version": "0.0.14",
|
"version": "0.0.15",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "sori.studio",
|
"name": "sori.studio",
|
||||||
"version": "0.0.14",
|
"version": "0.0.15",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "sori.studio",
|
"name": "sori.studio",
|
||||||
"version": "0.0.14",
|
"version": "0.0.15",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"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