미디어 카테고리 관리 추가
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
|
||||
|
||||
### 빈 문단 placeholder 표시와 네비게이션 관리 범위 결정
|
||||
|
||||
@@ -143,6 +143,7 @@
|
||||
| db/migrations/003_add_tag_display_fields.sql | 태그 표시 순서와 색상 필드 추가 |
|
||||
| db/migrations/004_add_site_settings.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 | 생성일 |
|
||||
| updated_at | DateTime | 수정일 |
|
||||
|
||||
### MediaMetadata
|
||||
|
||||
| 필드 | 타입 | 설명 |
|
||||
|------|------|------|
|
||||
| url | String | 업로드 미디어 URL |
|
||||
| category | String | 관리자 분류명 |
|
||||
| created_at | DateTime | 생성일 |
|
||||
| updated_at | DateTime | 수정일 |
|
||||
|
||||
### PostTags (다대다)
|
||||
|
||||
| 필드 | 타입 | 설명 |
|
||||
@@ -332,11 +341,13 @@ components/content/
|
||||
- 업로드 파일 크기 제한은 `MAX_FILE_SIZE` 환경 변수를 따른다.
|
||||
- 로컬 개발 업로드 파일은 `public/uploads/posts/YYYY/MM/` 아래 저장하고 `/uploads/posts/YYYY/MM/filename` URL로 제공한다.
|
||||
- 관리자 미디어 화면은 고밀도 썸네일 갤러리, 검색, 파일명 변경, 개별 삭제를 제공한다.
|
||||
- 관리자 미디어 화면은 카테고리 필터와 미디어별 카테고리 수정을 제공한다.
|
||||
- 미디어 파일 경로, 사용 현황, 용량 등 세부 정보는 썸네일 클릭 시 열리는 상세 모달에서 표시한다.
|
||||
- 미디어 카테고리는 파일 경로를 옮기지 않고 `media_metadata` 테이블에 URL별로 저장한다.
|
||||
- 글쓰기 미디어 선택 창은 업로드 미디어 목록에서 이미지를 선택해 단일 이미지 또는 갤러리에 삽입한다.
|
||||
- 미디어 사용 현황은 게시물/페이지의 대표 이미지와 본문 내 URL을 기준으로 표시한다.
|
||||
- 사용 중인 미디어는 저장된 콘텐츠 URL이 깨지지 않도록 파일명 변경과 삭제를 차단한다.
|
||||
- 향후 미디어 라이브러리는 카테고리 분류와 프로필/사이트 설정 이미지 사용처 추적을 제공한다.
|
||||
- 향후 미디어 라이브러리는 프로필/사이트 설정 이미지 사용처 추적을 제공한다.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
- [ ] 페이지 관리 브라우저 수동 QA: 생성, 수정, 삭제, 공개 보기 확인
|
||||
- [ ] 사이트 설정 브라우저 수동 QA: 저장, 공개 헤더/사이드바 반영, 잘못된 URL 실패 확인
|
||||
- [ ] 메뉴/네비게이션 브라우저 수동 QA: 항목 추가, 삭제, 숨김, 정렬, 공개 사이드바 반영 확인
|
||||
- [ ] 미디어 라이브러리 카테고리 분류
|
||||
- [ ] 미디어 라이브러리 카테고리 브라우저 수동 QA: 카테고리 저장, 필터, 파일명 변경 후 유지 확인
|
||||
- [ ] 미디어 라이브러리 프로필/사이트 설정 이미지 사용처 추적
|
||||
|
||||
## 3차 관리자 개발
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
# 업데이트 이력
|
||||
|
||||
## v0.0.26
|
||||
|
||||
- 미디어 메타데이터 테이블 추가.
|
||||
- 미디어 URL별 카테고리 저장 기능 추가.
|
||||
- 관리자 미디어 목록에 카테고리 필터 추가.
|
||||
- 관리자 미디어 상세 모달에 카테고리 수정 기능 추가.
|
||||
- 미디어 파일명 변경/삭제 시 메타데이터도 함께 갱신하도록 수정.
|
||||
- 패키지 버전을 0.0.26으로 갱신.
|
||||
|
||||
## v0.0.25
|
||||
|
||||
- 관리자 블록 에디터에서 빈 문단 placeholder를 마지막 보조 입력 블록에만 표시하도록 수정.
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "sori.studio",
|
||||
"version": "0.0.25",
|
||||
"version": "0.0.26",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "sori.studio",
|
||||
"version": "0.0.25",
|
||||
"version": "0.0.26",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "sori.studio",
|
||||
"version": "0.0.25",
|
||||
"version": "0.0.26",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -4,8 +4,10 @@ definePageMeta({
|
||||
})
|
||||
|
||||
const searchText = ref('')
|
||||
const categoryFilter = ref('')
|
||||
const editingUrl = ref('')
|
||||
const editingName = ref('')
|
||||
const editingCategory = ref('')
|
||||
const deletingUrl = ref('')
|
||||
const errorMessage = 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 mediaCategories = computed(() => [...new Set(mediaItems.value
|
||||
.map((item) => item.category)
|
||||
.filter(Boolean))]
|
||||
.sort((left, right) => left.localeCompare(right)))
|
||||
|
||||
const filteredMediaItems = computed(() => {
|
||||
const query = searchText.value.trim().toLowerCase()
|
||||
const category = categoryFilter.value
|
||||
|
||||
if (!query) {
|
||||
return mediaItems.value
|
||||
}
|
||||
|
||||
return mediaItems.value.filter((item) => [
|
||||
return mediaItems.value.filter((item) => (!category || item.category === category) && (!query || [
|
||||
item.name,
|
||||
item.url,
|
||||
item.category,
|
||||
...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
|
||||
editingUrl.value = item.url
|
||||
editingName.value = item.title
|
||||
editingCategory.value = item.category
|
||||
errorMessage.value = ''
|
||||
}
|
||||
|
||||
@@ -78,6 +83,27 @@ const cancelRename = () => {
|
||||
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>}
|
||||
@@ -154,12 +180,20 @@ const deleteMedia = async (item) => {
|
||||
미디어
|
||||
</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 class="admin-media__filters flex w-full flex-wrap gap-2 md:w-auto">
|
||||
<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">
|
||||
<option value="">전체 카테고리</option>
|
||||
<option v-for="category in mediaCategories" :key="category" :value="category">
|
||||
{{ 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>
|
||||
|
||||
<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>
|
||||
</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">
|
||||
<strong class="admin-media__usage-title text-ink">
|
||||
사용 현황 {{ selectedMedia.usage.length }}곳
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { readBody } from 'h3'
|
||||
import { requireAdminSession } from '../../../utils/admin-auth'
|
||||
import { renameMediaItem } from '../../../utils/media-library'
|
||||
import { renameMediaItem, updateMediaCategory } from '../../../utils/media-library'
|
||||
|
||||
/**
|
||||
* 관리자 미디어 파일명 변경 API
|
||||
@@ -12,5 +12,9 @@ export default defineEventHandler(async (event) => {
|
||||
|
||||
const body = await readBody(event)
|
||||
|
||||
if (body?.category !== undefined) {
|
||||
return updateMediaCategory(body?.url, body?.category)
|
||||
}
|
||||
|
||||
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 { createError } from 'h3'
|
||||
import { listAdminPosts, listPages } from '../repositories/content-repository'
|
||||
import { getPostgresClient } from '../repositories/postgres-client'
|
||||
|
||||
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 - 원본 파일명
|
||||
@@ -58,7 +98,7 @@ const createMediaItem = async (filePath) => {
|
||||
title: basename(filePath, extname(filePath)),
|
||||
size: fileStat.size,
|
||||
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 () => {
|
||||
const items = await readMediaDirectory(uploadRoot)
|
||||
const metadataMap = await getMediaMetadataMap()
|
||||
const [posts, pages] = await Promise.all([
|
||||
listAdminPosts(),
|
||||
listPages()
|
||||
])
|
||||
const itemsWithUsage = items.map((item) => ({
|
||||
...item,
|
||||
category: metadataMap[item.url]?.category || item.category,
|
||||
usage: getMediaUsage(item.url, posts, pages)
|
||||
}))
|
||||
|
||||
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
|
||||
@@ -190,6 +310,7 @@ export const deleteMediaItem = async (url) => {
|
||||
}
|
||||
|
||||
await rm(resolveMediaPath(url))
|
||||
await deleteMediaMetadata(url)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -227,5 +348,8 @@ export const renameMediaItem = async (url, name) => {
|
||||
|
||||
await rename(currentPath, nextPath)
|
||||
|
||||
const renamedItem = await createMediaItem(nextPath)
|
||||
await moveMediaMetadata(url, renamedItem.url)
|
||||
|
||||
return createMediaItem(nextPath)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user