미디어 카테고리 관리 추가
This commit is contained in:
9
db/migrations/006_add_media_metadata.sql
Normal file
9
db/migrations/006_add_media_metadata.sql
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS media_metadata (
|
||||||
|
url TEXT PRIMARY KEY,
|
||||||
|
category TEXT NOT NULL DEFAULT '미분류',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS media_metadata_category_idx
|
||||||
|
ON media_metadata (category ASC);
|
||||||
@@ -1,5 +1,13 @@
|
|||||||
# 의사결정 이력
|
# 의사결정 이력
|
||||||
|
|
||||||
|
## 2026-05-02 v0.0.26
|
||||||
|
|
||||||
|
### 미디어 카테고리 저장 방식 결정
|
||||||
|
|
||||||
|
미디어 카테고리는 실제 파일 경로나 URL을 변경하지 않고 `media_metadata` 테이블에 URL별 메타데이터로 저장한다. 업로드 파일을 폴더별로 이동하면 이미 게시물이나 페이지에 저장된 이미지 URL이 깨질 수 있기 때문이다.
|
||||||
|
|
||||||
|
파일명 변경은 사용 중인 미디어에서 차단되어 있지만, 미사용 파일명을 변경할 때는 기존 URL의 메타데이터도 새 URL로 옮긴다. 삭제 시에는 남은 메타데이터가 쌓이지 않도록 함께 정리한다.
|
||||||
|
|
||||||
## 2026-05-02 v0.0.25
|
## 2026-05-02 v0.0.25
|
||||||
|
|
||||||
### 빈 문단 placeholder 표시와 네비게이션 관리 범위 결정
|
### 빈 문단 placeholder 표시와 네비게이션 관리 범위 결정
|
||||||
|
|||||||
@@ -143,6 +143,7 @@
|
|||||||
| db/migrations/003_add_tag_display_fields.sql | 태그 표시 순서와 색상 필드 추가 |
|
| db/migrations/003_add_tag_display_fields.sql | 태그 표시 순서와 색상 필드 추가 |
|
||||||
| db/migrations/004_add_site_settings.sql | 사이트 설정 테이블 추가 |
|
| db/migrations/004_add_site_settings.sql | 사이트 설정 테이블 추가 |
|
||||||
| db/migrations/005_add_navigation_items.sql | 네비게이션 항목 테이블 추가 |
|
| db/migrations/005_add_navigation_items.sql | 네비게이션 항목 테이블 추가 |
|
||||||
|
| db/migrations/006_add_media_metadata.sql | 미디어 메타데이터 테이블 추가 |
|
||||||
|
|
||||||
## 설정/배포
|
## 설정/배포
|
||||||
|
|
||||||
|
|||||||
13
docs/spec.md
13
docs/spec.md
@@ -179,6 +179,15 @@ components/content/
|
|||||||
| created_at | DateTime | 생성일 |
|
| created_at | DateTime | 생성일 |
|
||||||
| updated_at | DateTime | 수정일 |
|
| updated_at | DateTime | 수정일 |
|
||||||
|
|
||||||
|
### MediaMetadata
|
||||||
|
|
||||||
|
| 필드 | 타입 | 설명 |
|
||||||
|
|------|------|------|
|
||||||
|
| url | String | 업로드 미디어 URL |
|
||||||
|
| category | String | 관리자 분류명 |
|
||||||
|
| created_at | DateTime | 생성일 |
|
||||||
|
| updated_at | DateTime | 수정일 |
|
||||||
|
|
||||||
### PostTags (다대다)
|
### PostTags (다대다)
|
||||||
|
|
||||||
| 필드 | 타입 | 설명 |
|
| 필드 | 타입 | 설명 |
|
||||||
@@ -332,11 +341,13 @@ components/content/
|
|||||||
- 업로드 파일 크기 제한은 `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로 제공한다.
|
||||||
- 관리자 미디어 화면은 고밀도 썸네일 갤러리, 검색, 파일명 변경, 개별 삭제를 제공한다.
|
- 관리자 미디어 화면은 고밀도 썸네일 갤러리, 검색, 파일명 변경, 개별 삭제를 제공한다.
|
||||||
|
- 관리자 미디어 화면은 카테고리 필터와 미디어별 카테고리 수정을 제공한다.
|
||||||
- 미디어 파일 경로, 사용 현황, 용량 등 세부 정보는 썸네일 클릭 시 열리는 상세 모달에서 표시한다.
|
- 미디어 파일 경로, 사용 현황, 용량 등 세부 정보는 썸네일 클릭 시 열리는 상세 모달에서 표시한다.
|
||||||
|
- 미디어 카테고리는 파일 경로를 옮기지 않고 `media_metadata` 테이블에 URL별로 저장한다.
|
||||||
- 글쓰기 미디어 선택 창은 업로드 미디어 목록에서 이미지를 선택해 단일 이미지 또는 갤러리에 삽입한다.
|
- 글쓰기 미디어 선택 창은 업로드 미디어 목록에서 이미지를 선택해 단일 이미지 또는 갤러리에 삽입한다.
|
||||||
- 미디어 사용 현황은 게시물/페이지의 대표 이미지와 본문 내 URL을 기준으로 표시한다.
|
- 미디어 사용 현황은 게시물/페이지의 대표 이미지와 본문 내 URL을 기준으로 표시한다.
|
||||||
- 사용 중인 미디어는 저장된 콘텐츠 URL이 깨지지 않도록 파일명 변경과 삭제를 차단한다.
|
- 사용 중인 미디어는 저장된 콘텐츠 URL이 깨지지 않도록 파일명 변경과 삭제를 차단한다.
|
||||||
- 향후 미디어 라이브러리는 카테고리 분류와 프로필/사이트 설정 이미지 사용처 추적을 제공한다.
|
- 향후 미디어 라이브러리는 프로필/사이트 설정 이미지 사용처 추적을 제공한다.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
- [ ] 페이지 관리 브라우저 수동 QA: 생성, 수정, 삭제, 공개 보기 확인
|
- [ ] 페이지 관리 브라우저 수동 QA: 생성, 수정, 삭제, 공개 보기 확인
|
||||||
- [ ] 사이트 설정 브라우저 수동 QA: 저장, 공개 헤더/사이드바 반영, 잘못된 URL 실패 확인
|
- [ ] 사이트 설정 브라우저 수동 QA: 저장, 공개 헤더/사이드바 반영, 잘못된 URL 실패 확인
|
||||||
- [ ] 메뉴/네비게이션 브라우저 수동 QA: 항목 추가, 삭제, 숨김, 정렬, 공개 사이드바 반영 확인
|
- [ ] 메뉴/네비게이션 브라우저 수동 QA: 항목 추가, 삭제, 숨김, 정렬, 공개 사이드바 반영 확인
|
||||||
- [ ] 미디어 라이브러리 카테고리 분류
|
- [ ] 미디어 라이브러리 카테고리 브라우저 수동 QA: 카테고리 저장, 필터, 파일명 변경 후 유지 확인
|
||||||
- [ ] 미디어 라이브러리 프로필/사이트 설정 이미지 사용처 추적
|
- [ ] 미디어 라이브러리 프로필/사이트 설정 이미지 사용처 추적
|
||||||
|
|
||||||
## 3차 관리자 개발
|
## 3차 관리자 개발
|
||||||
|
|||||||
@@ -1,5 +1,14 @@
|
|||||||
# 업데이트 이력
|
# 업데이트 이력
|
||||||
|
|
||||||
|
## v0.0.26
|
||||||
|
|
||||||
|
- 미디어 메타데이터 테이블 추가.
|
||||||
|
- 미디어 URL별 카테고리 저장 기능 추가.
|
||||||
|
- 관리자 미디어 목록에 카테고리 필터 추가.
|
||||||
|
- 관리자 미디어 상세 모달에 카테고리 수정 기능 추가.
|
||||||
|
- 미디어 파일명 변경/삭제 시 메타데이터도 함께 갱신하도록 수정.
|
||||||
|
- 패키지 버전을 0.0.26으로 갱신.
|
||||||
|
|
||||||
## v0.0.25
|
## v0.0.25
|
||||||
|
|
||||||
- 관리자 블록 에디터에서 빈 문단 placeholder를 마지막 보조 입력 블록에만 표시하도록 수정.
|
- 관리자 블록 에디터에서 빈 문단 placeholder를 마지막 보조 입력 블록에만 표시하도록 수정.
|
||||||
|
|||||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "sori.studio",
|
"name": "sori.studio",
|
||||||
"version": "0.0.25",
|
"version": "0.0.26",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "sori.studio",
|
"name": "sori.studio",
|
||||||
"version": "0.0.25",
|
"version": "0.0.26",
|
||||||
"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.25",
|
"version": "0.0.26",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -4,8 +4,10 @@ definePageMeta({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const searchText = ref('')
|
const searchText = ref('')
|
||||||
|
const categoryFilter = ref('')
|
||||||
const editingUrl = ref('')
|
const editingUrl = ref('')
|
||||||
const editingName = ref('')
|
const editingName = ref('')
|
||||||
|
const editingCategory = ref('')
|
||||||
const deletingUrl = ref('')
|
const deletingUrl = ref('')
|
||||||
const errorMessage = ref('')
|
const errorMessage = ref('')
|
||||||
const selectedMediaUrl = ref('')
|
const selectedMediaUrl = ref('')
|
||||||
@@ -16,19 +18,21 @@ const { data: mediaItems, refresh } = await useFetch('/admin/api/media', {
|
|||||||
|
|
||||||
const selectedMedia = computed(() => mediaItems.value.find((item) => item.url === selectedMediaUrl.value) || null)
|
const selectedMedia = computed(() => mediaItems.value.find((item) => item.url === selectedMediaUrl.value) || null)
|
||||||
|
|
||||||
|
const mediaCategories = computed(() => [...new Set(mediaItems.value
|
||||||
|
.map((item) => item.category)
|
||||||
|
.filter(Boolean))]
|
||||||
|
.sort((left, right) => left.localeCompare(right)))
|
||||||
|
|
||||||
const filteredMediaItems = computed(() => {
|
const filteredMediaItems = computed(() => {
|
||||||
const query = searchText.value.trim().toLowerCase()
|
const query = searchText.value.trim().toLowerCase()
|
||||||
|
const category = categoryFilter.value
|
||||||
|
|
||||||
if (!query) {
|
return mediaItems.value.filter((item) => (!category || item.category === category) && (!query || [
|
||||||
return mediaItems.value
|
|
||||||
}
|
|
||||||
|
|
||||||
return mediaItems.value.filter((item) => [
|
|
||||||
item.name,
|
item.name,
|
||||||
item.url,
|
item.url,
|
||||||
item.category,
|
item.category,
|
||||||
...item.usage.map((usage) => usage.title)
|
...item.usage.map((usage) => usage.title)
|
||||||
].some((value) => value.toLowerCase().includes(query)))
|
].some((value) => value.toLowerCase().includes(query))))
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -57,6 +61,7 @@ const openMediaDetail = (item) => {
|
|||||||
selectedMediaUrl.value = item.url
|
selectedMediaUrl.value = item.url
|
||||||
editingUrl.value = item.url
|
editingUrl.value = item.url
|
||||||
editingName.value = item.title
|
editingName.value = item.title
|
||||||
|
editingCategory.value = item.category
|
||||||
errorMessage.value = ''
|
errorMessage.value = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,6 +83,27 @@ const cancelRename = () => {
|
|||||||
editingName.value = ''
|
editingName.value = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 미디어 카테고리 저장
|
||||||
|
* @returns {Promise<void>} 저장 결과
|
||||||
|
*/
|
||||||
|
const saveMediaCategory = async () => {
|
||||||
|
errorMessage.value = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
await $fetch('/admin/api/media', {
|
||||||
|
method: 'PUT',
|
||||||
|
body: {
|
||||||
|
url: selectedMedia.value.url,
|
||||||
|
category: editingCategory.value
|
||||||
|
}
|
||||||
|
})
|
||||||
|
await refresh()
|
||||||
|
} catch (error) {
|
||||||
|
errorMessage.value = error?.data?.message || '카테고리를 저장하지 못했습니다.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 미디어 파일명 변경
|
* 미디어 파일명 변경
|
||||||
* @returns {Promise<void>}
|
* @returns {Promise<void>}
|
||||||
@@ -154,12 +180,20 @@ const deleteMedia = async (item) => {
|
|||||||
미디어
|
미디어
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<div class="admin-media__filters flex w-full flex-wrap gap-2 md:w-auto">
|
||||||
v-model="searchText"
|
<select v-model="categoryFilter" class="admin-media__category-filter w-full rounded border border-line bg-white px-3 py-2 text-sm md:w-44">
|
||||||
class="admin-media__search w-full rounded border border-line bg-white px-3 py-2 text-sm md:w-72"
|
<option value="">전체 카테고리</option>
|
||||||
type="search"
|
<option v-for="category in mediaCategories" :key="category" :value="category">
|
||||||
placeholder="파일명, 경로, 사용처 검색"
|
{{ category }}
|
||||||
>
|
</option>
|
||||||
|
</select>
|
||||||
|
<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>
|
||||||
</div>
|
</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">
|
<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">
|
||||||
@@ -233,6 +267,29 @@ const deleteMedia = async (item) => {
|
|||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
|
|
||||||
|
<div class="admin-media__category grid gap-2">
|
||||||
|
<label class="admin-media__category-label text-xs font-semibold text-muted" for="media-category">
|
||||||
|
카테고리
|
||||||
|
</label>
|
||||||
|
<div class="admin-media__category-row flex gap-2">
|
||||||
|
<input
|
||||||
|
id="media-category"
|
||||||
|
v-model="editingCategory"
|
||||||
|
class="admin-media__category-input min-w-0 flex-1 rounded border border-line px-3 py-2 text-sm"
|
||||||
|
type="text"
|
||||||
|
list="media-category-options"
|
||||||
|
placeholder="미분류"
|
||||||
|
@keydown.enter.prevent="saveMediaCategory"
|
||||||
|
>
|
||||||
|
<button class="admin-media__category-save rounded border border-line px-3 py-2 text-xs font-semibold" type="button" @click="saveMediaCategory">
|
||||||
|
저장
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<datalist id="media-category-options">
|
||||||
|
<option v-for="category in mediaCategories" :key="category" :value="category" />
|
||||||
|
</datalist>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="admin-media__usage rounded bg-surface p-3 text-xs">
|
<div class="admin-media__usage rounded bg-surface p-3 text-xs">
|
||||||
<strong class="admin-media__usage-title text-ink">
|
<strong class="admin-media__usage-title text-ink">
|
||||||
사용 현황 {{ selectedMedia.usage.length }}곳
|
사용 현황 {{ selectedMedia.usage.length }}곳
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { readBody } from 'h3'
|
import { readBody } from 'h3'
|
||||||
import { requireAdminSession } from '../../../utils/admin-auth'
|
import { requireAdminSession } from '../../../utils/admin-auth'
|
||||||
import { renameMediaItem } from '../../../utils/media-library'
|
import { renameMediaItem, updateMediaCategory } from '../../../utils/media-library'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 관리자 미디어 파일명 변경 API
|
* 관리자 미디어 파일명 변경 API
|
||||||
@@ -12,5 +12,9 @@ export default defineEventHandler(async (event) => {
|
|||||||
|
|
||||||
const body = await readBody(event)
|
const body = await readBody(event)
|
||||||
|
|
||||||
|
if (body?.category !== undefined) {
|
||||||
|
return updateMediaCategory(body?.url, body?.category)
|
||||||
|
}
|
||||||
|
|
||||||
return renameMediaItem(body?.url, body?.name || '')
|
return renameMediaItem(body?.url, body?.name || '')
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -2,9 +2,49 @@ import { readdir, rename, rm, stat } from 'node:fs/promises'
|
|||||||
import { basename, dirname, extname, join, relative } from 'node:path'
|
import { basename, dirname, extname, join, relative } from 'node:path'
|
||||||
import { createError } from 'h3'
|
import { createError } from 'h3'
|
||||||
import { listAdminPosts, listPages } from '../repositories/content-repository'
|
import { listAdminPosts, listPages } from '../repositories/content-repository'
|
||||||
|
import { getPostgresClient } from '../repositories/postgres-client'
|
||||||
|
|
||||||
const uploadRoot = join(process.cwd(), 'public', 'uploads')
|
const uploadRoot = join(process.cwd(), 'public', 'uploads')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 기본 미디어 카테고리 이름 반환
|
||||||
|
* @param {string} relativePath - 업로드 루트 기준 상대 경로
|
||||||
|
* @returns {string} 기본 카테고리
|
||||||
|
*/
|
||||||
|
const getDefaultMediaCategory = (relativePath) => relativePath.split('/')[0] || '미분류'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 미디어 메타데이터 목록을 URL 기준 객체로 조회
|
||||||
|
* @returns {Promise<Object>} URL별 미디어 메타데이터
|
||||||
|
*/
|
||||||
|
const getMediaMetadataMap = async () => {
|
||||||
|
const sql = getPostgresClient()
|
||||||
|
|
||||||
|
if (!sql) {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await sql`
|
||||||
|
SELECT *
|
||||||
|
FROM media_metadata
|
||||||
|
`
|
||||||
|
|
||||||
|
return Object.fromEntries(rows.map((row) => [row.url, {
|
||||||
|
category: row.category,
|
||||||
|
updatedAt: row.updated_at.toISOString()
|
||||||
|
}]))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 미디어 카테고리 정리
|
||||||
|
* @param {string} category - 입력 카테고리
|
||||||
|
* @returns {string} 정리된 카테고리
|
||||||
|
*/
|
||||||
|
const normalizeMediaCategory = (category) => String(category || '')
|
||||||
|
.trim()
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
|| '미분류'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 미디어 파일명 조각을 안전하게 정리
|
* 미디어 파일명 조각을 안전하게 정리
|
||||||
* @param {string} value - 원본 파일명
|
* @param {string} value - 원본 파일명
|
||||||
@@ -58,7 +98,7 @@ const createMediaItem = async (filePath) => {
|
|||||||
title: basename(filePath, extname(filePath)),
|
title: basename(filePath, extname(filePath)),
|
||||||
size: fileStat.size,
|
size: fileStat.size,
|
||||||
updatedAt: fileStat.mtime.toISOString(),
|
updatedAt: fileStat.mtime.toISOString(),
|
||||||
category: relativePath.split('/')[0] || 'uploads'
|
category: getDefaultMediaCategory(relativePath)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -158,18 +198,98 @@ const getMediaUsage = (url, posts, pages) => {
|
|||||||
*/
|
*/
|
||||||
export const listMediaItems = async () => {
|
export const listMediaItems = async () => {
|
||||||
const items = await readMediaDirectory(uploadRoot)
|
const items = await readMediaDirectory(uploadRoot)
|
||||||
|
const metadataMap = await getMediaMetadataMap()
|
||||||
const [posts, pages] = await Promise.all([
|
const [posts, pages] = await Promise.all([
|
||||||
listAdminPosts(),
|
listAdminPosts(),
|
||||||
listPages()
|
listPages()
|
||||||
])
|
])
|
||||||
const itemsWithUsage = items.map((item) => ({
|
const itemsWithUsage = items.map((item) => ({
|
||||||
...item,
|
...item,
|
||||||
|
category: metadataMap[item.url]?.category || item.category,
|
||||||
usage: getMediaUsage(item.url, posts, pages)
|
usage: getMediaUsage(item.url, posts, pages)
|
||||||
}))
|
}))
|
||||||
|
|
||||||
return itemsWithUsage.sort((left, right) => new Date(right.updatedAt) - new Date(left.updatedAt))
|
return itemsWithUsage.sort((left, right) => new Date(right.updatedAt) - new Date(left.updatedAt))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 미디어 메타데이터 삭제
|
||||||
|
* @param {string} url - 미디어 URL
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
const deleteMediaMetadata = async (url) => {
|
||||||
|
const sql = getPostgresClient()
|
||||||
|
|
||||||
|
if (!sql) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await sql`
|
||||||
|
DELETE FROM media_metadata
|
||||||
|
WHERE url = ${url}
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 미디어 메타데이터 URL 변경
|
||||||
|
* @param {string} currentUrl - 기존 미디어 URL
|
||||||
|
* @param {string} nextUrl - 새 미디어 URL
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
const moveMediaMetadata = async (currentUrl, nextUrl) => {
|
||||||
|
const sql = getPostgresClient()
|
||||||
|
|
||||||
|
if (!sql) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await sql`
|
||||||
|
UPDATE media_metadata
|
||||||
|
SET
|
||||||
|
url = ${nextUrl},
|
||||||
|
updated_at = now()
|
||||||
|
WHERE url = ${currentUrl}
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 미디어 카테고리 저장
|
||||||
|
* @param {string} url - 미디어 URL
|
||||||
|
* @param {string} category - 미디어 카테고리
|
||||||
|
* @returns {Promise<Object>} 수정된 미디어 항목
|
||||||
|
*/
|
||||||
|
export const updateMediaCategory = async (url, category) => {
|
||||||
|
const sql = getPostgresClient()
|
||||||
|
const mediaPath = resolveMediaPath(url)
|
||||||
|
|
||||||
|
if (!sql) {
|
||||||
|
throw new Error('DATABASE_REQUIRED')
|
||||||
|
}
|
||||||
|
|
||||||
|
await sql`
|
||||||
|
INSERT INTO media_metadata (
|
||||||
|
url,
|
||||||
|
category
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
${url},
|
||||||
|
${normalizeMediaCategory(category)}
|
||||||
|
)
|
||||||
|
ON CONFLICT (url) DO UPDATE
|
||||||
|
SET
|
||||||
|
category = EXCLUDED.category,
|
||||||
|
updated_at = now()
|
||||||
|
`
|
||||||
|
|
||||||
|
const item = await createMediaItem(mediaPath)
|
||||||
|
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
category: normalizeMediaCategory(category),
|
||||||
|
usage: []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 미디어 파일 삭제
|
* 미디어 파일 삭제
|
||||||
* @param {string} url - 삭제할 미디어 URL
|
* @param {string} url - 삭제할 미디어 URL
|
||||||
@@ -190,6 +310,7 @@ export const deleteMediaItem = async (url) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await rm(resolveMediaPath(url))
|
await rm(resolveMediaPath(url))
|
||||||
|
await deleteMediaMetadata(url)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -227,5 +348,8 @@ export const renameMediaItem = async (url, name) => {
|
|||||||
|
|
||||||
await rename(currentPath, nextPath)
|
await rename(currentPath, nextPath)
|
||||||
|
|
||||||
|
const renamedItem = await createMediaItem(nextPath)
|
||||||
|
await moveMediaMetadata(url, renamedItem.url)
|
||||||
|
|
||||||
return createMediaItem(nextPath)
|
return createMediaItem(nextPath)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user