From a7fcd7dce58d3c77846130281e2609b25ae2de0d Mon Sep 17 00:00:00 2001 From: zenn Date: Sat, 2 May 2026 09:45:37 +0900 Subject: [PATCH] =?UTF-8?q?=EB=8C=80=ED=91=9C=20=EC=9D=B4=EB=AF=B8?= =?UTF-8?q?=EC=A7=80=EC=99=80=20=EB=AF=B8=EB=94=94=EC=96=B4=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- components/admin/AdminPostForm.vue | 160 +++++++++++++++++++++++++-- docs/history.md | 8 ++ docs/map.md | 2 +- docs/spec.md | 5 +- docs/todo.md | 2 + docs/update.md | 9 ++ package-lock.json | 4 +- package.json | 2 +- pages/admin/media/index.vue | 169 ++++++++++++++++++++--------- 9 files changed, 298 insertions(+), 63 deletions(-) diff --git a/components/admin/AdminPostForm.vue b/components/admin/AdminPostForm.vue index 0cc0623..bd140f8 100644 --- a/components/admin/AdminPostForm.vue +++ b/components/admin/AdminPostForm.vue @@ -18,6 +18,10 @@ const emit = defineEmits(['submit']) const slugTouched = ref(Boolean(props.initialPost.slug)) const blockEditor = ref(null) +const mediaItems = ref([]) +const isMediaPickerOpen = ref(false) +const isLoadingMedia = ref(false) +const isUploadingFeaturedImage = ref(false) const form = reactive({ title: props.initialPost.title || '', @@ -67,6 +71,83 @@ const parseTags = (value) => [...new Set(value .map((tag) => toSlug(tag)) .filter(Boolean))] +/** + * 미디어 라이브러리 목록 조회 + * @returns {Promise} + */ +const fetchMediaItems = async () => { + isLoadingMedia.value = true + + try { + mediaItems.value = await $fetch('/admin/api/media') + } finally { + isLoadingMedia.value = false + } +} + +/** + * 대표 이미지 선택 창 열기 + * @returns {Promise} + */ +const openMediaPicker = async () => { + isMediaPickerOpen.value = true + await fetchMediaItems() +} + +/** + * 대표 이미지 선택 창 닫기 + * @returns {void} + */ +const closeMediaPicker = () => { + isMediaPickerOpen.value = false +} + +/** + * 대표 이미지 선택 + * @param {Object} item - 미디어 항목 + * @returns {void} + */ +const selectFeaturedImage = (item) => { + form.featuredImage = item.url + closeMediaPicker() +} + +/** + * 대표 이미지 삭제 + * @returns {void} + */ +const removeFeaturedImage = () => { + form.featuredImage = '' +} + +/** + * 대표 이미지 파일 업로드 + * @param {Event} event - 파일 입력 이벤트 + * @returns {Promise} + */ +const uploadFeaturedImage = async (event) => { + const files = event.target.files + + if (!files?.length) { + return + } + + const formData = new FormData() + formData.append('files', files[0]) + isUploadingFeaturedImage.value = true + + try { + const result = await $fetch('/admin/api/uploads', { + method: 'POST', + body: formData + }) + form.featuredImage = result.files?.[0]?.url || '' + } finally { + event.target.value = '' + isUploadingFeaturedImage.value = false + } +} + /** * 제목 입력 후 본문 에디터로 이동 * @returns {void} @@ -154,14 +235,38 @@ const submitPost = () => { > - +
+ 대표 이미지 +
+ +
+

+ {{ form.featuredImage }} +

+
+ + + +
+
+
+
+ + +
+
@@ -177,5 +282,44 @@ const submitPost = () => { {{ saving ? '저장 중' : submitLabel }} + + diff --git a/docs/history.md b/docs/history.md index 46fae11..f799e84 100644 --- a/docs/history.md +++ b/docs/history.md @@ -1,5 +1,13 @@ # 의사결정 이력 +## 2026-05-02 v0.0.17 + +### 대표 이미지와 미디어 화면 밀도 개선 결정 + +대표 이미지는 URL을 직접 입력하지 않고 미디어 라이브러리에서 선택하거나 새로 업로드하는 흐름으로 통일한다. 게시물 작성자가 파일 URL을 다루지 않아도 되고, 이미 업로드된 이미지를 재사용할 수 있어야 하기 때문이다. 대표 이미지가 설정되면 썸네일과 삭제/변경 액션을 바로 보여준다. + +미디어 화면은 수백 개 이상의 파일이 쌓이는 전제를 기준으로 카드형 목록에서 고밀도 썸네일 갤러리로 바꾼다. 파일 경로, 용량, 사용 현황, 파일명 변경, 삭제 같은 상세 정보는 워드프레스처럼 선택한 이미지의 상세 모달에서 확인하고 처리한다. + ## 2026-05-01 v0.0.16 ### 미디어 사용처 표시와 삭제 보호 결정 diff --git a/docs/map.md b/docs/map.md index 86a53f8..45f96c1 100644 --- a/docs/map.md +++ b/docs/map.md @@ -26,7 +26,7 @@ | 파일 | 화면 위치 | |------|-----------| -| components/admin/AdminPostForm.vue | 관리자 글 작성/수정 폼 | +| components/admin/AdminPostForm.vue | 관리자 글 작성/수정 폼, 대표 이미지 선택 | | components/admin/AdminBlockEditor.vue | 관리자 글 블록형 에디터, 이미지/갤러리 블록 | | components/admin/AdminTagForm.vue | 관리자 태그 생성/수정 폼 | diff --git a/docs/spec.md b/docs/spec.md index 3b79e6f..1530d66 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -219,6 +219,8 @@ components/content/ - 제목 입력에서 Enter를 누르면 본문 첫 블록으로 포커스를 이동한다. - 관리자 글 에디터의 실제 입력 텍스트 색상은 placeholder보다 진하게 표시한다. - 관리자 작성 화면과 공개 본문은 같은 마크다운 렌더링 기준을 사용한다. +- 대표 이미지는 URL 직접 입력이 아니라 미디어 선택 또는 새 이미지 업로드로 설정한다. +- 대표 이미지가 설정되면 관리자 글 폼에 썸네일과 삭제/변경 액션을 표시한다. - 이미지 블록은 관리자 업로드 API로 이미지를 업로드하고 `![alt](url){width=wide}` 형식으로 저장한다. - 이미지 블록 표시 옵션은 `regular`, `wide`, `full` 값을 사용하며 `regular`는 width 옵션을 생략한다. - 갤러리 블록은 `:::gallery` fenced block 안에 이미지 마크다운 행을 여러 개 저장한다. @@ -248,7 +250,8 @@ components/content/ - 관리자 이미지 업로드 API는 `image/jpeg`, `image/png`, `image/webp`, `image/gif`만 허용한다. - 업로드 파일 크기 제한은 `MAX_FILE_SIZE` 환경 변수를 따른다. - 로컬 개발 업로드 파일은 `public/uploads/posts/YYYY/MM/` 아래 저장하고 `/uploads/posts/YYYY/MM/filename` URL로 제공한다. -- 관리자 미디어 화면은 업로드 이미지 목록, 검색, 파일명 변경, 개별 삭제를 제공한다. +- 관리자 미디어 화면은 고밀도 썸네일 갤러리, 검색, 파일명 변경, 개별 삭제를 제공한다. +- 미디어 파일 경로, 사용 현황, 용량 등 세부 정보는 썸네일 클릭 시 열리는 상세 모달에서 표시한다. - 글쓰기 미디어 선택 창은 업로드 미디어 목록에서 이미지를 선택해 단일 이미지 또는 갤러리에 삽입한다. - 미디어 사용 현황은 게시물/페이지의 대표 이미지와 본문 내 URL을 기준으로 표시한다. - 사용 중인 미디어는 저장된 콘텐츠 URL이 깨지지 않도록 파일명 변경과 삭제를 차단한다. diff --git a/docs/todo.md b/docs/todo.md index 7a3bf90..f973061 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -6,6 +6,8 @@ - [ ] 블록 에디터 저장/수정 왕복 QA: 기존 글 수정 시 블록 파싱, 저장 후 다시 열기 확인 - [ ] 이미지/갤러리 블록 브라우저 수동 QA: 업로드, 너비 옵션, 저장 후 공개 렌더링, 갤러리 라이트박스 확인 - [ ] 미디어 선택 창 브라우저 수동 QA: 기존 이미지 선택, 갤러리 추가, 빈 미디어 상태 확인 +- [ ] 대표 이미지 브라우저 수동 QA: 기존 미디어 선택, 새 업로드, 썸네일 표시, 삭제/변경 확인 +- [ ] 미디어 라이브러리 상세 모달 수동 QA: 검색, 사용 현황, 파일명 변경, 삭제 잠금 확인 - [ ] 콜아웃, 토글, 임베드 블록 추가 - [ ] 글 작성 중 자동 저장 diff --git a/docs/update.md b/docs/update.md index 668da80..09b8b2a 100644 --- a/docs/update.md +++ b/docs/update.md @@ -1,5 +1,14 @@ # 업데이트 이력 +## v0.0.17 + +- 관리자 글 작성/수정 폼의 대표 이미지 URL 직접 입력을 이미지 선택 UI로 변경. +- 대표 이미지 썸네일, 삭제, 변경, 새 업로드 기능 추가. +- 대표 이미지를 기존 미디어 라이브러리에서 선택할 수 있도록 추가. +- 관리자 미디어 화면을 고밀도 썸네일 갤러리 구조로 변경. +- 미디어 경로, 사용 현황, 용량, 파일명 변경, 삭제 정보를 상세 모달로 이동. +- 패키지 버전을 0.0.17로 갱신. + ## v0.0.16 - 관리자 미디어 목록에 게시물/페이지 사용 현황 표시 추가. diff --git a/package-lock.json b/package-lock.json index 0ebcab4..8ebe929 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "sori.studio", - "version": "0.0.16", + "version": "0.0.17", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sori.studio", - "version": "0.0.16", + "version": "0.0.17", "hasInstallScript": true, "dependencies": { "@nuxtjs/tailwindcss": "^6.14.0", diff --git a/package.json b/package.json index 60e9ccb..69ab1ee 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sori.studio", - "version": "0.0.16", + "version": "0.0.17", "private": true, "type": "module", "scripts": { diff --git a/pages/admin/media/index.vue b/pages/admin/media/index.vue index c5830ad..74a2f20 100644 --- a/pages/admin/media/index.vue +++ b/pages/admin/media/index.vue @@ -8,11 +8,14 @@ const editingUrl = ref('') const editingName = ref('') const deletingUrl = ref('') const errorMessage = ref('') +const selectedMediaUrl = ref('') const { data: mediaItems, refresh } = await useFetch('/admin/api/media', { default: () => [] }) +const selectedMedia = computed(() => mediaItems.value.find((item) => item.url === selectedMediaUrl.value) || null) + const filteredMediaItems = computed(() => { const query = searchText.value.trim().toLowerCase() @@ -46,16 +49,26 @@ const formatFileSize = (size) => { } /** - * 미디어 수정 상태 시작 + * 미디어 상세 모달 열기 * @param {Object} item - 미디어 항목 * @returns {void} */ -const startRename = (item) => { +const openMediaDetail = (item) => { + selectedMediaUrl.value = item.url editingUrl.value = item.url editingName.value = item.title errorMessage.value = '' } +/** + * 미디어 상세 모달 닫기 + * @returns {void} + */ +const closeMediaDetail = () => { + selectedMediaUrl.value = '' + cancelRename() +} + /** * 미디어 파일명 변경 취소 * @returns {void} @@ -80,7 +93,7 @@ const renameMedia = async () => { errorMessage.value = '' try { - await $fetch('/admin/api/media', { + const renamedItem = await $fetch('/admin/api/media', { method: 'PUT', body: { url: editingUrl.value, @@ -89,6 +102,7 @@ const renameMedia = async () => { }) cancelRename() await refresh() + selectedMediaUrl.value = renamedItem.url } catch (error) { errorMessage.value = error?.data?.message || '파일명을 변경하지 못했습니다.' } @@ -119,6 +133,7 @@ const deleteMedia = async (item) => { url: item.url } }) + closeMediaDetail() await refresh() } catch (error) { errorMessage.value = error?.data?.message || '파일을 삭제하지 못했습니다.' @@ -143,7 +158,7 @@ const deleteMedia = async (item) => { 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="파일명 또는 경로 검색" + placeholder="파일명, 경로, 사용처 검색" > @@ -151,40 +166,79 @@ const deleteMedia = async (item) => { {{ errorMessage }}

-
-
- -
-
- -
- - -
-
- +
+ +
+ +

+ 표시할 미디어가 없습니다. +

+ + -
+ +
- -

- 표시할 미디어가 없습니다. -