관리자 미디어 오류 피드백을 useAdminToast 토스트로 통일 (v0.0.93)

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-12 11:03:23 +09:00
parent c1242e1409
commit 4de5589bcb
8 changed files with 89 additions and 26 deletions

View File

@@ -0,0 +1,51 @@
import { onUnmounted, ref } from 'vue'
const TOAST_AUTO_HIDE_MS = 4000
/**
* 관리자 화면 우측 상단 피드백 토스트. 모달(z-50 등)보다 위에 보이도록 사용처에서 `z-[100]` 클래스를 둔다.
* @returns {{ toast: import('vue').Ref<null | { type: string, message: string }>, showToast: (type: string, message: string) => void, clearToast: () => void }}
*/
export const useAdminToast = () => {
const toast = ref(null)
let toastTimer = null
/**
* 표시 중인 토스트를 즉시 제거한다.
* @returns {void}
*/
const clearToast = () => {
window.clearTimeout(toastTimer)
toastTimer = null
toast.value = null
}
/**
* 토스트를 표시한다. 이전 타이머가 있으면 취소한다.
* @param {'success' | 'error' | 'info'} type - 토스트 종류
* @param {string} message - 본문
* @returns {void}
*/
const showToast = (type, message) => {
window.clearTimeout(toastTimer)
const text = String(message || '').trim() || '알림'
toast.value = {
type,
message: text
}
toastTimer = window.setTimeout(() => {
toast.value = null
toastTimer = null
}, TOAST_AUTO_HIDE_MS)
}
onUnmounted(() => {
window.clearTimeout(toastTimer)
})
return {
toast,
showToast,
clearToast
}
}

View File

@@ -27,6 +27,7 @@
- 다크 인증(`signin`/`signup`)의 텍스트 입력에는 `auth-form-input` 클래스를 붙여 `main.css`의 글자색·캐럿·placeholder를 적용한다(폼 컨트롤은 부모 `color`를 상속하지 않는 경우가 많음).
- Tailwind 엔트리는 `nuxt.config.js``tailwindcss.cssPath: '~/assets/css/main.css'`로 통일한다(`@nuxtjs/tailwindcss` 기본 `assets/css/tailwind.css` 부재 시 패키지 `tailwind.css`가 중복 주입될 수 있음).
- 관리자 글 에디터는 블록 단위 UI로 작성하되 저장 값은 기존 마크다운 문자열을 유지
- 관리자 미디어 화면에서 모달에 가릴 수 있는 오류·안내는 `useAdminToast``showToast`로 우측 상단(`z-[100]`)에 표시한다.
```html
<main class="site-main w-full max-w-full lg:max-w-[720px]">

View File

@@ -1,5 +1,11 @@
# 의사결정 이력
## 2026-05-12 v0.0.93
### 관리자 미디어 오류 표시를 토스트로
상세·폴더 모달이 `z-50`~`z-[60]`일 때 본문 상단 배너는 모달 뒤에 깔려 사용자가 API 오류(예: 동일 파일명 409)를 볼 수 없었다. `useAdminToast`로 우측 상단 고정·높은 z-index에 두어 레이아웃과 무관하게 피드백이 보이게 했다.
## 2026-05-12 v0.0.92
### 프로필 썸네일 해제와 다운로드

View File

@@ -212,6 +212,7 @@
| assets/css/main.css | 전역 스타일, `#fcfcfc` 단일 배경 기준, 좌측 사이드바/그리드 전환 애니메이션, 네비게이션 세로 바 hover 효과 |
| composables/useMenuState.js | 좌측 메뉴 열림 상태·`closeMenu`(모바일 백드롭 등) |
| composables/useThemeMode.js | 사용자 화면 라이트/다크 테마 상태 관리 |
| composables/useAdminToast.js | 관리자 우측 상단 토스트(성공·오류·안내; 모달보다 위 z-index) |
| middleware/admin-auth.global.js | 관리자 페이지 접근 인증 |
| scripts/dev-server.js | 로컬 개발 서버 링크 요약 출력 |
| scripts/migrate-development-db.js | 로컬 개발 DB 마이그레이션 실행 |

View File

