프로필 썸네일 해제 시 메타 분리 통일·미디어 모달 다운로드(v0.0.92)

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-12 10:56:26 +09:00
parent 16bb9370fa
commit c1242e1409
7 changed files with 71 additions and 11 deletions

View File

@@ -1,5 +1,11 @@
# 의사결정 이력
## 2026-05-12 v0.0.92
### 프로필 썸네일 해제와 다운로드
서버는 이미 디스크를 지우지 않지만, 설정 화면이 `PUT /api/auth/profile`로만 `avatarUrl`을 비울 때는 메타 분리가 빠져 관리자 목록과 체감이 어긋날 수 있어 `DELETE /api/auth/avatar`와 같은 `removeManagedAvatarAsset` 호출을 맞췄다. 관리자 미디어 모달에 다운로드를 넣어 원본 확인을 쉽게 했다.
## 2026-05-12 v0.0.91
### 썸네일 미사용 자산과 업로드 파일명

View File

@@ -81,7 +81,7 @@
| pages/admin/pages/index.vue | 페이지 목록 |
| pages/admin/pages/new.vue | 페이지 작성, 저장 토스트 |
| pages/admin/pages/[id].vue | 페이지 수정, 저장/삭제 토스트 |
| pages/admin/media/index.vue | 미디어 관리, **미디어 라이브러리/썸네일** 탭, 검색은 파일명·게시물 사용처 제목만, 라이브러리: 폴더 트리·드래그 이동 등, 썸네일: 미참조 파일 삭제·이름 변경 가능, 상세 모달(연결 회원·폴더 편집) |
| pages/admin/media/index.vue | 미디어 관리, **미디어 라이브러리/썸네일** 탭, 검색은 파일명·게시물 사용처 제목만, 라이브러리: 폴더 트리·드래그 이동 등, 썸네일: 미참조 파일 삭제·이름 변경 가능, 상세 모달(연결 회원·폴더 편집·**다운로드**) |
| pages/admin/navigation/index.vue | 메뉴/네비게이션 관리(변경 시에만 메뉴 저장 활성) |
| pages/admin/tags/index.vue | 태그 관리(메인 태그 드래그 정렬·일반 강등, 일반 태그 검색/메인 전환/삭제), 액션 피드백 토스트, 순서 변경 시에만 정렬 저장 활성 |
| pages/admin/tags/new.vue | 태그 생성 |
@@ -123,7 +123,7 @@
| server/api/auth/me.get.js | 회원 세션 조회 API |
| server/api/auth/logout.post.js | 회원 로그아웃 API |
| server/api/auth/profile.get.js | 회원 프로필 조회 API |
| server/api/auth/profile.put.js | 회원 프로필 수정 API |
| server/api/auth/profile.put.js | 회원 프로필 수정 API(닉네임·`avatarUrl`; 관리 썸네일 URL 교체 시 메타만 분리) |
| server/api/auth/avatar.post.js | 회원 썸네일 업로드 API(WebP 변환, 최소 해상도 검증, 중앙 1:1 강제 크롭, 품질 보정, `media_metadata` 논리 폴더 `썸네일`) |
| server/api/auth/avatar.delete.js | 회원 썸네일 삭제 API |
| server/api/auth/check-username.get.js | 닉네임 중복 확인 API |

View File

@@ -355,7 +355,7 @@ components/content/
- `GET /api/auth/me` - 현재 회원 세션 조회
- `POST /api/auth/logout` - 회원 로그아웃
- `GET /api/auth/profile` - 회원 설정 조회
- `PUT /api/auth/profile` - 회원 프로필 수정(닉네임, 썸네일)
- `PUT /api/auth/profile` - 회원 프로필 수정(닉네임, `avatarUrl`). 이전 값이 `/uploads/members/avatars/` URL이고 새 값과 달라지면 `removeManagedAvatarAsset`으로 **메타만** 끊고 디스크 파일은 유지한다(`DELETE /api/auth/avatar`와 동일한 자산 정리 규칙).
- `POST /api/auth/avatar` - 회원 썸네일 이미지 업로드
- `DELETE /api/auth/avatar` - 회원 썸네일 제거
- `GET /api/auth/check-username?username=` - 닉네임 중복 확인
@@ -574,6 +574,7 @@ components/content/
- 미디어 라이브러리 탭에서만 선택한 미디어를 폴더로 드래그하면 해당 미디어들의 폴더 경로가 일괄 변경된다.
- 관리자 미디어 화면 검색은 **저장 파일명**과 게시물·페이지 **사용처 제목**만 대상으로 한다(URL 경로·논리 폴더명 문자열은 검색에 쓰지 않는다).
- 미디어 파일 경로, 사용 현황(라이브러리), 연결 회원(썸네일 탭), 용량 등 세부 정보는 상세 모달에서 표시한다.
- 상세 모달의 **다운로드**는 공개 `/uploads/...` URL을 `download` 속성으로 브라우저에 내려받는다(썸네일·게시물 이미지 공통).
- 미디어 폴더는 실제 파일 경로를 옮기지 않고 `media_metadata` 테이블에 URL별 경로 메타데이터로 저장한다.
- 글쓰기 미디어 선택 창은 업로드 미디어 목록에서 이미지를 선택해 단일 이미지 또는 갤러리에 삽입한다.
- 미디어 사용 현황은 게시물/페이지의 대표 이미지와 본문 내 URL을 기준으로 표시한다.

