미디어 사용 현황 표시 추가
This commit is contained in:
@@ -1,5 +1,13 @@
|
|||||||
# 의사결정 이력
|
# 의사결정 이력
|
||||||
|
|
||||||
|
## 2026-05-01 v0.0.16
|
||||||
|
|
||||||
|
### 미디어 사용처 표시와 삭제 보호 결정
|
||||||
|
|
||||||
|
미디어 라이브러리에서 파일명 변경과 삭제를 제공하면, 해당 이미지가 글 본문이나 대표 이미지에 사용 중인지 먼저 보여줘야 한다. 현재 콘텐츠는 이미지 URL을 게시물/페이지의 `content`와 `featuredImage`에 직접 저장하므로, 사용 중인 파일을 변경하거나 삭제하면 공개 화면의 이미지가 깨진다.
|
||||||
|
|
||||||
|
따라서 1차 사용처 추적은 게시물과 페이지를 대상으로 본문, 대표 이미지 위치를 표시한다. 사용 중인 미디어의 파일명 변경과 삭제는 차단하고, 미사용 파일만 정리할 수 있도록 한다. 프로필이나 사이트 설정 이미지는 아직 해당 데이터 모델이 없으므로 설정 기능 구현 시 사용처 추적에 추가한다.
|
||||||
|
|
||||||
## 2026-05-01 v0.0.15
|
## 2026-05-01 v0.0.15
|
||||||
|
|
||||||
### 미디어 라이브러리 1차 범위 결정
|
### 미디어 라이브러리 1차 범위 결정
|
||||||
|
|||||||
@@ -250,7 +250,9 @@ components/content/
|
|||||||
- 로컬 개발 업로드 파일은 `public/uploads/posts/YYYY/MM/` 아래 저장하고 `/uploads/posts/YYYY/MM/filename` URL로 제공한다.
|
- 로컬 개발 업로드 파일은 `public/uploads/posts/YYYY/MM/` 아래 저장하고 `/uploads/posts/YYYY/MM/filename` URL로 제공한다.
|
||||||
- 관리자 미디어 화면은 업로드 이미지 목록, 검색, 파일명 변경, 개별 삭제를 제공한다.
|
- 관리자 미디어 화면은 업로드 이미지 목록, 검색, 파일명 변경, 개별 삭제를 제공한다.
|
||||||
- 글쓰기 미디어 선택 창은 업로드 미디어 목록에서 이미지를 선택해 단일 이미지 또는 갤러리에 삽입한다.
|
- 글쓰기 미디어 선택 창은 업로드 미디어 목록에서 이미지를 선택해 단일 이미지 또는 갤러리에 삽입한다.
|
||||||
- 향후 미디어 라이브러리는 카테고리 분류와 이미지 사용처 추적을 제공한다.
|
- 미디어 사용 현황은 게시물/페이지의 대표 이미지와 본문 내 URL을 기준으로 표시한다.
|
||||||
|
- 사용 중인 미디어는 저장된 콘텐츠 URL이 깨지지 않도록 파일명 변경과 삭제를 차단한다.
|
||||||
|
- 향후 미디어 라이브러리는 카테고리 분류와 프로필/사이트 설정 이미지 사용처 추적을 제공한다.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
- [ ] 사이트 설정
|
- [ ] 사이트 설정
|
||||||
- [ ] 메뉴/네비게이션 관리
|
- [ ] 메뉴/네비게이션 관리
|
||||||
- [ ] 미디어 라이브러리 카테고리 분류
|
- [ ] 미디어 라이브러리 카테고리 분류
|
||||||
- [ ] 미디어 라이브러리 이미지 사용처 추적
|
- [ ] 미디어 라이브러리 프로필/사이트 설정 이미지 사용처 추적
|
||||||
|
|
||||||
## 3차 관리자 개발
|
## 3차 관리자 개발
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
# 업데이트 이력
|
# 업데이트 이력
|
||||||
|
|
||||||
|
## v0.0.16
|
||||||
|
|
||||||
|
- 관리자 미디어 목록에 게시물/페이지 사용 현황 표시 추가.
|
||||||
|
- 미디어 사용처를 대표 이미지와 본문 위치로 구분해 표시.
|
||||||
|
- 사용 중인 미디어의 파일명 변경과 삭제를 차단하도록 수정.
|
||||||
|
- 미디어 검색 대상에 사용 중인 게시물/페이지 제목 추가.
|
||||||
|
- 패키지 버전을 0.0.16으로 갱신.
|
||||||
|
|
||||||
## v0.0.15
|
## v0.0.15
|
||||||
|
|
||||||
- 관리자 블록 에디터 `/` 메뉴 항목 제목 색상을 진한 본문색으로 수정.
|
- 관리자 블록 에디터 `/` 메뉴 항목 제목 색상을 진한 본문색으로 수정.
|
||||||
|
|||||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "sori.studio",
|
"name": "sori.studio",
|
||||||
"version": "0.0.15",
|
"version": "0.0.16",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "sori.studio",
|
"name": "sori.studio",
|
||||||
"version": "0.0.15",
|
"version": "0.0.16",
|
||||||
"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.15",
|
"version": "0.0.16",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -23,7 +23,8 @@ const filteredMediaItems = computed(() => {
|
|||||||
return mediaItems.value.filter((item) => [
|
return mediaItems.value.filter((item) => [
|
||||||
item.name,
|
item.name,
|
||||||
item.url,
|
item.url,
|
||||||
item.category
|
item.category,
|
||||||
|
...item.usage.map((usage) => usage.title)
|
||||||
].some((value) => value.toLowerCase().includes(query)))
|
].some((value) => value.toLowerCase().includes(query)))
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -69,6 +70,13 @@ const cancelRename = () => {
|
|||||||
* @returns {Promise<void>}
|
* @returns {Promise<void>}
|
||||||
*/
|
*/
|
||||||
const renameMedia = async () => {
|
const renameMedia = async () => {
|
||||||
|
const editingItem = mediaItems.value.find((item) => item.url === editingUrl.value)
|
||||||
|
|
||||||
|
if (editingItem?.usage.length) {
|
||||||
|
errorMessage.value = '사용 중인 미디어는 파일명을 변경할 수 없습니다.'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
errorMessage.value = ''
|
errorMessage.value = ''
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -92,6 +100,11 @@ const renameMedia = async () => {
|
|||||||
* @returns {Promise<void>}
|
* @returns {Promise<void>}
|
||||||
*/
|
*/
|
||||||
const deleteMedia = async (item) => {
|
const deleteMedia = async (item) => {
|
||||||
|
if (item.usage.length) {
|
||||||
|
errorMessage.value = '사용 중인 미디어는 삭제할 수 없습니다.'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (!confirm(`"${item.name}" 파일을 삭제할까요?`)) {
|
if (!confirm(`"${item.name}" 파일을 삭제할까요?`)) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -166,14 +179,40 @@ const deleteMedia = async (item) => {
|
|||||||
<p class="admin-media__meta text-xs text-muted">
|
<p class="admin-media__meta text-xs text-muted">
|
||||||
{{ item.category }} · {{ formatFileSize(item.size) }}
|
{{ item.category }} · {{ formatFileSize(item.size) }}
|
||||||
</p>
|
</p>
|
||||||
|
<div class="admin-media__usage rounded bg-surface p-3 text-xs">
|
||||||
|
<strong class="admin-media__usage-title text-ink">
|
||||||
|
사용 현황 {{ item.usage.length }}곳
|
||||||
|
</strong>
|
||||||
|
<ul v-if="item.usage.length" class="admin-media__usage-list mt-2 grid gap-1.5 text-muted">
|
||||||
|
<li v-for="usage in item.usage" :key="`${item.url}-${usage.type}-${usage.id}-${usage.location}`" class="admin-media__usage-item">
|
||||||
|
<NuxtLink
|
||||||
|
v-if="usage.adminUrl"
|
||||||
|
class="admin-media__usage-link font-semibold text-ink hover:opacity-70"
|
||||||
|
:to="usage.adminUrl"
|
||||||
|
>
|
||||||
|
{{ usage.title }}
|
||||||
|
</NuxtLink>
|
||||||
|
<span v-else class="admin-media__usage-name font-semibold text-ink">{{ usage.title }}</span>
|
||||||
|
<span class="admin-media__usage-meta"> · {{ usage.typeLabel }} · {{ usage.label }}</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p v-else class="admin-media__usage-empty mt-2 text-muted">
|
||||||
|
사용 중인 곳이 없습니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
<div class="admin-media__actions flex gap-2">
|
<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
|
||||||
|
class="admin-media__rename-button rounded border border-line px-3 py-1.5 text-xs font-semibold disabled:opacity-50"
|
||||||
|
type="button"
|
||||||
|
:disabled="item.usage.length > 0"
|
||||||
|
@click="startRename(item)"
|
||||||
|
>
|
||||||
파일명 변경
|
파일명 변경
|
||||||
</button>
|
</button>
|
||||||
<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"
|
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"
|
type="button"
|
||||||
:disabled="deletingUrl === item.url"
|
:disabled="deletingUrl === item.url || item.usage.length > 0"
|
||||||
@click="deleteMedia(item)"
|
@click="deleteMedia(item)"
|
||||||
>
|
>
|
||||||
{{ deletingUrl === item.url ? '삭제 중' : '삭제' }}
|
{{ deletingUrl === item.url ? '삭제 중' : '삭제' }}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { readdir, rename, rm, stat } from 'node:fs/promises'
|
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'
|
||||||
|
|
||||||
const uploadRoot = join(process.cwd(), 'public', 'uploads')
|
const uploadRoot = join(process.cwd(), 'public', 'uploads')
|
||||||
|
|
||||||
@@ -92,14 +93,81 @@ const readMediaDirectory = async (directoryPath) => {
|
|||||||
return items.flat()
|
return items.flat()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 콘텐츠에서 미디어 URL 사용 여부 확인
|
||||||
|
* @param {Object} contentItem - 콘텐츠 항목
|
||||||
|
* @param {string} url - 미디어 URL
|
||||||
|
* @returns {Array<Object>} 사용처 목록
|
||||||
|
*/
|
||||||
|
const getContentMediaUsage = (contentItem, url) => {
|
||||||
|
const usages = []
|
||||||
|
|
||||||
|
if (contentItem.featuredImage === url) {
|
||||||
|
usages.push({
|
||||||
|
location: 'featuredImage',
|
||||||
|
label: '대표 이미지'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (contentItem.content?.includes(url)) {
|
||||||
|
usages.push({
|
||||||
|
location: 'content',
|
||||||
|
label: '본문'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return usages
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 미디어 URL 사용처 조회
|
||||||
|
* @param {string} url - 미디어 URL
|
||||||
|
* @returns {Promise<Array<Object>>} 사용처 목록
|
||||||
|
*/
|
||||||
|
const getMediaUsage = (url, posts, pages) => {
|
||||||
|
const postUsages = posts.flatMap((post) => getContentMediaUsage(post, url).map((usage) => ({
|
||||||
|
type: 'post',
|
||||||
|
typeLabel: '게시물',
|
||||||
|
id: post.id,
|
||||||
|
title: post.title,
|
||||||
|
slug: post.slug,
|
||||||
|
adminUrl: `/admin/posts/${post.id}`,
|
||||||
|
publicUrl: `/posts/${post.slug}`,
|
||||||
|
status: post.status,
|
||||||
|
...usage
|
||||||
|
})))
|
||||||
|
|
||||||
|
const pageUsages = pages.flatMap((page) => getContentMediaUsage(page, url).map((usage) => ({
|
||||||
|
type: 'page',
|
||||||
|
typeLabel: '페이지',
|
||||||
|
id: page.id,
|
||||||
|
title: page.title,
|
||||||
|
slug: page.slug,
|
||||||
|
adminUrl: '',
|
||||||
|
publicUrl: `/pages/${page.slug}`,
|
||||||
|
status: 'page',
|
||||||
|
...usage
|
||||||
|
})))
|
||||||
|
|
||||||
|
return [...postUsages, ...pageUsages]
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 미디어 목록 조회
|
* 미디어 목록 조회
|
||||||
* @returns {Promise<Array<Object>>} 미디어 항목 목록
|
* @returns {Promise<Array<Object>>} 미디어 항목 목록
|
||||||
*/
|
*/
|
||||||
export const listMediaItems = async () => {
|
export const listMediaItems = async () => {
|
||||||
const items = await readMediaDirectory(uploadRoot)
|
const items = await readMediaDirectory(uploadRoot)
|
||||||
|
const [posts, pages] = await Promise.all([
|
||||||
|
listAdminPosts(),
|
||||||
|
listPages()
|
||||||
|
])
|
||||||
|
const itemsWithUsage = items.map((item) => ({
|
||||||
|
...item,
|
||||||
|
usage: getMediaUsage(item.url, posts, pages)
|
||||||
|
}))
|
||||||
|
|
||||||
return items.sort((left, right) => new Date(right.updatedAt) - new Date(left.updatedAt))
|
return itemsWithUsage.sort((left, right) => new Date(right.updatedAt) - new Date(left.updatedAt))
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -108,6 +176,19 @@ export const listMediaItems = async () => {
|
|||||||
* @returns {Promise<void>}
|
* @returns {Promise<void>}
|
||||||
*/
|
*/
|
||||||
export const deleteMediaItem = async (url) => {
|
export const deleteMediaItem = async (url) => {
|
||||||
|
const [posts, pages] = await Promise.all([
|
||||||
|
listAdminPosts(),
|
||||||
|
listPages()
|
||||||
|
])
|
||||||
|
const usage = getMediaUsage(url, posts, pages)
|
||||||
|
|
||||||
|
if (usage.length) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 409,
|
||||||
|
message: '사용 중인 미디어는 삭제할 수 없습니다.'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
await rm(resolveMediaPath(url))
|
await rm(resolveMediaPath(url))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,6 +199,19 @@ export const deleteMediaItem = async (url) => {
|
|||||||
* @returns {Promise<Object>} 변경된 미디어 항목
|
* @returns {Promise<Object>} 변경된 미디어 항목
|
||||||
*/
|
*/
|
||||||
export const renameMediaItem = async (url, name) => {
|
export const renameMediaItem = async (url, name) => {
|
||||||
|
const [posts, pages] = await Promise.all([
|
||||||
|
listAdminPosts(),
|
||||||
|
listPages()
|
||||||
|
])
|
||||||
|
const usage = getMediaUsage(url, posts, pages)
|
||||||
|
|
||||||
|
if (usage.length) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 409,
|
||||||
|
message: '사용 중인 미디어는 파일명을 변경할 수 없습니다.'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const currentPath = resolveMediaPath(url)
|
const currentPath = resolveMediaPath(url)
|
||||||
const currentExtension = extname(currentPath)
|
const currentExtension = extname(currentPath)
|
||||||
const cleanName = sanitizeMediaName(name.replace(/\.[^.]+$/g, ''))
|
const cleanName = sanitizeMediaName(name.replace(/\.[^.]+$/g, ''))
|
||||||
|
|||||||
Reference in New Issue
Block a user