미디어 카테고리 관리 추가

This commit is contained in:
2026-05-02 17:56:00 +09:00
parent 04b8a7006a
commit dd0a643d73
11 changed files with 242 additions and 19 deletions

View 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);

View File

@@ -1,5 +1,13 @@
# 의사결정 이력
## 2026-05-02 v0.0.26
### 미디어 카테고리 저장 방식 결정
미디어 카테고리는 실제 파일 경로나 URL을 변경하지 않고 `media_metadata` 테이블에 URL별 메타데이터로 저장한다. 업로드 파일을 폴더별로 이동하면 이미 게시물이나 페이지에 저장된 이미지 URL이 깨질 수 있기 때문이다.
파일명 변경은 사용 중인 미디어에서 차단되어 있지만, 미사용 파일명을 변경할 때는 기존 URL의 메타데이터도 새 URL로 옮긴다. 삭제 시에는 남은 메타데이터가 쌓이지 않도록 함께 정리한다.
## 2026-05-02 v0.0.25
### 빈 문단 placeholder 표시와 네비게이션 관리 범위 결정

View File

@@ -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 | 미디어 메타데이터 테이블 추가 |
## 설정/배포

View File

@@ -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이 깨지지 않도록 파일명 변경과 삭제를 차단한다.
- 향후 미디어 라이브러리는 카테고리 분류와 프로필/사이트 설정 이미지 사용처 추적을 제공한다.
- 향후 미디어 라이브러리는 프로필/사이트 설정 이미지 사용처 추적을 제공한다.
---

View File

@@ -18,7 +18,7 @@
- [ ] 페이지 관리 브라우저 수동 QA: 생성, 수정, 삭제, 공개 보기 확인
- [ ] 사이트 설정 브라우저 수동 QA: 저장, 공개 헤더/사이드바 반영, 잘못된 URL 실패 확인
- [ ] 메뉴/네비게이션 브라우저 수동 QA: 항목 추가, 삭제, 숨김, 정렬, 공개 사이드바 반영 확인
- [ ] 미디어 라이브러리 카테고리 분류
- [ ] 미디어 라이브러리 카테고리 브라우저 수동 QA: 카테고리 저장, 필터, 파일명 변경 후 유지 확인
- [ ] 미디어 라이브러리 프로필/사이트 설정 이미지 사용처 추적
## 3차 관리자 개발

View File

@@ -1,5 +1,14 @@
# 업데이트 이력
## v0.0.26
- 미디어 메타데이터 테이블 추가.
- 미디어 URL별 카테고리 저장 기능 추가.
- 관리자 미디어 목록에 카테고리 필터 추가.
- 관리자 미디어 상세 모달에 카테고리 수정 기능 추가.
- 미디어 파일명 변경/삭제 시 메타데이터도 함께 갱신하도록 수정.
- 패키지 버전을 0.0.26으로 갱신.
## v0.0.25
- 관리자 블록 에디터에서 빈 문단 placeholder를 마지막 보조 입력 블록에만 표시하도록 수정.

4
package-lock.json generated
View File

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

View File

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

View File

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

View File

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

View File

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