From 4de5589bcbbf7e9e8d84f7a00b7856c3fe8521fd Mon Sep 17 00:00:00 2001 From: zenn Date: Tue, 12 May 2026 11:03:23 +0900 Subject: [PATCH] =?UTF-8?q?=EA=B4=80=EB=A6=AC=EC=9E=90=20=EB=AF=B8?= =?UTF-8?q?=EB=94=94=EC=96=B4=20=EC=98=A4=EB=A5=98=20=ED=94=BC=EB=93=9C?= =?UTF-8?q?=EB=B0=B1=EC=9D=84=20useAdminToast=20=ED=86=A0=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=EB=A1=9C=20=ED=86=B5=EC=9D=BC=20(v0.0.93)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- composables/useAdminToast.js | 51 ++++++++++++++++++++++++++++++++++++ docs/convention.md | 1 + docs/history.md | 6 +++++ docs/map.md | 1 + docs/spec.md | 1 + docs/update.md | 5 ++++ package.json | 2 +- pages/admin/media/index.vue | 48 ++++++++++++++++----------------- 8 files changed, 89 insertions(+), 26 deletions(-) create mode 100644 composables/useAdminToast.js diff --git a/composables/useAdminToast.js b/composables/useAdminToast.js new file mode 100644 index 0000000..17caab5 --- /dev/null +++ b/composables/useAdminToast.js @@ -0,0 +1,51 @@ +import { onUnmounted, ref } from 'vue' + +const TOAST_AUTO_HIDE_MS = 4000 + +/** + * 관리자 화면 우측 상단 피드백 토스트. 모달(z-50 등)보다 위에 보이도록 사용처에서 `z-[100]` 클래스를 둔다. + * @returns {{ toast: import('vue').Ref, 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 + } +} diff --git a/docs/convention.md b/docs/convention.md index 7269295..c94778d 100644 --- a/docs/convention.md +++ b/docs/convention.md @@ -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
diff --git a/docs/history.md b/docs/history.md index 1dfc597..b25890a 100644 --- a/docs/history.md +++ b/docs/history.md @@ -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 ### 프로필 썸네일 해제와 다운로드 diff --git a/docs/map.md b/docs/map.md index 590b5b4..0b6016b 100644 --- a/docs/map.md +++ b/docs/map.md @@ -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 마이그레이션 실행 | diff --git a/docs/spec.md b/docs/spec.md index 5eb69f5..4f1516c 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -574,6 +574,7 @@ components/content/ - 미디어 라이브러리 탭에서만 선택한 미디어를 폴더로 드래그하면 해당 미디어들의 폴더 경로가 일괄 변경된다. - 관리자 미디어 화면 검색은 **저장 파일명**과 게시물·페이지 **사용처 제목**만 대상으로 한다(URL 경로·논리 폴더명 문자열은 검색에 쓰지 않는다). - 미디어 파일 경로, 사용 현황(라이브러리), 연결 회원(썸네일 탭), 용량 등 세부 정보는 상세 모달에서 표시한다. +- API 실패·클라이언트 검증 실패 등 사용자 피드백은 본문 상단 고정 배너가 아니라 `useAdminToast` 우측 상단 토스트로 표시해 모달에 가리지 않는다. - 상세 모달의 **다운로드**는 공개 `/uploads/...` URL을 `download` 속성으로 브라우저에 내려받는다(썸네일·게시물 이미지 공통). - 미디어 폴더는 실제 파일 경로를 옮기지 않고 `media_metadata` 테이블에 URL별 경로 메타데이터로 저장한다. - 글쓰기 미디어 선택 창은 업로드 미디어 목록에서 이미지를 선택해 단일 이미지 또는 갤러리에 삽입한다. diff --git a/docs/update.md b/docs/update.md index 9702e0c..0c62455 100644 --- a/docs/update.md +++ b/docs/update.md @@ -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`으로 메타만 분리해, 해제 후에도 디스크·썸네일 탭 목록과 일치하도록 정리. diff --git a/package.json b/package.json index 4d19466..c649c01 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sori.studio", - "version": "0.0.92", + "version": "0.0.93", "private": true, "type": "module", "imports": { diff --git a/pages/admin/media/index.vue b/pages/admin/media/index.vue index 6a3b83e..26cbdb9 100644 --- a/pages/admin/media/index.vue +++ b/pages/admin/media/index.vue @@ -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) => { -

- {{ errorMessage }} -

-
+ +
+ {{ toast.message }} +