@@ -574,6 +574,7 @@ components/content/
- 미디어 라이브러리 탭에서만 선택한 미디어를 폴더로 드래그하면 해당 미디어들의 폴더 경로가 일괄 변경된다.
- 관리자 미디어 화면 검색은 **저장 파일명**과 게시물·페이지 **사용처 제목**만 대상으로 한다(URL 경로·논리 폴더명 문자열은 검색에 쓰지 않는다).
- 미디어 파일 경로, 사용 현황(라이브러리), 연결 회원(썸네일 탭), 용량 등 세부 정보는 상세 모달에서 표시한다.
- API 실패·클라이언트 검증 실패 등 사용자 피드백은 본문 상단 고정 배너가 아니라 `useAdminToast` 우측 상단 토스트로 표시해 모달에 가리지 않는다.
- 상세 모달의 **다운로드**는 공개 `/uploads/...` URL을 `download` 속성으로 브라우저에 내려받는다(썸네일·게시물 이미지 공통).
- 미디어 폴더는 실제 파일 경로를 옮기지 않고 `media_metadata` 테이블에 URL별 경로 메타데이터로 저장한다.
- 글쓰기 미디어 선택 창은 업로드 미디어 목록에서 이미지를 선택해 단일 이미지 또는 갤러리에 삽입한다.

View File

@@ -1,5 +1,10 @@
# 업데이트 이력
## v0.0.93
- `composables/useAdminToast.js` 추가: 관리자 우측 상단 토스트(자동 숨김).
- 관리자 미디어(`pages/admin/media/index.vue`): 본문 상단 `errorMessage` 배너 제거, 폴더·이름 변경·삭제 등 실패 피드백을 토스트로 통일해 모달에 가리지 않게 함.
## v0.0.92
- 회원 `PUT /api/auth/profile`에서 관리 썸네일 URL이 바뀌거나 비워질 때도 `removeManagedAvatarAsset`으로 메타만 분리해, 해제 후에도 디스크·썸네일 탭 목록과 일치하도록 정리.

View File