View File

@@ -1,5 +1,11 @@
# 업데이트 이력
## v0.0.92
- 회원 `PUT /api/auth/profile`에서 관리 썸네일 URL이 바뀌거나 비워질 때도 `removeManagedAvatarAsset`으로 메타만 분리해, 해제 후에도 디스크·썸네일 탭 목록과 일치하도록 정리.
- 관리자 미디어 상세 모달에 **다운로드** 버튼 추가.
- 썸네일 탭 안내: 프로필 해제 시에도 파일이 삭제되지 않음·목록 갱신은 새로고침을 명시.
## v0.0.91
- 회원 썸네일 교체·삭제·탈퇴 시 이전 파일은 디스크에 남기고 `media_metadata`만 제거해, 관리자 썸네일 탭에서 미사용 자산을 구분·삭제할 수 있게 함.

View File

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

View File

@@ -90,6 +90,27 @@ const formatDateTime = (iso) => {
}
}
/**
* 상세 모달에서 선택된 미디어를 브라우저로 내려받는다.
* @returns {void}
*/
const downloadSelectedMedia = () => {
const item = selectedMedia.value
if (!item?.url || !import.meta.client) {
return
}
const anchor = document.createElement('a')
anchor.href = item.url
anchor.download = item.name || 'image'
anchor.rel = 'noopener noreferrer'
anchor.target = '_blank'
document.body.appendChild(anchor)
anchor.click()
document.body.removeChild(anchor)
}
const { data: mediaFolders, refresh: refreshMediaFolders } = await useFetch('/admin/api/media-folders', {
default: () => ['미분류']
})
@@ -635,7 +656,7 @@ const deleteMedia = async (item) => {
<span>{{ thumbnailMediaItems.length }}</span>
</button>
<p class="admin-media__thumb-hint mt-4 text-xs leading-relaxed text-muted">
회원 프로필에서 쓰는 이미지는 연결 회원이 있을 때만 삭제·이름 변경이 막힙니다. 프로필에서 바꾸거나 해제된 파일은 이 목록에 남으며, 관리자가 직접 정리할 수 있습니다.
회원 프로필에서 쓰는 이미지는 연결 회원이 있을 때만 삭제·이름 변경이 막힙니다. 프로필에서 바꾸거나 해제해도 <strong class="font-semibold text-ink">디스크 파일은 삭제되지 않으며</strong> 이 목록에 남습니다. 목록이 바로 안 바뀌면 페이지를 새로고침하세요. 관리자는 필요 시 삭제·다운로드로 정리할 수 있습니다.
</p>
</aside>
@@ -775,8 +796,8 @@ const deleteMedia = async (item) => {
</div>
<aside class="admin-media__detail grid max-h-[86vh] content-start gap-5 overflow-y-auto border-l border-line p-5">
<div class="admin-media__detail-header flex items-start justify-between gap-3">
<div>
<div class="admin-media__detail-header flex flex-wrap items-start justify-between gap-3">
<div class="min-w-0 flex-1">
<p class="admin-media__detail-eyebrow text-xs font-semibold uppercase text-muted">
Attachment
</p>
@@ -784,9 +805,18 @@ const deleteMedia = async (item) => {
{{ selectedMedia.name }}
</h2>
</div>
<button class="admin-media__detail-close rounded border border-line px-3 py-1.5 text-sm font-semibold" type="button" @click="closeMediaDetail">
닫기
</button>
<div class="admin-media__detail-header-actions flex shrink-0 flex-wrap items-center gap-2">
<button
class="admin-media__download rounded border border-line px-3 py-1.5 text-sm font-semibold text-ink hover:bg-surface"
type="button"
@click="downloadSelectedMedia"
>
다운로드
</button>
<button class="admin-media__detail-close rounded border border-line px-3 py-1.5 text-sm font-semibold" type="button" @click="closeMediaDetail">
닫기
</button>
</div>
</div>
<dl class="admin-media__info grid gap-3 text-sm">

View File

@@ -2,6 +2,7 @@ import { createError, readBody } from 'h3'
import { z } from 'zod'
import { getUserById, isUsernameTaken, updateMemberProfile } from '../../repositories/member-repository'
import { requireMemberSession } from '../../utils/member-auth'
import { isManagedAvatarUrl, removeManagedAvatarAsset } from '../../utils/member-avatar'
const updateProfileSchema = z.object({
username: z.string().trim().min(1).max(30),
@@ -36,10 +37,26 @@ export default defineEventHandler(async (event) => {
})
}
const existing = await getUserById(session.userId)
if (!existing) {
throw createError({
statusCode: 404,
message: '회원 정보를 찾을 수 없습니다.'
})
}
const previousAvatarUrl = existing.avatarUrl || ''
const nextAvatarUrl = parsedBody.data.avatarUrl || ''
if (previousAvatarUrl && previousAvatarUrl !== nextAvatarUrl && isManagedAvatarUrl(previousAvatarUrl)) {
await removeManagedAvatarAsset(previousAvatarUrl)
}
const updated = await updateMemberProfile({
userId: session.userId,
username: parsedBody.data.username,
avatarUrl: parsedBody.data.avatarUrl
avatarUrl: nextAvatarUrl
})
if (!updated) {