diff --git a/db/migrations/006_add_media_metadata.sql b/db/migrations/006_add_media_metadata.sql new file mode 100644 index 0000000..cd79b92 --- /dev/null +++ b/db/migrations/006_add_media_metadata.sql @@ -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); diff --git a/docs/history.md b/docs/history.md index e2796ee..5d6eb84 100644 --- a/docs/history.md +++ b/docs/history.md @@ -1,5 +1,13 @@ # 의사결정 이력 +## 2026-05-02 v0.0.26 + +### 미디어 카테고리 저장 방식 결정 + +미디어 카테고리는 실제 파일 경로나 URL을 변경하지 않고 `media_metadata` 테이블에 URL별 메타데이터로 저장한다. 업로드 파일을 폴더별로 이동하면 이미 게시물이나 페이지에 저장된 이미지 URL이 깨질 수 있기 때문이다. + +파일명 변경은 사용 중인 미디어에서 차단되어 있지만, 미사용 파일명을 변경할 때는 기존 URL의 메타데이터도 새 URL로 옮긴다. 삭제 시에는 남은 메타데이터가 쌓이지 않도록 함께 정리한다. + ## 2026-05-02 v0.0.25 ### 빈 문단 placeholder 표시와 네비게이션 관리 범위 결정 diff --git a/docs/map.md b/docs/map.md index 97531ba..356eafd 100644 --- a/docs/map.md +++ b/docs/map.md @@ -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 | 미디어 메타데이터 테이블 추가 | ## 설정/배포 diff --git a/docs/spec.md b/docs/spec.md index 684865d..fa7667c 100644 --- a/docs/spec.md +++ b/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이 깨지지 않도록 파일명 변경과 삭제를 차단한다. -- 향후 미디어 라이브러리는 카테고리 분류와 프로필/사이트 설정 이미지 사용처 추적을 제공한다. +- 향후 미디어 라이브러리는 프로필/사이트 설정 이미지 사용처 추적을 제공한다. --- diff --git a/docs/todo.md b/docs/todo.md index c23ec00..76221a1 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -18,7 +18,7 @@ - [ ] 페이지 관리 브라우저 수동 QA: 생성, 수정, 삭제, 공개 보기 확인 - [ ] 사이트 설정 브라우저 수동 QA: 저장, 공개 헤더/사이드바 반영, 잘못된 URL 실패 확인 - [ ] 메뉴/네비게이션 브라우저 수동 QA: 항목 추가, 삭제, 숨김, 정렬, 공개 사이드바 반영 확인 -- [ ] 미디어 라이브러리 카테고리 분류 +- [ ] 미디어 라이브러리 카테고리 브라우저 수동 QA: 카테고리 저장, 필터, 파일명 변경 후 유지 확인 - [ ] 미디어 라이브러리 프로필/사이트 설정 이미지 사용처 추적 ## 3차 관리자 개발 diff --git a/docs/update.md b/docs/update.md index 709029b..95d1b9f 100644 --- a/docs/update.md +++ b/docs/update.md @@ -1,5 +1,14 @@ # 업데이트 이력 +## v0.0.26 + +- 미디어 메타데이터 테이블 추가. +- 미디어 URL별 카테고리 저장 기능 추가. +- 관리자 미디어 목록에 카테고리 필터 추가. +- 관리자 미디어 상세 모달에 카테고리 수정 기능 추가. +- 미디어 파일명 변경/삭제 시 메타데이터도 함께 갱신하도록 수정. +- 패키지 버전을 0.0.26으로 갱신. + ## v0.0.25 - 관리자 블록 에디터에서 빈 문단 placeholder를 마지막 보조 입력 블록에만 표시하도록 수정. diff --git a/package-lock.json b/package-lock.json index c51078f..931b1b9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index dba9efa..9da5a05 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sori.studio", - "version": "0.0.25", + "version": "0.0.26", "private": true, "type": "module", "scripts": { diff --git a/pages/admin/media/index.vue b/pages/admin/media/index.vue index 74a2f20..c6734fb 100644 --- a/pages/admin/media/index.vue +++ b/pages/admin/media/index.vue @@ -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} 저장 결과 + */ +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} @@ -154,12 +180,20 @@ const deleteMedia = async (item) => { 미디어 - +
+ + +

@@ -233,6 +267,29 @@ const deleteMedia = async (item) => { +

+ +
+ + +
+ + +
+
사용 현황 {{ selectedMedia.usage.length }}곳 diff --git a/server/routes/admin/api/media.put.js b/server/routes/admin/api/media.put.js index 2fc9868..ad27588 100644 --- a/server/routes/admin/api/media.put.js +++ b/server/routes/admin/api/media.put.js @@ -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 || '') }) diff --git a/server/utils/media-library.js b/server/utils/media-library.js index 2aefb74..4fd0bb0 100644 --- a/server/utils/media-library.js +++ b/server/utils/media-library.js @@ -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} 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} + */ +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} + */ +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} 수정된 미디어 항목 + */ +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) }