@@ -1,6 +1,6 @@
{
"name": "sori.studio",
"version": "0.0.92",
"version": "0.0.93",
"private": true,
"type": "module",
"imports": {

View File

@@ -23,12 +23,13 @@ const editingUrl = ref('')
const editingName = ref('')
const editingCategory = ref('')
const deletingUrl = ref('')
const errorMessage = ref('')
const selectedMediaUrl = ref('')
const selectedMediaUrls = ref([])
const lastSelectedIndex = ref(-1)
const draggingUrls = ref([])
const { toast, showToast } = useAdminToast()
const { data: mediaItems, refresh } = await useFetch('/admin/api/media', {
default: () => []
})
@@ -255,7 +256,6 @@ const openMediaDetail = (item) => {
editingUrl.value = item.url
editingName.value = item.title
editingCategory.value = item.category
errorMessage.value = ''
}
/**
@@ -282,7 +282,6 @@ const cancelRename = () => {
*/
const openCreateFolderModal = () => {
createFolderModalName.value = ''
errorMessage.value = ''
isCreateFolderModalOpen.value = true
}
@@ -310,8 +309,6 @@ const submitCreateFolderModal = async () => {
return
}
errorMessage.value = ''
try {
const folderPath = activeFolder.value ? `${activeFolder.value}/${folderName}` : folderName
const createdFolder = await $fetch('/admin/api/media-folders', {
@@ -325,7 +322,7 @@ const submitCreateFolderModal = async () => {
activeFolder.value = createdFolder.path
await refreshMediaFolders()
} catch (error) {
errorMessage.value = error?.data?.message || '폴더를 만들지 못했습니다.'
showToast('error', error?.data?.message || '폴더를 만들지 못했습니다.')
}
}
@@ -344,7 +341,6 @@ const removeMediaFolder = async (folder) => {
}
deletingFolder.value = folder
errorMessage.value = ''
try {
await $fetch('/admin/api/media-folders', {
@@ -363,7 +359,7 @@ const removeMediaFolder = async (folder) => {
refreshMediaFolders()
])
} catch (error) {
errorMessage.value = error?.data?.message || '폴더를 삭제하지 못했습니다.'
showToast('error', error?.data?.message || '폴더를 삭제하지 못했습니다.')
} finally {
deletingFolder.value = ''
}
@@ -386,8 +382,6 @@ const moveMediaToFolder = async (folder, urls = selectedMediaUrls.value) => {
return
}
errorMessage.value = ''
try {
await $fetch('/admin/api/media', {
method: 'PUT',
@@ -403,7 +397,7 @@ const moveMediaToFolder = async (folder, urls = selectedMediaUrls.value) => {
activeFolder.value = folder || '미분류'
clearMediaSelection()
} catch (error) {
errorMessage.value = error?.data?.message || '미디어 폴더를 변경하지 못했습니다.'
showToast('error', error?.data?.message || '미디어 폴더를 변경하지 못했습니다.')
}
}
@@ -455,8 +449,6 @@ const saveMediaCategory = async () => {
return
}
errorMessage.value = ''
try {
await $fetch('/admin/api/media', {
method: 'PUT',
@@ -470,7 +462,7 @@ const saveMediaCategory = async () => {
refreshMediaFolders()
])
} catch (error) {
errorMessage.value = error?.data?.message || '카테고리를 저장하지 못했습니다.'
showToast('error', error?.data?.message || '카테고리를 저장하지 못했습니다.')
}
}
@@ -482,12 +474,10 @@ const renameMedia = async () => {
const editingItem = mediaItems.value.find((item) => item.url === editingUrl.value)
if (editingItem && isMediaItemLocked(editingItem)) {
errorMessage.value = '게시물·페이지에서 쓰이거나, 회원 프로필에 연결된 썸네일은 파일명을 바꿀 수 없습니다.'
showToast('error', '게시물·페이지에서 쓰이거나, 회원 프로필에 연결된 썸네일은 파일명을 바꿀 수 없습니다.')
return
}
errorMessage.value = ''
try {
const renamedItem = await $fetch('/admin/api/media', {
method: 'PUT',
@@ -500,7 +490,7 @@ const renameMedia = async () => {
await refresh()
selectedMediaUrl.value = renamedItem.url
} catch (error) {
errorMessage.value = error?.data?.message || '파일명을 변경하지 못했습니다.'
showToast('error', error?.data?.message || '파일명을 변경하지 못했습니다.')
}
}
@@ -511,7 +501,7 @@ const renameMedia = async () => {
*/
const deleteMedia = async (item) => {
if (isMediaItemLocked(item)) {
errorMessage.value = '게시물·페이지에서 쓰이거나, 회원 프로필에 연결된 썸네일은 삭제할 수 없습니다.'
showToast('error', '게시물·페이지에서 쓰이거나, 회원 프로필에 연결된 썸네일은 삭제할 수 없습니다.')
return
}
@@ -520,7 +510,6 @@ const deleteMedia = async (item) => {
}
deletingUrl.value = item.url
errorMessage.value = ''
try {
await $fetch('/admin/api/media', {
@@ -532,7 +521,7 @@ const deleteMedia = async (item) => {
closeMediaDetail()
await refresh()
} catch (error) {
errorMessage.value = error?.data?.message || '파일을 삭제하지 못했습니다.'
showToast('error', error?.data?.message || '파일을 삭제하지 못했습니다.')
} finally {
deletingUrl.value = ''
}
@@ -578,10 +567,6 @@ const deleteMedia = async (item) => {
</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">
{{ errorMessage }}
</p>
<div class="admin-media__layout mt-8 grid gap-5 lg:grid-cols-[16rem_minmax(0,1fr)]">
<aside
v-if="activeTab === 'library'"
@@ -948,5 +933,18 @@ const deleteMedia = async (item) => {
</aside>
</section>
</div>
<div
v-if="toast"
class="admin-media__toast fixed right-5 top-5 z-[100] max-w-[min(24rem,calc(100vw-2.5rem))] rounded border px-4 py-3 text-sm font-semibold shadow-lg"
:class="{
'border-green-200 bg-green-50 text-green-800': toast.type === 'success',
'border-red-200 bg-red-50 text-red-800': toast.type === 'error',
'border-line bg-white text-ink': toast.type === 'info'
}"
role="status"
>
{{ toast.message }}
</div>
</section>
</template>